2717 lines
93 KiB
TypeScript
2717 lines
93 KiB
TypeScript
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">-></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 'Неизвестная ошибка.';
|
||
}
|