Refactor proxy routing and installer flows
This commit is contained in:
631
src/app/App.tsx
631
src/app/App.tsx
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, Trash2, Wand2 } from 'lucide-react';
|
||||
import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Trash2, Wand2 } from 'lucide-react';
|
||||
import {
|
||||
applyProfiles,
|
||||
fetchSingBoxSubscription,
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
type SingBoxSetupStatus,
|
||||
} from '../api/tauriCommands';
|
||||
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
|
||||
import { Button, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
|
||||
import { Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
|
||||
import { getApplyReadiness } from './readiness';
|
||||
import { serviceControlState } from './viewModel';
|
||||
|
||||
@@ -76,6 +76,48 @@ interface ConfigSnapshot {
|
||||
items: ConfigSnapshotItem[];
|
||||
}
|
||||
|
||||
interface ConnectionCheckView {
|
||||
tone: StatusTone;
|
||||
title: string;
|
||||
text: string;
|
||||
endpoint: string;
|
||||
details: string[];
|
||||
disabledReason?: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface RouteChainSegment {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
tone: StatusTone;
|
||||
details: string[];
|
||||
}
|
||||
|
||||
interface ConnectionCheckInput {
|
||||
routeMode: RouteMode;
|
||||
proxyInput: string;
|
||||
proxyPing: PingServerResponse | null;
|
||||
singbox: ComponentStatus | undefined;
|
||||
singBoxStatus: LocalSingBoxStatusResponse | null;
|
||||
selectedServer: SubscriptionServer | null;
|
||||
selectedServerPing: PingServerResponse | undefined;
|
||||
isDetectingComponents: boolean;
|
||||
isProxyChecking: boolean;
|
||||
serverPingTag: string | null;
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -123,8 +165,6 @@ export function App() {
|
||||
const [subscriptionInput, setSubscriptionInput] = useState('');
|
||||
const [serverPings, setServerPings] = useState<Record<string, PingServerResponse>>({});
|
||||
const [proxyPing, setProxyPing] = useState<PingServerResponse | null>(null);
|
||||
const [isSingBoxSetupOpen, setIsSingBoxSetupOpen] = useState(false);
|
||||
const [isSingBoxInfoOpen, setIsSingBoxInfoOpen] = useState(false);
|
||||
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
|
||||
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||
const [activeLogId, setActiveLogId] = useState<string | null>(null);
|
||||
@@ -157,6 +197,11 @@ export function App() {
|
||||
);
|
||||
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 selectedServerPing = selectedServerTag ? serverPings[selectedServerTag] : undefined;
|
||||
const currentSnapshot = useMemo(
|
||||
() => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag),
|
||||
[items, proxyInput, routeMode, selectedServerTag],
|
||||
@@ -820,6 +865,19 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function pingSelectedSingBoxServer() {
|
||||
if (!selectedServer) {
|
||||
showNotice({
|
||||
kind: 'error',
|
||||
title: 'Сервер не выбран',
|
||||
text: 'Выбери сервер Local sing-box перед проверкой.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await pingSingleSingBoxServer(selectedServer);
|
||||
}
|
||||
|
||||
async function generateSingBoxNow() {
|
||||
setSingBoxAction('generate');
|
||||
try {
|
||||
@@ -1173,10 +1231,9 @@ export function App() {
|
||||
externalProxyError,
|
||||
busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction),
|
||||
});
|
||||
const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents;
|
||||
const disabledReason = !readiness.ready && readiness.title && readiness.text
|
||||
? `${readiness.title}. ${readiness.text}`
|
||||
: undefined;
|
||||
const blockerAlreadyVisible = routeBlockerIsVisibleInProxyPanel(context, routeMode, readiness.title);
|
||||
const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents && !blockerAlreadyVisible;
|
||||
const showPending = readiness.ready && hasUnappliedChanges;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1185,7 +1242,7 @@ export function App() {
|
||||
<strong>{readiness.title}</strong>
|
||||
<span>{readiness.text}</span>
|
||||
</div>
|
||||
) : showState && hasUnappliedChanges ? (
|
||||
) : showState && showPending ? (
|
||||
<div className="apply-state pending" role="status">
|
||||
<strong>Изменения еще не применены в ProxiFyre</strong>
|
||||
<span>{applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))}</span>
|
||||
@@ -1201,7 +1258,6 @@ export function App() {
|
||||
disabled={!readiness.ready}
|
||||
loading={isApplying}
|
||||
loadingLabel={applyButtonLabel(context, true, hasUnappliedChanges, singBoxAction)}
|
||||
title={disabledReason}
|
||||
>
|
||||
{applyButtonLabel(context, isApplying, hasUnappliedChanges, singBoxAction)}
|
||||
</Button>
|
||||
@@ -1250,30 +1306,60 @@ export function App() {
|
||||
placeholder="socks5://127.0.0.1:1080"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{proxyValidation ? <span className="field-error">{proxyValidation}</span> : null}
|
||||
</label>
|
||||
<div className="proxy-check-line">
|
||||
<div className={`proxy-check-state ${proxyPing ? pingTone(proxyPing) : proxyValidation ? 'warning' : 'muted'}`}>
|
||||
<strong>{proxyPing ? pingResultTitle(proxyPing) : proxyValidation ? 'Проверь формат' : 'Проверка не запускалась'}</strong>
|
||||
<span>{proxyPing ? pingResultText(proxyPing) : proxyValidation ?? 'TCP check покажет, доступен ли host:port.'}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
size="lg"
|
||||
onClick={() => void pingExternalProxy()}
|
||||
disabled={Boolean(proxyValidation)}
|
||||
loading={isProxyChecking}
|
||||
loadingLabel="Проверяю"
|
||||
>
|
||||
Проверить
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderConnectionCheck(check: ConnectionCheckView) {
|
||||
const buttonDisabled = Boolean(check.disabledReason);
|
||||
|
||||
return (
|
||||
<div className="connection-check" aria-label="Проверка соединения">
|
||||
<div className="connection-check-main">
|
||||
<span>Проверка endpoint</span>
|
||||
<strong>{check.title}</strong>
|
||||
<p>{check.text}</p>
|
||||
</div>
|
||||
<DetailsPopover
|
||||
className="connection-endpoint"
|
||||
details={check.details}
|
||||
popoverLabel="Проверка endpoint"
|
||||
align="end"
|
||||
aria-label={`Endpoint: ${check.endpoint}. ${check.details.join('. ')}`}
|
||||
>
|
||||
<span>Endpoint</span>
|
||||
<strong>{check.endpoint}</strong>
|
||||
</DetailsPopover>
|
||||
<Button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
if (routeMode === 'external') {
|
||||
void pingExternalProxy();
|
||||
} else {
|
||||
void pingSelectedSingBoxServer();
|
||||
}
|
||||
}}
|
||||
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 ? 'Остановить' : 'Запустить',
|
||||
@@ -1297,7 +1383,7 @@ export function App() {
|
||||
state={state}
|
||||
visualState={singBoxAction ? 'working' : null}
|
||||
className="singbox-card"
|
||||
title={singBoxTitle(singbox, isDetectingComponents)}
|
||||
title="Local sing-box"
|
||||
detail={singBoxDetails(singbox, singBoxStatus, isDetectingComponents)}
|
||||
primaryAction={primaryAction}
|
||||
menu={singbox?.installed ? {
|
||||
@@ -1313,107 +1399,18 @@ export function App() {
|
||||
}],
|
||||
} : undefined}
|
||||
inlineActions={(
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
size="sm"
|
||||
className="setup-toggle"
|
||||
onClick={() => setIsSingBoxSetupOpen((current) => !current)}
|
||||
disabled={isDetectingComponents && !singBoxSetupStatus}
|
||||
aria-expanded={isSingBoxSetupOpen}
|
||||
>
|
||||
{singbox?.installed ? 'Состав Local sing-box' : 'Что будет установлено'}
|
||||
{singBoxSetupStatus ? (
|
||||
<span>{singBoxSetupStatus.ready ? 'все есть' : `не хватает: ${singBoxSetupStatus.missingCount}`}</span>
|
||||
) : null}
|
||||
</Button>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="neutral"
|
||||
className="info-toggle"
|
||||
onClick={() => setIsSingBoxInfoOpen((current) => !current)}
|
||||
label="Подробности Local sing-box"
|
||||
aria-expanded={isSingBoxInfoOpen}
|
||||
title="Подробности"
|
||||
icon={<Info size={16} strokeWidth={2} />}
|
||||
/>
|
||||
</>
|
||||
<DetailsPopover
|
||||
className="setup-summary"
|
||||
details={singBoxDetailLines(singbox, singBoxStatus, singBoxSetupStatus, selectedServerTag)}
|
||||
popoverLabel="Состав Local sing-box"
|
||||
aria-label="Подробности Local sing-box"
|
||||
>
|
||||
<span>Детали</span>
|
||||
<strong>{setupSummary}</strong>
|
||||
</DetailsPopover>
|
||||
)}
|
||||
>
|
||||
{isSingBoxInfoOpen ? (
|
||||
<div className="singbox-info-popover">
|
||||
<div className="singbox-info-grid">
|
||||
<span>Локально</span>
|
||||
<strong>{localSingBoxAddress(singBoxStatus)}</strong>
|
||||
<span>LAN</span>
|
||||
<strong>{lanSingBoxAddress(singBoxStatus) ?? 'недоступен'}</strong>
|
||||
<span>Сервер</span>
|
||||
<strong>
|
||||
{selectedServerTag
|
||||
? displayServerTag(selectedServerTag)
|
||||
: 'не выбран'}
|
||||
</strong>
|
||||
<span>Файл</span>
|
||||
<strong>{singbox?.path ?? 'не найден'}</strong>
|
||||
<span>Конфиг</span>
|
||||
<strong>{singBoxStatus?.generatedConfigPath ?? 'не создан'}</strong>
|
||||
</div>
|
||||
<div className="singbox-info-actions">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void pingSingBoxServers()}
|
||||
disabled={Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length}
|
||||
variant="neutral"
|
||||
size="sm"
|
||||
>
|
||||
<Gauge size={15} strokeWidth={1.9} />
|
||||
Ping все
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void generateSingBoxNow()}
|
||||
disabled={Boolean(singBoxAction) || !selectedServerTag}
|
||||
variant="neutral"
|
||||
size="sm"
|
||||
>
|
||||
<Wand2 size={15} strokeWidth={1.9} />
|
||||
Конфиг
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSingBoxSetupOpen ? (
|
||||
<div className="setup-details">
|
||||
{singBoxSetupStatus ? (
|
||||
singBoxSetupStatus.items.map((item) => (
|
||||
<div className={`setup-item ${item.installed ? 'installed' : 'missing'}`} key={item.id}>
|
||||
<span className="setup-state-dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{item.name}</strong>
|
||||
<span>{setupItemDetails(item.installed, item.version, item.details)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="setup-item">
|
||||
<span className="setup-state-dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Проверяю состав</strong>
|
||||
<span>Ищу sing-box, WinSW wrapper и службу.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSingBoxInstalled ? renderSingBoxWorkspace() : (
|
||||
<div className="singbox-install-note">
|
||||
<strong>Local sing-box не установлен</strong>
|
||||
<span>Установи компонент, чтобы подключить подписку, выбрать сервер и включить локальный маршрут.</span>
|
||||
</div>
|
||||
)}
|
||||
{isSingBoxInstalled ? renderSingBoxWorkspace() : null}
|
||||
</ServiceControlRow>
|
||||
);
|
||||
}
|
||||
@@ -1446,11 +1443,37 @@ export function App() {
|
||||
onClick={() => void forgetSingBoxSubscriptionData()}
|
||||
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription}
|
||||
label="Очистить подписку Local sing-box"
|
||||
title="Очистить"
|
||||
tooltip="Очистить"
|
||||
icon={<Trash2 size={18} strokeWidth={1.9} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="singbox-workspace-head">
|
||||
<span>Серверы подписки</span>
|
||||
<div className="singbox-workspace-actions">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void pingSingBoxServers()}
|
||||
disabled={Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length}
|
||||
variant="neutral"
|
||||
size="sm"
|
||||
leftIcon={<Gauge size={15} strokeWidth={1.9} />}
|
||||
>
|
||||
Ping все
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void generateSingBoxNow()}
|
||||
disabled={Boolean(singBoxAction) || !selectedServerTag}
|
||||
variant="neutral"
|
||||
size="sm"
|
||||
leftIcon={<Wand2 size={15} strokeWidth={1.9} />}
|
||||
>
|
||||
Конфиг
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{singBoxStatus?.cache?.servers.length ? (
|
||||
<div className="server-list">
|
||||
{singBoxStatus.cache.servers.map((server) => {
|
||||
@@ -1462,7 +1485,8 @@ export function App() {
|
||||
type="button"
|
||||
className="server-select-button"
|
||||
onClick={() => void chooseSingBoxServer(server)}
|
||||
title={serverTooltip(server, ping)}
|
||||
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>
|
||||
@@ -1474,7 +1498,7 @@ export function App() {
|
||||
disabled={Boolean(serverPingTag)}
|
||||
loading={serverPingTag === server.tag}
|
||||
label={`Проверить ${displayServerTag(server.tag)}`}
|
||||
title="Ping"
|
||||
tooltip="Ping"
|
||||
icon={<Gauge size={14} strokeWidth={1.9} />}
|
||||
/>
|
||||
</div>
|
||||
@@ -1488,6 +1512,34 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function renderProxyOverview() {
|
||||
const check = connectionCheckView({
|
||||
routeMode,
|
||||
proxyInput,
|
||||
proxyPing,
|
||||
singbox,
|
||||
singBoxStatus,
|
||||
selectedServer,
|
||||
selectedServerPing,
|
||||
isDetectingComponents,
|
||||
isProxyChecking,
|
||||
serverPingTag,
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={`proxy-overview ${check.tone}`} aria-labelledby="proxy-overview-title">
|
||||
<div className="proxy-overview-head">
|
||||
<div>
|
||||
<span>Настройки VPN / Прокси</span>
|
||||
<h2 id="proxy-overview-title">Маршрут и проверка соединения</h2>
|
||||
</div>
|
||||
<strong>{routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'}</strong>
|
||||
</div>
|
||||
{renderConnectionCheck(check)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function renderProxyPanel() {
|
||||
return (
|
||||
<section
|
||||
@@ -1496,13 +1548,7 @@ export function App() {
|
||||
id="panel-proxy"
|
||||
aria-labelledby="tab-proxy"
|
||||
>
|
||||
<div className="panel-section-head">
|
||||
<div>
|
||||
<span>Настройки VPN / Прокси</span>
|
||||
<h2>Маршрут и проверка соединения</h2>
|
||||
</div>
|
||||
<strong>{routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'}</strong>
|
||||
</div>
|
||||
{renderProxyOverview()}
|
||||
|
||||
<section className="route-panel" aria-label="Маршрут приложений">
|
||||
<div className="route-switch">
|
||||
@@ -1527,16 +1573,46 @@ export function App() {
|
||||
{routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()}
|
||||
</section>
|
||||
|
||||
<div className="route-preview">
|
||||
<span>Маршрут</span>
|
||||
<strong>{routePreviewText(routeMode, proxyInput, singBoxStatus)}</strong>
|
||||
</div>
|
||||
{renderRouteChain()}
|
||||
|
||||
{renderApplyActions('proxy')}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRouteChain() {
|
||||
const segments = routeChainSegments({
|
||||
routeMode,
|
||||
proxyInput,
|
||||
proxyfier,
|
||||
singbox,
|
||||
singBoxStatus,
|
||||
selectedServer,
|
||||
appCount: items.length,
|
||||
isDetectingComponents,
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="route-chain" aria-label="Текущий маршрут">
|
||||
{segments.map((segment, index) => (
|
||||
<DetailsPopover
|
||||
className={`route-chain-segment ${segment.tone}`}
|
||||
details={segment.details}
|
||||
popoverLabel={segment.label}
|
||||
align={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">-></span> : null}
|
||||
</DetailsPopover>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function renderActivePanel() {
|
||||
if (activePanel === 'proxifyre') return renderProxiFyrePanel();
|
||||
if (activePanel === 'proxy') return renderProxyPanel();
|
||||
@@ -1772,6 +1848,227 @@ function routeEndpointLabel(
|
||||
}
|
||||
}
|
||||
|
||||
function connectionCheckView(input: ConnectionCheckInput): ConnectionCheckView {
|
||||
if (input.routeMode === 'external') {
|
||||
const proxyValidation = input.proxyInput.trim() ? safeProxyError(input.proxyInput) : 'Введи адрес SOCKS5 прокси.';
|
||||
const endpoint = routeEndpointLabel('external', input.proxyInput, input.singBoxStatus);
|
||||
const details = [
|
||||
'Режим: внешний SOCKS5',
|
||||
`Endpoint: ${endpoint}`,
|
||||
'Проверка: TCP connect до proxy host:port.',
|
||||
'Local sing-box для внешнего маршрута не требуется.',
|
||||
];
|
||||
|
||||
if (proxyValidation) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
title: 'Endpoint не готов',
|
||||
text: proxyValidation,
|
||||
endpoint,
|
||||
details,
|
||||
disabledReason: proxyValidation,
|
||||
loading: input.isProxyChecking,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.proxyPing) {
|
||||
return {
|
||||
tone: pingTone(input.proxyPing),
|
||||
title: input.proxyPing.ok ? 'Endpoint отвечает' : 'Endpoint не отвечает',
|
||||
text: pingResultText(input.proxyPing),
|
||||
endpoint,
|
||||
details,
|
||||
loading: input.isProxyChecking,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: 'muted',
|
||||
title: 'Проверка не запускалась',
|
||||
text: 'Проверит доступность SOCKS5 endpoint без изменения маршрута.',
|
||||
endpoint,
|
||||
details,
|
||||
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 = [
|
||||
'Режим: локальный sing-box',
|
||||
`Local listener: ${localAddress}`,
|
||||
`LAN: ${lanSingBoxAddress(input.singBoxStatus) ?? 'недоступен'}`,
|
||||
`Сервер: ${selectedEndpoint}`,
|
||||
'Проверка: TCP connect до выбранного сервера подписки.',
|
||||
];
|
||||
|
||||
if (input.isDetectingComponents) {
|
||||
return {
|
||||
tone: 'checking',
|
||||
title: 'Проверяю компоненты',
|
||||
text: 'Обновляю состояние Local sing-box перед проверкой.',
|
||||
endpoint: localAddress,
|
||||
details,
|
||||
disabledReason: 'Дождись завершения проверки компонентов.',
|
||||
loading: Boolean(input.serverPingTag),
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.singbox?.installed) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
title: 'Локальный runtime не готов',
|
||||
text: 'Установи Local sing-box, затем загрузи подписку и выбери сервер.',
|
||||
endpoint: localAddress,
|
||||
details,
|
||||
disabledReason: 'Local sing-box не установлен.',
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.singbox.running) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
title: 'Служба остановлена',
|
||||
text: 'Запусти Local sing-box перед проверкой локального маршрута.',
|
||||
endpoint: localAddress,
|
||||
details,
|
||||
disabledReason: 'Local sing-box остановлен.',
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.selectedServer) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
title: 'Сервер не выбран',
|
||||
text: 'Выбери сервер подписки для проверки.',
|
||||
endpoint: selectedEndpoint,
|
||||
details,
|
||||
disabledReason: 'Сервер Local sing-box не выбран.',
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.selectedServerPing) {
|
||||
return {
|
||||
tone: pingTone(input.selectedServerPing),
|
||||
title: input.selectedServerPing.ok ? 'Сервер отвечает' : 'Сервер не отвечает',
|
||||
text: pingResultText(input.selectedServerPing),
|
||||
endpoint: selectedEndpoint,
|
||||
details,
|
||||
loading: input.serverPingTag === input.selectedServer.tag,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: 'muted',
|
||||
title: 'Проверка не запускалась',
|
||||
text: 'Проверит выбранный сервер подписки без применения маршрута.',
|
||||
endpoint: selectedEndpoint,
|
||||
details,
|
||||
loading: input.serverPingTag === input.selectedServer.tag,
|
||||
};
|
||||
}
|
||||
|
||||
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 routeBlockerIsVisibleInProxyPanel(
|
||||
context: 'proxifyre' | 'proxy',
|
||||
routeMode: RouteMode,
|
||||
title: string | undefined,
|
||||
) {
|
||||
if (context !== 'proxy' || !title) return false;
|
||||
|
||||
if (routeMode === 'external') {
|
||||
return title === 'Прокси не указан' || title === 'Проверь формат прокси';
|
||||
}
|
||||
|
||||
return title === 'Local sing-box не установлен' || title === 'Сервер не выбран';
|
||||
}
|
||||
|
||||
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} приложения маршрутизируются через профиль.`;
|
||||
@@ -1800,21 +2097,6 @@ function pingResultText(ping: PingServerResponse) {
|
||||
return `${ping.server}:${ping.serverPort}: ${ping.error ?? 'нет ответа'}`;
|
||||
}
|
||||
|
||||
function routePreviewText(
|
||||
routeMode: RouteMode,
|
||||
proxyInput: string,
|
||||
status: LocalSingBoxStatusResponse | null,
|
||||
) {
|
||||
if (routeMode === 'local-singbox') {
|
||||
const server = status?.config.selectedServerTag
|
||||
? displayServerTag(status.config.selectedServerTag)
|
||||
: 'сервер не выбран';
|
||||
return `Выбранные приложения -> ProxiFyre -> Local sing-box ${localSingBoxAddress(status)} -> ${server}`;
|
||||
}
|
||||
|
||||
return `Выбранные приложения -> ProxiFyre -> внешний прокси ${routeEndpointLabel('external', proxyInput, status)}`;
|
||||
}
|
||||
|
||||
function applyButtonLabel(
|
||||
context: 'proxifyre' | 'proxy',
|
||||
isApplying: boolean,
|
||||
@@ -1961,12 +2243,6 @@ function itemIcon(type: DraftItemType) {
|
||||
return <FileCode2 size={18} strokeWidth={1.9} />;
|
||||
}
|
||||
|
||||
function setupItemDetails(installed: boolean, version: string | undefined, details: string) {
|
||||
if (!installed) return `Нужно установить. ${details}`;
|
||||
if (version) return `${version}. ${details}`;
|
||||
return details;
|
||||
}
|
||||
|
||||
function proxifyreSetupPlaceholders(): ProxiFyreSetupStatus['items'] {
|
||||
return [
|
||||
{ id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' },
|
||||
@@ -2019,14 +2295,6 @@ function proxyfierDetails(component: ComponentStatus | undefined, checking: bool
|
||||
return component.problems[0] ?? 'Путь установки не найден.';
|
||||
}
|
||||
|
||||
function singBoxTitle(component: ComponentStatus | undefined, checking: boolean) {
|
||||
if (checking) return 'Проверяю Local sing-box';
|
||||
if (!component) return 'Local sing-box не проверен';
|
||||
if (component.running) return 'Local sing-box найден и запущен';
|
||||
if (component.installed) return 'Local sing-box найден';
|
||||
return 'Local sing-box не установлен';
|
||||
}
|
||||
|
||||
function singBoxDetails(
|
||||
component: ComponentStatus | undefined,
|
||||
status: LocalSingBoxStatusResponse | null,
|
||||
@@ -2043,7 +2311,30 @@ function singBoxDetails(
|
||||
if (status?.config.hasSubscription) {
|
||||
return status.config.subscriptionDisplayUrl ?? 'Подписка сохранена.';
|
||||
}
|
||||
return component?.problems[0] ?? 'Установи компонент, чтобы подключить подписку и выбрать сервер.';
|
||||
return component?.problems[0] ?? 'Установи компонент для подписки и локального маршрута.';
|
||||
}
|
||||
|
||||
function singBoxDetailLines(
|
||||
component: ComponentStatus | undefined,
|
||||
status: LocalSingBoxStatusResponse | null,
|
||||
setupStatus: SingBoxSetupStatus | null,
|
||||
selectedServerTag: string | undefined,
|
||||
) {
|
||||
const setupDetails = setupStatus
|
||||
? setupStatus.items
|
||||
.map((item) => `${item.name}: ${setupItemShortStatus(item)}`)
|
||||
.join('; ')
|
||||
: 'состав не проверен';
|
||||
|
||||
return [
|
||||
`Локально: ${localSingBoxAddress(status)}`,
|
||||
`LAN: ${lanSingBoxAddress(status) ?? 'недоступен'}`,
|
||||
`Сервер: ${selectedServerTag ? displayServerTag(selectedServerTag) : 'не выбран'}`,
|
||||
`Файл: ${component?.path ?? 'не найден'}`,
|
||||
`Конфиг: ${status?.generatedConfigPath ?? 'не создан'}`,
|
||||
`Подписка: ${status?.config.subscriptionDisplayUrl ?? (status?.config.hasSubscription ? 'сохранена' : 'не загружена')}`,
|
||||
`Состав: ${setupDetails}`,
|
||||
];
|
||||
}
|
||||
|
||||
function componentDetails(component: ComponentStatus | undefined, checking: boolean) {
|
||||
|
||||
Reference in New Issue
Block a user