diff --git a/apps/windows-client/src-tauri/src/commands.rs b/apps/windows-client/src-tauri/src/commands.rs index ab21240..cafabfd 100644 --- a/apps/windows-client/src-tauri/src/commands.rs +++ b/apps/windows-client/src-tauri/src/commands.rs @@ -219,6 +219,13 @@ pub struct PingSingBoxServerInputDto { pub tag: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PingProxyTargetInputDto { + pub host: String, + pub port: u16, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PingServerResponse { @@ -619,6 +626,11 @@ pub fn ping_all_singbox_servers( ping_all_singbox_servers_in_storage(&state.storage()) } +#[tauri::command] +pub fn ping_proxy_target(input: PingProxyTargetInputDto) -> Result { + ping_proxy_target_endpoint(input) +} + #[tauri::command] pub fn generate_singbox_config( state: tauri::State<'_, CommandState>, @@ -1115,6 +1127,20 @@ pub fn ping_all_singbox_servers_in_storage( Ok(cache.servers.iter().map(ping_subscription_server).collect()) } +pub fn ping_proxy_target_endpoint( + input: PingProxyTargetInputDto, +) -> Result { + let host = input.host.trim(); + if host.is_empty() { + return Err(CommandError::new( + "proxy_target_host_missing", + "Хост внешнего прокси не указан.", + )); + } + + Ok(ping_endpoint("external-proxy", host, input.port)) +} + pub fn generate_singbox_config_with_services( storage: &JsonStorage, adapter: &SingBoxAdapter, @@ -1196,14 +1222,18 @@ fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError> } fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse { + ping_endpoint(&server.tag, &server.server, server.server_port) +} + +fn ping_endpoint(tag: &str, server: &str, server_port: u16) -> PingServerResponse { let started = Instant::now(); - let addresses = match (server.server.as_str(), server.server_port).to_socket_addrs() { + let addresses = match (server, server_port).to_socket_addrs() { Ok(addresses) => addresses.collect::>(), Err(error) => { return PingServerResponse { - tag: server.tag.clone(), - server: server.server.clone(), - server_port: server.server_port, + tag: tag.to_string(), + server: server.to_string(), + server_port, ok: false, latency: None, error: Some(format!("DNS/адрес недоступен: {error}")), @@ -1213,9 +1243,9 @@ fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse { if addresses.is_empty() { return PingServerResponse { - tag: server.tag.clone(), - server: server.server.clone(), - server_port: server.server_port, + tag: tag.to_string(), + server: server.to_string(), + server_port, ok: false, latency: None, error: Some("DNS не вернул адреса".to_string()), @@ -1228,9 +1258,9 @@ fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse { match TcpStream::connect_timeout(&address, timeout) { Ok(_) => { return PingServerResponse { - tag: server.tag.clone(), - server: server.server.clone(), - server_port: server.server_port, + tag: tag.to_string(), + server: server.to_string(), + server_port, ok: true, latency: Some(started.elapsed().as_millis()), error: None, @@ -1241,9 +1271,9 @@ fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse { } PingServerResponse { - tag: server.tag.clone(), - server: server.server.clone(), - server_port: server.server_port, + tag: tag.to_string(), + server: server.to_string(), + server_port, ok: false, latency: None, error: last_error, diff --git a/apps/windows-client/src-tauri/src/main.rs b/apps/windows-client/src-tauri/src/main.rs index b7a834a..ae5dec6 100644 --- a/apps/windows-client/src-tauri/src/main.rs +++ b/apps/windows-client/src-tauri/src/main.rs @@ -52,6 +52,7 @@ fn main() { commands::select_singbox_server, commands::ping_singbox_server, commands::ping_all_singbox_servers, + commands::ping_proxy_target, commands::generate_singbox_config, commands::apply_profiles, commands::get_logs, diff --git a/apps/windows-client/src-tauri/tests/command_tests.rs b/apps/windows-client/src-tauri/tests/command_tests.rs index 60ae4b8..0362c6a 100644 --- a/apps/windows-client/src-tauri/tests/command_tests.rs +++ b/apps/windows-client/src-tauri/tests/command_tests.rs @@ -37,6 +37,7 @@ use models::{ use proxifyre::ProxiFyreAdapter; use std::collections::HashSet; use std::fs; +use std::net::TcpListener; use std::path::{Path, PathBuf}; #[cfg(windows)] use std::process::Command as ProcessCommand; @@ -127,6 +128,24 @@ fn resolve_preview_returns_structured_apps_without_filesystem_scan() { .any(|warning| warning.contains("Сканирование папок отложено"))); } +#[test] +fn ping_proxy_target_reports_open_tcp_endpoint() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener"); + let port = listener.local_addr().expect("read local addr").port(); + + let result = commands::ping_proxy_target_endpoint(commands::PingProxyTargetInputDto { + host: "127.0.0.1".to_string(), + port, + }) + .expect("ping should return response"); + + assert_eq!(result.tag, "external-proxy"); + assert_eq!(result.server, "127.0.0.1"); + assert_eq!(result.server_port, port); + assert!(result.ok); + assert!(result.latency.is_some()); +} + #[test] #[cfg(windows)] fn proxifyre_install_script_parses_as_powershell() { diff --git a/apps/windows-client/src/api/tauriCommands.ts b/apps/windows-client/src/api/tauriCommands.ts index ff4a036..ab2bf7b 100644 --- a/apps/windows-client/src/api/tauriCommands.ts +++ b/apps/windows-client/src/api/tauriCommands.ts @@ -184,6 +184,12 @@ export function pingAllSingBoxServers(): Promise { return invoke('ping_all_singbox_servers'); } +export function pingProxyTarget(host: string, port: number): Promise { + return invoke('ping_proxy_target', { + input: { host, port }, + }); +} + export function generateSingBoxConfig(): Promise { return invoke('generate_singbox_config'); } diff --git a/apps/windows-client/src/app/App.tsx b/apps/windows-client/src/app/App.tsx index 88d1d0e..54039dd 100644 --- a/apps/windows-client/src/app/App.tsx +++ b/apps/windows-client/src/app/App.tsx @@ -15,6 +15,8 @@ import { installSingBox, openConfigLocation, pingAllSingBoxServers, + pingProxyTarget, + pingSingBoxServer, saveProfile, saveSingBoxSubscription, saveTarget, @@ -38,6 +40,9 @@ 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; +type PanelId = 'summary' | 'proxifyre' | 'proxy'; +type StatusTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted'; +type TabTransitionDirection = 'left' | 'right'; interface DraftItem { id: string; @@ -56,10 +61,23 @@ interface LogEntry extends Notice { at: number; } +interface ConfigSnapshotItem { + type: DraftItemType; + value: string; +} + +interface ConfigSnapshot { + routeMode: RouteMode; + proxy: string; + selectedServerTag: string; + items: ConfigSnapshotItem[]; +} + 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 fallbackComponents: ComponentStatus[] = [ { @@ -83,12 +101,14 @@ const fallbackComponents: ComponentStatus[] = [ ]; export function App() { + const [activePanel, setActivePanel] = useState('summary'); + const [tabTransitionDirection, setTabTransitionDirection] = useState('right'); const [proxyInput, setProxyInput] = useState(''); const [routeMode, setRouteMode] = useState('external'); const [profileId, setProfileId] = useState(MAIN_PROFILE_ID); const [targetId, setTargetId] = useState(MAIN_TARGET_ID); const [items, setItems] = useState([]); - const [hasUnappliedChanges, setHasUnappliedChanges] = useState(false); + const [appliedSnapshot, setAppliedSnapshot] = useState(null); const [loadedProfiles, setLoadedProfiles] = useState([]); const [isProcessInputOpen, setIsProcessInputOpen] = useState(false); const [processInput, setProcessInput] = useState(''); @@ -99,7 +119,7 @@ export function App() { const [singBoxSetupStatus, setSingBoxSetupStatus] = useState(null); const [subscriptionInput, setSubscriptionInput] = useState(''); const [serverPings, setServerPings] = useState>({}); - const [isSetupOpen, setIsSetupOpen] = useState(false); + const [proxyPing, setProxyPing] = useState(null); const [isSingBoxSetupOpen, setIsSingBoxSetupOpen] = useState(false); const [isSingBoxInfoOpen, setIsSingBoxInfoOpen] = useState(false); const [generatedConfigPath, setGeneratedConfigPath] = useState(''); @@ -110,12 +130,15 @@ export function App() { const [isDetectingComponents, setIsDetectingComponents] = useState(true); const [isApplying, setIsApplying] = useState(false); const [isOpeningConfig, setIsOpeningConfig] = useState(false); + const [isProxyChecking, setIsProxyChecking] = useState(false); + const [serverPingTag, setServerPingTag] = useState(null); const [serviceAction, setServiceAction] = useState(null); const [singBoxAction, setSingBoxAction] = useState(null); const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false); const [isSingBoxMenuOpen, setIsSingBoxMenuOpen] = useState(false); const [serviceVisualState, setServiceVisualState] = useState(null); const serviceVisualTimerRef = useRef(null); + const appsSectionRef = useRef(null); const proxyfier = useMemo( () => components.find((component) => component.id === 'proxyfier'), @@ -135,6 +158,24 @@ export function App() { const isSingBoxInstalled = Boolean(singbox?.installed); const singBoxStateClass = isDetectingComponents ? 'checking' : singbox?.installed ? 'found' : 'missing'; const singBoxVisualClass = singBoxAction ? 'working' : ''; + const selectedServerTag = singBoxStatus?.config.selectedServerTag; + const currentSnapshot = useMemo( + () => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag), + [items, proxyInput, routeMode, selectedServerTag], + ); + const hasUnappliedChanges = appliedSnapshot + ? !sameConfigSnapshot(currentSnapshot, appliedSnapshot) + : false; + const systemSummary = systemSummaryState({ + isLoading, + isDetectingComponents, + proxyfier, + singbox, + routeMode, + singBoxStatus, + proxyPing, + hasUnappliedChanges, + }); useEffect(() => { void refresh(); @@ -189,6 +230,13 @@ export function App() { setSetupStatus(detectedSetupStatus); setSingBoxStatus(detectedSingBoxStatus); setSingBoxSetupStatus(detectedSingBoxSetupStatus); + setAppliedSnapshot((current) => { + if (!current || current.routeMode !== 'local-singbox' || current.selectedServerTag) return current; + return { + ...current, + selectedServerTag: normalizeServerTag(detectedSingBoxStatus.config.selectedServerTag), + }; + }); } catch (error) { showNotice({ kind: 'error', @@ -200,26 +248,39 @@ export function App() { } } - function applySavedState(profiles: Profile[], targets: Target[], generatedPath: string) { + function applySavedState( + profiles: Profile[], + targets: Target[], + generatedPath: string, + singBoxStatusForSnapshot = singBoxStatus, + ) { 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; + const savedProxyInput = externalTarget ? formatProxy(externalTarget) : ''; + const savedItems = itemsForProfiles(editableProfiles); + const savedRouteMode = + activeTarget?.id === LOCAL_SINGBOX_TARGET_ID || activeProfile?.targetId === LOCAL_SINGBOX_TARGET_ID + ? 'local-singbox' + : 'external'; - if (externalTarget) setProxyInput(formatProxy(externalTarget)); - setItems(itemsForProfiles(editableProfiles)); + setProxyInput(savedProxyInput); + setProxyPing(null); + setItems(savedItems); 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', - ); + setRouteMode(savedRouteMode); setGeneratedConfigPath(generatedPath); - setHasUnappliedChanges(false); + setAppliedSnapshot(configSnapshotFromUi( + savedRouteMode, + savedProxyInput, + savedItems, + singBoxStatusForSnapshot?.config.selectedServerTag, + )); } function addItem(type: DraftItemType, rawValue: string) { @@ -250,7 +311,6 @@ export function App() { value, }, ]); - setHasUnappliedChanges(true); return true; } @@ -263,19 +323,31 @@ export function App() { 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; - }); + setRouteMode(nextMode); } function changeProxyInput(nextValue: string) { setProxyInput(nextValue); - setHasUnappliedChanges(true); + setProxyPing(null); + } + + function openAppsPanel() { + switchPanel('proxifyre'); + window.setTimeout(() => { + appsSectionRef.current?.scrollIntoView({ block: 'start' }); + }, 0); + } + + function switchPanel(nextPanel: PanelId) { + if (activePanel === nextPanel) return; + + const currentIndex = PANEL_ORDER.indexOf(activePanel); + const nextIndex = PANEL_ORDER.indexOf(nextPanel); + setTabTransitionDirection(nextIndex > currentIndex ? 'right' : 'left'); + setActivePanel(nextPanel); } async function pickAndAddItem(type: Extract) { @@ -363,7 +435,6 @@ export function App() { setSetupStatus(detectedSetupStatus); setSingBoxStatus(detectedSingBoxStatus); setSingBoxSetupStatus(detectedSingBoxSetupStatus); - setHasUnappliedChanges(false); showNotice(routeMode === 'local-singbox' ? noticeFromLocalApply(result, singBoxGeneratedPath) : noticeFromApply(result)); } catch (error) { showNotice({ @@ -619,7 +690,6 @@ export function App() { setComponents((current) => upsertComponent(current, status.component)); setSubscriptionInput(''); setServerPings({}); - setHasUnappliedChanges(true); showNotice({ kind: 'success', title: 'Подписка обновлена', @@ -644,7 +714,6 @@ export function App() { setSingBoxStatus(status); setComponents((current) => upsertComponent(current, status.component)); setServerPings({}); - setHasUnappliedChanges(true); showNotice({ kind: 'info', title: 'Подписка очищена', @@ -666,7 +735,6 @@ export function App() { const status = await selectSingBoxServer(server); setSingBoxStatus(status); setComponents((current) => upsertComponent(current, status.component)); - setHasUnappliedChanges(true); } catch (error) { showNotice({ kind: 'error', @@ -697,6 +765,63 @@ export function App() { } } + async function pingSingleSingBoxServer(server: SubscriptionServer) { + setServerPingTag(server.tag); + try { + const result = await pingSingBoxServer(server.tag); + setServerPings((current) => ({ + ...current, + [result.tag]: result, + })); + showNotice({ + kind: result.ok ? 'success' : 'error', + title: result.ok ? 'Сервер отвечает' : 'Сервер не ответил', + text: serverTooltip(server, result), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Ping не выполнен', + text: errorMessage(error), + }); + } finally { + setServerPingTag(null); + } + } + + async function pingExternalProxy() { + let parsed: ParsedProxy; + try { + parsed = parseProxy(proxyInput); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Прокси не проверен', + text: errorMessage(error), + }); + return; + } + + setIsProxyChecking(true); + try { + const result = await pingProxyTarget(parsed.host, parsed.port); + setProxyPing(result); + showNotice({ + kind: result.ok ? 'success' : 'error', + title: result.ok ? 'Внешний прокси отвечает' : 'Внешний прокси не ответил', + text: pingResultText(result), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Прокси не проверен', + text: errorMessage(error), + }); + } finally { + setIsProxyChecking(false); + } + } + async function generateSingBoxNow() { setSingBoxAction('generate'); try { @@ -750,118 +875,657 @@ export function App() { setActiveLogId(entry.id); } - return ( -
-
-
+ function renderTabs() { + const tabs: Array<{ id: PanelId; label: string }> = [ + { id: 'summary', label: 'Сводка' }, + { id: 'proxifyre', label: 'ProxiFyre' }, + { id: 'proxy', label: 'VPN / Прокси' }, + ]; + + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); + } + + function renderSummaryPanel() { + const appliedRouteMode = appliedSnapshot?.routeMode ?? routeMode; + const draftHasLocalServer = currentSnapshot.routeMode === 'local-singbox' && currentSnapshot.selectedServerTag; + + return ( +
+
+
+ +
+
+
+ Применено сейчас + {appliedSnapshot ? routeModeLabel(appliedRouteMode) : 'Состояние загружается'} +
+ + {hasUnappliedChanges ? 'Есть черновик' : 'Совпадает'} + +
+
+
+
ProxiFyre
+
{proxyfierTitle(proxyfier, isDetectingComponents)}
+
+
+
Маршрут
+
{snapshotRouteLine(appliedSnapshot, singBoxStatus)}
+
+
+
VPN сервер
+
{snapshotServerText(appliedSnapshot)}
+
+
+
Приложения
+
{snapshotItemsText(appliedSnapshot)}
+
+
+
Конфиг
+
{generatedConfigPath || 'Примененный config еще не создан.'}
+
+
+
+ +
+
+ Черновик интерфейса + {hasUnappliedChanges ? 'Есть реальные изменения' : 'Изменений нет'} +

+ {hasUnappliedChanges + ? 'Текущие настройки отличаются от примененного состояния. Если вернуть значения назад, предупреждение исчезнет.' + : 'То, что выбрано в интерфейсе, совпадает с примененной конфигурацией.'} +

+
+ {hasUnappliedChanges ? ( +
+
+
Будет маршрут
+
{snapshotRouteLine(currentSnapshot, singBoxStatus)}
+
+
+
VPN сервер
+
{draftHasLocalServer ? displayServerTag(currentSnapshot.selectedServerTag) : snapshotServerText(currentSnapshot)}
+
+
+
Приложения
+
{snapshotItemsText(currentSnapshot)}
+
+
+ ) : null} +
+ +
+ + +
+
+ ); + } + + function renderProxiFyreCard() { + return ( +
+
+ ); + } + + function renderProxiFyreSetupStrip() { + const stripItems = setupStatus?.items ?? proxifyreSetupPlaceholders(); + + return ( +
+ Состав +
+ {stripItems.map((item) => ( +
+
+ ))} +
+
+ ); + } + + function renderAppsSection() { + return ( +
+
+
+

Приложения

+ {items.length} +
+
+ {renderApplyActions('proxifyre', { showConfigPath: false })} +
+
+ + {isProcessInputOpen ? ( +
+ setProcessInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') addProcess(); + if (event.key === 'Escape') { + setProcessInput(''); + setIsProcessInputOpen(false); + } + }} + placeholder="Discord" + spellCheck={false} + autoFocus + /> + + +
+ ) : ( +
+
+ +
+ Новое приложение + Добавь процесс, EXE-файл или папку +
+
+
+ + + +
+
+ )} + +
+ {isLoading ? ( +
+ + +
+ ) : items.length ? ( + items.map((item) => ( +
+
+ +
+ {item.value} + {itemTypeLabel(item.type)} +
+
+ +
+ )) + ) : ( +
Список пуст. Добавь первое приложение сверху.
+ )} +
+
+ ); + } + + function renderApplyActions( + context: 'proxifyre' | 'proxy', + options: { showConfigPath?: boolean } = {}, + ) { + const showConfigPath = options.showConfigPath ?? true; + + return ( + <> + {hasUnappliedChanges ? ( +
+ Изменения еще не применены в ProxiFyre + {applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))} +
+ ) : null} + +
+ + +
+ + {showConfigPath && generatedConfigPath ?

{generatedConfigPath}

: null} + + ); + } + + function renderProxiFyrePanel() { + return ( +
+ {renderProxiFyreCard()} + {renderProxiFyreSetupStrip()} + {renderAppsSection()} +
+ ); + } + + function renderExternalProxyControls() { + const proxyValidation = proxyInput.trim() ? safeProxyError(proxyInput) : null; + + return ( +
+ +
+
+ {proxyPing ? pingResultTitle(proxyPing) : proxyValidation ? 'Проверь формат' : 'Проверка не запускалась'} + {proxyPing ? pingResultText(proxyPing) : proxyValidation ?? 'TCP check покажет, доступен ли host:port.'}
-
+ + + ); + } -
-
- {routeMode === 'local-singbox' ? ( -
-
- ) : null} - -
-
-

Приложения

- {items.length} -
- - {isProcessInputOpen ? ( -
- setProcessInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') addProcess(); - if (event.key === 'Escape') { - setProcessInput(''); - setIsProcessInputOpen(false); - } - }} - placeholder="Discord" - spellCheck={false} - autoFocus - /> - - -
- ) : ( -
- - - -
- )} - -
- {isLoading ? ( -
- - -
- ) : items.length ? ( - items.map((item) => ( -
-
- -
- {item.value} - {itemTypeLabel(item.type)} -
-
- -
- )) - ) : ( -
Добавь процесс, папку или путь к EXE-файлу.
- )} -
-
- - {hasUnappliedChanges ? ( -
- Изменения еще не применены в ProxiFyre - {applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))} -
- ) : null} - -
- - +
+ Маршрут + {routePreviewText(routeMode, proxyInput, singBoxStatus)}
- {generatedConfigPath ?

{generatedConfigPath}

: null} + {renderApplyActions('proxy')} + + ); + } + + function renderActivePanel() { + if (activePanel === 'proxifyre') return renderProxiFyrePanel(); + if (activePanel === 'proxy') return renderProxyPanel(); + return renderSummaryPanel(); + } + + return ( +
+
+
+
+

Proxy для приложений

+
+ +
+ + {renderTabs()} +
+ {renderActivePanel()} +
- {logEntries.length ? ( - - ) : null} + )) + ) : ( +
+ +
+ Журнал пуст + События появятся после проверок или применения конфигурации. +
+
+ )} +
+ ) : null} +
); } @@ -1279,6 +1639,250 @@ interface ParsedProxy { port: number; } +interface SummaryStateInput { + isLoading: boolean; + isDetectingComponents: boolean; + proxyfier: ComponentStatus | undefined; + singbox: ComponentStatus | undefined; + routeMode: RouteMode; + singBoxStatus: LocalSingBoxStatusResponse | null; + proxyPing: PingServerResponse | null; + hasUnappliedChanges: boolean; +} + +interface SummaryState { + tone: StatusTone; + title: string; + text: string; +} + +function configSnapshotFromUi( + routeMode: RouteMode, + proxyInput: string, + items: Array>, + selectedServerTag?: string, +): ConfigSnapshot { + return { + routeMode, + proxy: routeMode === 'external' ? normalizeProxySnapshot(proxyInput) : '', + selectedServerTag: routeMode === 'local-singbox' ? normalizeServerTag(selectedServerTag) : '', + items: normalizeSnapshotItems(items), + }; +} + +function normalizeProxySnapshot(value: string) { + try { + const parsed = parseProxy(value); + return `${parsed.protocol}://${parsed.host.trim().toLowerCase()}:${parsed.port}`; + } catch { + return value.trim().toLowerCase(); + } +} + +function normalizeServerTag(tag: string | undefined) { + return tag?.trim() ?? ''; +} + +function normalizeSnapshotItems(items: Array>): ConfigSnapshotItem[] { + return items + .map((item) => ({ + type: item.type, + value: normalizeItemValue(item.value, item.type).toLowerCase(), + })) + .filter((item) => item.value) + .sort((left, right) => `${left.type}:${left.value}`.localeCompare(`${right.type}:${right.value}`)); +} + +function sameConfigSnapshot(left: ConfigSnapshot, right: ConfigSnapshot) { + if (left.routeMode !== right.routeMode) return false; + if (left.proxy !== right.proxy) return false; + if (left.selectedServerTag !== right.selectedServerTag) return false; + if (left.items.length !== right.items.length) return false; + + return left.items.every((item, index) => { + const other = right.items[index]; + return item.type === other.type && item.value === other.value; + }); +} + +function routeModeLabel(routeMode: RouteMode) { + return routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'; +} + +function snapshotRouteLine(snapshot: ConfigSnapshot | null, status: LocalSingBoxStatusResponse | null) { + if (!snapshot) return 'Сохраненное состояние еще не загружено.'; + + if (snapshot.routeMode === 'local-singbox') { + const server = snapshot.selectedServerTag ? displayServerTag(snapshot.selectedServerTag) : 'сервер не выбран'; + return `Выбранные приложения -> ProxiFyre -> Local sing-box ${localSingBoxAddress(status)} -> ${server}`; + } + + return `Выбранные приложения -> ProxiFyre -> внешний прокси ${displaySnapshotProxy(snapshot.proxy)}`; +} + +function snapshotServerText(snapshot: ConfigSnapshot | null) { + if (!snapshot) return 'Сохраненное состояние еще не загружено.'; + if (snapshot.routeMode === 'external') return 'Не используется: применен внешний прокси.'; + return snapshot.selectedServerTag ? displayServerTag(snapshot.selectedServerTag) : 'Сервер не выбран.'; +} + +function snapshotItemsText(snapshot: ConfigSnapshot | null) { + if (!snapshot) return 'Сохраненное состояние еще не загружено.'; + return snapshot.items.length ? appCountText(snapshot.items.length) : 'Приложения не настроены.'; +} + +function displaySnapshotProxy(proxy: string) { + return proxy.replace(/^socks5:\/\//, '') || 'не указан'; +} + +function systemSummaryState(input: SummaryStateInput): SummaryState { + if (input.isLoading || input.isDetectingComponents) { + return { + tone: 'checking', + title: 'Проверяю', + text: 'Обновляю сохраненный маршрут, компоненты и локальный proxy path.', + }; + } + + if (!input.proxyfier?.installed) { + return { + tone: 'error', + title: 'Не настроено', + text: 'ProxiFyre не найден. Перейди в настройки ProxiFyre и установи компонент.', + }; + } + + if (!input.proxyfier.running) { + return { + tone: 'warning', + title: 'Требует внимания', + text: 'ProxiFyre установлен, но служба сейчас остановлена.', + }; + } + + if (input.routeMode === 'local-singbox') { + if (!input.singbox?.installed) { + return { + tone: 'warning', + title: 'Локальный прокси не готов', + text: 'Для локального маршрута нужно установить Local sing-box.', + }; + } + if (!input.singbox.running) { + return { + tone: 'warning', + title: 'Локальный прокси остановлен', + text: 'Local sing-box установлен, но служба не запущена.', + }; + } + if (!input.singBoxStatus?.config.selectedServerTag) { + return { + tone: 'warning', + title: 'Сервер не выбран', + text: 'Выбери сервер Local sing-box на вкладке VPN / Прокси.', + }; + } + } + + if (input.routeMode === 'external' && input.proxyPing && !input.proxyPing.ok) { + return { + tone: 'warning', + title: 'Прокси не ответил', + text: pingResultText(input.proxyPing), + }; + } + + if (input.hasUnappliedChanges) { + return { + tone: 'warning', + title: 'Есть непримененные изменения', + text: 'Настройки изменены, но ProxiFyre еще не обновлен.', + }; + } + + return { + tone: 'ok', + title: 'Работает', + text: 'Сохраненная конфигурация выглядит готовой к маршрутизации выбранных приложений.', + }; +} + +function routeEndpointLabel( + routeMode: RouteMode, + proxyInput: string, + status: LocalSingBoxStatusResponse | null, +) { + if (routeMode === 'local-singbox') { + const lan = lanSingBoxAddress(status); + return lan ? `${localSingBoxAddress(status)} · LAN: ${lan}` : localSingBoxAddress(status); + } + + try { + const parsed = parseProxy(proxyInput); + return formatHostPort(parsed.host, parsed.port); + } catch { + return proxyInput.trim() || 'не указан'; + } +} + +function appCountText(count: number) { + if (count === 1) return '1 приложение маршрутизируется через профиль.'; + if (count > 1 && count < 5) return `${count} приложения маршрутизируются через профиль.`; + return `${count} приложений маршрутизируются через профиль.`; +} + +function safeProxyError(value: string) { + try { + parseProxy(value); + return null; + } catch (error) { + return errorMessage(error); + } +} + +function pingTone(ping: PingServerResponse): StatusTone { + return ping.ok ? 'ok' : 'error'; +} + +function pingResultTitle(ping: PingServerResponse) { + return ping.ok ? 'Доступен' : 'Недоступен'; +} + +function pingResultText(ping: PingServerResponse) { + if (ping.ok) return `${ping.server}:${ping.serverPort} ответил за ${ping.latency ?? 0} ms.`; + return `${ping.server}:${ping.serverPort}: ${ping.error ?? 'нет ответа'}`; +} + +function routePreviewText( + routeMode: RouteMode, + proxyInput: string, + status: LocalSingBoxStatusResponse | null, +) { + if (routeMode === 'local-singbox') { + const server = status?.config.selectedServerTag + ? displayServerTag(status.config.selectedServerTag) + : 'сервер не выбран'; + return `Выбранные приложения -> ProxiFyre -> Local sing-box ${localSingBoxAddress(status)} -> ${server}`; + } + + return `Выбранные приложения -> ProxiFyre -> внешний прокси ${routeEndpointLabel('external', proxyInput, status)}`; +} + +function applyButtonLabel( + context: 'proxifyre' | 'proxy', + isApplying: boolean, + hasUnappliedChanges: boolean, + singBoxAction: SingBoxAction | null, +) { + if (isApplying) { + if (singBoxAction === 'start') return 'Запускаю sing-box...'; + if (singBoxAction === 'stop') return 'Перезапускаю sing-box...'; + return 'Применяю...'; + } + if (hasUnappliedChanges) return context === 'proxy' ? 'Применить маршрут' : 'Применить в ProxiFyre'; + return context === 'proxy' ? 'Обновить маршрут' : 'Обновить конфиг'; +} + function parseProxy(rawValue: string): ParsedProxy { const value = rawValue.trim(); if (!value) throw new Error('Введи адрес прокси.'); @@ -1416,6 +2020,28 @@ function setupItemDetails(installed: boolean, version: string | undefined, detai return details; } +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 setupItemShortStatus(item: ProxiFyreSetupStatus['items'][number]) { + if (item.details === 'Проверяю') return 'проверяю'; + if (!item.installed) return 'нужно установить'; + if (item.id === 'proxifyre') return item.version?.includes('не запущена') ? 'остановлен' : 'запущен'; + return 'готово'; +} + function profileInputFromProfile(profile: Profile, enabled: boolean) { return { id: profile.id, diff --git a/apps/windows-client/src/styles/app.css b/apps/windows-client/src/styles/app.css index cf6a914..bf01762 100644 --- a/apps/windows-client/src/styles/app.css +++ b/apps/windows-client/src/styles/app.css @@ -1,24 +1,63 @@ :root { + --app-footer-height: 54px; + --app-header-row-height: 50px; + --app-tab-height: 46px; + --app-header-height: calc(var(--app-header-row-height) + var(--app-tab-height)); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #e5e7eb; background: #101216; font-synthesis: none; + height: 100%; + overflow: hidden; + scrollbar-color: #4b5568 #101216; + scrollbar-gutter: stable; + scrollbar-width: thin; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; +} + +::-webkit-scrollbar-track { + background: #101216; +} + +::-webkit-scrollbar-thumb { + min-height: 36px; + border: 2px solid #101216; + border-radius: 999px; + background: #4b5568; +} + +::-webkit-scrollbar-thumb:hover { + background: #64748b; +} + +::-webkit-scrollbar-corner { + background: #101216; +} + * { box-sizing: border-box; } body { margin: 0; - min-height: 100vh; + height: 100vh; background: #101216; - overflow-x: hidden; + overflow: hidden; } button, @@ -37,21 +76,26 @@ button:disabled { .simple-shell { display: block; - min-height: 100vh; + height: 100vh; + overflow: hidden; background: #101216; - padding: 0; + padding: var(--app-header-height) 0 var(--app-footer-height); } .simple-panel { display: grid; align-content: start; - min-height: 100vh; + min-height: 0; + height: calc(100vh - var(--app-header-height) - var(--app-footer-height)); width: 100%; border: 0; border-radius: 0; background: #101216; box-shadow: none; - padding: 18px 18px 96px; + overflow-x: hidden; + overflow-y: scroll; + scrollbar-gutter: stable; + padding: 14px 18px 20px; } .simple-header, @@ -61,6 +105,7 @@ button:disabled { .app-row, .route-switch, .singbox-info-actions, +.panel-tabs, .server-row { display: flex; align-items: center; @@ -69,10 +114,15 @@ button:disabled { } .simple-header { - margin: -18px -18px 16px; - border-bottom: 1px solid #2a2f3a; + position: fixed; + top: 0; + right: 0; + left: 0; + z-index: 40; + min-height: var(--app-header-row-height); + margin: 0; background: #181b22; - padding: 14px 18px; + padding: 7px 14px; } .simple-header small, @@ -90,16 +140,406 @@ button:disabled { } .simple-header h1 { - margin-top: 4px; - font-size: 22px; + margin-top: 0; + font-size: 18px; line-height: 1.1; font-weight: 650; } +.simple-header .ghost-button { + min-height: 32px; + padding: 6px 10px; + font-size: 13px; +} + .section-head h2 { font-size: 16px; } +.panel-tabs { + position: fixed; + top: var(--app-header-row-height); + right: 0; + left: 0; + z-index: 40; + display: flex; + align-items: stretch; + gap: 0; + min-height: var(--app-tab-height); + overflow-x: auto; + border-top: 1px solid #242a35; + border-bottom: 1px solid #2b3342; + background: #181b22; + scrollbar-width: none; + margin: 0; + padding: 0 18px; +} + +.panel-tabs::-webkit-scrollbar { + display: none; +} + +.panel-tabs button { + appearance: none; + position: relative; + flex: 1 1 0; + min-width: 0; + min-height: var(--app-tab-height); + border: 0; + border-radius: 0; + background: transparent; + color: #9aa8bd; + font-weight: 750; + cursor: pointer; + margin: 0; + padding: 10px 12px 12px; + white-space: nowrap; +} + +.panel-tabs button:hover { + background: #1c212b; + color: #eef2ff; +} + +.panel-tabs button.active { + background: #181b22; + color: #eff6ff; + box-shadow: none; +} + +.panel-tabs button.active::after { + position: absolute; + right: 12px; + bottom: 0; + left: 12px; + height: 3px; + border-radius: 999px 999px 0 0; + background: #3b82f6; + box-shadow: 0 -6px 18px rgba(59, 130, 246, 0.34); + content: ""; +} + +.tab-panel-frame { + min-width: 0; + will-change: opacity, transform; +} + +.tab-panel-frame.swipe-right { + animation: panel-swipe-right 0.24s cubic-bezier(0.22, 0.72, 0.18, 1); +} + +.tab-panel-frame.swipe-left { + animation: panel-swipe-left 0.24s cubic-bezier(0.22, 0.72, 0.18, 1); +} + +.tab-panel { + display: grid; + gap: 10px; +} + +.panel-section-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; + min-height: 48px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #131720; + padding: 11px 12px; +} + +.panel-section-head span { + display: block; + color: #8d99ae; + font-size: 12px; +} + +.panel-section-head h2 { + margin: 2px 0 0; + color: #eef2ff; + font-size: 17px; + letter-spacing: 0; +} + +.panel-section-head > strong { + min-width: 0; + color: #bfdbfe; + font-size: 13px; + overflow-wrap: anywhere; + text-align: right; +} + +.summary-panel { + gap: 12px; +} + +.summary-hero { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: start; + border: 1px solid #2b3342; + border-radius: 4px; + background: #151923; + padding: 14px; +} + +.summary-hero.ok { + border-color: rgba(34, 197, 94, 0.44); +} + +.summary-hero.warning { + border-color: rgba(245, 158, 11, 0.48); +} + +.summary-hero.error { + border-color: rgba(239, 68, 68, 0.52); +} + +.summary-hero.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 { + border: 2px solid #3b82f6; + border-top-color: transparent; + background: transparent; + animation: spin 0.75s linear infinite; +} + +.summary-hero span, +.summary-card-label { + color: #8d99ae; + font-size: 12px; + font-weight: 700; +} + +.summary-hero strong { + display: block; + color: #f8fafc; + font-size: 22px; + line-height: 1.15; + margin-top: 2px; +} + +.summary-hero p { + color: #b6c2d4; + margin: 5px 0 0; + overflow-wrap: anywhere; +} + +.summary-readout, +.summary-draft { + border: 1px solid #2b3342; + border-radius: 4px; + background: #131720; + padding: 13px 14px; +} + +.summary-readout-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; +} + +.summary-readout-head span, +.summary-draft span { + display: block; + color: #8d99ae; + font-size: 12px; + font-weight: 700; +} + +.summary-readout-head strong, +.summary-draft strong { + display: block; + color: #f8fafc; + font-size: 18px; + line-height: 1.2; + margin-top: 2px; +} + +.summary-pill { + flex: 0 0 auto; + border: 1px solid #2b3342; + border-radius: 999px; + background: #1a202b; + padding: 5px 9px; +} + +.summary-pill.ok { + border-color: rgba(34, 197, 94, 0.38); + color: #86efac; +} + +.summary-pill.warning { + border-color: rgba(245, 158, 11, 0.46); + color: #fcd34d; +} + +.summary-lines { + display: grid; + gap: 0; + margin: 12px 0 0; +} + +.summary-lines.compact { + margin-top: 10px; +} + +.summary-lines div { + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + gap: 12px; + border-top: 1px solid #242b38; + padding: 9px 0; +} + +.summary-lines dt, +.summary-lines dd { + margin: 0; +} + +.summary-lines dt { + color: #8d99ae; + font-size: 12px; + font-weight: 750; +} + +.summary-lines dd { + color: #dbe4f0; + overflow-wrap: anywhere; +} + +.summary-draft { + display: grid; + gap: 8px; +} + +.summary-draft.ok { + border-color: rgba(34, 197, 94, 0.34); +} + +.summary-draft.warning { + border-color: rgba(245, 158, 11, 0.48); + background: rgba(120, 53, 15, 0.16); +} + +.summary-draft p { + color: #b6c2d4; + margin: 5px 0 0; + overflow-wrap: anywhere; +} + +.summary-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.summary-actions button { + min-height: 34px; + border: 1px solid #343b49; + border-radius: 4px; + background: #242a35; + color: #eef2ff; + padding: 7px 10px; + cursor: pointer; +} + +.summary-actions button:hover { + background: #2d3543; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 9px; +} + +.summary-card { + display: grid; + gap: 5px; + align-content: start; + min-height: 104px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #131720; + color: #dbeafe; + padding: 11px 12px; + text-align: left; +} + +button.summary-card { + cursor: pointer; +} + +button.summary-card:hover { + border-color: #3b82f6; + background: #172033; +} + +.summary-card.ok { + border-color: rgba(34, 197, 94, 0.36); +} + +.summary-card.warning { + border-color: rgba(245, 158, 11, 0.42); +} + +.summary-card.error { + border-color: rgba(239, 68, 68, 0.42); +} + +.summary-card.checking { + border-color: rgba(59, 130, 246, 0.42); +} + +.summary-card strong, +.summary-card span { + overflow-wrap: anywhere; +} + +.summary-card strong { + color: #f8fafc; + font-size: 15px; + line-height: 1.25; +} + +.summary-card > span:not(.summary-card-label) { + color: #9aa8bd; + font-size: 13px; +} + +.summary-card.readout { + cursor: default; +} + .ghost-button, .add-toolbar button, .process-add-line button, @@ -314,6 +754,69 @@ button:disabled { font-weight: 700; } +.setup-strip { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: center; + min-height: 42px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #111720; + padding: 7px 10px; +} + +.setup-strip-title { + color: #8d99ae; + font-size: 12px; + font-weight: 800; +} + +.setup-strip-items { + display: flex; + gap: 7px; + min-width: 0; + overflow-x: auto; + scrollbar-width: thin; +} + +.setup-strip-item { + display: inline-flex; + flex: 0 0 auto; + gap: 7px; + align-items: center; + min-height: 28px; + border: 1px solid #2b3342; + border-radius: 999px; + background: #151b25; + color: #dbeafe; + padding: 5px 9px; +} + +.setup-strip-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #f59e0b; + box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.11); +} + +.setup-strip-item.installed .setup-strip-dot { + background: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.11); +} + +.setup-strip-item strong { + font-size: 12px; + white-space: nowrap; +} + +.setup-strip-item span:not(.setup-strip-dot) { + color: #9aa8bd; + font-size: 12px; + white-space: nowrap; +} + .setup-details { display: grid; grid-column: 2 / -1; @@ -442,6 +945,57 @@ button:disabled { padding: 10px; } +.external-proxy-card { + display: grid; + gap: 9px; +} + +.proxy-check-line { + display: grid; + grid-template-columns: minmax(0, 1fr) 132px; + gap: 8px; + align-items: stretch; +} + +.proxy-check-state, +.route-preview { + display: grid; + gap: 3px; + min-height: 46px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #111720; + padding: 9px 11px; +} + +.proxy-check-state.ok { + border-color: rgba(34, 197, 94, 0.42); +} + +.proxy-check-state.warning { + border-color: rgba(245, 158, 11, 0.42); +} + +.proxy-check-state.error { + border-color: rgba(239, 68, 68, 0.42); +} + +.proxy-check-state strong, +.route-preview strong { + color: #eef2ff; + overflow-wrap: anywhere; +} + +.proxy-check-state span, +.route-preview span { + color: #8d99ae; + overflow-wrap: anywhere; +} + +.route-preview { + margin-top: 2px; +} + .simple-field { display: grid; gap: 7px; @@ -620,7 +1174,10 @@ button:disabled { } .server-row { - justify-content: flex-start; + display: grid; + grid-template-columns: minmax(0, 1fr) 34px; + align-items: center; + justify-content: stretch; gap: 7px; width: 100%; min-width: 0; @@ -634,6 +1191,45 @@ button:disabled { background: #17213a; } +.server-row.ok { + border-color: rgba(34, 197, 94, 0.42); +} + +.server-row.error { + border-color: rgba(239, 68, 68, 0.42); +} + +.server-select-button, +.server-ping-button { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + padding: 0; +} + +.server-select-button { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + text-align: left; +} + +.server-ping-button { + display: grid; + place-items: center; + width: 30px; + height: 28px; + border: 1px solid #263040; + border-radius: 4px; + color: #bfdbfe; +} + +.server-ping-button:hover { + background: #1d2735; +} + .server-select-dot { width: 10px; height: 10px; @@ -657,6 +1253,16 @@ button:disabled { white-space: nowrap; } +.server-ping-badge { + flex: 0 0 auto; + border: 1px solid #263040; + border-radius: 4px; + color: #bfdbfe; + padding: 2px 5px; + font-size: 11px; + font-weight: 800; +} + .singbox-install-note { display: grid; grid-column: 1 / -1; @@ -689,40 +1295,93 @@ button:disabled { .apps-section { display: grid; - gap: 10px; + gap: 9px; margin-top: 8px; + border: 1px solid #2b3342; + border-radius: 4px; + background: #10141c; + padding: 12px; +} + +.apps-header { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(340px, 520px); + gap: 12px; + align-items: center; + min-height: 48px; +} + +.apps-title { + min-height: 48px; + align-items: center; + justify-content: flex-start; +} + +.apps-config-actions { + display: grid; + gap: 8px; + align-items: center; +} + +.apps-config-actions .apply-state { + margin: 0; +} + +.apps-config-actions .command-row { + grid-template-columns: minmax(0, 1fr) 118px; + margin-top: 0; +} + +.apps-config-actions .apply-button, +.apps-config-actions .open-config-button { + height: 48px; + min-height: 48px; +} + +.apps-config-actions .config-path { + margin: 0; + color: #8d99ae; } .section-head span { min-width: 28px; + height: 30px; border: 1px solid #343b49; border-radius: 4px; background: #1b202b; color: #dbeafe; - padding: 3px 9px; + padding: 0 9px; text-align: center; font-size: 12px; font-weight: 700; + line-height: 28px; } .add-toolbar { - justify-content: center; - padding: 4px 0; + justify-content: flex-end; + padding: 0; } .add-tile { - display: grid; - place-items: center; - width: 74px; - height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + width: 44px; + height: 38px; border-color: #2b3342; - background: #131720; + background: #202633; color: #dbeafe; + line-height: 0; padding: 0; } .add-tile svg { + width: 20px; + height: 20px; display: block; + flex: 0 0 auto; + transform: translate(0.5px, 0.5px); } .add-tile:hover { @@ -730,6 +1389,61 @@ button:disabled { background: #182033; } +.add-tile[data-tooltip]::before, +.add-tile[data-tooltip]::after { + position: absolute; + left: 50%; + z-index: 30; + pointer-events: none; + opacity: 0; + transition: opacity 90ms ease, transform 120ms ease; + transition-delay: 55ms; +} + +.add-tile[data-tooltip]::before { + bottom: calc(100% + 3px); + width: 8px; + height: 8px; + background: #0d1118; + border-right: 1px solid #334155; + border-bottom: 1px solid #334155; + content: ""; + transform: translate(-50%, 2px) rotate(45deg); +} + +.add-tile[data-tooltip]::after { + bottom: calc(100% + 8px); + border: 1px solid #334155; + border-radius: 4px; + background: #0d1118; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.34); + color: #e5e7eb; + content: attr(data-tooltip); + font-size: 12px; + font-weight: 750; + line-height: 1; + padding: 7px 8px; + transform: translate(-50%, 4px); + white-space: nowrap; +} + +.add-tile[data-tooltip]:hover::before, +.add-tile[data-tooltip]:hover::after, +.add-tile[data-tooltip]:focus-visible::before, +.add-tile[data-tooltip]:focus-visible::after { + opacity: 1; +} + +.add-tile[data-tooltip]:hover::before, +.add-tile[data-tooltip]:focus-visible::before { + transform: translate(-50%, 0) rotate(45deg); +} + +.add-tile[data-tooltip]:hover::after, +.add-tile[data-tooltip]:focus-visible::after { + transform: translate(-50%, 0); +} + .add-tile[aria-pressed="true"] { border-color: #3b82f6; background: #1e3a8a; @@ -741,6 +1455,43 @@ button:disabled { animation: pulse 1s ease-in-out infinite; } +.app-add-skeleton { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + min-height: 58px; + border: 1px dashed #334155; + border-radius: 4px; + background: + linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.08), transparent), + #121722; + background-size: 220% 100%; + color: #94a3b8; + padding: 9px 12px; +} + +.app-add-main { + display: flex; + gap: 10px; + align-items: center; + min-width: 0; +} + +.app-add-main strong, +.app-add-main span { + display: block; +} + +.app-add-main strong { + color: #cbd5e1; +} + +.app-add-main span { + color: #7d8aa0; + font-size: 13px; +} + .process-add-line { display: grid; grid-template-columns: minmax(0, 1fr) 72px 88px; @@ -769,7 +1520,8 @@ button:disabled { .app-list { display: grid; - gap: 8px; + gap: 6px; + margin-top: 1px; } .app-row-main { @@ -778,11 +1530,13 @@ button:disabled { gap: 10px; } -.app-row .item-icon { - display: grid; +.app-row .item-icon, +.app-add-main .item-icon { + display: inline-flex; + align-items: center; + justify-content: center; flex: 0 0 auto; align-self: center; - place-items: center; width: 34px; height: 34px; border: 1px solid #2b3342; @@ -792,8 +1546,16 @@ button:disabled { line-height: 0; } -.app-row .item-icon svg { +.app-add-main .app-add-icon { + color: #64748b; +} + +.app-row .item-icon svg, +.app-add-main .item-icon svg { + width: 20px; + height: 20px; display: block; + flex: 0 0 auto; } .app-row, @@ -876,32 +1638,32 @@ button:disabled { .log-dock { position: fixed; - right: 10px; - bottom: 10px; - left: 10px; - z-index: 30; + right: 0; + bottom: 0; + left: 0; + z-index: 40; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; - min-height: 48px; - border: 1px solid #2b3342; - border-radius: 4px; - background: rgba(19, 23, 32, 0.98); - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.34); - padding: 7px; + min-height: var(--app-footer-height); + border-top: 1px solid #2b3342; + background: #151923; + box-shadow: none; + margin: 0; + padding: 8px 14px; } .log-dock.error { - border-color: rgba(239, 68, 68, 0.58); + border-top-color: rgba(239, 68, 68, 0.58); } .log-dock.success { - border-color: rgba(34, 197, 94, 0.58); + border-top-color: rgba(34, 197, 94, 0.58); } .log-dock.info { - border-color: rgba(59, 130, 246, 0.58); + border-top-color: rgba(59, 130, 246, 0.58); } .log-current { @@ -930,7 +1692,8 @@ button:disabled { } .log-current strong { - flex: 0 0 auto; + flex: 0 1 auto; + min-width: 0; } .log-current span { @@ -945,12 +1708,12 @@ button:disabled { display: inline-flex; gap: 8px; align-items: center; - min-height: 34px; + min-height: 32px; border: 1px solid #343b49; border-radius: 4px; background: #242a35; color: #eef2ff; - padding: 7px 10px; + padding: 6px 10px; cursor: pointer; } @@ -970,13 +1733,19 @@ button:disabled { } .log-history { + position: absolute; + right: 0; + bottom: 100%; + left: 0; display: grid; grid-column: 1 / -1; gap: 6px; max-height: 230px; overflow: auto; - border-top: 1px solid #2b3342; - padding-top: 7px; + border: 1px solid #2b3342; + border-bottom: 0; + background: #151923; + padding: 7px 14px; } .log-history-row { @@ -1113,23 +1882,145 @@ button:disabled { } } +@keyframes panel-swipe-right { + from { + opacity: 0; + transform: translateX(34px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes panel-swipe-left { + from { + opacity: 0; + transform: translateX(-34px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .tab-panel-frame, + .tab-panel, + .finder-card .finder-border-glow, + .finder-border-glow-segment, + .status-light, + .summary-status-dot.checking, + .list-skeleton span, + .tile-loading { + animation: none; + transition: none; + } +} + @media (max-width: 680px) { .simple-shell { - padding: 0; + padding: var(--app-header-height) 0 var(--app-footer-height); } .simple-panel { - padding: 14px 14px 100px; + height: calc(100vh - var(--app-header-height) - var(--app-footer-height)); + padding: 12px 12px 16px; } - .simple-header, .app-row { align-items: stretch; flex-direction: column; } .simple-header { - margin: -14px -14px 16px; + align-items: center; + flex-direction: row; + gap: 10px; + margin: 0; + padding: 7px 10px; + } + + .simple-header h1 { + font-size: 16px; + } + + .simple-header .ghost-button { + min-height: 30px; + padding: 5px 8px; + font-size: 12px; + } + + .panel-tabs { + gap: 1px; + padding: 0 10px; + } + + .panel-tabs button { + flex: 1 0 110px; + min-height: var(--app-tab-height); + padding: 8px 10px 9px; + font-size: 13px; + } + + .panel-section-head { + align-items: stretch; + flex-direction: column; + } + + .panel-section-head > strong { + text-align: left; + } + + .summary-grid { + grid-template-columns: 1fr; + } + + .summary-readout-head { + align-items: stretch; + flex-direction: column; + } + + .summary-pill { + width: fit-content; + } + + .summary-lines div { + grid-template-columns: 1fr; + gap: 4px; + } + + .summary-actions { + display: grid; + grid-template-columns: 1fr; + } + + .setup-strip { + grid-template-columns: 1fr; + align-items: stretch; + } + + .apps-header { + grid-template-columns: 1fr; + min-height: 0; + } + + .apps-title { + min-height: 34px; + } + + .apps-config-actions .command-row { + grid-template-columns: 1fr; + } + + .app-add-skeleton { + grid-template-columns: 1fr; + } + + .proxy-check-line { + grid-template-columns: 1fr; } .add-toolbar { @@ -1195,20 +2086,30 @@ button:disabled { } .log-dock { - right: 8px; - left: 8px; - grid-template-columns: 1fr; + position: fixed; + right: 0; + bottom: 0; + left: 0; + grid-template-columns: minmax(0, 1fr) auto; + margin: 0; + padding: 7px 10px; } .log-current { - align-items: flex-start; - flex-direction: column; - gap: 2px; + align-items: baseline; + flex-direction: row; + gap: 8px; + padding: 0 4px; } .log-current strong, .log-current span { - white-space: normal; + white-space: nowrap; + } + + .log-history { + max-height: 220px; + padding: 7px 10px; } .log-history-row { diff --git a/docs/goals/windows-client-three-panel-ui/EVIDENCE.md b/docs/goals/windows-client-three-panel-ui/EVIDENCE.md new file mode 100644 index 0000000..e3beac5 --- /dev/null +++ b/docs/goals/windows-client-three-panel-ui/EVIDENCE.md @@ -0,0 +1,85 @@ +# Доказательства трехпанельного интерфейса Windows Client + +## Приемочные доказательства + +Записать реальный артефакт, который доказывает результат с точки зрения пользователя. + +Обязательные доказательства: +- `summary-panel.png`: read-only `Сводка` показывает статус системы, ProxiFyre, подключение, маршрут, активный сервер, количество приложений, config path state, dirty state и последнее событие. +- Browser state для `Сводка`: `summaryInputs = 0`, `summaryForbiddenButtons = []`, активная вкладка `panel-summary`. +- `proxifyre-panel.png`: панель `ProxiFyre` показывает статус компонента, setup action, service install action, список приложений, add controls, dirty/apply command row. +- `vpn-proxy-external-panel.png`: панель `VPN / Прокси` в режиме внешнего прокси показывает route switch, SOCKS5 input, TCP check state, route preview и apply/open actions. +- `vpn-proxy-local-panel.png`: панель `VPN / Прокси` в режиме локального прокси показывает Local sing-box empty/install state, route preview и dirty/apply state. +- `narrow-proxifyre-panel.png`: узкий viewport показывает tab bar, ProxiFyre card, app list controls и command area без overlap с log dock. +- Browser overlap state после фикса: desktop `commandBottom = 705.1875`, `logTop = 815.1875`, `overlaps = false`; narrow `appListBottom = 701.1875`, `logTop = 1072.1875`, `overlaps = false`. +- Root `src/web` и `src/server` не менялись. + +Сервисные действия install/start/stop/uninstall проверены на уровне существующих handlers и сборки UI. Реальный elevated Windows service lane для ProxiFyre/Local sing-box в этой сессии не запускался: `implemented but unproven`. + +## Проверка + +Записать сфокусированные проверки, которые прошли, включая команды и важный вывод. + +Ожидаемые команды: + +```powershell +cd apps/windows-client +npm run build +``` + +Результат: + +```text +tsc && vite build +1789 modules transformed +dist/index.html +dist/assets/index-Oqrmqzau.css +dist/assets/index-CJqhju7q.js +built in 1.31s +``` + +Если менялись Rust/Tauri commands: + +```powershell +cd apps/windows-client/src-tauri +cargo test +``` + +Результат: + +```text +command_tests: 10 passed, including ping_proxy_target_reports_open_tcp_endpoint +component_detection_tests: 7 passed +domain_tests: 5 passed +helper_tests: 6 passed +proxifyre_adapter_tests: 6 passed +singbox_adapter_tests: 6 passed +singbox_command_tests: 8 passed +singbox_service_tests: 7 passed +storage_tests: 8 passed +subscription_tests: 7 passed +doc-tests: 0 passed +overall: passed, warnings only for existing dead-code/test module duplication +``` + +Browser preview: + +```text +Opened http://127.0.0.1:5174/ +Tabs detected: Сводка, ProxiFyre, VPN / Прокси +Summary activePanel: panel-summary +ProxiFyre activePanel: panel-proxifyre +VPN/Proxy activePanel: panel-proxy +External route aria-pressed=true in external mode +Local route aria-pressed=true in local mode +``` + +## Заметки review + +Записать PRE review, reviewer, maintainer или verifier findings, если они изменили результат. + +- PRE review: aligned. No blockers. Main risk was weak UI evidence; resolved by browser screenshots and DOM state checks. +- POST plan review: aligned. The displaced monolithic screen path is removed from the active render path and replaced by three panels. +- Correctness review: summary has no mutating controls; ProxiFyre actions remain in ProxiFyre panel; route/proxy actions remain in VPN/Proxy panel; new `ping_proxy_target` is read-only and tested with a local TCP listener. +- Maintainability review: backend source of truth unchanged; no direct generated config writes from React; no root `src/web` or `src/server` edits. +- Residual risk: real elevated service actions were not manually executed in native Tauri/elevated Windows mode during this UI pass. diff --git a/docs/goals/windows-client-three-panel-ui/GOAL.md b/docs/goals/windows-client-three-panel-ui/GOAL.md new file mode 100644 index 0000000..6a47950 --- /dev/null +++ b/docs/goals/windows-client-three-panel-ui/GOAL.md @@ -0,0 +1,12 @@ +# Goal: Трехпанельный интерфейс Windows Client + +Use Krypton Execution to execute `docs/goals/windows-client-three-panel-ui/PLAN.md`. + +Core rules: +- Treat `PLAN.md` as the source plan. +- Preserve intent, ownership, contract, cutover, evidence, and kill criteria. +- Summary panel must remain read-only: no apply, install, start, stop, delete, input, subscription, or route mutations there. +- Keep Rust/Tauri storage and commands as the source of truth; React may hold only UI state and derived display models. +- Do not add a new dominant path without deleting, redirecting, demoting, or shimming the displaced monolithic screen path. +- Capture acceptance evidence from the target perspective and record it in `EVIDENCE.md`. +- Say `implemented but unproven` if target-perspective or Windows service evidence cannot be captured. diff --git a/docs/goals/windows-client-three-panel-ui/PLAN.md b/docs/goals/windows-client-three-panel-ui/PLAN.md new file mode 100644 index 0000000..ee9d418 --- /dev/null +++ b/docs/goals/windows-client-three-panel-ui/PLAN.md @@ -0,0 +1,423 @@ +# План реализации трехпанельного интерфейса Windows Client + +**Intent:** Перестроить текущий монолитный экран Windows-клиента в три переключаемые панели: read-only сводка, настройки ProxiFyre и настройки VPN/прокси. +**Current Behavior:** Сейчас `apps/windows-client/src/app/App.tsx` смешивает статус ProxiFyre, выбор маршрута, Local sing-box, список приложений, применение конфига и журнал событий на одном экране. Пользователь сразу видит все управляющие элементы, поэтому главная страница не отделяет состояние системы от действий. +**Expected Outcome:** Пользователь открывает приложение и сначала видит чистую сводку без редактирования. Управление ProxiFyre, список приложений и диагностика компонента находятся во второй панели. Выбор локального или внешнего прокси, подписка, серверы, ping и генерация VPN/proxy-конфига находятся в третьей панели. +**Target-Perspective Output:** Пользователь может за 5-10 секунд понять, работает ли система, запущен ли ProxiFyre, какой маршрут применен, локальный он или внешний, и какой файл конфига активен. Затем пользователь переключается во вкладку ProxiFyre для сервисных действий и списка приложений либо во вкладку VPN/Прокси для выбора маршрута и проверки соединения. +**Truth Owner:** Rust/Tauri команды и JSON-файлы под `C:\ProgramData\VpnProxy` остаются источником правды. React хранит только состояние текущей вкладки, черновики полей, loading/error/success состояния и производные view model для отображения. +**Contract Boundary:** UI вызывает существующие typed Tauri commands из `apps/windows-client/src/api/tauriCommands.ts`. Rust владеет профилями, targets, компонентами, local-singbox config/cache, generated config и применением ProxiFyre. Новые UI-панели не должны писать напрямую в derived/generated артефакты. +**Cutover:** Текущий единый экран в `App.tsx` заменяется shell-layout с тремя вкладками. Существующие функции загрузки, установки, запуска, выбора сервера, ping, apply и notifications переиспользуются, но раскладываются по панелям. +**Displaced Path:** Убирается доминирующий путь "все настройки на одной странице". Сводка не дублирует формы и кнопки apply; она только отображает состояние и ведет пользователя к нужной панели. +**Value Density:** Самый ценный срез: верхняя навигация по трем панелям плюс read-only сводка, которая корректно собирает текущее состояние из уже загружаемых данных. После этого переносим существующие настройки без изменения backend-контракта. +**Evidence Gate:** Приемка требует не только `npm run build`, но и доказательство глазами пользователя: скриншоты/состояния всех трех панелей, read-only поведение сводки, dirty/apply состояние на вкладках настроек, responsive-проверка. +**Acceptance Evidence:** `npm run build` проходит; при необходимости Rust tests проходят для нового ping/target DTO; в `EVIDENCE.md` записаны скриншоты или browser/Tauri state для трех панелей, а также проверка, что сводка не содержит изменяющих controls. +**Evidence Lane:** Доказательства исполнения будут фиксироваться в `docs/goals/windows-client-three-panel-ui/EVIDENCE.md`. +**Kill Criteria:** Нет input/button apply на сводке; нет второго источника состояния маршрута; нет скрытой установки компонентов при открытии вкладок; нет изменения root `src/web` или root `src/server`; нет расхождения терминов ProxiFyre / Local sing-box / внешний прокси. +**Architecture Slice:** Изменения сосредоточены в `apps/windows-client/src/app/App.tsx`, `apps/windows-client/src/styles/app.css`, при необходимости `apps/windows-client/src/api/tauriCommands.ts`, `apps/windows-client/src/domain/types.ts`, `apps/windows-client/src-tauri/src/commands.rs` и связанных tests для проверки внешнего target ping. +**Plan Review Gate:** Требуется PRE review перед исполнением. + +## Краткий дизайн-brief + +Продукт: Windows desktop utility для маршрутизации выбранных приложений через ProxiFyre и внешний или локальный proxy path. + +Визуальный источник: текущий темный utilitarian-стиль `apps/windows-client/src/styles/app.css`: компактные панели, 4px radius, статусные точки, lucide icons, сдержанные borders, без landing/hero. + +Интерактивность: полная. Вкладки, меню, установка/запуск/остановка, добавление приложений, выбор маршрута, подписка, выбор сервера, ping, генерация и apply должны работать через реальные текущие команды. + +## Архитектурный срез + +Файлы для создания: +- `docs/goals/windows-client-three-panel-ui/PLAN.md` +- `docs/goals/windows-client-three-panel-ui/GOAL.md` +- `docs/goals/windows-client-three-panel-ui/EVIDENCE.md` + +Файлы для изменения: +- `apps/windows-client/src/app/App.tsx` +- `apps/windows-client/src/styles/app.css` +- `apps/windows-client/src/api/tauriCommands.ts`, только если понадобится новый внешний proxy ping command. +- `apps/windows-client/src/domain/types.ts`, только если понадобится новый DTO для внешнего proxy ping. +- `apps/windows-client/src-tauri/src/commands.rs`, только если понадобится новый внешний proxy ping command. +- `apps/windows-client/src-tauri/src/main.rs`, только для регистрации нового command. +- `apps/windows-client/src-tauri/tests/*`, только для нового command или сохранения существующих контрактов. +- `docs/goals/windows-client-three-panel-ui/EVIDENCE.md`, при исполнении. + +Файлы, которых не касаться: +- `src/web/*` +- `src/server/*` +- Docker/compose/entrypoint файлы. +- Installer scripts, если UI-разрез не требует изменения install boundary. +- Старые goal packages, кроме ссылок в evidence при необходимости. + +Источник правды: +- `C:\ProgramData\VpnProxy\config\profiles.json` +- `C:\ProgramData\VpnProxy\config\targets.json` +- `C:\ProgramData\VpnProxy\config\components.json` +- `C:\ProgramData\VpnProxy\config\local-singbox.json` +- `C:\ProgramData\VpnProxy\state\singbox-subscription-cache.json` +- `C:\ProgramData\VpnProxy\state\activity.json` + +Производные артефакты: +- `C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json` +- `C:\ProgramData\VpnProxy\generated\sing-box-config.json` + +Путь чтения: +- React вызывает `get_saved_state`, `get_components`, `get_proxifyre_setup_status`, `get_singbox_status`, `get_singbox_setup_status`. +- Summary panel строит производную view model из уже загруженных `profiles`, `targets`, `components`, `setupStatus`, `singBoxStatus`, `generatedConfigPath`, `serverPings` и `hasUnappliedChanges`. +- ProxiFyre panel читает `proxyfier`, `setupStatus`, `items`, `loadedProfiles`, `generatedConfigPath`. +- VPN/Proxy panel читает `routeMode`, `proxyInput`, `singBoxStatus`, `singBoxSetupStatus`, `serverPings`, selected server и target. + +Путь записи: +- ProxiFyre panel пишет только через существующие handlers: install/start/stop/uninstall ProxiFyre, add/remove app items, `updateConfig`. +- VPN/Proxy panel пишет только через route/subscription/server/config handlers: `changeRouteMode`, `changeProxyInput`, `saveSingBoxSubscription`, `fetchSingBoxSubscription`, `forgetSingBoxSubscription`, `selectSingBoxServer`, `ping*`, `generateSingBoxConfig`, service actions, `updateConfig`. +- Summary panel не пишет ничего, кроме глобального non-mutating refresh, если refresh остается в header. + +Граница контракта: +- `App.tsx` отвечает за orchestration и view composition. +- Typed Tauri command wrappers отвечают за API shape. +- Rust commands отвечают за validation, persistence, service/check actions и generated config. +- CSS отвечает за layout, tab transitions, loading skeletons, menu/popover transitions и responsive behavior. + +Точки интеграции: +- `routeMode === 'external'` показывает внешний/глобальный proxy target. +- `routeMode === 'local-singbox'` показывает Local sing-box path. +- `hasUnappliedChanges` остается общим индикатором для вкладок настроек. +- `log-dock` остается глобальным и не привязывается к одной вкладке. + +Миграция и переключение: +- Сначала добавить `activePanel` и shell-вкладки без изменения бизнес-логики. +- Затем перенести JSX блоки в три render-функции или локальные компоненты в том же файле. +- После стабилизации можно вынести панели в отдельные файлы, но только если это уменьшит размер `App.tsx` без изменения контрактов. + +Гейт приемочных доказательств: +- Доказательство должно показать каждую панель отдельно. +- Должно быть видно, что summary read-only. +- Должно быть видно, что actions доступны на вкладках настроек. +- Должно быть видно, что mobile layout не ломает tab bar, app list, server list и bottom log dock. + +## Детальное описание интерфейса + +### Общая оболочка + +Верхняя часть экрана остается компактной, но становится навигационной: +- слева: `VPN Proxy` и заголовок `Прокси для приложений`; +- справа: кнопка `Обновить`, которая запускает текущий `refresh`; +- под заголовком: segmented tabs из трех пунктов: `Сводка`, `ProxiFyre`, `VPN / Прокси`. + +Поведение вкладок: +- активная вкладка имеет более яркий border/background и status underline; +- при клике меняется только `activePanel`, данные не перезагружаются автоматически; +- переход между панелями: fade + легкий slide по Y на 6-8px, длительность 140-180ms; +- при `prefers-reduced-motion: reduce` transition отключается; +- keyboard support: tabs работают как buttons с `aria-pressed` или `role="tablist"`/`role="tab"`/`role="tabpanel`. + +Глобальные состояния: +- `isLoading` показывает skeleton в содержимом активной панели; +- `isDetectingComponents` показывает проверку компонентов без блокировки переключения вкладок; +- `log-dock` остается закрепленным снизу и продолжает показывать success/error/info; +- dirty/apply bar появляется только на вкладках `ProxiFyre` и `VPN / Прокси`, не на read-only сводке. + +### Панель 1: `Сводка` + +Назначение: главная read-only страница, которая отвечает на вопрос "что сейчас происходит с системой". + +Содержимое: +- `Статус системы`: одно агрегированное состояние `Работает`, `Требует внимания`, `Не настроено`, `Проверяю`. +- `ProxiFyre`: установлен/не установлен, запущен/остановлен, путь или первая проблема. +- `Подключение`: корректность текущего маршрута: ProxiFyre running, выбран target, для Local sing-box выбран сервер и служба запущена, для внешнего proxy валиден host/port и есть последний результат ping/check, если он уже запускался. +- `Маршрут`: `Локальный прокси` или `Внешний прокси`; рядом человекочитаемый endpoint: `127.0.0.1:1080`, LAN адрес Local sing-box или внешний `host:port`. +- `Активный сервер`: selected Local sing-box server tag, если применим; иначе `не используется`. +- `Приложения в профиле`: количество настроенных process/folder/exe items. +- `Примененный конфиг`: путь `generatedConfigPath` для ProxiFyre и, если есть, `singBoxStatus.generatedConfigPath`. +- `Состояние изменений`: `Все применено` или `Есть непримененные изменения`, но без кнопки apply. +- `Последнее событие`: последний элемент `logEntries` или activity, если он уже доступен на клиенте. + +Визуально: +- это не форма, а read-only dashboard; +- вместо input используются value rows: label слева, value справа, status dot рядом; +- важные значения не спрятаны в tooltip; +- длинные пути переносятся через `overflow-wrap: anywhere`; +- при широкой ширине блоки идут сеткой 2-3 колонки, на узком экране складываются в один столбец. + +Действия при нажатии: +- клик по строке `ProxiFyre` переключает на вкладку `ProxiFyre`; +- клик по строке `Маршрут` или `Активный сервер` переключает на вкладку `VPN / Прокси`; +- клик по `Приложения в профиле` переключает на вкладку `ProxiFyre` и фокусирует список приложений; +- никаких input, add/remove, install/start/stop, apply или clear на этой панели нет. + +Анимации: +- при refresh status rows показывают мягкий shimmer/skeleton; +- status dot для `Проверяю` использует текущий spinner pattern; +- при смене агрегированного статуса value row подсвечивается на 400-600ms через background flash; +- предупреждение `Есть непримененные изменения` может слегка проявляться fade-in, но без кнопки. + +### Панель 2: `ProxiFyre` + +Назначение: диагностика и настройка того, что относится именно к ProxiFyre и маршрутизируемым приложениям. + +Содержимое: +- главный статус ProxiFyre: найден/не найден, запущен/остановлен, path/problem; +- setup/modules list из `setupStatus.items`: binary/service/install folder/config capability и все, что возвращает backend; +- service controls: install, start, stop, uninstall через меню `MoreHorizontal`; +- `Список приложений`: настроенные process/folder/exe items, сгруппированные или помеченные типом; +- счетчик приложений и отдельные пустые состояния для "ничего не настроено"; +- generated ProxiFyre config path; +- dirty/apply state для изменений списка приложений. + +Визуально: +- верхний status block сохраняет текущий `finder-card` pattern, status-light и animated border glow; +- modules list разворачивается внутри панели через `Состав ProxiFyre` / `Что будет установлено`; +- список приложений становится основной частью панели, а не хвостом общей страницы; +- app rows имеют icon по типу: process, exe, folder; +- add-toolbar остается с icon buttons: process, exe, folder, с tooltip/title. + +Действия при нажатии: +- `Установить`: вызывает текущий `installProxiFyrePackage`, показывает loading на кнопке и animated border; +- `Запустить`/`Остановить`: вызывает `setProxiFyreServiceRunning`, блокирует повторный клик на время action; +- `Еще` открывает popover с `Удалить ProxiFyre`; uninstall сохраняет текущий confirm boundary; +- `Состав ProxiFyre`: раскрывает/скрывает setup details; +- `Добавить процесс`: открывает inline input, Enter добавляет, Escape закрывает; +- `Добавить EXE`: открывает file picker через Tauri dialog; +- `Добавить папку`: открывает directory picker; +- `Удалить` у app row удаляет item и выставляет `hasUnappliedChanges`; +- `Применить в ProxiFyre`: валидирует items и текущий route, затем вызывает `updateConfig`; +- `Открыть конфиг`: открывает generated config location, если эта кнопка остается в ProxiFyre panel. + +Анимации: +- поиск/установка/запуск используют существующий moving border glow; +- раскрытие modules list: height/opacity transition 160-220ms; +- добавление app row: fade/slide-in; +- удаление app row: быстрый opacity collapse, если реализация не усложнит state; +- dirty bar появляется slide-up/fade-in над command row; +- меню `Еще`: fade + scale 0.98 -> 1. + +### Панель 3: `VPN / Прокси` + +Назначение: выбор маршрута и настройка самого proxy/VPN path. + +Содержимое: +- segmented control `Внешний прокси` / `Локальный прокси`; +- для `Внешний прокси`: input адреса `socks5://host:port` или `host:port`, validation hint, кнопка проверки доступности; +- для `Локальный прокси`: Local sing-box status, install/start/stop/uninstall, setup details, локальный и LAN endpoint, selected server, generated sing-box config path; +- subscription input: paste URL/link, load/update, clear; +- server list из `singBoxStatus.cache.servers`; +- ping controls: `Ping все`, per-server ping action, отображение latency/error рядом с server row; +- generate sing-box config action; +- route preview: `Выбранные приложения -> ProxiFyre -> внешний proxy` или `Выбранные приложения -> ProxiFyre -> Local sing-box -> selected server`; +- dirty/apply state для route/proxy/subscription/server changes. + +Визуально: +- mode switch остается заметным вверху панели; +- внешний proxy block не должен выглядеть менее важным, потому что это полноценный route path; +- Local sing-box block визуально похож на ProxiFyre status block, но расположен только в VPN/Proxy panel; +- server list может оставаться grid, но row должен показывать selected dot, server name и ping badge; +- для плохого ping row получает amber/red border, для хорошего ping зеленый badge; +- route preview оформляется как read-only line/flow, без декоративной схемы. + +Действия при нажатии: +- `Внешний прокси`: меняет `routeMode` на `external`, выставляет dirty state; +- ввод адреса proxy: обновляет `proxyInput`, выставляет dirty state, frontend validation показывает ошибки до apply; +- `Проверить`: парсит внешний proxy и вызывает новый или переиспользованный backend TCP connect check; результат сохраняется в UI state и отображается на этой панели и в summary; +- `Локальный прокси`: меняет `routeMode` на `local-singbox`, выставляет dirty state; +- `Установить Local sing-box`: вызывает текущий install handler; +- `Запустить`/`Остановить`: вызывает текущий service handler; +- `Состав Local sing-box`: раскрывает setup details; +- `Подробности`: показывает локальный endpoint, LAN, server, binary path, config path; +- `Загрузить`/`Обновить` подписку: вызывает текущий subscription flow; +- `Очистить`: вызывает forget flow, очищает server pings и selected server; +- click по server row: вызывает `selectSingBoxServer`, выставляет dirty state; +- `Ping все`: вызывает `pingAllSingBoxServers`; +- per-server ping: вызывает существующий `pingSingBoxServer(tag)`, если добавляем кнопку/иконку в row; +- `Конфиг`: вызывает `generateSingBoxConfig`; +- `Применить маршрут`: вызывает общий `updateConfig`. + +Анимации: +- mode switch меняет активный segment без скачка высоты; +- при смене mode panel content cross-fade; +- ping badge показывает loading spinner только на проверяемых row; +- server row после ping обновляет latency с короткой подсветкой; +- subscription/server empty state появляется fade-in; +- generated config success подсвечивает config path на 400-600ms. + +## Задачи исполнения + +### Задача 1: Ввести shell вкладок и производные view models + +Разрешенные файлы: +- `apps/windows-client/src/app/App.tsx` +- `apps/windows-client/src/styles/app.css` + +Ожидаемый результат: +- `activePanel: 'summary' | 'proxifyre' | 'proxy'`. +- Общий header с tab control. +- Производные функции для summary: system status, route label, endpoint label, config labels, app count, issue list. +- Старые JSX-блоки временно остаются, но render path начинает разделяться. + +Проверка: +- `cd apps/windows-client && npm run build` + +Приемочные доказательства: +- Screenshot/state показывает три tab buttons и корректную активную вкладку. + +Параллельное выполнение: нет, общий `App.tsx`. + +### Задача 2: Реализовать read-only панель `Сводка` + +Разрешенные файлы: +- `apps/windows-client/src/app/App.tsx` +- `apps/windows-client/src/styles/app.css` + +Ожидаемый результат: +- Summary panel без input/apply/install/start/stop/delete. +- Статус системы, ProxiFyre, подключение, маршрут, активный server, apps count, config paths, dirty state. +- Навигационные клики по read-only строкам переводят на соответствующие вкладки. + +Проверка: +- `cd apps/windows-client && npm run build` +- Manual DOM/visual check: на summary нет изменяющих controls. + +Приемочные доказательства: +- Screenshot summary panel. +- Запись в evidence: какие элементы read-only и куда ведут navigation clicks. + +Параллельное выполнение: нет. + +### Задача 3: Перенести ProxiFyre и список приложений во вторую панель + +Разрешенные файлы: +- `apps/windows-client/src/app/App.tsx` +- `apps/windows-client/src/styles/app.css` + +Ожидаемый результат: +- ProxiFyre panel содержит status card, setup details, service actions, app add/remove, generated config path и apply/open config actions. +- Существующие handlers переиспользованы без изменения backend ownership. +- `hasUnappliedChanges` продолжает работать после add/remove. + +Проверка: +- `cd apps/windows-client && npm run build` + +Приемочные доказательства: +- Screenshot ProxiFyre panel с service status и apps list. +- Проверка add/remove вызывает dirty state. + +Параллельное выполнение: нет. + +### Задача 4: Перенести VPN/Proxy route settings в третью панель + +Разрешенные файлы: +- `apps/windows-client/src/app/App.tsx` +- `apps/windows-client/src/styles/app.css` +- `apps/windows-client/src/api/tauriCommands.ts`, если нужен внешний target ping. +- `apps/windows-client/src/domain/types.ts`, если нужен внешний target ping DTO. + +Ожидаемый результат: +- VPN/Proxy panel содержит route mode switch, external proxy editor, Local sing-box controls, subscription, server selection, ping, generate config, route preview и apply. +- Внешний proxy path не зависит от Local sing-box. +- Local sing-box UI не показывается на summary и не смешивается с ProxiFyre panel. + +Проверка: +- `cd apps/windows-client && npm run build` + +Приемочные доказательства: +- Screenshot external proxy mode. +- Screenshot local proxy mode с server list или empty state. + +Параллельное выполнение: нет. + +### Задача 5: Добавить или переиспользовать проверку доступности внешнего proxy target + +Разрешенные файлы: +- `apps/windows-client/src-tauri/src/commands.rs` +- `apps/windows-client/src-tauri/src/main.rs` +- `apps/windows-client/src/api/tauriCommands.ts` +- `apps/windows-client/src/domain/types.ts` +- `apps/windows-client/src-tauri/tests/command_tests.rs` или отдельный focused test. + +Ожидаемый результат: +- Если существующих данных недостаточно для "все ли окей с подключением" во внешнем proxy mode, добавить typed command наподобие `ping_proxy_target`. +- Command делает TCP connect check к host/port с timeout, возвращает `{ ok, latency, error }`. +- UI отображает результат в VPN/Proxy panel и summary. +- Это не заменяет apply и не пишет source config. + +Проверка: +- `cd apps/windows-client/src-tauri && cargo test command` +- `cd apps/windows-client && npm run build` + +Приемочные доказательства: +- Test или manual state показывает успешный/ошибочный result shape. + +Параллельное выполнение: частично, если UI DTO names согласованы заранее; интеграция не parallel-safe. + +### Задача 6: Привести CSS, анимации и responsive behavior + +Разрешенные файлы: +- `apps/windows-client/src/styles/app.css` +- `apps/windows-client/src/app/App.tsx`, только className/ARIA adjustments. + +Ожидаемый результат: +- Tab bar, panel transitions, setup expand/collapse, ping badges, dirty/apply bar и responsive grids. +- Нет перекрытия текста, длинные paths/servers переносятся. +- Mobile layout: tabs не ломаются, server list минимум 2 колонки или 1 колонка на очень узком экране, command row складывается. +- `prefers-reduced-motion` отключает декоративные transitions. + +Проверка: +- `cd apps/windows-client && npm run build` +- Browser/Tauri visual check на desktop и узком viewport. + +Приемочные доказательства: +- Screenshots desktop + narrow viewport. + +Параллельное выполнение: нет, потому что зависит от финальной разметки. + +### Задача 7: Финальная проверка и evidence + +Разрешенные файлы: +- `docs/goals/windows-client-three-panel-ui/EVIDENCE.md` +- При необходимости `apps/windows-client/README.md`, если нужно зафиксировать новую навигацию. + +Ожидаемый результат: +- Evidence содержит commands, результаты build/tests, screenshots/state checks и список остаточных рисков. +- Если Tauri service actions не проверялись на реальной Windows elevated среде, это явно помечено как `implemented but unproven` только для service lane. + +Проверка: +- `git status --short` +- `cd apps/windows-client && npm run build` +- `cd apps/windows-client/src-tauri && cargo test`, если Rust command менялся. + +Приемочные доказательства: +- Summary read-only verified. +- ProxiFyre actions verified. +- VPN/Proxy route switch and ping states verified. +- No root web/server changes. + +Параллельное выполнение: нет. + +## Prompt для PRE review + +Использовать `C:\Users\PC\.agents\skills\krypton-planning\plan-reviewer-prompt.md` со следующими данными: + +Файл плана: +- `docs/goals/windows-client-three-panel-ui/PLAN.md` + +Исходный запрос: +- Разбить Windows client UI на три переключаемые панели: общая read-only сводка, настройки ProxiFyre, настройки VPN/proxy; подробно описать внешний вид, анимации и действия. + +Контракт результата: +- Пользователь сначала видит статус системы и примененный route/config без изменения данных, затем переходит в нужную панель для ProxiFyre или VPN/proxy действий. + +Архитектурный срез: +- Основной scope: `apps/windows-client/src/app/App.tsx` и `apps/windows-client/src/styles/app.css`; backend только для внешнего target ping, если текущих команд недостаточно. + +Требование к приемочным доказательствам: +- Build/tests плюс screenshots/state proof трех панелей и read-only summary. + +Известные non-goals: +- Не менять root `src/web`/`src/server`. +- Не переписывать Tauri backend без нужды. +- Не добавлять скрытую установку компонентов. +- Не делать summary редактируемой. + +Опасные пути или слои: +- Дублирование source of truth в React. +- Изменение generated config напрямую. +- Смешивание Local sing-box controls обратно в ProxiFyre panel. +- Потеря external proxy path как полноценного режима. diff --git a/docs/goals/windows-client-three-panel-ui/narrow-proxifyre-panel.png b/docs/goals/windows-client-three-panel-ui/narrow-proxifyre-panel.png new file mode 100644 index 0000000..5087612 Binary files /dev/null and b/docs/goals/windows-client-three-panel-ui/narrow-proxifyre-panel.png differ diff --git a/docs/goals/windows-client-three-panel-ui/proxifyre-panel.png b/docs/goals/windows-client-three-panel-ui/proxifyre-panel.png new file mode 100644 index 0000000..a4e77f7 Binary files /dev/null and b/docs/goals/windows-client-three-panel-ui/proxifyre-panel.png differ diff --git a/docs/goals/windows-client-three-panel-ui/summary-panel.png b/docs/goals/windows-client-three-panel-ui/summary-panel.png new file mode 100644 index 0000000..9833826 Binary files /dev/null and b/docs/goals/windows-client-three-panel-ui/summary-panel.png differ diff --git a/docs/goals/windows-client-three-panel-ui/vpn-proxy-external-panel.png b/docs/goals/windows-client-three-panel-ui/vpn-proxy-external-panel.png new file mode 100644 index 0000000..c8085b0 Binary files /dev/null and b/docs/goals/windows-client-three-panel-ui/vpn-proxy-external-panel.png differ diff --git a/docs/goals/windows-client-three-panel-ui/vpn-proxy-local-panel.png b/docs/goals/windows-client-three-panel-ui/vpn-proxy-local-panel.png new file mode 100644 index 0000000..e680e90 Binary files /dev/null and b/docs/goals/windows-client-three-panel-ui/vpn-proxy-local-panel.png differ