Clarify active Windows client architecture
This commit is contained in:
462
apps/windows-client/src/app/App.tsx
Normal file
462
apps/windows-client/src/app/App.tsx
Normal 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 'Неизвестная ошибка.';
|
||||
}
|
||||
Reference in New Issue
Block a user