diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 870fefc..aba5d85 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,9 +13,10 @@ "windows": [ { "title": "ProxyWarden", - "width": 1120, + "width": 820, "height": 760, - "minWidth": 760, + "minWidth": 820, + "maxWidth": 820, "minHeight": 560, "resizable": true } diff --git a/src/app/App.tsx b/src/app/App.tsx index 3215c90..de3893f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { open } from '@tauri-apps/plugin-dialog'; -import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Power, ShieldAlert, Trash2, Wand2 } from 'lucide-react'; +import { Cpu, FileCode2, FolderOpen, Gauge, Link2, ShieldAlert, Trash2, Wand2 } from 'lucide-react'; import { applyProfiles, fetchSingBoxSubscription, @@ -50,6 +50,7 @@ type ServiceVisualState = 'active' | 'settling' | null; type PanelId = 'summary' | 'proxifyre' | 'proxy'; type StatusTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted'; type TabTransitionDirection = 'left' | 'right'; +type SummaryRouteFlow = 'proxy' | 'direct' | 'idle'; interface DraftItem { id: string; @@ -140,7 +141,9 @@ 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 PANEL_ORDER: PanelId[] = ['summary', 'proxifyre', 'proxy']; +const PANEL_ORDER: PanelId[] = ['proxifyre', 'summary', 'proxy']; +const proxyWardenToggleOnImage = new URL('../assets/proxywarden-toggle-on.png', import.meta.url).href; +const proxyWardenToggleOffImage = new URL('../assets/proxywarden-toggle-off.png', import.meta.url).href; const fallbackComponents: ComponentStatus[] = [ { @@ -993,17 +996,15 @@ export function App() { function renderTabs() { const tabs: Array<{ id: PanelId; label: string }> = [ - { id: 'summary', label: 'Сводка' }, { id: 'proxifyre', label: 'ProxiFyre' }, + { id: 'summary', label: 'ProxyWarden' }, { id: 'proxy', label: 'VPN / Прокси' }, ]; - return ; + return ; } function renderSummaryPanel() { - const appliedRouteMode = appliedSnapshot?.routeMode ?? routeMode; - return (
- {renderSummaryStatusControl()} - -
-
-
- Применено сейчас - {appliedSnapshot ? routeModeLabel(appliedRouteMode) : 'Состояние загружается'} -
-
-
-
-
ProxiFyre
-
{proxyfierTitle(proxyfier, isDetectingComponents)}
-
-
-
Маршрут
-
{snapshotRouteLine(appliedSnapshot, singBoxStatus)}
-
-
-
VPN сервер
-
{snapshotServerText(appliedSnapshot)}
-
-
-
Приложения
-
{snapshotItemsText(appliedSnapshot)}
-
-
-
Конфиг
-
{generatedConfigPath || 'Примененный config еще не создан.'}
-
-
+

ProxyWarden

+
+ {renderSummaryStatusControl()} + {renderRouteChain('vertical')}
-
); } @@ -1230,30 +1203,28 @@ export function App() { const installed = Boolean(proxyfier?.installed); const running = Boolean(proxyfier?.running); const working = serviceAction === 'start' || serviceAction === 'stop' || serviceAction === 'restart'; - const buttonLabel = !installed ? 'Не установлен' : running ? 'Отключить' : 'Включить'; + const stateLabel = working || systemSummary.tone === 'checking' + ? 'Проверяю' + : systemSummary.tone === 'ok' ? 'Работает' : 'Не работает'; + const buttonAriaLabel = !installed + ? 'ProxiFyre не установлен' + : running ? 'Отключить ProxyWarden' : 'Включить ProxyWarden'; + const imageSrc = running ? proxyWardenToggleOnImage : proxyWardenToggleOffImage; return (
- {systemSummary.tone === 'checking' ? : null} -
); } @@ -1602,13 +1573,12 @@ export function App() { {routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()} - {renderRouteChain()} ); } - function renderRouteChain() { - const segments = routeChainSegments({ + function renderRouteChain(variant: 'horizontal' | 'vertical' = 'horizontal') { + const input: RouteChainInput = { routeMode, proxyInput, proxyfier, @@ -1617,23 +1587,41 @@ export function App() { selectedServer, appCount: items.length, isDetectingComponents, - }); + }; + const flow = variant === 'vertical' ? summaryRouteFlow(input) : 'proxy'; + const segments = variant === 'vertical' + ? summaryRouteChainSegments(input, flow) + : routeChainSegments(input); + const isVertical = variant === 'vertical'; return ( -
+
+ {isVertical ? ( +
@@ -1656,23 +1644,6 @@ export function App() { style={shellStyle} >
-
-
-

ProxyWarden

- Proxy для приложений -
- -
- {renderTabs()}
{renderActivePanel()} @@ -2111,6 +2082,57 @@ function connectionCheckView(input: ConnectionCheckInput): ConnectionCheckView { }; } +function summaryRouteFlow(input: RouteChainInput): SummaryRouteFlow { + if (input.isDetectingComponents || input.appCount <= 0) return 'idle'; + if (!input.proxyfier?.running) return 'direct'; + return 'proxy'; +} + +function summaryRouteChainSegments(input: RouteChainInput, flow: SummaryRouteFlow): RouteChainSegment[] { + if (flow === 'proxy') return routeChainSegments(input); + + const appSegment: RouteChainSegment = { + id: 'apps', + label: 'Приложения', + value: appCountCompact(input.appCount), + tone: input.appCount > 0 ? 'ok' : 'warning', + details: [ + input.appCount > 0 ? appCountText(input.appCount) : 'Добавь хотя бы одно приложение на вкладке ProxiFyre.', + ], + }; + + if (flow === 'idle') { + return [ + appSegment, + { + id: 'idle', + label: 'Трафик', + value: input.isDetectingComponents ? 'проверяю' : 'ожидает приложения', + tone: input.isDetectingComponents ? 'checking' : 'warning', + details: input.isDetectingComponents + ? ['Проверяю ProxiFyre и текущий маршрут.'] + : ['Пока нет выбранных приложений, ProxyWarden ничего не маршрутизирует.'], + }, + ]; + } + + return [ + appSegment, + { + id: 'direct', + label: 'Интернет', + value: 'напрямую', + tone: 'warning', + details: [ + input.proxyfier?.installed + ? 'ProxiFyre остановлен, поэтому выбранные приложения не перехватываются.' + : 'ProxiFyre не найден, поэтому выбранные приложения не перехватываются.', + 'Пакеты идут обычным системным маршрутом без SOCKS5.', + ], + }, + ]; +} + function routeChainSegments(input: RouteChainInput): RouteChainSegment[] { const proxyValidation = input.routeMode === 'external' && input.proxyInput.trim() ? safeProxyError(input.proxyInput) diff --git a/src/assets/proxywarden-toggle-off.png b/src/assets/proxywarden-toggle-off.png new file mode 100644 index 0000000..8965175 Binary files /dev/null and b/src/assets/proxywarden-toggle-off.png differ diff --git a/src/assets/proxywarden-toggle-on.png b/src/assets/proxywarden-toggle-on.png new file mode 100644 index 0000000..a14cc08 Binary files /dev/null and b/src/assets/proxywarden-toggle-on.png differ diff --git a/src/styles/app.css b/src/styles/app.css index ac27f41..f9f97b9 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -3,7 +3,7 @@ --app-change-dock-height: 0px; --app-admin-prompt-height: 0px; --change-row-count: 1; - --app-header-row-height: 50px; + --app-header-row-height: 0px; --app-tab-height: 46px; --app-header-height: calc(var(--app-header-row-height) + var(--app-tab-height)); --motion-fast: 120ms; @@ -776,7 +776,8 @@ button:disabled { .simple-panel { display: grid; - align-content: start; + grid-template-rows: minmax(0, 1fr); + align-content: stretch; min-height: 0; height: calc(100vh - var(--app-header-height) - var(--app-footer-height) - var(--app-change-dock-height) - var(--app-admin-prompt-height)); width: 100%; @@ -973,6 +974,8 @@ button:disabled { .tab-panel-frame { min-width: 0; + min-height: 100%; + height: 100%; will-change: opacity, transform; } @@ -1054,98 +1057,133 @@ button:disabled { } .summary-panel { - gap: 12px; + grid-template-rows: auto minmax(0, 1fr); + align-content: stretch; + justify-items: center; + gap: 0; + min-height: 100%; + height: 100%; + width: 100%; + padding-top: clamp(12px, 3vh, 28px); +} + +.summary-brand-title { + margin: 0; + color: #f8fafc; + font-size: clamp(22px, 4vw, 34px); + font-weight: 800; + line-height: 1.05; + text-align: center; +} + +.summary-main { + align-self: stretch; + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(220px, 288px); + gap: clamp(22px, 5vw, 54px); + align-items: center; + width: min(100%, 760px); + min-height: 0; + padding: 0 18px clamp(34px, 10vh, 92px); } .summary-status-control { position: relative; isolation: isolate; display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 12px; - align-items: center; - border: 1px solid #2b3342; - border-radius: 4px; - background: #151923; - padding: 13px 14px; + align-content: center; + align-self: center; + justify-items: center; + gap: 14px; + width: 100%; + overflow: visible; + padding: 0; + text-align: center; } -.summary-status-control > :not(.ui-busy-ring) { - position: relative; - z-index: 1; -} - -.summary-status-control.ok { - border-color: rgba(34, 197, 94, 0.44); -} - -.summary-status-control.warning { - border-color: rgba(245, 158, 11, 0.48); -} - -.summary-status-control.error { - border-color: rgba(239, 68, 68, 0.52); -} - -.summary-status-control.checking { - border-color: rgba(59, 130, 246, 0.54); -} - -.summary-status-dot { - width: 14px; - height: 14px; - border-radius: 999px; - background: #64748b; - margin-top: 5px; -} - -.summary-status-dot.ok { - background: #22c55e; - box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12); -} - -.summary-status-dot.warning { - background: #f59e0b; - box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12); -} - -.summary-status-dot.error { - background: #ef4444; - box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.12); -} - -.summary-status-dot.checking { - background: #60a5fa; - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14); -} - -.summary-status-copy span, .summary-card-label { color: #8d99ae; font-size: 12px; font-weight: 700; } -.summary-status-copy { - min-width: 0; +.summary-toggle-button { + appearance: none; + position: relative; + isolation: isolate; + display: grid; + place-items: center; + width: clamp(180px, 30vw, 268px); + aspect-ratio: 1; + overflow: hidden; + border: 0; + border-radius: 50%; + background: transparent; + box-shadow: + 0 22px 54px rgba(0, 0, 0, 0.48), + 0 0 38px rgba(59, 130, 246, 0.1); + cursor: pointer; + padding: 0; + transition: + border-color var(--motion-fast) var(--ease-out), + box-shadow var(--motion-fast) var(--ease-out), + opacity var(--motion-fast) var(--ease-out), + transform var(--motion-fast) var(--ease-out); } -.summary-status-copy strong { +.summary-toggle-button:hover:not(:disabled) { + box-shadow: + 0 26px 62px rgba(0, 0, 0, 0.54), + 0 0 52px rgba(59, 130, 246, 0.18); + transform: translateY(-2px); +} + +.summary-toggle-button:active:not(:disabled) { + transform: scale(0.97); +} + +.summary-toggle-button:focus-visible { + outline: 0; + box-shadow: + 0 0 0 2px rgba(59, 130, 246, 0.72), + 0 22px 54px rgba(0, 0, 0, 0.48); +} + +.summary-toggle-button > :not(.ui-busy-ring) { + position: relative; + z-index: 1; +} + +.summary-toggle-button img { display: block; - color: #f8fafc; - font-size: 22px; - line-height: 1.15; - margin-top: 2px; + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + pointer-events: none; + transform: scale(1.18); + user-select: none; } -.summary-status-copy p { - color: #b6c2d4; - margin: 4px 0 0; - overflow-wrap: anywhere; +.summary-state-label { + color: #e5e7eb; + font-size: clamp(22px, 3.4vw, 34px); + font-weight: 800; + line-height: 1.1; + text-align: center; } -.summary-status-button { - min-width: 148px; +.summary-state-label.ok { + color: #bbf7d0; +} + +.summary-state-label.warning, +.summary-state-label.checking { + color: #fcd34d; +} + +.summary-state-label.error { + color: #fecaca; } .summary-readout { @@ -1983,12 +2021,90 @@ button.summary-card:hover { } .route-chain { + position: relative; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 7px; margin-top: 8px; } +.route-chain--vertical { + grid-template-columns: 1fr; + gap: 10px; + width: 100%; + max-width: 288px; + margin-top: 0; +} + +.route-chain-rail { + position: absolute; + z-index: 0; + top: 28px; + bottom: 28px; + left: 13px; + width: 2px; + overflow: hidden; + border-radius: 999px; + background: linear-gradient( + 180deg, + rgba(96, 165, 250, 0.08), + rgba(96, 165, 250, 0.34) 28%, + rgba(34, 197, 94, 0.28) 72%, + rgba(96, 165, 250, 0.08) + ); + pointer-events: none; +} + +.route-chain--direct .route-chain-rail { + background: linear-gradient( + 180deg, + rgba(245, 158, 11, 0.08), + rgba(245, 158, 11, 0.38) 32%, + rgba(245, 158, 11, 0.3) 68%, + rgba(245, 158, 11, 0.08) + ); +} + +.route-chain--idle .route-chain-rail { + background: linear-gradient( + 180deg, + rgba(100, 116, 139, 0.06), + rgba(100, 116, 139, 0.24) 48%, + rgba(100, 116, 139, 0.06) + ); +} + +.route-chain-packet { + position: absolute; + top: -8px; + left: 50%; + width: 6px; + height: 6px; + border-radius: 999px; + background: #93c5fd; + box-shadow: + 0 0 8px rgba(96, 165, 250, 0.95), + 0 0 16px rgba(34, 197, 94, 0.35); + transform: translateX(-50%); + animation: route-packet-flow 2.7s linear infinite; + animation-delay: -0.15s; +} + +.route-chain--direct .route-chain-packet { + background: #fcd34d; + box-shadow: + 0 0 8px rgba(245, 158, 11, 0.9), + 0 0 16px rgba(245, 158, 11, 0.28); +} + +.route-chain-packet--two { + animation-delay: -1.05s; +} + +.route-chain-packet--three { + animation-delay: -1.95s; +} + .route-chain-segment { position: relative; isolation: isolate; @@ -2010,6 +2126,17 @@ button.summary-card:hover { width: 100%; } +.route-chain--vertical .route-chain-segment { + z-index: 1; + grid-template-columns: auto minmax(0, 1fr); + grid-template-areas: + "dot label" + "dot value"; + min-height: 58px; + background: rgba(17, 23, 32, 0.94); + padding: 9px 10px 9px 9px; +} + .route-chain-segment > :not(.ui-busy-ring) { position: relative; z-index: 1; @@ -2052,6 +2179,22 @@ button.summary-card:hover { font-weight: 800; } +.route-chain--vertical .route-chain-arrow { + position: absolute; + z-index: 2; + bottom: -17px; + left: 9px; + color: #60a5fa; + font-size: 13px; + line-height: 1; + text-shadow: 0 0 8px rgba(96, 165, 250, 0.52); +} + +.route-chain--direct .route-chain-arrow { + color: #f59e0b; + text-shadow: 0 0 8px rgba(245, 158, 11, 0.42); +} + .route-chain-segment.ok .route-chain-dot { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.12); @@ -2774,6 +2917,23 @@ button.summary-card:hover { } } +@keyframes route-packet-flow { + 0% { + top: -8px; + opacity: 0; + } + + 8%, + 88% { + opacity: 1; + } + + 100% { + top: calc(100% + 8px); + opacity: 0; + } +} + @media (prefers-reduced-motion: reduce) { .tab-panel-frame, .tab-panel, @@ -2784,7 +2944,8 @@ button.summary-card:hover { .finder-border-glow-segment, .ui-service-border-glow-segment, .status-light, - .summary-status-dot.checking { + .summary-toggle-button, + .route-chain-packet { animation: none; transition: none; } @@ -2900,19 +3061,22 @@ button.summary-card:hover { gap: 4px; } - .summary-status-control { + .summary-main { grid-template-columns: 1fr; - align-items: stretch; + align-content: center; + justify-items: center; + gap: 22px; + padding: 0 10px clamp(24px, 8vh, 64px); } - .summary-status-dot { - display: none; - } - - .summary-status-button { + .summary-status-control { width: 100%; } + .summary-toggle-button { + width: min(202px, 64vw); + } + .setup-strip { grid-template-columns: 1fr; align-items: stretch; @@ -2962,6 +3126,11 @@ button.summary-card:hover { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .route-chain--vertical { + grid-template-columns: 1fr; + width: min(100%, 300px); + } + .add-toolbar { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));