Clarify active Windows client architecture

This commit is contained in:
2026-07-07 21:19:41 +03:00
parent a0f41baa36
commit 59f2264a2e
55 changed files with 19554 additions and 8 deletions

View File

@@ -0,0 +1,462 @@
import { useEffect, useMemo, useState } from 'react';
import {
applyProfiles,
getComponents,
getProfiles,
getStatus,
getTargets,
openConfigLocation,
saveProfile,
saveTarget,
type ApplyProfilesResponse,
} from '../api/tauriCommands';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, Target } from '../domain/types';
type DraftItemType = Extract<ProfileItemType, 'process' | 'exe'>;
interface DraftItem {
id: string;
type: DraftItemType;
value: string;
}
interface Notice {
kind: 'success' | 'error' | 'info';
title: string;
text: string;
}
const MAIN_TARGET_ID = 'main-proxy';
const MAIN_PROFILE_ID = 'main-profile';
const fallbackComponents: ComponentStatus[] = [
{
id: 'proxyfier',
name: 'ProxiFyre',
state: 'missing',
installed: false,
running: false,
problems: ['ProxiFyre не найден'],
actions: [],
},
];
export function App() {
const [proxyInput, setProxyInput] = useState('');
const [profileId, setProfileId] = useState(MAIN_PROFILE_ID);
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 [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
const [notice, setNotice] = useState<Notice | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isApplying, setIsApplying] = useState(false);
const [isOpeningConfig, setIsOpeningConfig] = useState(false);
const proxyfier = useMemo(
() => components.find((component) => component.id === 'proxyfier'),
[components],
);
useEffect(() => {
void refresh();
}, []);
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);
} catch {
setNotice({
kind: 'info',
title: 'Режим предпросмотра',
text: 'Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.',
});
} finally {
setIsLoading(false);
}
}
function addItem() {
const value = normalizeItemValue(newItemValue, newItemType);
if (!value) {
setNotice({
kind: 'error',
title: 'Нечего добавить',
text: newItemType === 'process' ? 'Введи имя процесса.' : 'Введи путь к EXE-файлу.',
});
return;
}
if (items.some((item) => item.type === newItemType && sameValue(item.value, value))) {
setNotice({
kind: 'info',
title: 'Уже добавлено',
text: value,
});
return;
}
setItems((current) => [
...current,
{
id: `${newItemType}-${Date.now()}`,
type: newItemType,
value,
},
]);
setNewItemValue('');
setNotice(null);
}
function removeItem(id: string) {
setItems((current) => current.filter((item) => item.id !== id));
}
async function updateConfig() {
let parsedProxy: ParsedProxy;
try {
parsedProxy = parseProxy(proxyInput);
if (!items.length) {
throw new Error('Добавь хотя бы один процесс или EXE-файл.');
}
} catch (error) {
setNotice({
kind: 'error',
title: 'Проверь данные',
text: errorMessage(error),
});
return;
}
setIsApplying(true);
try {
await saveTarget({
id: targetId,
name: 'Основной прокси',
kind: 'external',
protocol: parsedProxy.protocol,
host: parsedProxy.host,
port: parsedProxy.port,
});
await saveProfile({
id: profileId,
name: 'Приложения через прокси',
enabled: true,
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 [status, detectedComponents, profiles] = await Promise.all([
getStatus(),
getComponents(),
getProfiles(),
]);
setComponents(detectedComponents);
setGeneratedConfigPath(status.generatedConfigPath);
setLoadedProfiles(profiles);
setNotice(noticeFromApply(result));
} catch (error) {
setNotice({
kind: 'error',
title: 'Конфиг не обновлен',
text: errorMessage(error),
});
} finally {
setIsApplying(false);
}
}
async function openConfig() {
setIsOpeningConfig(true);
try {
const openedPath = await openConfigLocation();
setNotice({
kind: 'info',
title: 'Конфиг открыт',
text: openedPath,
});
} catch (error) {
setNotice({
kind: 'error',
title: 'Не удалось открыть конфиг',
text: errorMessage(error),
});
} finally {
setIsOpeningConfig(false);
}
}
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}>
{isLoading ? 'Ищу...' : 'Обновить'}
</button>
</header>
<div className={`finder-card ${proxyfier?.installed ? 'found' : 'missing'}`}>
<span className="status-light" />
<div>
<strong>{proxyfierTitle(proxyfier)}</strong>
<span>{proxyfierDetails(proxyfier)}</span>
</div>
</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="apps-section">
<div className="section-head">
<h2>Приложения</h2>
<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>
<div className="app-list">
{items.length ? (
items.map((item) => (
<div className="app-row" key={item.id}>
<div>
<strong>{item.value}</strong>
<span>{item.type === 'process' ? 'процесс' : 'EXE-файл'}</span>
</div>
<button type="button" onClick={() => removeItem(item.id)} aria-label={`Удалить ${item.value}`}>
Удалить
</button>
</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 ? 'Обновляю...' : 'Обновить конфиг'}
</button>
<button
type="button"
className="open-config-button"
onClick={openConfig}
disabled={isOpeningConfig}
>
{isOpeningConfig ? '...' : 'Открыть'}
</button>
</div>
{generatedConfigPath ? <p className="config-path">{generatedConfigPath}</p> : null}
</section>
</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[], activeTarget: Target | undefined, profile: Profile | undefined) {
if (activeTarget) return activeTarget;
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 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 !== '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 === 'exe') return clean;
return clean
.split(/[\\/]/)
.pop()
?.replace(/\.exe$/i, '')
.trim() ?? '';
}
function profileItemInput(item: DraftItem): ProfileItemInput {
return {
type: item.type,
value: item.value,
recursive: false,
};
}
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) {
if (!component) return 'ProxiFyre не проверен';
if (component.running) return 'ProxiFyre найден и запущен';
if (component.installed) return 'ProxiFyre найден';
return 'ProxiFyre не найден';
}
function proxyfierDetails(component: ComponentStatus | undefined) {
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 sameValue(left: string, right: string) {
return left.trim().toLowerCase() === right.trim().toLowerCase();
}
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 'Неизвестная ошибка.';
}