Refactor proxy routing and session management

This commit is contained in:
2026-07-08 00:09:38 +03:00
parent c5bdb10445
commit b45dd2ae05
26 changed files with 5193 additions and 307 deletions

View File

@@ -1,25 +1,42 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { Cpu, FileCode2, FolderOpen, MoreHorizontal } from 'lucide-react';
import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, MoreHorizontal, Trash2, Wand2 } from 'lucide-react';
import {
applyProfiles,
fetchSingBoxSubscription,
forgetSingBoxSubscription,
generateSingBoxConfig,
getComponents,
getProxiFyreSetupStatus,
getSavedState,
getSingBoxSetupStatus,
getSingBoxStatus,
installProxiFyre,
installSingBox,
openConfigLocation,
pingAllSingBoxServers,
saveProfile,
saveSingBoxSubscription,
saveTarget,
selectSingBoxServer,
startProxiFyreService,
startSingBoxService,
stopProxiFyreService,
stopSingBoxService,
uninstallProxiFyre,
uninstallSingBox,
type ApplyProfilesResponse,
type LocalSingBoxStatusResponse,
type PingServerResponse,
type ProxiFyreSetupStatus,
type SingBoxSetupStatus,
} from '../api/tauriCommands';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, Target } from '../domain/types';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
type ProxiFyreAction = 'start' | 'stop' | 'install' | 'uninstall';
type SingBoxAction = 'start' | 'stop' | 'install' | 'uninstall' | 'fetch' | 'forget' | 'generate' | 'ping';
type RouteMode = 'external' | 'local-singbox';
type ServiceVisualState = 'active' | 'settling' | null;
interface DraftItem {
@@ -41,6 +58,7 @@ interface LogEntry extends Notice {
const MAIN_TARGET_ID = 'main-proxy';
const MAIN_PROFILE_ID = 'main-profile';
const LOCAL_SINGBOX_TARGET_ID = 'local-singbox';
const LOG_VISIBLE_MS = 6500;
const fallbackComponents: ComponentStatus[] = [
@@ -53,20 +71,37 @@ const fallbackComponents: ComponentStatus[] = [
problems: ['ProxiFyre не найден'],
actions: [],
},
{
id: 'singbox',
name: 'Локальный sing-box',
state: 'missing',
installed: false,
running: false,
problems: [],
actions: [],
},
];
export function App() {
const [proxyInput, setProxyInput] = useState('');
const [routeMode, setRouteMode] = useState<RouteMode>('external');
const [profileId, setProfileId] = useState(MAIN_PROFILE_ID);
const [targetId, setTargetId] = useState(MAIN_TARGET_ID);
const [items, setItems] = useState<DraftItem[]>([]);
const [hasUnappliedChanges, setHasUnappliedChanges] = useState(false);
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 [isSetupOpen, setIsSetupOpen] = useState(false);
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);
@@ -76,7 +111,9 @@ export function App() {
const [isApplying, setIsApplying] = useState(false);
const [isOpeningConfig, setIsOpeningConfig] = useState(false);
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);
@@ -84,6 +121,10 @@ export function App() {
() => 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],
@@ -91,6 +132,9 @@ export function App() {
const finderStateClass = isDetectingComponents ? 'checking' : proxyfier?.installed ? 'found' : 'missing';
const finderVisualClass =
serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : '';
const isSingBoxInstalled = Boolean(singbox?.installed);
const singBoxStateClass = isDetectingComponents ? 'checking' : singbox?.installed ? 'found' : 'missing';
const singBoxVisualClass = singBoxAction ? 'working' : '';
useEffect(() => {
void refresh();
@@ -135,16 +179,20 @@ export function App() {
async function refreshComponents() {
setIsDetectingComponents(true);
try {
const [detectedComponents, detectedSetupStatus] = await Promise.all([
const [detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([
getComponents(),
getProxiFyreSetupStatus(),
getSingBoxStatus(),
getSingBoxSetupStatus(),
]);
setComponents(detectedComponents);
setSetupStatus(detectedSetupStatus);
setSingBoxStatus(detectedSingBoxStatus);
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
} catch (error) {
showNotice({
kind: 'error',
title: 'ProxiFyre не проверен',
title: 'Компоненты не проверены',
text: errorMessage(error),
});
} finally {
@@ -157,14 +205,21 @@ export function App() {
const mainProfile = profiles.find((profile) => profile.id === MAIN_PROFILE_ID);
const activeProfile = mainProfile ?? activeProfiles[0];
const activeTarget = targetForUi(targets, activeProfile);
const externalTarget = targetForExternalProxy(targets);
const editableProfiles = mainProfile ? [mainProfile] : activeProfiles;
if (activeTarget) setProxyInput(formatProxy(activeTarget));
if (externalTarget) setProxyInput(formatProxy(externalTarget));
setItems(itemsForProfiles(editableProfiles));
setLoadedProfiles(profiles);
setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID);
setTargetId(activeTarget?.id ?? activeProfile?.targetId ?? MAIN_TARGET_ID);
setTargetId(externalTarget?.id ?? MAIN_TARGET_ID);
setRouteMode(
activeTarget?.id === LOCAL_SINGBOX_TARGET_ID || activeProfile?.targetId === LOCAL_SINGBOX_TARGET_ID
? 'local-singbox'
: 'external',
);
setGeneratedConfigPath(generatedPath);
setHasUnappliedChanges(false);
}
function addItem(type: DraftItemType, rawValue: string) {
@@ -195,6 +250,7 @@ export function App() {
value,
},
]);
setHasUnappliedChanges(true);
return true;
}
@@ -207,6 +263,19 @@ export function App() {
function removeItem(id: string) {
setItems((current) => current.filter((item) => item.id !== id));
setHasUnappliedChanges(true);
}
function changeRouteMode(nextMode: RouteMode) {
setRouteMode((current) => {
if (current !== nextMode) setHasUnappliedChanges(true);
return nextMode;
});
}
function changeProxyInput(nextValue: string) {
setProxyInput(nextValue);
setHasUnappliedChanges(true);
}
async function pickAndAddItem(type: Extract<DraftItemType, 'exe' | 'folder'>) {
@@ -228,10 +297,16 @@ export function App() {
}
async function updateConfig() {
let parsedProxy: ParsedProxy;
let parsedProxy: ParsedProxy | null = null;
try {
parsedProxy = parseProxy(proxyInput);
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',
@@ -243,19 +318,28 @@ export function App() {
setIsApplying(true);
try {
await saveTarget({
id: targetId,
name: 'Основной прокси',
kind: 'external',
protocol: parsedProxy.protocol,
host: parsedProxy.host,
port: parsedProxy.port,
});
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,
targetId: routeMode === 'local-singbox' ? LOCAL_SINGBOX_TARGET_ID : targetId,
protocols: ['TCP', 'UDP'],
items: items.map(profileItemInput),
});
@@ -266,16 +350,21 @@ export function App() {
);
const result = await applyProfiles();
const [saved, detectedComponents, detectedSetupStatus] = await Promise.all([
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);
showNotice(noticeFromApply(result));
setSingBoxStatus(detectedSingBoxStatus);
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
setHasUnappliedChanges(false);
showNotice(routeMode === 'local-singbox' ? noticeFromLocalApply(result, singBoxGeneratedPath) : noticeFromApply(result));
} catch (error) {
showNotice({
kind: 'error',
@@ -287,6 +376,30 @@ export function App() {
}
}
async function ensureSingBoxRunningForApply() {
if (routeMode !== 'local-singbox' || !singbox?.installed) return;
setIsSingBoxMenuOpen(false);
try {
await nextFrame();
if (singbox.running) {
setSingBoxAction('stop');
const stopped = await stopSingBoxService();
setComponents((current) => upsertComponent(current, stopped));
}
setSingBoxAction('start');
const component = await startSingBoxService();
setComponents((current) => upsertComponent(current, component));
const status = await refreshSingBoxState();
if (!status.component.running) {
throw new Error('Local sing-box установлен, но служба не запустилась.');
}
} finally {
setSingBoxAction(null);
}
}
async function openConfig() {
setIsOpeningConfig(true);
try {
@@ -395,6 +508,216 @@ export function App() {
}
}
async function refreshSingBoxState() {
const [detectedSingBoxStatus, detectedSingBoxSetupStatus, detectedComponents] = await Promise.all([
getSingBoxStatus(),
getSingBoxSetupStatus(),
getComponents(),
]);
setSingBoxStatus(detectedSingBoxStatus);
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
setComponents(detectedComponents);
return detectedSingBoxStatus;
}
async function setSingBoxServiceRunning(shouldRun: boolean) {
const action: SingBoxAction = shouldRun ? 'start' : 'stop';
setSingBoxAction(action);
setIsSingBoxMenuOpen(false);
try {
await nextFrame();
const component = shouldRun ? await startSingBoxService() : await stopSingBoxService();
setComponents((current) => upsertComponent(current, component));
await refreshSingBoxState();
showNotice({
kind: 'success',
title: shouldRun ? 'sing-box запущен' : 'sing-box остановлен',
text: componentDetails(component, false),
});
} catch (error) {
showNotice({
kind: 'error',
title: shouldRun ? 'sing-box не запущен' : 'sing-box не остановлен',
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function installSingBoxPackage() {
setSingBoxAction('install');
setIsSingBoxMenuOpen(false);
try {
await nextFrame();
const component = await installSingBox();
setComponents((current) => upsertComponent(current, component));
await refreshSingBoxState();
showNotice({
kind: 'success',
title: 'Local sing-box установлен',
text: componentDetails(component, false),
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Local sing-box не установлен',
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function uninstallSingBoxPackage() {
const confirmed = window.confirm(
'Удалить Local sing-box с компьютера? Будет удалена служба и папка установки sing-box.',
);
if (!confirmed) return;
setSingBoxAction('uninstall');
setIsSingBoxMenuOpen(false);
try {
await nextFrame();
const component = await uninstallSingBox();
setComponents((current) => upsertComponent(current, component));
await refreshSingBoxState();
showNotice({
kind: 'success',
title: 'Local sing-box удален',
text: 'Служба и папка установки Local sing-box удалены.',
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Local sing-box не удален',
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function syncSingBoxSubscription() {
const subscriptionUrl = subscriptionInput.trim();
if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) {
showNotice({
kind: 'error',
title: 'Ссылка не указана',
text: 'Вставь ссылку подписки Local sing-box.',
});
return;
}
setSingBoxAction('fetch');
try {
if (subscriptionUrl) {
await saveSingBoxSubscription(subscriptionUrl);
}
const status = await fetchSingBoxSubscription();
setSingBoxStatus(status);
setComponents((current) => upsertComponent(current, status.component));
setSubscriptionInput('');
setServerPings({});
setHasUnappliedChanges(true);
showNotice({
kind: 'success',
title: 'Подписка обновлена',
text: `Серверов: ${status.cache?.servers.length ?? 0}`,
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Подписка не обновлена',
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function forgetSingBoxSubscriptionData() {
setSingBoxAction('forget');
setIsSingBoxMenuOpen(false);
try {
const status = await forgetSingBoxSubscription();
setSingBoxStatus(status);
setComponents((current) => upsertComponent(current, status.component));
setServerPings({});
setHasUnappliedChanges(true);
showNotice({
kind: 'info',
title: 'Подписка очищена',
text: 'Ссылка, cache и выбранный сервер Local sing-box удалены.',
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Подписка не очищена',
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function chooseSingBoxServer(server: SubscriptionServer) {
try {
const status = await selectSingBoxServer(server);
setSingBoxStatus(status);
setComponents((current) => upsertComponent(current, status.component));
setHasUnappliedChanges(true);
} catch (error) {
showNotice({
kind: 'error',
title: 'Сервер не выбран',
text: errorMessage(error),
});
}
}
async function pingSingBoxServers() {
setSingBoxAction('ping');
try {
const results = await pingAllSingBoxServers();
setServerPings(Object.fromEntries(results.map((result) => [result.tag, result])));
showNotice({
kind: 'info',
title: 'Ping завершен',
text: pingSummary(results),
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Ping не выполнен',
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function generateSingBoxNow() {
setSingBoxAction('generate');
try {
const result = await generateSingBoxConfig();
await refreshSingBoxState();
showNotice({
kind: 'success',
title: 'Конфиг sing-box создан',
text: result.generatedConfigPath,
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Конфиг sing-box не создан',
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
function startServiceVisual() {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
@@ -541,15 +864,245 @@ export function App() {
) : null}
</div>
<label className="simple-field">
<span>Прокси</span>
<input
value={proxyInput}
onChange={(event) => setProxyInput(event.target.value)}
placeholder="socks5://127.0.0.1:1080"
spellCheck={false}
/>
</label>
<section className="route-panel" aria-label="Маршрут приложений">
<div className="route-switch">
<button
type="button"
className={routeMode === 'external' ? 'active' : ''}
onClick={() => changeRouteMode('external')}
aria-pressed={routeMode === 'external'}
>
Внешний прокси
</button>
<button
type="button"
className={routeMode === 'local-singbox' ? 'active' : ''}
onClick={() => changeRouteMode('local-singbox')}
aria-pressed={routeMode === 'local-singbox'}
>
Local sing-box
</button>
</div>
{routeMode === 'external' ? (
<label className="simple-field route-proxy-field">
<span className="sr-only">Внешний прокси</span>
<input
value={proxyInput}
onChange={(event) => changeProxyInput(event.target.value)}
placeholder="socks5://127.0.0.1:1080"
spellCheck={false}
/>
</label>
) : null}
</section>
{routeMode === 'local-singbox' ? (
<div className={`finder-card singbox-card ${singBoxStateClass} ${singBoxVisualClass}`.trim()}>
<span className="finder-border-glow" aria-hidden="true">
<span className="finder-border-glow-segment top" />
<span className="finder-border-glow-segment right" />
<span className="finder-border-glow-segment bottom" />
<span className="finder-border-glow-segment left" />
</span>
<span className="status-light" />
<div className="finder-text">
<strong>{singBoxTitle(singbox, isDetectingComponents)}</strong>
<span>{singBoxDetails(singbox, singBoxStatus, isDetectingComponents)}</span>
<div className="singbox-inline-actions">
<button
type="button"
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>
<button
type="button"
className="info-toggle"
onClick={() => setIsSingBoxInfoOpen((current) => !current)}
aria-label="Подробности Local sing-box"
aria-expanded={isSingBoxInfoOpen}
title="Подробности"
>
<Info size={16} strokeWidth={2} />
</button>
</div>
</div>
<div className="service-actions" aria-label="Управление службой Local sing-box">
{singbox?.installed ? (
<>
<button
type="button"
className={`service-button ${singbox.running ? 'stop' : ''}`.trim()}
onClick={() => void setSingBoxServiceRunning(!singbox.running)}
disabled={isDetectingComponents || Boolean(singBoxAction)}
>
{singBoxAction === 'start' || singBoxAction === 'stop'
? '...'
: singbox.running
? 'Остановить'
: 'Запустить'}
</button>
<div className="service-menu">
<button
type="button"
className="service-menu-button"
onClick={() => setIsSingBoxMenuOpen((current) => !current)}
disabled={isDetectingComponents || Boolean(singBoxAction)}
aria-label="Дополнительные действия Local sing-box"
aria-expanded={isSingBoxMenuOpen}
title="Еще"
>
<MoreHorizontal size={20} strokeWidth={2} />
</button>
{isSingBoxMenuOpen ? (
<div className="service-menu-popover">
<button type="button" onClick={() => void uninstallSingBoxPackage()}>
{singBoxAction === 'uninstall' ? 'Удаляю...' : 'Удалить Local sing-box'}
</button>
</div>
) : null}
</div>
</>
) : (
<button
type="button"
className="service-button install"
onClick={() => void installSingBoxPackage()}
disabled={isDetectingComponents || Boolean(singBoxAction)}
>
{singBoxAction === 'install' ? '...' : 'Установить'}
</button>
)}
</div>
{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>
{singBoxStatus?.config.selectedServerTag
? displayServerTag(singBoxStatus.config.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}
>
<Gauge size={15} strokeWidth={1.9} />
Ping
</button>
<button
type="button"
onClick={() => void generateSingBoxNow()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.selectedServerTag}
>
<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 ? (
<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()}
disabled={singBoxAction === 'fetch'}
>
{singBoxAction === 'fetch' ? '...' : subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'}
</button>
<button
type="button"
className="icon-command"
onClick={() => void forgetSingBoxSubscriptionData()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription}
aria-label="Очистить подписку Local sing-box"
title="Очистить"
>
<Trash2 size={18} strokeWidth={1.9} />
</button>
</div>
{singBoxStatus?.cache?.servers.length ? (
<div className="server-list">
{singBoxStatus.cache.servers.map((server) => (
<button
type="button"
className={`server-row ${server.tag === singBoxStatus.config.selectedServerTag ? 'selected' : ''}`.trim()}
key={server.tag}
onClick={() => void chooseSingBoxServer(server)}
title={serverTooltip(server, serverPings[server.tag])}
>
<span className="server-select-dot" aria-hidden="true" />
<strong>{displayServerTag(server.tag)}</strong>
</button>
))}
</div>
) : (
<div className="empty-state">Подписка Local sing-box еще не загружена.</div>
)}
</div>
) : (
<div className="singbox-install-note">
<strong>Local sing-box не установлен</strong>
<span>Установи компонент, чтобы подключить подписку, выбрать сервер и включить локальный маршрут.</span>
</div>
)}
</div>
) : null}
<section className="apps-section">
<div className="section-head">
@@ -653,9 +1206,24 @@ export function App() {
</div>
</section>
{hasUnappliedChanges ? (
<div className="apply-state pending" role="status">
<strong>Изменения еще не применены в ProxiFyre</strong>
<span>{applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))}</span>
</div>
) : null}
<div className="command-row">
<button type="button" className="apply-button" onClick={updateConfig} disabled={isApplying}>
{isApplying ? 'Обновляю...' : 'Обновить конфиг'}
{isApplying
? singBoxAction === 'start'
? 'Запускаю sing-box...'
: singBoxAction === 'stop'
? 'Перезапускаю sing-box...'
: 'Применяю...'
: hasUnappliedChanges
? 'Применить в ProxiFyre'
: 'Обновить конфиг'}
</button>
<button
type="button"
@@ -745,6 +1313,11 @@ function targetForUi(targets: Target[], profile: Profile | undefined) {
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[] = [];
@@ -873,6 +1446,40 @@ 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,
checking: boolean,
) {
if (checking) return 'Ищу sing-box, wrapper и службу.';
if (component?.running && status) {
const lanAddress = lanSingBoxAddress(status);
return lanAddress
? `Доступен локально: ${localSingBoxAddress(status)} · LAN: ${lanAddress}`
: `Доступен локально: ${localSingBoxAddress(status)}`;
}
if (component?.installed) return 'Служба остановлена. Запусти Local sing-box перед применением маршрута.';
if (status?.config.hasSubscription) {
return status.config.subscriptionDisplayUrl ?? 'Подписка сохранена.';
}
return component?.problems[0] ?? 'Установи компонент, чтобы подключить подписку и выбрать сервер.';
}
function componentDetails(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Проверяю состояние службы.';
if (!component) return 'Компонент не проверен.';
if (component.path) return component.path;
return component.problems[0] ?? 'Путь установки не найден.';
}
function noticeFromApply(result: ApplyProfilesResponse): Notice {
return {
kind: result.success ? 'success' : 'error',
@@ -881,6 +1488,24 @@ function noticeFromApply(result: ApplyProfilesResponse): Notice {
};
}
function noticeFromLocalApply(result: ApplyProfilesResponse, singBoxGeneratedPath: string): Notice {
return {
kind: result.success ? 'success' : 'error',
title: result.success ? 'Local sing-box применен' : 'Конфиг создан, но не применен',
text: singBoxGeneratedPath ? `${result.message} sing-box: ${singBoxGeneratedPath}` : result.message,
};
}
function applyStateText(routeMode: RouteMode, isSingBoxInstalled: boolean, isSingBoxRunning: boolean) {
if (routeMode === 'local-singbox' && isSingBoxInstalled && !isSingBoxRunning) {
return 'Local sing-box сейчас остановлен. При применении клиент сначала запустит службу, затем обновит ProxiFyre.';
}
if (routeMode === 'local-singbox' && isSingBoxInstalled && isSingBoxRunning) {
return 'При применении клиент обновит конфиг, перезапустит Local sing-box и затем обновит ProxiFyre.';
}
return 'Нажми «Применить в ProxiFyre». Если служба уже запущена и маршрут не обновился, перезапусти ProxiFyre.';
}
function upsertComponent(components: ComponentStatus[], component: ComponentStatus) {
const index = components.findIndex((current) => current.id === component.id);
if (index === -1) return [...components, component];
@@ -904,6 +1529,61 @@ function formatLogTime(timestamp: number) {
});
}
function serverLabel(server: SubscriptionServer) {
return `${server.type} · ${server.server}:${server.serverPort}`;
}
function serverTooltip(server: SubscriptionServer, ping: PingServerResponse | undefined) {
const details = serverLabel(server);
if (!ping) return details;
return ping.ok ? `${details} · ping ${ping.latency ?? 0} ms` : `${details} · ping fail`;
}
function displayServerTag(tag: string) {
const withoutFlags = tag
.replace(/[\u{1f1e6}-\u{1f1ff}]/gu, '')
.replace(/\s*->\s*/g, ' -> ')
.replace(/\s*->\s*$/g, '')
.replace(/^\s*->\s*/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
return withoutFlags || tag;
}
function pingSummary(results: PingServerResponse[]) {
if (!results.length) return 'Серверов для проверки нет.';
const ok = results.filter((result) => result.ok);
if (!ok.length) return `Не ответил ни один сервер из ${results.length}.`;
const best = ok.reduce((current, result) => {
if ((result.latency ?? Number.MAX_SAFE_INTEGER) < (current.latency ?? Number.MAX_SAFE_INTEGER)) return result;
return current;
});
return `Ответили ${ok.length}/${results.length}; быстрее ${best.tag}: ${best.latency ?? 0} ms.`;
}
function localSingBoxAddress(status: LocalSingBoxStatusResponse | null) {
if (!status) return 'не загружен';
const host = status.config.listenHost.trim();
const displayHost = host === '0.0.0.0' || host === '::' || !host ? '127.0.0.1' : host;
return formatHostPort(displayHost, status.config.listenPort);
}
function lanSingBoxAddress(status: LocalSingBoxStatusResponse | null) {
if (!status) return null;
const host = status.config.listenHost.trim().toLowerCase();
if (host === '127.0.0.1' || host === 'localhost' || host === '::1') return null;
const lanHost = host === '0.0.0.0' || host === '::' || !host
? status.lanListenHost
: status.config.listenHost;
if (!lanHost) return null;
return formatHostPort(lanHost, status.config.listenPort);
}
function formatHostPort(host: string, port: number) {
return host.includes(':') && !host.startsWith('[') ? `[${host}]:${port}` : `${host}:${port}`;
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;