Files
ProxyWarden/src/app/App.tsx

2717 lines
93 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, 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<ProfileItemType, 'process' | 'folder' | 'exe'>;
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<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 [proxyCheck, setProxyCheck] = useState<ProxyTargetCheckResponse | null>(null);
const [adminStatus, setAdminStatus] = useState<AdminStatusResponse | null>(null);
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 [isRestartingAsAdmin, setIsRestartingAsAdmin] = 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 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<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();
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 (
<aside className="admin-prompt" aria-label="Права администратора">
<span className="admin-prompt-icon" aria-hidden="true">
<ShieldAlert size={15} strokeWidth={1.9} />
</span>
<div className="admin-prompt-copy">
<strong>Права администратора</strong>
<span>{adminStatus?.message}</span>
</div>
<Button
type="button"
variant="primary"
size="sm"
className="admin-prompt-action"
onClick={() => void restartApplicationAsAdmin()}
loading={isRestartingAsAdmin}
loadingLabel="Открываю UAC"
>
Перезапустить с правами
</Button>
</aside>
);
}
function renderTabs() {
const tabs: Array<{ id: PanelId; label: string }> = [
{ id: 'proxifyre', label: 'ProxiFyre' },
{ id: 'summary', label: 'ProxyWarden' },
{ id: 'proxy', label: 'VPN / Прокси' },
];
return <Tabs items={tabs} activeId={activePanel} onChange={switchPanel} ariaLabel="Разделы ProxyWarden" />;
}
function renderSummaryPanel() {
return (
<section
className="tab-panel summary-panel"
role="tabpanel"
id="panel-summary"
aria-labelledby="tab-summary"
>
<h1 className="summary-brand-title">ProxyWarden</h1>
<div className="summary-main">
{renderSummaryStatusControl()}
{renderRouteChain('vertical')}
</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' || 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 (
<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">
<div className="apps-header">
<div className="section-head apps-title">
<h2>Приложения</h2>
<span>{items.length}</span>
</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 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 (
<div className={`summary-status-control ${systemSummary.tone} ${running ? 'on' : 'off'}`}>
<button
type="button"
className="summary-toggle-button"
onClick={() => void setProxiFyreServiceRunning(!running)}
disabled={!installed || isDetectingComponents || Boolean(serviceAction)}
aria-label={buttonAriaLabel}
aria-pressed={installed ? running : undefined}
>
{systemSummary.tone === 'checking' || working ? <BusyRing /> : null}
<img src={imageSrc} alt="" draggable={false} />
</button>
<strong className={`summary-state-label ${systemSummary.tone}`}>{stateLabel}</strong>
</div>
);
}
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 (
<aside className="changes-dock" aria-label="Черновик конфигурации">
<div className="changes-dock-main">
<ul className="changes-list">
{pendingChanges.map((change) => (
<li className={`change-row ${change.tone ?? 'changed'}`} key={change.id}>
<span className="change-label">{change.label}</span>
<span className="change-values">
{change.before ? (
<>
<span>{change.before}</span>
<span className="change-arrow" aria-hidden="true">-&gt;</span>
</>
) : null}
<strong>{change.after}</strong>
</span>
</li>
))}
</ul>
</div>
<div className="changes-actions">
<Button
type="button"
variant={readiness.ready ? 'primary' : 'neutral'}
size="md"
onClick={updateConfig}
disabled={!readiness.ready}
loading={isApplying}
loadingLabel={changesApplyButtonLabel(true, singBoxAction, serviceAction)}
>
{applyButtonText}
</Button>
</div>
</aside>
);
}
function renderProxiFyrePanel() {
return (
<section
className="tab-panel"
role="tabpanel"
id="panel-proxifyre"
aria-labelledby="tab-proxifyre"
>
{renderProxiFyreCard()}
{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}
/>
{proxyValidation ? <span className="field-error">{proxyValidation}</span> : null}
</label>
</div>
);
}
function renderConnectionCheck(check: ConnectionCheckView) {
const buttonDisabled = Boolean(check.disabledReason);
return (
<div className="connection-check" aria-label="Проверка соединения">
<div className="connection-check-status">
<span>Проверка маршрута</span>
<strong>{check.title}</strong>
<p>{check.text}</p>
</div>
{check.probes.length ? (
<div className="connection-probes" aria-label="Контрольные точки">
{check.probes.map((probe) => (
<span className={`connection-probe ${probe.tone}`} key={probe.id}>
<strong>{probe.label}</strong>
<span>{probe.value}</span>
</span>
))}
</div>
) : null}
<DetailsPopover
className="connection-endpoint"
details={check.details}
popoverLabel="Проверка маршрута"
align="end"
aria-label={`${check.endpoint}. ${check.details.join('. ')}`}
>
<strong>{check.endpoint}</strong>
</DetailsPopover>
<Button
type="button"
variant="neutral"
size="md"
onClick={() => void checkRouteProxy()}
disabled={buttonDisabled}
loading={check.loading}
loadingLabel="Проверяю"
>
Проверить
</Button>
</div>
);
}
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 (
<ServiceControlRow
state={state}
visualState={singBoxAction ? 'working' : null}
className="singbox-card"
title="Local sing-box"
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={(
<DetailsPopover
className="setup-summary"
details={singBoxDetailLines(
singbox,
singBoxStatus,
singBoxSetupStatus,
selectedServerTag,
SHOW_DEV_SUBSCRIPTION_IDENTITY,
)}
popoverLabel="Состав Local sing-box"
aria-label={`Подробности Local sing-box: ${setupSummary}`}
>
<span className="setup-summary-glyph" aria-hidden="true">i</span>
</DetailsPopover>
)}
>
{isSingBoxInstalled ? renderSingBoxWorkspace() : null}
</ServiceControlRow>
);
}
function renderSingBoxWorkspace() {
return (
<div className="singbox-workspace">
<div className="subscription-line">
<span className="subscription-icon" aria-hidden="true">
<Link2 size={18} strokeWidth={1.9} />
</span>
<input
value={subscriptionInput}
onChange={(event) => setSubscriptionInput(event.target.value)}
placeholder={singBoxStatus?.config.subscriptionDisplayUrl ?? 'https://example.com/sub'}
spellCheck={false}
/>
<Button
type="button"
onClick={() => void syncSingBoxSubscription()}
loading={singBoxAction === 'fetch'}
loadingLabel="Загружаю"
variant="neutral"
disabled={Boolean(singBoxAction)}
>
{subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'}
</Button>
<IconButton
type="button"
variant="danger"
onClick={() => void forgetSingBoxSubscriptionData()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription}
label="Очистить подписку Local sing-box"
tooltip="Очистить"
icon={<Trash2 size={18} strokeWidth={1.9} />}
/>
</div>
<div className="singbox-workspace-head">
<span>Серверы подписки</span>
<div className="singbox-workspace-actions">
<IconButton
type="button"
onClick={() => void pingSingBoxServers()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length}
loading={singBoxAction === 'ping'}
label="Проверить все серверы подписки"
tooltip="Проверить все серверы"
icon={<Gauge size={16} strokeWidth={1.9} />}
/>
<IconButton
type="button"
onClick={() => void generateSingBoxNow()}
disabled={Boolean(singBoxAction) || !selectedServerTag}
loading={singBoxAction === 'generate'}
label="Создать конфиг Local sing-box"
tooltip="Создать конфиг"
icon={<Wand2 size={16} strokeWidth={1.9} />}
/>
</div>
</div>
{singBoxStatus?.cache?.servers.length ? (
<div className="server-list">
{singBoxStatus.cache.servers.map((server) => {
const ping = serverPings[server.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)}
data-tooltip={serverTooltip(server, ping)}
aria-label={`Выбрать ${displayServerTag(server.tag)}. ${serverTooltip(server, ping)}`}
>
<span className="server-select-dot" aria-hidden="true" />
<strong>{displayServerTag(server.tag)}</strong>
{ping ? <span className="server-ping-badge">{ping.ok ? `${ping.latency ?? 0} ms` : 'fail'}</span> : null}
</button>
<IconButton
type="button"
onClick={() => void pingSingleSingBoxServer(server)}
disabled={Boolean(serverPingTag)}
loading={serverPingTag === server.tag}
label={`Проверить ${displayServerTag(server.tag)}`}
tooltip="Ping"
icon={<Gauge size={14} strokeWidth={1.9} />}
/>
</div>
);
})}
</div>
) : (
<div className="empty-state">Подписка Local sing-box еще не загружена.</div>
)}
</div>
);
}
function renderProxyOverview() {
const check = connectionCheckView({
routeMode,
proxyInput,
proxyCheck,
singbox,
singBoxStatus,
selectedServer,
isDetectingComponents,
isProxyChecking,
});
return (
<section className={`proxy-overview ${check.tone}`} aria-label="Состояние прокси">
{check.tone === 'checking' ? <BusyRing /> : null}
{renderConnectionCheck(check)}
</section>
);
}
function renderProxyPanel() {
return (
<section
className="tab-panel"
role="tabpanel"
id="panel-proxy"
aria-labelledby="tab-proxy"
>
{renderProxyOverview()}
<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>
</section>
);
}
function renderRouteChain(variant: 'horizontal' | 'vertical' = 'horizontal') {
const input: RouteChainInput = {
routeMode,
proxyInput,
proxyfier,
singbox,
singBoxStatus,
selectedServer,
appCount: items.length,
isDetectingComponents,
};
const flow = variant === 'vertical' ? summaryRouteFlow(input) : 'proxy';
const segments = variant === 'vertical'
? summaryRouteChainSegments(input, flow)
: routeChainSegments(input);
const isVertical = variant === 'vertical';
return (
<section className={`route-chain route-chain--${variant} route-chain--${flow}`} aria-label="Текущий маршрут">
{isVertical ? (
<span className="route-chain-rail" aria-hidden="true">
{flow === 'idle' ? null : (
<>
<span className="route-chain-packet route-chain-packet--one" />
<span className="route-chain-packet route-chain-packet--two" />
<span className="route-chain-packet route-chain-packet--three" />
</>
)}
</span>
) : null}
{segments.map((segment, index) => (
<DetailsPopover
className={`route-chain-segment ${segment.tone}`}
details={segment.details}
popoverLabel={segment.label}
align={isVertical || index >= segments.length - 2 ? 'end' : 'start'}
aria-label={`${segment.label}: ${segment.value}. ${segment.details.join('. ')}`}
key={segment.id}
>
<span className="route-chain-dot" aria-hidden="true" />
<span>{segment.label}</span>
<strong>{segment.value}</strong>
{index < segments.length - 1 ? (
<span className="route-chain-arrow" aria-hidden="true">{isVertical ? '↓' : '->'}</span>
) : null}
</DetailsPopover>
))}
</section>
);
}
function renderActivePanel() {
if (activePanel === 'proxifyre') return renderProxiFyrePanel();
if (activePanel === 'proxy') return renderProxyPanel();
return renderSummaryPanel();
}
return (
<main
className={[
'simple-shell',
hasUnappliedChanges ? 'has-change-dock' : '',
hasAdminPrompt ? 'has-admin-prompt' : '',
].filter(Boolean).join(' ')}
style={shellStyle}
>
<section className="simple-panel">
{renderTabs()}
<div className={`tab-panel-frame swipe-${tabTransitionDirection}`} key={activePanel}>
{renderActivePanel()}
</div>
</section>
{renderChangesDock()}
<LogDock
entries={logEntries}
activeEntry={activeLog}
open={isLogOpen}
onToggle={() => setIsLogOpen((current) => !current)}
formatTime={formatLogTime}
/>
{renderAdminPrompt()}
</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;
proxyCheck: ProxyTargetCheckResponse | null;
}
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 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<ProxyProbeResponse | null>((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<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 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 'Неизвестная ошибка.';
}