Files
ProxyWarden/src/app/App.tsx

2163 lines
75 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, Trash2, Wand2 } from 'lucide-react';
import {
applyProfiles,
fetchSingBoxSubscription,
forgetSingBoxSubscription,
generateSingBoxConfig,
getComponents,
getProxiFyreSetupStatus,
getSavedState,
getSingBoxSetupStatus,
getSingBoxStatus,
installProxiFyre,
installSingBox,
openConfigLocation,
pingAllSingBoxServers,
pingProxyTarget,
pingSingBoxServer,
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';
import { Button, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
import { getApplyReadiness } from './readiness';
import { serviceControlState } from './viewModel';
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
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;
type PanelId = 'summary' | 'proxifyre' | 'proxy';
type StatusTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
type TabTransitionDirection = 'left' | 'right';
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[];
}
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[] = ['summary', 'proxifyre', 'proxy'];
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<PanelId>('summary');
const [tabTransitionDirection, setTabTransitionDirection] = useState<TabTransitionDirection>('right');
const [proxyInput, setProxyInput] = useState('');
const [routeMode, setRouteMode] = useState<RouteMode>('external');
const [profileId, setProfileId] = useState(MAIN_PROFILE_ID);
const [targetId, setTargetId] = useState(MAIN_TARGET_ID);
const [items, setItems] = useState<DraftItem[]>([]);
const [appliedSnapshot, setAppliedSnapshot] = useState<ConfigSnapshot | null>(null);
const [loadedProfiles, setLoadedProfiles] = useState<Profile[]>([]);
const [isProcessInputOpen, setIsProcessInputOpen] = useState(false);
const [processInput, setProcessInput] = useState('');
const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null);
const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
const [setupStatus, setSetupStatus] = useState<ProxiFyreSetupStatus | null>(null);
const [singBoxStatus, setSingBoxStatus] = useState<LocalSingBoxStatusResponse | null>(null);
const [singBoxSetupStatus, setSingBoxSetupStatus] = useState<SingBoxSetupStatus | null>(null);
const [subscriptionInput, setSubscriptionInput] = useState('');
const [serverPings, setServerPings] = useState<Record<string, PingServerResponse>>({});
const [proxyPing, setProxyPing] = useState<PingServerResponse | null>(null);
const [isSingBoxSetupOpen, setIsSingBoxSetupOpen] = useState(false);
const [isSingBoxInfoOpen, setIsSingBoxInfoOpen] = useState(false);
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [activeLogId, setActiveLogId] = useState<string | null>(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 [isProxyChecking, setIsProxyChecking] = useState(false);
const [serverPingTag, setServerPingTag] = useState<string | null>(null);
const [serviceAction, setServiceAction] = useState<ProxiFyreAction | null>(null);
const [singBoxAction, setSingBoxAction] = useState<SingBoxAction | null>(null);
const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false);
const [isSingBoxMenuOpen, setIsSingBoxMenuOpen] = useState(false);
const [serviceVisualState, setServiceVisualState] = useState<ServiceVisualState>(null);
const serviceVisualTimerRef = useRef<number | null>(null);
const appsSectionRef = useRef<HTMLElement | null>(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 currentSnapshot = useMemo(
() => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag),
[items, proxyInput, routeMode, selectedServerTag],
);
const hasUnappliedChanges = appliedSnapshot
? !sameConfigSnapshot(currentSnapshot, appliedSnapshot)
: false;
const systemSummary = systemSummaryState({
isLoading,
isDetectingComponents,
proxyfier,
singbox,
routeMode,
singBoxStatus,
proxyPing,
hasUnappliedChanges,
});
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);
setAppliedSnapshot((current) => {
if (!current || current.routeMode !== 'local-singbox' || current.selectedServerTag) return current;
return {
...current,
selectedServerTag: normalizeServerTag(detectedSingBoxStatus.config.selectedServerTag),
};
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Компоненты не проверены',
text: errorMessage(error),
});
} finally {
setIsDetectingComponents(false);
}
}
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);
setProxyPing(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);
}
function changeProxyInput(nextValue: string) {
setProxyInput(nextValue);
setProxyPing(null);
}
function openAppsPanel() {
switchPanel('proxifyre');
window.setTimeout(() => {
appsSectionRef.current?.scrollIntoView({ block: 'start' });
}, 0);
}
function switchPanel(nextPanel: PanelId) {
if (activePanel === nextPanel) return;
const currentIndex = PANEL_ORDER.indexOf(activePanel);
const nextIndex = PANEL_ORDER.indexOf(nextPanel);
setTabTransitionDirection(nextIndex > currentIndex ? 'right' : 'left');
setActivePanel(nextPanel);
}
async function pickAndAddItem(type: Extract<DraftItemType, 'exe' | 'folder'>) {
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);
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({});
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({});
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));
} 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 pingExternalProxy() {
let parsed: ParsedProxy;
try {
parsed = parseProxy(proxyInput);
} catch (error) {
showNotice({
kind: 'error',
title: 'Прокси не проверен',
text: errorMessage(error),
});
return;
}
setIsProxyChecking(true);
try {
const result = await pingProxyTarget(parsed.host, parsed.port);
setProxyPing(result);
showNotice({
kind: result.ok ? 'success' : 'error',
title: result.ok ? 'Внешний прокси отвечает' : 'Внешний прокси не ответил',
text: pingResultText(result),
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Прокси не проверен',
text: errorMessage(error),
});
} finally {
setIsProxyChecking(false);
}
}
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 renderTabs() {
const tabs: Array<{ id: PanelId; label: string }> = [
{ id: 'summary', label: 'Сводка' },
{ id: 'proxifyre', label: 'ProxiFyre' },
{ id: 'proxy', label: 'VPN / Прокси' },
];
return <Tabs items={tabs} activeId={activePanel} onChange={switchPanel} ariaLabel="Разделы Proxy" />;
}
function renderSummaryPanel() {
const appliedRouteMode = appliedSnapshot?.routeMode ?? routeMode;
const draftHasLocalServer = currentSnapshot.routeMode === 'local-singbox' && currentSnapshot.selectedServerTag;
return (
<section
className="tab-panel summary-panel"
role="tabpanel"
id="panel-summary"
aria-labelledby="tab-summary"
>
<div className={`summary-hero ${systemSummary.tone}`}>
<span className={`summary-status-dot ${systemSummary.tone}`} aria-hidden="true" />
<div>
<span>Статус системы</span>
<strong>{systemSummary.title}</strong>
<p>{systemSummary.text}</p>
</div>
</div>
<div className="summary-readout">
<div className="summary-readout-head">
<div>
<span>Применено сейчас</span>
<strong>{appliedSnapshot ? routeModeLabel(appliedRouteMode) : 'Состояние загружается'}</strong>
</div>
<span className={`summary-pill ${hasUnappliedChanges ? 'warning' : 'ok'}`}>
{hasUnappliedChanges ? 'Есть черновик' : 'Совпадает'}
</span>
</div>
<dl className="summary-lines">
<div>
<dt>ProxiFyre</dt>
<dd>{proxyfierTitle(proxyfier, isDetectingComponents)}</dd>
</div>
<div>
<dt>Маршрут</dt>
<dd>{snapshotRouteLine(appliedSnapshot, singBoxStatus)}</dd>
</div>
<div>
<dt>VPN сервер</dt>
<dd>{snapshotServerText(appliedSnapshot)}</dd>
</div>
<div>
<dt>Приложения</dt>
<dd>{snapshotItemsText(appliedSnapshot)}</dd>
</div>
<div>
<dt>Конфиг</dt>
<dd>{generatedConfigPath || 'Примененный config еще не создан.'}</dd>
</div>
</dl>
</div>
<div className={`summary-draft ${hasUnappliedChanges ? 'warning' : 'ok'}`}>
<div>
<span>Черновик интерфейса</span>
<strong>{hasUnappliedChanges ? 'Есть реальные изменения' : 'Изменений нет'}</strong>
<p>
{hasUnappliedChanges
? 'Текущие настройки отличаются от примененного состояния. Если вернуть значения назад, предупреждение исчезнет.'
: 'То, что выбрано в интерфейсе, совпадает с примененной конфигурацией.'}
</p>
</div>
{hasUnappliedChanges ? (
<dl className="summary-lines compact">
<div>
<dt>Будет маршрут</dt>
<dd>{snapshotRouteLine(currentSnapshot, singBoxStatus)}</dd>
</div>
<div>
<dt>VPN сервер</dt>
<dd>{draftHasLocalServer ? displayServerTag(currentSnapshot.selectedServerTag) : snapshotServerText(currentSnapshot)}</dd>
</div>
<div>
<dt>Приложения</dt>
<dd>{snapshotItemsText(currentSnapshot)}</dd>
</div>
</dl>
) : null}
</div>
<div className="summary-actions">
<Button type="button" variant="neutral" onClick={() => switchPanel('proxy')}>
Настроить маршрут
</Button>
<Button type="button" variant="neutral" onClick={openAppsPanel}>
Приложения ProxiFyre
</Button>
</div>
</section>
);
}
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',
loadingLabel: serviceAction === 'start' ? 'Запускаю' : 'Останавливаю',
disabled: isDetectingComponents || Boolean(serviceAction),
}
: {
label: 'Установить',
onClick: () => void installProxiFyrePackage(),
variant: 'primary' as const,
loading: serviceAction === 'install',
loadingLabel: 'Устанавливаю',
disabled: isDetectingComponents || Boolean(serviceAction),
};
return (
<ServiceControlRow
state={state}
visualState={visualState}
className="proxifyre-card"
title={proxyfierTitle(proxyfier, isDetectingComponents)}
detail={proxyfierDetails(proxyfier, isDetectingComponents)}
primaryAction={primaryAction}
menu={proxyfier?.installed ? {
label: 'Дополнительные действия ProxiFyre',
open: isServiceMenuOpen,
onOpenChange: setIsServiceMenuOpen,
disabled: isDetectingComponents || Boolean(serviceAction),
items: [{
label: serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre',
danger: true,
disabled: Boolean(serviceAction),
onClick: () => void uninstallProxiFyrePackage(),
}],
} : undefined}
/>
);
}
function renderProxiFyreSetupStrip() {
const stripItems = setupStatus?.items ?? proxifyreSetupPlaceholders();
return (
<div className={`setup-strip ${setupStatus?.ready ? 'ready' : 'attention'}`} aria-label="Состав ProxiFyre">
<span className="setup-strip-title">Состав</span>
<div className="setup-strip-items">
{stripItems.map((item) => (
<div className={`setup-strip-item ${item.installed ? 'installed' : 'missing'}`} key={item.id}>
<span className="setup-strip-dot" aria-hidden="true" />
<strong>{setupItemUserName(item.id, item.name)}</strong>
<span>{setupItemShortStatus(item)}</span>
</div>
))}
</div>
</div>
);
}
function renderAppsSection() {
return (
<section className="apps-section" ref={appsSectionRef}>
<div className="apps-header">
<div className="section-head apps-title">
<h2>Приложения</h2>
<span>{items.length}</span>
</div>
<div className="apps-config-actions">
{renderApplyActions('proxifyre', { showConfigPath: false })}
</div>
</div>
{isProcessInputOpen ? (
<div className="process-add-line">
<input
value={processInput}
onChange={(event) => setProcessInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') addProcess();
if (event.key === 'Escape') {
setProcessInput('');
setIsProcessInputOpen(false);
}
}}
placeholder="Discord"
spellCheck={false}
autoFocus
/>
<Button type="button" variant="primary" onClick={addProcess}>
OK
</Button>
<Button
type="button"
variant="neutral"
onClick={() => {
setProcessInput('');
setIsProcessInputOpen(false);
}}
>
Отмена
</Button>
</div>
) : (
<div className="app-add-skeleton">
<div className="app-add-main">
<span className="item-icon app-add-icon" aria-hidden="true">
<Cpu size={18} strokeWidth={1.9} />
</span>
<div>
<strong>Новое приложение</strong>
<span>Добавь процесс, EXE-файл или папку</span>
</div>
</div>
<div className="add-toolbar" aria-label="Добавить приложение">
<IconButton
type="button"
variant="add"
onClick={() => setIsProcessInputOpen(true)}
label="Добавить процесс"
icon={<Cpu size={20} strokeWidth={1.8} />}
/>
<IconButton
type="button"
variant="add"
onClick={() => void pickAndAddItem('exe')}
disabled={Boolean(pickerAction)}
loading={pickerAction === 'exe'}
label="Добавить EXE-файл"
icon={<FileCode2 size={20} strokeWidth={1.8} />}
/>
<IconButton
type="button"
variant="add"
onClick={() => void pickAndAddItem('folder')}
disabled={Boolean(pickerAction)}
loading={pickerAction === 'folder'}
label="Добавить папку"
icon={<FolderOpen size={20} strokeWidth={1.8} />}
/>
</div>
</div>
)}
<div className="app-list">
{isLoading ? (
<div className="list-skeleton" aria-label="Загрузка приложений">
<span />
<span />
</div>
) : items.length ? (
items.map((item) => (
<div className="app-row" key={item.id}>
<div className="app-row-main">
<span className="item-icon" aria-hidden="true">
{itemIcon(item.type)}
</span>
<div>
<strong>{item.value}</strong>
<span>{itemTypeLabel(item.type)}</span>
</div>
</div>
<Button type="button" variant="danger" onClick={() => removeItem(item.id)} aria-label={`Удалить ${item.value}`}>
Удалить
</Button>
</div>
))
) : (
<div className="empty-state">Список пуст. Добавь первое приложение сверху.</div>
)}
</div>
</section>
);
}
function renderApplyActions(
context: 'proxifyre' | 'proxy',
options: { showConfigPath?: boolean } = {},
) {
const showConfigPath = options.showConfigPath ?? true;
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 showBlocker = !readiness.ready && !isLoading && !isDetectingComponents;
return (
<>
{showBlocker ? (
<div className="apply-state blocked" role="status">
<strong>{readiness.title}</strong>
<span>{readiness.text}</span>
</div>
) : hasUnappliedChanges ? (
<div className="apply-state pending" role="status">
<strong>Изменения еще не применены в ProxiFyre</strong>
<span>{applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))}</span>
</div>
) : null}
<div className="command-row">
<Button
type="button"
variant={readiness.ready ? 'primary' : 'neutral'}
size="lg"
onClick={updateConfig}
disabled={!readiness.ready}
loading={isApplying}
loadingLabel={applyButtonLabel(context, true, hasUnappliedChanges, singBoxAction)}
>
{applyButtonLabel(context, isApplying, hasUnappliedChanges, singBoxAction)}
</Button>
<Button
type="button"
variant="neutral"
size="lg"
onClick={openConfig}
loading={isOpeningConfig}
loadingLabel="Открываю"
>
Открыть
</Button>
</div>
{showConfigPath && generatedConfigPath ? <p className="config-path">{generatedConfigPath}</p> : null}
</>
);
}
function renderProxiFyrePanel() {
return (
<section
className="tab-panel"
role="tabpanel"
id="panel-proxifyre"
aria-labelledby="tab-proxifyre"
>
{renderProxiFyreCard()}
{renderProxiFyreSetupStrip()}
{renderAppsSection()}
</section>
);
}
function renderExternalProxyControls() {
const proxyValidation = proxyInput.trim() ? safeProxyError(proxyInput) : null;
return (
<div className="external-proxy-card">
<label className="simple-field route-proxy-field">
<span>Внешний SOCKS5 прокси</span>
<input
value={proxyInput}
onChange={(event) => changeProxyInput(event.target.value)}
placeholder="socks5://127.0.0.1:1080"
spellCheck={false}
/>
</label>
<div className="proxy-check-line">
<div className={`proxy-check-state ${proxyPing ? pingTone(proxyPing) : proxyValidation ? 'warning' : 'muted'}`}>
<strong>{proxyPing ? pingResultTitle(proxyPing) : proxyValidation ? 'Проверь формат' : 'Проверка не запускалась'}</strong>
<span>{proxyPing ? pingResultText(proxyPing) : proxyValidation ?? 'TCP check покажет, доступен ли host:port.'}</span>
</div>
<Button
type="button"
variant="neutral"
size="lg"
onClick={() => void pingExternalProxy()}
disabled={Boolean(proxyValidation)}
loading={isProxyChecking}
loadingLabel="Проверяю"
>
Проверить
</Button>
</div>
</div>
);
}
function renderSingBoxCard() {
const state = serviceControlState(singbox, isDetectingComponents);
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 (
<ServiceControlRow
state={state}
visualState={singBoxAction ? 'working' : null}
className="singbox-card"
title={singBoxTitle(singbox, isDetectingComponents)}
detail={singBoxDetails(singbox, singBoxStatus, isDetectingComponents)}
primaryAction={primaryAction}
menu={singbox?.installed ? {
label: 'Дополнительные действия Local sing-box',
open: isSingBoxMenuOpen,
onOpenChange: setIsSingBoxMenuOpen,
disabled: isDetectingComponents || Boolean(singBoxAction),
items: [{
label: singBoxAction === 'uninstall' ? 'Удаляю...' : 'Удалить Local sing-box',
danger: true,
disabled: Boolean(singBoxAction),
onClick: () => void uninstallSingBoxPackage(),
}],
} : undefined}
inlineActions={(
<>
<Button
type="button"
variant="neutral"
size="sm"
className="setup-toggle"
onClick={() => setIsSingBoxSetupOpen((current) => !current)}
disabled={isDetectingComponents && !singBoxSetupStatus}
aria-expanded={isSingBoxSetupOpen}
>
{singbox?.installed ? 'Состав Local sing-box' : 'Что будет установлено'}
{singBoxSetupStatus ? (
<span>{singBoxSetupStatus.ready ? 'все есть' : `не хватает: ${singBoxSetupStatus.missingCount}`}</span>
) : null}
</Button>
<IconButton
type="button"
variant="neutral"
className="info-toggle"
onClick={() => setIsSingBoxInfoOpen((current) => !current)}
label="Подробности Local sing-box"
aria-expanded={isSingBoxInfoOpen}
title="Подробности"
icon={<Info size={16} strokeWidth={2} />}
/>
</>
)}
>
{isSingBoxInfoOpen ? (
<div className="singbox-info-popover">
<div className="singbox-info-grid">
<span>Локально</span>
<strong>{localSingBoxAddress(singBoxStatus)}</strong>
<span>LAN</span>
<strong>{lanSingBoxAddress(singBoxStatus) ?? 'недоступен'}</strong>
<span>Сервер</span>
<strong>
{selectedServerTag
? displayServerTag(selectedServerTag)
: 'не выбран'}
</strong>
<span>Файл</span>
<strong>{singbox?.path ?? 'не найден'}</strong>
<span>Конфиг</span>
<strong>{singBoxStatus?.generatedConfigPath ?? 'не создан'}</strong>
</div>
<div className="singbox-info-actions">
<Button
type="button"
onClick={() => void pingSingBoxServers()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length}
variant="neutral"
size="sm"
>
<Gauge size={15} strokeWidth={1.9} />
Ping все
</Button>
<Button
type="button"
onClick={() => void generateSingBoxNow()}
disabled={Boolean(singBoxAction) || !selectedServerTag}
variant="neutral"
size="sm"
>
<Wand2 size={15} strokeWidth={1.9} />
Конфиг
</Button>
</div>
</div>
) : null}
{isSingBoxSetupOpen ? (
<div className="setup-details">
{singBoxSetupStatus ? (
singBoxSetupStatus.items.map((item) => (
<div className={`setup-item ${item.installed ? 'installed' : 'missing'}`} key={item.id}>
<span className="setup-state-dot" aria-hidden="true" />
<div>
<strong>{item.name}</strong>
<span>{setupItemDetails(item.installed, item.version, item.details)}</span>
</div>
</div>
))
) : (
<div className="setup-item">
<span className="setup-state-dot" aria-hidden="true" />
<div>
<strong>Проверяю состав</strong>
<span>Ищу sing-box, WinSW wrapper и службу.</span>
</div>
</div>
)}
</div>
) : null}
{isSingBoxInstalled ? renderSingBoxWorkspace() : (
<div className="singbox-install-note">
<strong>Local sing-box не установлен</strong>
<span>Установи компонент, чтобы подключить подписку, выбрать сервер и включить локальный маршрут.</span>
</div>
)}
</ServiceControlRow>
);
}
function renderSingBoxWorkspace() {
return (
<div className="singbox-workspace">
<div className="subscription-line">
<span className="subscription-icon" aria-hidden="true">
<Link2 size={18} strokeWidth={1.9} />
</span>
<input
value={subscriptionInput}
onChange={(event) => setSubscriptionInput(event.target.value)}
placeholder={singBoxStatus?.config.subscriptionDisplayUrl ?? 'https://example.com/sub'}
spellCheck={false}
/>
<Button
type="button"
onClick={() => void syncSingBoxSubscription()}
loading={singBoxAction === 'fetch'}
loadingLabel="Загружаю"
variant="neutral"
>
{subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'}
</Button>
<IconButton
type="button"
variant="danger"
onClick={() => void forgetSingBoxSubscriptionData()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription}
label="Очистить подписку Local sing-box"
title="Очистить"
icon={<Trash2 size={18} strokeWidth={1.9} />}
/>
</div>
{singBoxStatus?.cache?.servers.length ? (
<div className="server-list">
{singBoxStatus.cache.servers.map((server) => {
const ping = serverPings[server.tag];
const selected = server.tag === selectedServerTag;
return (
<div className={`server-row ${selected ? 'selected' : ''} ${ping ? pingTone(ping) : ''}`.trim()} key={server.tag}>
<button
type="button"
className="server-select-button"
onClick={() => void chooseSingBoxServer(server)}
title={serverTooltip(server, ping)}
>
<span className="server-select-dot" aria-hidden="true" />
<strong>{displayServerTag(server.tag)}</strong>
{ping ? <span className="server-ping-badge">{ping.ok ? `${ping.latency ?? 0} ms` : 'fail'}</span> : null}
</button>
<IconButton
type="button"
onClick={() => void pingSingleSingBoxServer(server)}
disabled={Boolean(serverPingTag)}
loading={serverPingTag === server.tag}
label={`Проверить ${displayServerTag(server.tag)}`}
title="Ping"
icon={<Gauge size={14} strokeWidth={1.9} />}
/>
</div>
);
})}
</div>
) : (
<div className="empty-state">Подписка Local sing-box еще не загружена.</div>
)}
</div>
);
}
function renderProxyPanel() {
return (
<section
className="tab-panel"
role="tabpanel"
id="panel-proxy"
aria-labelledby="tab-proxy"
>
<div className="panel-section-head">
<div>
<span>Настройки VPN / Прокси</span>
<h2>Маршрут и проверка соединения</h2>
</div>
<strong>{routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'}</strong>
</div>
<section className="route-panel" aria-label="Маршрут приложений">
<div className="route-switch">
<Button
type="button"
variant={routeMode === 'external' ? 'primary' : 'neutral'}
onClick={() => changeRouteMode('external')}
aria-pressed={routeMode === 'external'}
>
Внешний прокси
</Button>
<Button
type="button"
variant={routeMode === 'local-singbox' ? 'primary' : 'neutral'}
onClick={() => changeRouteMode('local-singbox')}
aria-pressed={routeMode === 'local-singbox'}
>
Локальный прокси
</Button>
</div>
{routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()}
</section>
<div className="route-preview">
<span>Маршрут</span>
<strong>{routePreviewText(routeMode, proxyInput, singBoxStatus)}</strong>
</div>
{renderApplyActions('proxy')}
</section>
);
}
function renderActivePanel() {
if (activePanel === 'proxifyre') return renderProxiFyrePanel();
if (activePanel === 'proxy') return renderProxyPanel();
return renderSummaryPanel();
}
return (
<main className="simple-shell">
<section className="simple-panel">
<header className="simple-header">
<div>
<h1>ProxyWarden</h1>
<small>Proxy для приложений</small>
</div>
<Button
type="button"
variant="neutral"
size="sm"
onClick={refresh}
loading={isLoading || isDetectingComponents}
loadingLabel={isLoading ? 'Загружаю' : 'Проверяю'}
>
Обновить
</Button>
</header>
{renderTabs()}
<div className={`tab-panel-frame swipe-${tabTransitionDirection}`} key={activePanel}>
{renderActivePanel()}
</div>
</section>
<LogDock
entries={logEntries}
activeEntry={activeLog}
open={isLogOpen}
onToggle={() => setIsLogOpen((current) => !current)}
formatTime={formatLogTime}
/>
</main>
);
}
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;
proxyPing: PingServerResponse | null;
hasUnappliedChanges: boolean;
}
interface SummaryState {
tone: StatusTone;
title: string;
text: string;
}
function configSnapshotFromUi(
routeMode: RouteMode,
proxyInput: string,
items: Array<Pick<DraftItem, 'type' | 'value'>>,
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<Pick<DraftItem, 'type' | 'value'>>): 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 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 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.routeMode === 'external' && input.proxyPing && !input.proxyPing.ok) {
return {
tone: 'warning',
title: 'Прокси не ответил',
text: pingResultText(input.proxyPing),
};
}
if (input.hasUnappliedChanges) {
return {
tone: 'warning',
title: 'Есть непримененные изменения',
text: 'Настройки изменены, но ProxiFyre еще не обновлен.',
};
}
return {
tone: 'ok',
title: 'Работает',
text: 'Сохраненная конфигурация выглядит готовой к маршрутизации выбранных приложений.',
};
}
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 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 routePreviewText(
routeMode: RouteMode,
proxyInput: string,
status: LocalSingBoxStatusResponse | null,
) {
if (routeMode === 'local-singbox') {
const server = status?.config.selectedServerTag
? displayServerTag(status.config.selectedServerTag)
: 'сервер не выбран';
return `Выбранные приложения -> ProxiFyre -> Local sing-box ${localSingBoxAddress(status)} -> ${server}`;
}
return `Выбранные приложения -> ProxiFyre -> внешний прокси ${routeEndpointLabel('external', proxyInput, status)}`;
}
function applyButtonLabel(
context: 'proxifyre' | 'proxy',
isApplying: boolean,
hasUnappliedChanges: boolean,
singBoxAction: SingBoxAction | null,
) {
if (isApplying) {
if (singBoxAction === 'start') return 'Запускаю sing-box...';
if (singBoxAction === 'stop') return 'Перезапускаю sing-box...';
return 'Применяю...';
}
if (hasUnappliedChanges) return context === 'proxy' ? 'Применить маршрут' : 'Применить в ProxiFyre';
return context === 'proxy' ? 'Обновить маршрут' : 'Обновить конфиг';
}
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<string>();
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<DraftItemType, 'exe' | 'folder'>) {
const selected = await open(
type === 'folder'
? {
title: 'Выбери папку',
directory: true,
multiple: false,
}
: {
title: 'Выбери EXE-файл',
directory: false,
multiple: false,
filters: [{ name: 'EXE-файлы', extensions: ['exe'] }],
},
);
if (Array.isArray(selected)) return selected[0] ?? null;
return selected;
}
function nextFrame() {
return new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
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 <Cpu size={18} strokeWidth={1.9} />;
if (type === 'folder') return <FolderOpen size={18} strokeWidth={1.9} />;
return <FileCode2 size={18} strokeWidth={1.9} />;
}
function setupItemDetails(installed: boolean, version: string | undefined, details: string) {
if (!installed) return `Нужно установить. ${details}`;
if (version) return `${version}. ${details}`;
return details;
}
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 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 'Неизвестная ошибка.';
}