Refine ProxyWarden routing and config flows

This commit is contained in:
2026-07-10 21:15:17 +03:00
parent 9fd0a8c0b9
commit dbba3806cc
31 changed files with 1823 additions and 191 deletions
+13
View File
@@ -66,6 +66,15 @@ export interface ProxiFyreSetupStatus {
items: ProxiFyreSetupItem[];
}
export interface ProxiFyreSetupProgress {
operation: 'idle' | 'install' | 'uninstall' | string;
status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
activeStep?: string;
percent: number;
message: string;
updatedAt?: string;
}
export type SingBoxSetupItem = ProxiFyreSetupItem;
export interface SingBoxSetupStatus {
@@ -201,6 +210,10 @@ export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status');
}
export function getProxiFyreSetupProgress(): Promise<ProxiFyreSetupProgress> {
return invoke<ProxiFyreSetupProgress>('get_proxifyre_setup_progress');
}
export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('get_singbox_status');
}
+122 -54
View File
@@ -7,6 +7,7 @@ import {
forgetSingBoxSubscription,
generateSingBoxConfig,
getComponents,
getProxiFyreSetupProgress,
getProxiFyreSetupStatus,
getSavedState,
getSingBoxSetupStatus,
@@ -34,11 +35,13 @@ import {
type PingServerResponse,
type ProxyProbeResponse,
type ProxyTargetCheckResponse,
type ProxiFyreSetupProgress,
type ProxiFyreSetupStatus,
type SingBoxSetupStatus,
} from '../api/tauriCommands';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
import { BusyRing, Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
import { ProxiFyreSetupStrip } from './components/ProxiFyreSetupStrip';
import { parseProxy, type ParsedProxy } from './lib/parseProxy';
import { getApplyReadiness } from './readiness';
import { serviceControlState } from './viewModel';
@@ -183,6 +186,7 @@ export function App() {
const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null);
const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
const [setupStatus, setSetupStatus] = useState<ProxiFyreSetupStatus | null>(null);
const [setupProgress, setSetupProgress] = useState<ProxiFyreSetupProgress | null>(null);
const [singBoxStatus, setSingBoxStatus] = useState<LocalSingBoxStatusResponse | null>(null);
const [singBoxSetupStatus, setSingBoxSetupStatus] = useState<SingBoxSetupStatus | null>(null);
const [subscriptionInput, setSubscriptionInput] = useState('');
@@ -269,14 +273,39 @@ export function App() {
return () => window.clearTimeout(timer);
}, [activeLogId]);
useEffect(() => {
if (serviceAction !== 'install' && serviceAction !== 'uninstall') return undefined;
let cancelled = false;
const pollProgress = async () => {
try {
const progress = await getProxiFyreSetupProgress();
if (!cancelled) setSetupProgress(progress);
} catch {
// Progress is best-effort; the main install/uninstall action still reports the real error.
}
};
void pollProgress();
const timer = window.setInterval(() => void pollProgress(), 650);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [serviceAction]);
async function refresh() {
setIsLoading(true);
setIsDetectingComponents(true);
try {
const snapshot = await getStartupSnapshot();
const [snapshot, progress] = await Promise.all([
getStartupSnapshot(),
getProxiFyreSetupProgress(),
]);
setAdminStatus(snapshot.adminStatus);
setComponents(snapshot.components);
setSetupStatus(snapshot.proxifyreSetupStatus);
setSetupProgress(progress);
setSingBoxStatus(snapshot.singboxStatus);
setSingBoxSetupStatus(snapshot.singboxSetupStatus);
applySavedState(
@@ -562,19 +591,25 @@ export function App() {
async function installProxiFyrePackage() {
setServiceAction('install');
setIsServiceMenuOpen(false);
setSetupProgress(localSetupProgress('install', 'packet-filter', 1, 'Готовлю установку сетевого драйвера.'));
startServiceVisual();
try {
await nextFrame();
const component = await installProxiFyre();
const detectedSetupStatus = await getProxiFyreSetupStatus();
const [detectedSetupStatus, detectedProgress] = await Promise.all([
getProxiFyreSetupStatus(),
getProxiFyreSetupProgress(),
]);
setComponents((current) => upsertComponent(current, component));
setSetupStatus(detectedSetupStatus);
setSetupProgress(detectedProgress);
showNotice({
kind: 'success',
title: 'ProxiFyre установлен',
text: proxyfierDetails(component, false),
});
} catch (error) {
void getProxiFyreSetupProgress().then(setSetupProgress).catch(() => undefined);
showNotice({
kind: 'error',
title: 'ProxiFyre не установлен',
@@ -588,25 +623,31 @@ export function App() {
async function uninstallProxiFyrePackage() {
const confirmed = window.confirm(
'Удалить ProxiFyre с компьютера? Будет удалена служба и папка установки ProxiFyre.',
'Удалить ProxiFyre и Windows Packet Filter с компьютера? Это остановит службу, удалит папку ProxiFyre и сетевой драйвер. Другие программы WireSock могут перестать работать до повторной установки драйвера.',
);
if (!confirmed) return;
setServiceAction('uninstall');
setIsServiceMenuOpen(false);
setSetupProgress(localSetupProgress('uninstall', 'proxifyre', 1, 'Готовлю удаление ProxiFyre и сетевого драйвера.'));
startServiceVisual();
try {
await nextFrame();
const component = await uninstallProxiFyre();
const detectedSetupStatus = await getProxiFyreSetupStatus();
const [detectedSetupStatus, detectedProgress] = await Promise.all([
getProxiFyreSetupStatus(),
getProxiFyreSetupProgress(),
]);
setComponents((current) => upsertComponent(current, component));
setSetupStatus(detectedSetupStatus);
setSetupProgress(detectedProgress);
showNotice({
kind: 'success',
title: 'ProxiFyre удален',
text: 'Служба и папка установки ProxiFyre удалены.',
text: 'Служба, папка установки ProxiFyre и Windows Packet Filter удалены.',
});
} catch (error) {
void getProxiFyreSetupProgress().then(setSetupProgress).catch(() => undefined);
showNotice({
kind: 'error',
title: 'ProxiFyre не удален',
@@ -995,8 +1036,21 @@ export function App() {
function renderProxiFyreCard() {
const state = serviceControlState(proxyfier, isDetectingComponents);
const visualState = serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : null;
const primaryAction = proxyfier?.installed
const packetFilterInstalled = Boolean(
setupStatus?.items.some((item) => item.id === 'packet-filter' && item.installed),
);
const canCleanupSetup = Boolean(proxyfier?.installed || packetFilterInstalled);
const shouldInstallProxiFyre = !proxyfier?.installed || !proxyfier.serviceStatus;
const primaryAction = shouldInstallProxiFyre
? {
label: proxyfier?.installed ? 'Переустановить' : 'Установить',
onClick: () => void installProxiFyrePackage(),
variant: 'primary' as const,
loading: serviceAction === 'install',
loadingLabel: 'Устанавливаю',
disabled: isDetectingComponents || Boolean(serviceAction),
}
: {
label: proxyfier.running ? 'Остановить' : 'Запустить',
onClick: () => void setProxiFyreServiceRunning(!proxyfier.running),
variant: proxyfier.running ? 'danger' as const : 'neutral' as const,
@@ -1005,14 +1059,6 @@ export function App() {
? 'Перезапускаю'
: serviceAction === 'start' ? 'Запускаю' : 'Останавливаю',
disabled: isDetectingComponents || Boolean(serviceAction),
}
: {
label: 'Установить',
onClick: () => void installProxiFyrePackage(),
variant: 'primary' as const,
loading: serviceAction === 'install',
loadingLabel: 'Устанавливаю',
disabled: isDetectingComponents || Boolean(serviceAction),
};
return (
@@ -1023,13 +1069,13 @@ export function App() {
title={proxyfierTitle(proxyfier, isDetectingComponents)}
detail={proxyfierDetails(proxyfier, isDetectingComponents)}
primaryAction={primaryAction}
menu={proxyfier?.installed ? {
menu={canCleanupSetup ? {
label: 'Дополнительные действия ProxiFyre',
open: isServiceMenuOpen,
onOpenChange: setIsServiceMenuOpen,
disabled: isDetectingComponents || Boolean(serviceAction),
items: [{
label: serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre',
label: serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre и драйвер',
danger: true,
disabled: Boolean(serviceAction),
onClick: () => void uninstallProxiFyrePackage(),
@@ -1040,22 +1086,7 @@ export function App() {
}
function renderProxiFyreSetupStrip() {
const stripItems = setupStatus?.items ?? proxifyreSetupPlaceholders();
return (
<div className={`setup-strip ${setupStatus?.ready ? 'ready' : 'attention'}`} aria-label="Состав ProxiFyre">
<span className="setup-strip-title">Состав</span>
<div className="setup-strip-items">
{stripItems.map((item) => (
<div className={`setup-strip-item ${item.installed ? 'installed' : 'missing'}`} key={item.id}>
<span className="setup-strip-dot" aria-hidden="true" />
<strong>{setupItemUserName(item.id, item.name)}</strong>
<span>{setupItemShortStatus(item)}</span>
</div>
))}
</div>
</div>
);
return <ProxiFyreSetupStrip setupStatus={setupStatus} progress={setupProgress} />;
}
function renderAppsSection() {
@@ -2096,9 +2127,7 @@ function summaryRouteChainSegments(input: RouteChainInput, flow: SummaryRouteFlo
value: 'напрямую',
tone: 'warning',
details: [
input.proxyfier?.installed
? 'ProxiFyre остановлен, поэтому выбранные приложения не перехватываются.'
: 'ProxiFyre не найден, поэтому выбранные приложения не перехватываются.',
proxyfierBypassReason(input.proxyfier),
'Пакеты идут обычным системным маршрутом без SOCKS5.',
],
},
@@ -2136,7 +2165,7 @@ function routeChainSegments(input: RouteChainInput): RouteChainSegment[] {
{
id: 'proxifyre',
label: 'ProxiFyre',
value: input.isDetectingComponents ? 'проверяю' : input.proxyfier?.running ? 'запущен' : input.proxyfier?.installed ? 'остановлен' : 'не найден',
value: proxyfierCompactStatus(input.proxyfier, input.isDetectingComponents),
tone: componentChainTone(input.proxyfier, input.isDetectingComponents),
details: [
proxyfierTitle(input.proxyfier, input.isDetectingComponents),
@@ -2449,25 +2478,26 @@ function itemIcon(type: DraftItemType) {
return <FileCode2 size={18} strokeWidth={1.9} />;
}
function proxifyreSetupPlaceholders(): ProxiFyreSetupStatus['items'] {
return [
{ id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' },
{ id: 'packet-filter', name: 'Сетевой драйвер', installed: false, details: 'Проверяю' },
{ id: 'proxifyre', name: 'Клиент ProxiFyre', installed: false, details: 'Проверяю' },
];
}
function setupItemUserName(id: string, fallbackName: string) {
if (id === 'vc-runtime') return 'Среда запуска';
if (id === 'packet-filter') return 'Сетевой драйвер';
if (id === 'proxifyre') return 'Клиент ProxiFyre';
return fallbackName;
function localSetupProgress(
operation: 'install' | 'uninstall',
activeStep: string,
percent: number,
message: string,
): ProxiFyreSetupProgress {
return {
operation,
status: 'running',
activeStep,
percent,
message,
updatedAt: new Date().toISOString(),
};
}
function setupItemShortStatus(item: ProxiFyreSetupStatus['items'][number]) {
if (item.details === 'Проверяю') return 'проверяю';
if (!item.installed) return 'нужно установить';
if (item.id === 'proxifyre') return item.version?.includes('не запущена') ? 'остановлен' : 'запущен';
if (item.id === 'proxifyre') return proxifyreSetupServiceSummary(item.version);
return 'готово';
}
@@ -2489,16 +2519,54 @@ function profileInputFromProfile(profile: Profile, enabled: boolean) {
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 найден';
if (component.installed) return `ProxiFyre найден, служба ${serviceStatusLabel(component.serviceStatus)}`;
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] ?? 'Путь установки не найден.';
const service = proxyfierServiceDetails(component);
if (component.path) return service ? `${component.path} · ${service}` : component.path;
const problem = component.problems[0] ?? 'Путь установки не найден.';
return service ? `${problem} · ${service}` : problem;
}
function proxyfierCompactStatus(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'проверяю';
if (!component?.installed) return 'не найден';
return `служба ${serviceStatusLabel(component.serviceStatus)}`;
}
function proxyfierBypassReason(component: ComponentStatus | undefined) {
if (!component?.installed) {
return 'ProxiFyre не найден, поэтому выбранные приложения не перехватываются.';
}
return `Служба ProxiFyre ${serviceStatusLabel(component.serviceStatus)}, поэтому выбранные приложения не перехватываются.`;
}
function proxyfierServiceDetails(component: ComponentStatus) {
const serviceName = component.serviceName ?? 'ProxiFyreService';
return `служба ${serviceName}: ${serviceStatusLabel(component.serviceStatus)}`;
}
function serviceStatusLabel(status: string | undefined) {
const normalized = status?.trim().toLowerCase();
if (!normalized) return 'не установлена';
if (normalized === 'running') return 'запущена';
if (normalized === 'stopped') return 'остановлена';
if (normalized === 'startpending' || normalized === 'start_pending') return 'запускается';
if (normalized === 'stoppending' || normalized === 'stop_pending') return 'останавливается';
if (normalized === 'paused') return 'на паузе';
return status;
}
function proxifyreSetupServiceSummary(version: string | undefined) {
const normalized = version?.trim().toLowerCase() ?? '';
if (normalized.includes('не установлена')) return 'служба не установлена';
if (normalized.includes('остановлена') || normalized.includes('не запущена')) return 'служба остановлена';
if (normalized.includes('запущена')) return 'служба запущена';
return 'готово';
}
function singBoxDetails(
+109
View File
@@ -0,0 +1,109 @@
import type { ProxiFyreSetupProgress, ProxiFyreSetupStatus } from '../../api/tauriCommands';
interface ProxiFyreSetupStripProps {
setupStatus: ProxiFyreSetupStatus | null;
progress: ProxiFyreSetupProgress | null;
}
const SETUP_PLACEHOLDERS: ProxiFyreSetupStatus['items'] = [
{ id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' },
{ id: 'packet-filter', name: 'Сетевой драйвер', installed: false, details: 'Проверяю' },
{ id: 'proxifyre', name: 'Клиент ProxiFyre', installed: false, details: 'Проверяю' },
];
export function ProxiFyreSetupStrip({ setupStatus, progress }: ProxiFyreSetupStripProps) {
const stripItems = setupStatus?.items ?? SETUP_PLACEHOLDERS;
const visibleProgress = isVisibleProgress(progress) ? progress : null;
const progressTone = visibleProgress?.status === 'failed' ? 'failed' : 'running';
const percent = clampPercent(visibleProgress?.percent ?? 0);
return (
<div
className={`setup-strip ${setupStatus?.ready ? 'ready' : 'attention'} ${visibleProgress ? 'with-progress' : ''}`}
aria-label="Состав ProxiFyre"
>
<span className="setup-strip-title">Состав</span>
<div className="setup-strip-items">
{stripItems.map((item) => (
<div
className={`setup-strip-item ${setupItemClass(item, visibleProgress)}`}
key={item.id}
>
<span className="setup-strip-dot" aria-hidden="true" />
<strong>{setupItemUserName(item.id, item.name)}</strong>
<span>{setupItemShortStatus(item, visibleProgress)}</span>
</div>
))}
</div>
{visibleProgress ? (
<div className={`setup-progress setup-progress--${progressTone}`}>
<div
className="setup-progress-track"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
aria-label={visibleProgress.message}
>
<span className="setup-progress-fill" style={{ width: `${percent}%` }} />
</div>
<span className="setup-progress-message">{visibleProgress.message}</span>
</div>
) : null}
</div>
);
}
function setupItemClass(
item: ProxiFyreSetupStatus['items'][number],
progress: ProxiFyreSetupProgress | null,
) {
if (progress?.activeStep === item.id) {
if (progress.status === 'failed') return 'failed';
return 'active';
}
if (item.installed) return 'installed';
return 'missing';
}
function setupItemUserName(id: string, fallbackName: string) {
if (id === 'vc-runtime') return 'Среда запуска';
if (id === 'packet-filter') return 'Сетевой драйвер';
if (id === 'proxifyre') return 'Клиент ProxiFyre';
return fallbackName;
}
function setupItemShortStatus(
item: ProxiFyreSetupStatus['items'][number],
progress: ProxiFyreSetupProgress | null,
) {
if (progress?.activeStep === item.id) {
if (progress.status === 'failed') return 'ошибка';
if (progress.status === 'succeeded') return progress.operation === 'uninstall' ? 'удалено' : 'готово';
return 'в процессе';
}
if (item.details === 'Проверяю') return 'проверяю';
if (!item.installed) return progress?.operation === 'uninstall' && progress.status === 'succeeded'
? 'удалено'
: 'нужно установить';
if (item.id === 'proxifyre') return proxifyreSetupServiceSummary(item.version);
return 'готово';
}
function proxifyreSetupServiceSummary(version: string | undefined) {
const normalized = version?.trim().toLowerCase() ?? '';
if (normalized.includes('не установлена')) return 'служба не установлена';
if (normalized.includes('остановлена') || normalized.includes('не запущена')) return 'служба остановлена';
if (normalized.includes('запущена')) return 'служба запущена';
return 'готово';
}
function isVisibleProgress(progress: ProxiFyreSetupProgress | null): progress is ProxiFyreSetupProgress {
if (!progress || progress.status === 'idle') return false;
return progress.status === 'running' || progress.status === 'failed';
}
function clampPercent(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
+1
View File
@@ -9,6 +9,7 @@ export function serviceControlState(
if (!component) return 'missing';
if (component.state === 'error') return 'error';
if (component.running) return 'running';
if (component.installed && !component.serviceStatus) return 'installed';
if (component.installed) return 'stopped';
return 'missing';
}
+2
View File
@@ -64,6 +64,8 @@ export interface ComponentStatus {
running: boolean;
version?: string;
path?: string;
serviceName?: string;
serviceStatus?: string;
problems: string[];
actions: string[];
}
+61
View File
@@ -1514,6 +1514,10 @@ button.summary-card:hover {
padding: 7px 10px;
}
.setup-strip.with-progress {
row-gap: 8px;
}
.setup-strip-title {
color: #8d99ae;
font-size: 12px;
@@ -1554,6 +1558,26 @@ button.summary-card:hover {
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.11);
}
.setup-strip-item.active {
border-color: #60a5fa;
background: #122033;
}
.setup-strip-item.active .setup-strip-dot {
background: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.14);
}
.setup-strip-item.failed {
border-color: #ef4444;
background: #26151a;
}
.setup-strip-item.failed .setup-strip-dot {
background: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);
}
.setup-strip-item strong {
font-size: 12px;
white-space: nowrap;
@@ -1565,6 +1589,43 @@ button.summary-card:hover {
white-space: nowrap;
}
.setup-progress {
display: grid;
grid-column: 1 / -1;
grid-template-columns: minmax(96px, 160px) minmax(0, 1fr);
gap: 9px;
align-items: center;
}
.setup-progress-track {
overflow: hidden;
height: 6px;
border-radius: 999px;
background: #202a38;
}
.setup-progress-fill {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: #60a5fa;
transition: width 180ms ease;
}
.setup-progress--failed .setup-progress-fill {
background: #ef4444;
}
.setup-progress-message {
min-width: 0;
overflow: hidden;
color: #9aa8bd;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-actions {
position: relative;
display: flex;