Refactor VPN proxy routing and session handling

This commit is contained in:
2026-07-07 22:07:17 +03:00
parent 59f2264a2e
commit 7dbf786c56
25 changed files with 1921 additions and 236 deletions

View File

@@ -1,18 +1,21 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { Cpu, FileCode2, FolderOpen } from 'lucide-react';
import {
applyProfiles,
getComponents,
getProfiles,
getStatus,
getTargets,
getSavedState,
openConfigLocation,
saveProfile,
saveTarget,
startProxiFyreService,
stopProxiFyreService,
type ApplyProfilesResponse,
} from '../api/tauriCommands';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, Target } from '../domain/types';
type DraftItemType = Extract<ProfileItemType, 'process' | 'exe'>;
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
type ServiceVisualState = 'active' | 'settling' | null;
interface DraftItem {
id: string;
@@ -26,8 +29,14 @@ interface Notice {
text: string;
}
interface LogEntry extends Notice {
id: string;
at: number;
}
const MAIN_TARGET_ID = 'main-proxy';
const MAIN_PROFILE_ID = 'main-profile';
const LOG_VISIBLE_MS = 6500;
const fallbackComponents: ComponentStatus[] = [
{
@@ -47,50 +56,63 @@ export function App() {
const [targetId, setTargetId] = useState(MAIN_TARGET_ID);
const [items, setItems] = useState<DraftItem[]>([]);
const [loadedProfiles, setLoadedProfiles] = useState<Profile[]>([]);
const [newItemType, setNewItemType] = useState<DraftItemType>('process');
const [newItemValue, setNewItemValue] = useState('');
const [isProcessInputOpen, setIsProcessInputOpen] = useState(false);
const [processInput, setProcessInput] = useState('');
const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null);
const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
const [notice, setNotice] = useState<Notice | null>(null);
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<'start' | 'stop' | null>(null);
const [serviceVisualState, setServiceVisualState] = useState<ServiceVisualState>(null);
const serviceVisualTimerRef = useRef<number | null>(null);
const proxyfier = useMemo(
() => components.find((component) => component.id === 'proxyfier'),
[components],
);
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' : '';
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 [status, profiles, targets, detectedComponents] = await Promise.all([
getStatus(),
getProfiles(),
getTargets(),
getComponents(),
]);
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, status.activeTarget, activeProfile);
const editableProfiles = mainProfile ? [mainProfile] : activeProfiles;
if (activeTarget) setProxyInput(formatProxy(activeTarget));
setItems(itemsForProfiles(editableProfiles));
setLoadedProfiles(profiles);
setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID);
setTargetId(activeTarget?.id ?? activeProfile?.targetId ?? MAIN_TARGET_ID);
setComponents(detectedComponents);
setGeneratedConfigPath(status.generatedConfigPath);
setNotice(null);
const saved = await getSavedState();
applySavedState(saved.profiles, saved.targets, saved.generatedConfigPath);
} catch {
setNotice({
showNotice({
kind: 'info',
title: 'Режим предпросмотра',
text: 'Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.',
@@ -98,53 +120,108 @@ export function App() {
} finally {
setIsLoading(false);
}
void refreshComponents();
}
function addItem() {
const value = normalizeItemValue(newItemValue, newItemType);
async function refreshComponents() {
setIsDetectingComponents(true);
try {
const detectedComponents = await getComponents();
setComponents(detectedComponents);
} catch (error) {
showNotice({
kind: 'error',
title: 'ProxiFyre не проверен',
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 editableProfiles = mainProfile ? [mainProfile] : activeProfiles;
if (activeTarget) setProxyInput(formatProxy(activeTarget));
setItems(itemsForProfiles(editableProfiles));
setLoadedProfiles(profiles);
setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID);
setTargetId(activeTarget?.id ?? activeProfile?.targetId ?? MAIN_TARGET_ID);
setGeneratedConfigPath(generatedPath);
}
function addItem(type: DraftItemType, rawValue: string) {
const value = normalizeItemValue(rawValue, type);
if (!value) {
setNotice({
showNotice({
kind: 'error',
title: 'Нечего добавить',
text: newItemType === 'process' ? 'Введи имя процесса.' : 'Введи путь к EXE-файлу.',
text: emptyItemMessage(type),
});
return;
return false;
}
if (items.some((item) => item.type === newItemType && sameValue(item.value, value))) {
setNotice({
if (items.some((item) => item.type === type && sameValue(item.value, value))) {
showNotice({
kind: 'info',
title: 'Уже добавлено',
text: value,
});
return;
return false;
}
setItems((current) => [
...current,
{
id: `${newItemType}-${Date.now()}`,
type: newItemType,
id: `${type}-${Date.now()}`,
type,
value,
},
]);
setNewItemValue('');
setNotice(null);
return true;
}
function addProcess() {
if (addItem('process', processInput)) {
setProcessInput('');
setIsProcessInputOpen(false);
}
}
function removeItem(id: string) {
setItems((current) => current.filter((item) => item.id !== id));
}
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;
try {
parsedProxy = parseProxy(proxyInput);
if (!items.length) {
throw new Error('Добавь хотя бы один процесс или EXE-файл.');
}
if (!items.length) throw new Error('Добавь хотя бы один процесс, EXE-файл или папку.');
} catch (error) {
setNotice({
showNotice({
kind: 'error',
title: 'Проверь данные',
text: errorMessage(error),
@@ -177,18 +254,16 @@ export function App() {
);
const result = await applyProfiles();
const [status, detectedComponents, profiles] = await Promise.all([
getStatus(),
const [saved, detectedComponents] = await Promise.all([
getSavedState(),
getComponents(),
getProfiles(),
]);
applySavedState(saved.profiles, saved.targets, result.generatedConfigPath);
setComponents(detectedComponents);
setGeneratedConfigPath(status.generatedConfigPath);
setLoadedProfiles(profiles);
setNotice(noticeFromApply(result));
showNotice(noticeFromApply(result));
} catch (error) {
setNotice({
showNotice({
kind: 'error',
title: 'Конфиг не обновлен',
text: errorMessage(error),
@@ -202,13 +277,13 @@ export function App() {
setIsOpeningConfig(true);
try {
const openedPath = await openConfigLocation();
setNotice({
showNotice({
kind: 'info',
title: 'Конфиг открыт',
text: openedPath,
});
} catch (error) {
setNotice({
showNotice({
kind: 'error',
title: 'Не удалось открыть конфиг',
text: errorMessage(error),
@@ -218,6 +293,66 @@ export function App() {
}
}
async function setProxiFyreServiceRunning(shouldRun: boolean) {
const action = shouldRun ? 'start' : 'stop';
setServiceAction(action);
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();
}
}
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">
@@ -226,16 +361,45 @@ export function App() {
<small>VPN Proxy</small>
<h1>Прокси для приложений</h1>
</div>
<button type="button" className="ghost-button" onClick={refresh} disabled={isLoading}>
{isLoading ? 'Ищу...' : 'Обновить'}
<button
type="button"
className="ghost-button"
onClick={refresh}
disabled={isLoading || isDetectingComponents}
>
{isLoading ? 'Загружаю...' : isDetectingComponents ? 'Проверяю...' : 'Обновить'}
</button>
</header>
<div className={`finder-card ${proxyfier?.installed ? 'found' : 'missing'}`}>
<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>
<strong>{proxyfierTitle(proxyfier)}</strong>
<span>{proxyfierDetails(proxyfier)}</span>
<div className="finder-text">
<strong>{proxyfierTitle(proxyfier, isDetectingComponents)}</strong>
<span>{proxyfierDetails(proxyfier, isDetectingComponents)}</span>
</div>
<div className="service-actions" aria-label="Управление службой ProxiFyre">
<button
type="button"
className="service-button"
onClick={() => setProxiFyreServiceRunning(true)}
disabled={isDetectingComponents || Boolean(serviceAction) || !proxyfier?.installed || Boolean(proxyfier?.running)}
>
{serviceAction === 'start' ? '...' : 'Запустить'}
</button>
<button
type="button"
className="service-button stop"
onClick={() => setProxiFyreServiceRunning(false)}
disabled={isDetectingComponents || Boolean(serviceAction) || !proxyfier?.installed || !proxyfier?.running}
>
{serviceAction === 'stop' ? '...' : 'Остановить'}
</button>
</div>
</div>
@@ -255,35 +419,90 @@ export function App() {
<span>{items.length}</span>
</div>
<div className="add-line">
<select
value={newItemType}
onChange={(event) => setNewItemType(event.target.value as DraftItemType)}
>
<option value="process">Процесс</option>
<option value="exe">EXE-файл</option>
</select>
<input
value={newItemValue}
onChange={(event) => setNewItemValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') addItem();
}}
placeholder={newItemType === 'process' ? 'Discord' : 'C:\\Apps\\app.exe'}
spellCheck={false}
/>
<button type="button" onClick={addItem}>
Добавить
</button>
</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">
{items.length ? (
{isLoading ? (
<div className="list-skeleton" aria-label="Загрузка приложений">
<span />
<span />
</div>
) : items.length ? (
items.map((item) => (
<div className="app-row" key={item.id}>
<div>
<strong>{item.value}</strong>
<span>{item.type === 'process' ? 'процесс' : 'EXE-файл'}</span>
<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}`}>
Удалить
@@ -291,18 +510,11 @@ export function App() {
</div>
))
) : (
<div className="empty-state">Добавь процесс или путь к EXE-файлу.</div>
<div className="empty-state">Добавь процесс, папку или путь к EXE-файлу.</div>
)}
</div>
</section>
{notice ? (
<div className={`notice-line ${notice.kind}`}>
<strong>{notice.title}</strong>
<span>{notice.text}</span>
</div>
) : null}
<div className="command-row">
<button type="button" className="apply-button" onClick={updateConfig} disabled={isApplying}>
{isApplying ? 'Обновляю...' : 'Обновить конфиг'}
@@ -319,6 +531,38 @@ export function App() {
{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>
);
}
@@ -358,8 +602,7 @@ function parseProxy(rawValue: string): ParsedProxy {
return { protocol: 'socks5', host, port };
}
function targetForUi(targets: Target[], activeTarget: Target | undefined, profile: Profile | undefined) {
if (activeTarget) return activeTarget;
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');
}
@@ -370,7 +613,7 @@ function itemsForProfiles(profiles: Profile[]): DraftItem[] {
for (const profile of profiles) {
for (const item of profile.items) {
if (item.type !== 'process' && item.type !== 'exe') continue;
if (item.type !== 'process' && item.type !== 'folder' && item.type !== 'exe') continue;
const key = `${item.type}:${item.value.trim().toLowerCase()}`;
if (seen.has(key)) continue;
@@ -395,7 +638,7 @@ function formatProxy(target: Target) {
function normalizeItemValue(value: string, type: DraftItemType) {
const clean = value.trim().replace(/^"|"$/g, '');
if (!clean) return '';
if (type === 'exe') return clean;
if (type === 'folder' || type === 'exe') return clean;
return clean
.split(/[\\/]/)
@@ -404,14 +647,58 @@ function normalizeItemValue(value: string, type: DraftItemType) {
.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: false,
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 profileInputFromProfile(profile: Profile, enabled: boolean) {
return {
id: profile.id,
@@ -427,14 +714,16 @@ function profileInputFromProfile(profile: Profile, enabled: boolean) {
};
}
function proxyfierTitle(component: ComponentStatus | undefined) {
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) {
function proxyfierDetails(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Ищу установленный клиент и состояние службы.';
if (!component) return 'Нажми «Обновить», чтобы проверить компьютер.';
if (component.path) return component.path;
return component.problems[0] ?? 'Путь установки не найден.';
@@ -448,10 +737,29 @@ function noticeFromApply(result: ApplyProfilesResponse): Notice {
};
}
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 errorMessage(error: unknown) {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;