1595 lines
57 KiB
TypeScript
1595 lines
57 KiB
TypeScript
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 'Неизвестная ошибка.';
|
||
}
|