Files
harbor-net/apps/windows-client/src/app/App.tsx

1595 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, 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, 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 {
id: string;
type: DraftItemType;
value: string;
}
interface Notice {
kind: 'success' | 'error' | 'info';
title: string;
text: string;
}
interface LogEntry extends Notice {
id: string;
at: number;
}
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[] = [
{
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 [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);
const [isLogOpen, setIsLogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isDetectingComponents, setIsDetectingComponents] = useState(true);
const [isApplying, setIsApplying] = useState(false);
const [isOpeningConfig, setIsOpeningConfig] = useState(false);
const [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 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();
}, []);
useEffect(() => {
return () => {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!activeLogId) return undefined;
const timer = window.setTimeout(() => {
setActiveLogId((current) => (current === activeLogId ? null : current));
}, LOG_VISIBLE_MS);
return () => window.clearTimeout(timer);
}, [activeLogId]);
async function refresh() {
setIsLoading(true);
try {
const saved = await getSavedState();
applySavedState(saved.profiles, saved.targets, saved.generatedConfigPath);
} catch {
showNotice({
kind: 'info',
title: 'Режим предпросмотра',
text: 'Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.',
});
} finally {
setIsLoading(false);
}
void refreshComponents();
}
async function refreshComponents() {
setIsDetectingComponents(true);
try {
const [detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([
getComponents(),
getProxiFyreSetupStatus(),
getSingBoxStatus(),
getSingBoxSetupStatus(),
]);
setComponents(detectedComponents);
setSetupStatus(detectedSetupStatus);
setSingBoxStatus(detectedSingBoxStatus);
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
} catch (error) {
showNotice({
kind: 'error',
title: 'Компоненты не проверены',
text: errorMessage(error),
});
} finally {
setIsDetectingComponents(false);
}
}
function applySavedState(profiles: Profile[], targets: Target[], generatedPath: string) {
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;
if (externalTarget) setProxyInput(formatProxy(externalTarget));
setItems(itemsForProfiles(editableProfiles));
setLoadedProfiles(profiles);
setProfileId(mainProfile?.id ?? MAIN_PROFILE_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) {
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,
},
]);
setHasUnappliedChanges(true);
return true;
}
function addProcess() {
if (addItem('process', processInput)) {
setProcessInput('');
setIsProcessInputOpen(false);
}
}
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'>) {
setPickerAction(type);
try {
const selectedPath = await pickPath(type);
if (selectedPath) {
addItem(type, selectedPath);
}
} catch (error) {
showNotice({
kind: 'error',
title: type === 'exe' ? 'EXE не выбран' : 'Папка не выбрана',
text: errorMessage(error),
});
} finally {
setPickerAction(null);
}
}
async function updateConfig() {
let parsedProxy: ParsedProxy | null = null;
try {
if (!items.length) throw new Error('Добавь хотя бы один процесс, EXE-файл или папку.');
if (routeMode === 'external') {
parsedProxy = parseProxy(proxyInput);
} else if (!isSingBoxInstalled) {
throw new Error('Сначала установи Local sing-box.');
} else if (!singBoxStatus?.config.selectedServerTag) {
throw new Error('Выбери сервер Local sing-box.');
}
} catch (error) {
showNotice({
kind: 'error',
title: 'Проверь данные',
text: errorMessage(error),
});
return;
}
setIsApplying(true);
try {
let singBoxGeneratedPath = '';
if (routeMode === 'external') {
if (!parsedProxy) throw new Error('Прокси не разобран.');
await saveTarget({
id: targetId,
name: 'Основной прокси',
kind: 'external',
protocol: parsedProxy.protocol,
host: parsedProxy.host,
port: parsedProxy.port,
});
} else {
const singBoxResult = await generateSingBoxConfig();
singBoxGeneratedPath = singBoxResult.generatedConfigPath;
await ensureSingBoxRunningForApply();
}
await saveProfile({
id: profileId,
name: 'Приложения через прокси',
enabled: true,
targetId: routeMode === 'local-singbox' ? LOCAL_SINGBOX_TARGET_ID : targetId,
protocols: ['TCP', 'UDP'],
items: items.map(profileItemInput),
});
await Promise.all(
loadedProfiles
.filter((profile) => profile.enabled && profile.id !== profileId)
.map((profile) => saveProfile(profileInputFromProfile(profile, false))),
);
const result = await applyProfiles();
const [saved, detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([
getSavedState(),
getComponents(),
getProxiFyreSetupStatus(),
getSingBoxStatus(),
getSingBoxSetupStatus(),
]);
applySavedState(saved.profiles, saved.targets, result.generatedConfigPath);
setComponents(detectedComponents);
setSetupStatus(detectedSetupStatus);
setSingBoxStatus(detectedSingBoxStatus);
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
setHasUnappliedChanges(false);
showNotice(routeMode === 'local-singbox' ? noticeFromLocalApply(result, singBoxGeneratedPath) : noticeFromApply(result));
} catch (error) {
showNotice({
kind: 'error',
title: 'Конфиг не обновлен',
text: errorMessage(error),
});
} finally {
setIsApplying(false);
}
}
async function ensureSingBoxRunningForApply() {
if (routeMode !== 'local-singbox' || !singbox?.installed) return;
setIsSingBoxMenuOpen(false);
try {
await nextFrame();
if (singbox.running) {
setSingBoxAction('stop');
const stopped = await stopSingBoxService();
setComponents((current) => upsertComponent(current, stopped));
}
setSingBoxAction('start');
const component = await startSingBoxService();
setComponents((current) => upsertComponent(current, component));
const status = await refreshSingBoxState();
if (!status.component.running) {
throw new Error('Local sing-box установлен, но служба не запустилась.');
}
} finally {
setSingBoxAction(null);
}
}
async function openConfig() {
setIsOpeningConfig(true);
try {
const openedPath = await openConfigLocation();
showNotice({
kind: 'info',
title: 'Конфиг открыт',
text: openedPath,
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Не удалось открыть конфиг',
text: errorMessage(error),
});
} finally {
setIsOpeningConfig(false);
}
}
async function setProxiFyreServiceRunning(shouldRun: boolean) {
const action = shouldRun ? 'start' : 'stop';
setServiceAction(action);
setIsServiceMenuOpen(false);
startServiceVisual();
try {
await nextFrame();
const component = shouldRun
? await startProxiFyreService()
: await stopProxiFyreService();
setComponents((current) => upsertComponent(current, component));
showNotice({
kind: 'success',
title: shouldRun ? 'Служба запущена' : 'Служба остановлена',
text: proxyfierDetails(component, false),
});
} catch (error) {
showNotice({
kind: 'error',
title: shouldRun ? 'Служба не запущена' : 'Служба не остановлена',
text: errorMessage(error),
});
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
async function installProxiFyrePackage() {
setServiceAction('install');
setIsServiceMenuOpen(false);
startServiceVisual();
try {
await nextFrame();
const component = await installProxiFyre();
const detectedSetupStatus = await getProxiFyreSetupStatus();
setComponents((current) => upsertComponent(current, component));
setSetupStatus(detectedSetupStatus);
showNotice({
kind: 'success',
title: 'ProxiFyre установлен',
text: proxyfierDetails(component, false),
});
} catch (error) {
showNotice({
kind: 'error',
title: 'ProxiFyre не установлен',
text: errorMessage(error),
});
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
async function uninstallProxiFyrePackage() {
const confirmed = window.confirm(
'Удалить ProxiFyre с компьютера? Будет удалена служба и папка установки ProxiFyre.',
);
if (!confirmed) return;
setServiceAction('uninstall');
setIsServiceMenuOpen(false);
startServiceVisual();
try {
await nextFrame();
const component = await uninstallProxiFyre();
const detectedSetupStatus = await getProxiFyreSetupStatus();
setComponents((current) => upsertComponent(current, component));
setSetupStatus(detectedSetupStatus);
showNotice({
kind: 'success',
title: 'ProxiFyre удален',
text: 'Служба и папка установки ProxiFyre удалены.',
});
} catch (error) {
showNotice({
kind: 'error',
title: 'ProxiFyre не удален',
text: errorMessage(error),
});
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
async function refreshSingBoxState() {
const [detectedSingBoxStatus, detectedSingBoxSetupStatus, detectedComponents] = await Promise.all([
getSingBoxStatus(),
getSingBoxSetupStatus(),
getComponents(),
]);
setSingBoxStatus(detectedSingBoxStatus);
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
setComponents(detectedComponents);
return detectedSingBoxStatus;
}
async function setSingBoxServiceRunning(shouldRun: boolean) {
const action: SingBoxAction = shouldRun ? 'start' : 'stop';
setSingBoxAction(action);
setIsSingBoxMenuOpen(false);
try {
await nextFrame();
const component = shouldRun ? await startSingBoxService() : await stopSingBoxService();
setComponents((current) => upsertComponent(current, component));
await refreshSingBoxState();
showNotice({
kind: 'success',
title: shouldRun ? 'sing-box запущен' : 'sing-box остановлен',
text: componentDetails(component, false),
});
} catch (error) {
showNotice({
kind: 'error',
title: shouldRun ? 'sing-box не запущен' : 'sing-box не остановлен',
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function installSingBoxPackage() {
setSingBoxAction('install');
setIsSingBoxMenuOpen(false);
try {
await nextFrame();
const component = await installSingBox();
setComponents((current) => upsertComponent(current, component));
await refreshSingBoxState();
showNotice({
kind: 'success',
title: 'Local sing-box установлен',
text: componentDetails(component, false),
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Local sing-box не установлен',
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function uninstallSingBoxPackage() {
const confirmed = window.confirm(
'Удалить Local sing-box с компьютера? Будет удалена служба и папка установки sing-box.',
);
if (!confirmed) return;
setSingBoxAction('uninstall');
setIsSingBoxMenuOpen(false);
try {
await nextFrame();
const component = await uninstallSingBox();
setComponents((current) => upsertComponent(current, component));
await refreshSingBoxState();
showNotice({
kind: 'success',
title: 'Local sing-box удален',
text: 'Служба и папка установки Local sing-box удалены.',
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Local sing-box не удален',
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function syncSingBoxSubscription() {
const subscriptionUrl = subscriptionInput.trim();
if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) {
showNotice({
kind: 'error',
title: 'Ссылка не указана',
text: 'Вставь ссылку подписки Local sing-box.',
});
return;
}
setSingBoxAction('fetch');
try {
if (subscriptionUrl) {
await saveSingBoxSubscription(subscriptionUrl);
}
const status = await fetchSingBoxSubscription();
setSingBoxStatus(status);
setComponents((current) => upsertComponent(current, status.component));
setSubscriptionInput('');
setServerPings({});
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);
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);
}
return (
<main className="simple-shell">
<section className="simple-panel">
<header className="simple-header">
<div>
<small>VPN Proxy</small>
<h1>Прокси для приложений</h1>
</div>
<button
type="button"
className="ghost-button"
onClick={refresh}
disabled={isLoading || isDetectingComponents}
>
{isLoading ? 'Загружаю...' : isDetectingComponents ? 'Проверяю...' : 'Обновить'}
</button>
</header>
<div className={`finder-card ${finderStateClass} ${finderVisualClass}`.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>{proxyfierTitle(proxyfier, isDetectingComponents)}</strong>
<span>{proxyfierDetails(proxyfier, isDetectingComponents)}</span>
<button
type="button"
className="setup-toggle"
onClick={() => setIsSetupOpen((current) => !current)}
disabled={isDetectingComponents && !setupStatus}
aria-expanded={isSetupOpen}
>
{proxyfier?.installed ? 'Состав ProxiFyre' : 'Что будет установлено'}
{setupStatus ? (
<span>{setupStatus.ready ? 'все есть' : `не хватает: ${setupStatus.missingCount}`}</span>
) : null}
</button>
</div>
<div className="service-actions" aria-label="Управление службой ProxiFyre">
{proxyfier?.installed ? (
<>
<button
type="button"
className={`service-button ${proxyfier.running ? 'stop' : ''}`.trim()}
onClick={() => setProxiFyreServiceRunning(!proxyfier.running)}
disabled={isDetectingComponents || Boolean(serviceAction)}
>
{serviceAction === 'start' || serviceAction === 'stop'
? '...'
: proxyfier.running
? 'Остановить'
: 'Запустить'}
</button>
<div className="service-menu">
<button
type="button"
className="service-menu-button"
onClick={() => setIsServiceMenuOpen((current) => !current)}
disabled={isDetectingComponents || Boolean(serviceAction)}
aria-label="Дополнительные действия ProxiFyre"
aria-expanded={isServiceMenuOpen}
title="Еще"
>
<MoreHorizontal size={20} strokeWidth={2} />
</button>
{isServiceMenuOpen ? (
<div className="service-menu-popover">
<button type="button" onClick={() => void uninstallProxiFyrePackage()}>
{serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre'}
</button>
</div>
) : null}
</div>
</>
) : (
<button
type="button"
className="service-button install"
onClick={() => void installProxiFyrePackage()}
disabled={isDetectingComponents || Boolean(serviceAction)}
>
{serviceAction === 'install' ? '...' : 'Установить'}
</button>
)}
</div>
{isSetupOpen ? (
<div className="setup-details">
{setupStatus ? (
setupStatus.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>Ищу установленные зависимости ProxiFyre.</span>
</div>
</div>
)}
</div>
) : null}
</div>
<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">
<h2>Приложения</h2>
<span>{items.length}</span>
</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" onClick={addProcess}>
OK
</button>
<button
type="button"
className="process-cancel-button"
onClick={() => {
setProcessInput('');
setIsProcessInputOpen(false);
}}
>
Отмена
</button>
</div>
) : (
<div className="add-toolbar" aria-label="Добавить приложение">
<button
type="button"
className="add-tile"
onClick={() => setIsProcessInputOpen(true)}
aria-label="Добавить процесс"
title="Процесс"
>
<Cpu size={22} strokeWidth={1.8} />
<span className="sr-only">Процесс</span>
</button>
<button
type="button"
className="add-tile"
onClick={() => void pickAndAddItem('exe')}
disabled={Boolean(pickerAction)}
aria-label="Добавить EXE-файл"
title="EXE-файл"
>
{pickerAction === 'exe' ? <span className="tile-loading">...</span> : <FileCode2 size={22} strokeWidth={1.8} />}
<span className="sr-only">EXE-файл</span>
</button>
<button
type="button"
className="add-tile"
onClick={() => void pickAndAddItem('folder')}
disabled={Boolean(pickerAction)}
aria-label="Добавить папку"
title="Папка"
>
{pickerAction === 'folder' ? <span className="tile-loading">...</span> : <FolderOpen size={22} strokeWidth={1.8} />}
<span className="sr-only">Папка</span>
</button>
</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" onClick={() => removeItem(item.id)} aria-label={`Удалить ${item.value}`}>
Удалить
</button>
</div>
))
) : (
<div className="empty-state">Добавь процесс, папку или путь к EXE-файлу.</div>
)}
</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
? singBoxAction === 'start'
? 'Запускаю sing-box...'
: singBoxAction === 'stop'
? 'Перезапускаю sing-box...'
: 'Применяю...'
: hasUnappliedChanges
? 'Применить в ProxiFyre'
: 'Обновить конфиг'}
</button>
<button
type="button"
className="open-config-button"
onClick={openConfig}
disabled={isOpeningConfig}
>
{isOpeningConfig ? '...' : 'Открыть'}
</button>
</div>
{generatedConfigPath ? <p className="config-path">{generatedConfigPath}</p> : null}
</section>
{logEntries.length ? (
<aside className={`log-dock ${activeLog?.kind ?? 'idle'}`} aria-live="polite">
<div className={`log-current ${activeLog ? 'visible' : 'hidden'}`}>
{activeLog ? (
<>
<strong>{activeLog.title}</strong>
<span>{activeLog.text}</span>
</>
) : (
<span className="log-muted">Журнал событий</span>
)}
</div>
<button type="button" className="log-toggle" onClick={() => setIsLogOpen((current) => !current)}>
{isLogOpen ? 'Скрыть' : 'Посмотреть'}
<span>{logEntries.length}</span>
</button>
{isLogOpen ? (
<div className="log-history">
{logEntries.map((entry) => (
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
<time>{formatLogTime(entry.at)}</time>
<div>
<strong>{entry.title}</strong>
<span>{entry.text}</span>
</div>
</div>
))}
</div>
) : null}
</aside>
) : null}
</main>
);
}
interface ParsedProxy {
protocol: 'socks5';
host: string;
port: number;
}
function parseProxy(rawValue: string): ParsedProxy {
const value = rawValue.trim();
if (!value) throw new Error('Введи адрес прокси.');
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`;
let parsed: URL;
try {
parsed = new URL(withProtocol);
} catch {
throw new Error('Формат: socks5://host:port или host:port.');
}
const protocol = parsed.protocol.replace(':', '').toLowerCase();
if (protocol !== 'socks5') {
throw new Error('Сейчас поддерживается только SOCKS5.');
}
if (parsed.username || parsed.password) {
throw new Error('Прокси с логином и паролем пока не поддерживаются.');
}
const host = parsed.hostname.replace(/^\[|\]$/g, '');
const port = Number(parsed.port);
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('Укажи хост и порт прокси.');
}
return { protocol: 'socks5', host, port };
}
function targetForUi(targets: Target[], profile: Profile | undefined) {
if (profile) return targets.find((target) => target.id === profile.targetId);
return targets.find((target) => target.id === MAIN_TARGET_ID) ?? targets.find((target) => target.kind === 'external');
}
function targetForExternalProxy(targets: Target[]) {
return targets.find((target) => target.id === MAIN_TARGET_ID)
?? targets.find((target) => target.kind === 'external' && target.id !== LOCAL_SINGBOX_TARGET_ID);
}
function itemsForProfiles(profiles: Profile[]): DraftItem[] {
const seen = new Set<string>();
const items: DraftItem[] = [];
for (const profile of profiles) {
for (const item of profile.items) {
if (item.type !== 'process' && item.type !== 'folder' && item.type !== 'exe') continue;
const key = `${item.type}:${item.value.trim().toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
items.push({
id: `${item.type}-${items.length}-${item.value}`,
type: item.type,
value: item.value,
});
}
}
return items;
}
function formatProxy(target: Target) {
return target.protocol === 'socks5'
? `${target.host}:${target.port}`
: `${target.protocol}://${target.host}:${target.port}`;
}
function normalizeItemValue(value: string, type: DraftItemType) {
const clean = value.trim().replace(/^"|"$/g, '');
if (!clean) return '';
if (type === 'folder' || type === 'exe') return clean;
return clean
.split(/[\\/]/)
.pop()
?.replace(/\.exe$/i, '')
.trim() ?? '';
}
async function pickPath(type: Extract<DraftItemType, 'exe' | 'folder'>) {
const selected = await open(
type === 'folder'
? {
title: 'Выбери папку',
directory: true,
multiple: false,
}
: {
title: 'Выбери EXE-файл',
directory: false,
multiple: false,
filters: [{ name: 'EXE-файлы', extensions: ['exe'] }],
},
);
if (Array.isArray(selected)) return selected[0] ?? null;
return selected;
}
function nextFrame() {
return new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
function profileItemInput(item: DraftItem): ProfileItemInput {
return {
type: item.type,
value: item.value,
recursive: item.type === 'folder',
};
}
function emptyItemMessage(type: DraftItemType) {
if (type === 'process') return 'Введи имя процесса.';
if (type === 'folder') return 'Введи путь к папке.';
return 'Введи путь к EXE-файлу.';
}
function itemTypeLabel(type: DraftItemType) {
if (type === 'process') return 'процесс';
if (type === 'folder') return 'папка';
return 'EXE-файл';
}
function itemIcon(type: DraftItemType) {
if (type === 'process') return <Cpu size={18} strokeWidth={1.9} />;
if (type === 'folder') return <FolderOpen size={18} strokeWidth={1.9} />;
return <FileCode2 size={18} strokeWidth={1.9} />;
}
function setupItemDetails(installed: boolean, version: string | undefined, details: string) {
if (!installed) return `Нужно установить. ${details}`;
if (version) return `${version}. ${details}`;
return details;
}
function profileInputFromProfile(profile: Profile, enabled: boolean) {
return {
id: profile.id,
name: profile.name,
enabled,
targetId: profile.targetId,
protocols: profile.protocols,
items: profile.items.map((item) => ({
type: item.type,
value: item.value,
recursive: item.recursive,
})),
};
}
function proxyfierTitle(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Проверяю ProxiFyre';
if (!component) return 'ProxiFyre не проверен';
if (component.running) return 'ProxiFyre найден и запущен';
if (component.installed) return 'ProxiFyre найден';
return 'ProxiFyre не найден';
}
function proxyfierDetails(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Ищу установленный клиент и состояние службы.';
if (!component) return 'Нажми «Обновить», чтобы проверить компьютер.';
if (component.path) return component.path;
return component.problems[0] ?? 'Путь установки не найден.';
}
function singBoxTitle(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Проверяю Local sing-box';
if (!component) return 'Local sing-box не проверен';
if (component.running) return 'Local sing-box найден и запущен';
if (component.installed) return 'Local sing-box найден';
return 'Local sing-box не установлен';
}
function singBoxDetails(
component: ComponentStatus | undefined,
status: LocalSingBoxStatusResponse | null,
checking: boolean,
) {
if (checking) return 'Ищу sing-box, wrapper и службу.';
if (component?.running && status) {
const lanAddress = lanSingBoxAddress(status);
return lanAddress
? `Доступен локально: ${localSingBoxAddress(status)} · LAN: ${lanAddress}`
: `Доступен локально: ${localSingBoxAddress(status)}`;
}
if (component?.installed) return 'Служба остановлена. Запусти Local sing-box перед применением маршрута.';
if (status?.config.hasSubscription) {
return status.config.subscriptionDisplayUrl ?? 'Подписка сохранена.';
}
return component?.problems[0] ?? 'Установи компонент, чтобы подключить подписку и выбрать сервер.';
}
function componentDetails(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Проверяю состояние службы.';
if (!component) return 'Компонент не проверен.';
if (component.path) return component.path;
return component.problems[0] ?? 'Путь установки не найден.';
}
function noticeFromApply(result: ApplyProfilesResponse): Notice {
return {
kind: result.success ? 'success' : 'error',
title: result.success ? 'Конфиг обновлен' : 'Конфиг создан, но не применен',
text: result.message,
};
}
function noticeFromLocalApply(result: ApplyProfilesResponse, singBoxGeneratedPath: string): Notice {
return {
kind: result.success ? 'success' : 'error',
title: result.success ? 'Local sing-box применен' : 'Конфиг создан, но не применен',
text: singBoxGeneratedPath ? `${result.message} sing-box: ${singBoxGeneratedPath}` : result.message,
};
}
function applyStateText(routeMode: RouteMode, isSingBoxInstalled: boolean, isSingBoxRunning: boolean) {
if (routeMode === 'local-singbox' && isSingBoxInstalled && !isSingBoxRunning) {
return 'Local sing-box сейчас остановлен. При применении клиент сначала запустит службу, затем обновит ProxiFyre.';
}
if (routeMode === 'local-singbox' && isSingBoxInstalled && isSingBoxRunning) {
return 'При применении клиент обновит конфиг, перезапустит Local sing-box и затем обновит ProxiFyre.';
}
return 'Нажми «Применить в ProxiFyre». Если служба уже запущена и маршрут не обновился, перезапусти ProxiFyre.';
}
function upsertComponent(components: ComponentStatus[], component: ComponentStatus) {
const index = components.findIndex((current) => current.id === component.id);
if (index === -1) return [...components, component];
return [
...components.slice(0, index),
component,
...components.slice(index + 1),
];
}
function sameValue(left: string, right: string) {
return left.trim().toLowerCase() === right.trim().toLowerCase();
}
function formatLogTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function serverLabel(server: SubscriptionServer) {
return `${server.type} · ${server.server}:${server.serverPort}`;
}
function serverTooltip(server: SubscriptionServer, ping: PingServerResponse | undefined) {
const details = serverLabel(server);
if (!ping) return details;
return ping.ok ? `${details} · ping ${ping.latency ?? 0} ms` : `${details} · ping fail`;
}
function displayServerTag(tag: string) {
const withoutFlags = tag
.replace(/[\u{1f1e6}-\u{1f1ff}]/gu, '')
.replace(/\s*->\s*/g, ' -> ')
.replace(/\s*->\s*$/g, '')
.replace(/^\s*->\s*/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
return withoutFlags || tag;
}
function pingSummary(results: PingServerResponse[]) {
if (!results.length) return 'Серверов для проверки нет.';
const ok = results.filter((result) => result.ok);
if (!ok.length) return `Не ответил ни один сервер из ${results.length}.`;
const best = ok.reduce((current, result) => {
if ((result.latency ?? Number.MAX_SAFE_INTEGER) < (current.latency ?? Number.MAX_SAFE_INTEGER)) return result;
return current;
});
return `Ответили ${ok.length}/${results.length}; быстрее ${best.tag}: ${best.latency ?? 0} ms.`;
}
function localSingBoxAddress(status: LocalSingBoxStatusResponse | null) {
if (!status) return 'не загружен';
const host = status.config.listenHost.trim();
const displayHost = host === '0.0.0.0' || host === '::' || !host ? '127.0.0.1' : host;
return formatHostPort(displayHost, status.config.listenPort);
}
function lanSingBoxAddress(status: LocalSingBoxStatusResponse | null) {
if (!status) return null;
const host = status.config.listenHost.trim().toLowerCase();
if (host === '127.0.0.1' || host === 'localhost' || host === '::1') return null;
const lanHost = host === '0.0.0.0' || host === '::' || !host
? status.lanListenHost
: status.config.listenHost;
if (!lanHost) return null;
return formatHostPort(lanHost, status.config.listenPort);
}
function formatHostPort(host: string, port: number) {
return host.includes(':') && !host.startsWith('[') ? `[${host}]:${port}` : `${host}:${port}`;
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
if (error && typeof error === 'object' && 'message' in error) {
return String((error as { message: unknown }).message);
}
return 'Неизвестная ошибка.';
}