diff --git a/AGENTS.md b/AGENTS.md index 1d6de75..738ffff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ ProxyWarden - standalone Windows desktop client в корне репозитор - Для service/install операций сохранять UAC/admin boundary и человекочитаемые ошибки. - При удалении install folders сохранять safe-path checks; не ослаблять рекурсивное удаление. - В UI держать стиль компактной Windows-утилиты, а не landing/dashboard. Использовать existing `Button`, `Tabs`, `ServiceControlRow`, `StatusPill`, `Field`, `ActionMenu`, `LogDock`. +- Всплывающие подсказки при наведении делать быстрыми, кастомными и читаемыми: темная compact-плашка с мягкой рамкой/тенью, появление ~120ms, без нативного browser `title` как основного UI. Для иконок расширять общий `IconButton`/tooltip-паттерн, а не дублировать JSX/CSS локально. - Apply actions должны быть disabled с объяснением, когда нет приложений, ProxiFyre отсутствует, proxy input неверный или local route не готов. ## Проверка diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0f00896..002657e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,5 +1,5 @@ #[cfg(not(test))] -use crate::adapters::proxifyre::ProxiFyreAdapter; +use crate::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy}; #[cfg(not(test))] use crate::adapters::proxy_router::{ ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig, @@ -21,7 +21,7 @@ use crate::models::{ SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind, }; #[cfg(test)] -use crate::proxifyre::ProxiFyreAdapter; +use crate::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy}; #[cfg(test)] use crate::proxy_router::{ ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig, @@ -50,6 +50,8 @@ use std::time::{Duration, Instant}; use std::time::{SystemTime, UNIX_EPOCH}; const PROXIFYRE_INSTALL_DIR: &str = r"C:\Tools\ProxiFyre"; +const MAIN_PROFILE_ID: &str = "main-profile"; +const MAIN_TARGET_ID: &str = "main-proxy"; const PROXIFYRE_RELEASE_API_URL: &str = "https://api.github.com/repos/wiresock/proxifyre/releases/latest"; const NDISAPI_RELEASE_API_URL: &str = @@ -627,7 +629,9 @@ pub fn ping_all_singbox_servers( } #[tauri::command] -pub fn ping_proxy_target(input: PingProxyTargetInputDto) -> Result { +pub fn ping_proxy_target( + input: PingProxyTargetInputDto, +) -> Result { ping_proxy_target_endpoint(input) } @@ -744,9 +748,11 @@ pub async fn start_singbox_service( #[tauri::command] pub async fn stop_singbox_service() -> Result { - tauri::async_runtime::spawn_blocking(|| control_singbox_service(SingBoxServiceAction::Stop, None)) - .await - .map_err(background_task_error)? + tauri::async_runtime::spawn_blocking(|| { + control_singbox_service(SingBoxServiceAction::Stop, None) + }) + .await + .map_err(background_task_error)? } #[tauri::command] @@ -869,9 +875,31 @@ pub fn read_activity(storage: &JsonStorage) -> Result, Com } pub fn read_saved_state(storage: &JsonStorage) -> Result { + let detected_config_path = detect_proxyfier_install().and_then(|detected| detected.config_path); + read_saved_state_with_proxifyre_config(storage, detected_config_path.as_deref()) +} + +pub fn read_saved_state_with_proxifyre_config( + storage: &JsonStorage, + proxifyre_config_path: Option<&Path>, +) -> Result { + let mut profiles = storage.read_profiles().map_err(storage_error)?; + let mut targets = storage.read_targets().map_err(storage_error)?; + + if should_bootstrap_profiles(&profiles) { + if let Some(imported) = + proxifyre_config_path.and_then(import_saved_state_from_proxifyre_config) + { + profiles = imported.profiles; + upsert_targets(&mut targets, imported.targets); + storage.write_targets(&targets).map_err(storage_error)?; + storage.write_profiles(&profiles).map_err(storage_error)?; + } + } + Ok(SavedStateResponse { - profiles: read_profiles(storage)?, - targets: read_targets(storage)?, + profiles: profiles.iter().map(ProfileDto::from).collect(), + targets: targets.iter().map(TargetDto::from).collect(), generated_config_path: storage .paths() .generated_dir @@ -881,6 +909,198 @@ pub fn read_saved_state(storage: &JsonStorage) -> Result, + targets: Vec, +} + +fn should_bootstrap_profiles(profiles: &[Profile]) -> bool { + !profiles + .iter() + .any(|profile| profile.enabled && !profile.items.is_empty()) +} + +fn import_saved_state_from_proxifyre_config(path: &Path) -> Option { + let contents = fs::read_to_string(path).ok()?; + let config: ProxiFyreConfig = serde_json::from_str(&contents).ok()?; + + let proxy_entries = config + .proxies + .iter() + .filter_map(import_proxy_entry) + .collect::>(); + if proxy_entries.is_empty() { + return None; + } + + let single_entry = proxy_entries.len() == 1; + let mut profiles = Vec::with_capacity(proxy_entries.len()); + let mut targets = Vec::with_capacity(proxy_entries.len()); + + for (index, entry) in proxy_entries.into_iter().enumerate() { + let ordinal = index + 1; + let target_id = if single_entry { + MAIN_TARGET_ID.to_string() + } else { + format!("proxifyre-import-target-{ordinal}") + }; + let profile_id = if single_entry { + MAIN_PROFILE_ID.to_string() + } else { + format!("proxifyre-import-profile-{ordinal}") + }; + let profile_name = if single_entry { + "Приложения через прокси".to_string() + } else { + format!("Импорт ProxiFyre {ordinal}") + }; + + targets.push(Target { + id: target_id.clone(), + name: if single_entry { + "Основной прокси".to_string() + } else { + format!("Прокси ProxiFyre {ordinal}") + }, + kind: TargetKind::External, + protocol: ProxyProtocol::Socks5, + host: entry.host, + port: entry.port, + requires_component: None, + }); + profiles.push(Profile { + id: profile_id, + name: profile_name, + enabled: true, + target_id, + protocols: entry.protocols, + items: entry.items, + }); + } + + Some(ImportedSavedState { profiles, targets }) +} + +struct ImportedProxyEntry { + items: Vec, + protocols: Vec, + host: String, + port: u16, +} + +fn import_proxy_entry(proxy: &ProxiFyreProxy) -> Option { + let items = proxy + .app_names + .iter() + .filter_map(|name| imported_profile_item(name)) + .collect::>(); + if items.is_empty() { + return None; + } + + let (host, port) = parse_socks5_endpoint(&proxy.socks5_proxy_endpoint)?; + + Some(ImportedProxyEntry { + items, + protocols: imported_protocols(&proxy.supported_protocols), + host, + port, + }) +} + +fn imported_profile_item(raw_value: &str) -> Option { + let value = raw_value.trim().trim_matches('"'); + if value.is_empty() { + return None; + } + + let looks_like_path = value.contains('\\') || value.contains('/'); + let item_type = if looks_like_path && value.to_ascii_lowercase().ends_with(".exe") { + ProfileItemType::Exe + } else if looks_like_path { + ProfileItemType::Folder + } else { + ProfileItemType::Process + }; + let value = match item_type { + ProfileItemType::Process => { + let base = value.rsplit(['\\', '/']).next().unwrap_or(value); + if base.to_ascii_lowercase().ends_with(".exe") { + base[..base.len() - 4].to_string() + } else { + base.to_string() + } + } + ProfileItemType::Folder | ProfileItemType::Exe => value.to_string(), + }; + + if value.is_empty() { + return None; + } + + Some(ProfileItem { + recursive: matches!(item_type, ProfileItemType::Folder), + item_type, + value, + }) +} + +fn imported_protocols(values: &[String]) -> Vec { + let mut protocols = Vec::new(); + for value in values { + let protocol = match value.trim().to_ascii_uppercase().as_str() { + "TCP" => Protocol::Tcp, + "UDP" => Protocol::Udp, + _ => continue, + }; + if !protocols.contains(&protocol) { + protocols.push(protocol); + } + } + + if protocols.is_empty() { + vec![Protocol::Tcp, Protocol::Udp] + } else { + protocols + } +} + +fn parse_socks5_endpoint(endpoint: &str) -> Option<(String, u16)> { + let endpoint = endpoint.trim(); + let endpoint = if endpoint + .get(.."socks5://".len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("socks5://")) + { + &endpoint["socks5://".len()..] + } else { + endpoint + }; + if endpoint.is_empty() { + return None; + } + + if let Some(rest) = endpoint.strip_prefix('[') { + let (host, rest) = rest.split_once(']')?; + let port = rest.strip_prefix(':')?.parse::().ok()?; + let host = host.trim(); + return (!host.is_empty()).then(|| (host.to_string(), port)); + } + + let (host, port) = endpoint.rsplit_once(':')?; + let host = host.trim(); + let port = port.trim().parse::().ok()?; + (!host.is_empty()).then(|| (host.to_string(), port)) +} + +fn upsert_targets(targets: &mut Vec, imported_targets: Vec) { + for target in imported_targets { + match targets.iter().position(|existing| existing.id == target.id) { + Some(index) => targets[index] = target, + None => targets.push(target), + } + } +} + pub fn resolve_preview( input: ProfileInputDto, ) -> Result { @@ -1510,7 +1730,8 @@ fn write_elevated_singbox_service_script( .map(|duration| duration.as_millis()) .unwrap_or(0); let script_path = env::temp_dir().join(format!("proxywarden-singbox-service-{nonce}.ps1")); - let script = elevated_singbox_service_script(action, service_name, config_source, config_target); + let script = + elevated_singbox_service_script(action, service_name, config_source, config_target); write_powershell_script(&script_path, &script).map_err(|error| { CommandError::new( diff --git a/src-tauri/tests/command_tests.rs b/src-tauri/tests/command_tests.rs index a1d4d8e..4aaa0f1 100644 --- a/src-tauri/tests/command_tests.rs +++ b/src-tauri/tests/command_tests.rs @@ -22,10 +22,10 @@ mod subscription; mod validation; use commands::{ - apply_profiles_with_services, build_status, resolve_component_statuses, resolve_preview, - save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper, - HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper, - TargetInputDto, + apply_profiles_with_services, build_status, read_saved_state_with_proxifyre_config, + resolve_component_statuses, resolve_preview, save_profile_to_storage, save_target_to_storage, + Clock, CommandError, DetectedProxyApplyHelper, HelperApplyRequest, HelperApplyResult, + ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper, TargetInputDto, }; use component_detection::{ DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry, @@ -95,6 +95,95 @@ fn save_commands_normalize_and_persist_profile_and_target() { cleanup(&root); } +#[test] +fn saved_state_bootstraps_from_existing_proxifyre_app_config() { + let root = test_root("proxifyre-config-import"); + let storage = JsonStorage::new(root.clone()); + let install_dir = root.join("ProxiFyre"); + let config_path = install_dir.join("app-config.json"); + fs::create_dir_all(&install_dir).expect("create proxifyre dir"); + fs::write( + &config_path, + r#"{ + "logLevel": "Info", + "bypassLan": true, + "proxies": [ + { + "appNames": ["Discord.exe", "C:\\Games\\Launcher.exe"], + "socks5ProxyEndpoint": "127.0.0.1:1090", + "supportedProtocols": ["TCP", "UDP"] + } + ] +}"#, + ) + .expect("write proxifyre config"); + + let state = read_saved_state_with_proxifyre_config(&storage, Some(&config_path)) + .expect("state should import proxifyre app config"); + + assert_eq!(state.profiles.len(), 1); + assert_eq!(state.targets.len(), 1); + assert_eq!(state.profiles[0].id, "main-profile"); + assert_eq!(state.profiles[0].target_id, "main-proxy"); + assert_eq!(state.profiles[0].items.len(), 2); + assert_eq!( + state.profiles[0].items[0].item_type, + ProfileItemType::Process + ); + assert_eq!(state.profiles[0].items[0].value, "Discord"); + assert_eq!(state.profiles[0].items[1].item_type, ProfileItemType::Exe); + assert_eq!(state.profiles[0].items[1].value, r"C:\Games\Launcher.exe"); + assert_eq!(state.targets[0].id, "main-proxy"); + assert_eq!(state.targets[0].host, "127.0.0.1"); + assert_eq!(state.targets[0].port, 1090); + + let persisted = storage.read_profiles().expect("read persisted profiles"); + assert_eq!(persisted.len(), 1); + assert_eq!(persisted[0].items.len(), 2); + + cleanup(&root); +} + +#[test] +fn saved_state_keeps_existing_proxywarden_profiles_over_proxifyre_config() { + let root = test_root("proxifyre-config-keeps-state"); + let storage = JsonStorage::new(root.clone()); + let install_dir = root.join("ProxiFyre"); + let config_path = install_dir.join("app-config.json"); + fs::create_dir_all(&install_dir).expect("create proxifyre dir"); + fs::write( + &config_path, + r#"{ + "logLevel": "Info", + "bypassLan": true, + "proxies": [ + { + "appNames": ["Telegram.exe"], + "socks5ProxyEndpoint": "127.0.0.1:1091", + "supportedProtocols": ["TCP"] + } + ] +}"#, + ) + .expect("write proxifyre config"); + storage + .write_profiles(&[discord_profile("home-gateway")]) + .expect("write profiles"); + storage + .write_targets(&[external_socks5_target()]) + .expect("write targets"); + + let state = read_saved_state_with_proxifyre_config(&storage, Some(&config_path)) + .expect("state should keep proxywarden storage"); + + assert_eq!(state.profiles.len(), 1); + assert_eq!(state.profiles[0].id, "discord"); + assert_eq!(state.profiles[0].items[0].value, "Discord"); + assert_eq!(state.targets[0].id, "home-gateway"); + + cleanup(&root); +} + #[test] fn resolve_preview_returns_structured_apps_without_filesystem_scan() { let preview = resolve_preview(ProfileInputDto { @@ -194,9 +283,14 @@ fn singbox_runner_preserves_installer_args_with_spaces() { ], ); - assert!(script.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'")); - assert!(script.contains("& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs")); - assert!(!script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList")); + assert!(script + .contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'")); + assert!(script.contains( + "& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs" + )); + assert!( + !script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList") + ); } #[test] diff --git a/src/app/App.tsx b/src/app/App.tsx index ec45fa9..e0bcbd5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1050,7 +1050,7 @@ export function App() { {items.length}
- {renderApplyActions('proxifyre', { showConfigPath: false })} + {renderApplyActions('proxifyre', { showConfigPath: false, showState: false })}
@@ -1158,9 +1158,10 @@ export function App() { function renderApplyActions( context: 'proxifyre' | 'proxy', - options: { showConfigPath?: boolean } = {}, + options: { showConfigPath?: boolean; showState?: boolean } = {}, ) { const showConfigPath = options.showConfigPath ?? true; + const showState = options.showState ?? true; const externalProxyError = routeMode === 'external' ? safeProxyError(proxyInput) : null; const readiness = getApplyReadiness({ routeMode, @@ -1173,15 +1174,18 @@ export function App() { busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction), }); const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents; + const disabledReason = !readiness.ready && readiness.title && readiness.text + ? `${readiness.title}. ${readiness.text}` + : undefined; return ( <> - {showBlocker ? ( + {showState && showBlocker ? (
{readiness.title} {readiness.text}
- ) : hasUnappliedChanges ? ( + ) : showState && hasUnappliedChanges ? (
Изменения еще не применены в ProxiFyre {applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))} @@ -1197,6 +1201,7 @@ export function App() { disabled={!readiness.ready} loading={isApplying} loadingLabel={applyButtonLabel(context, true, hasUnappliedChanges, singBoxAction)} + title={disabledReason} > {applyButtonLabel(context, isApplying, hasUnappliedChanges, singBoxAction)} diff --git a/src/styles/app.css b/src/styles/app.css index 2c8308e..a3619b1 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -188,9 +188,11 @@ button:disabled { } .ui-icon-button { + position: relative; width: 42px; min-width: 42px; min-height: 38px; + overflow: visible; padding: 0; } @@ -1889,6 +1891,8 @@ button.summary-card:hover { background: #182033; } +.ui-icon-button[data-tooltip]::before, +.ui-icon-button[data-tooltip]::after, .add-tile[data-tooltip]::before, .add-tile[data-tooltip]::after { position: absolute; @@ -1896,10 +1900,13 @@ button.summary-card:hover { z-index: 30; pointer-events: none; opacity: 0; - transition: opacity 90ms ease, transform 120ms ease; + transition: + opacity 90ms var(--ease-out), + transform var(--motion-fast) var(--ease-out); transition-delay: 55ms; } +.ui-icon-button[data-tooltip]::before, .add-tile[data-tooltip]::before { bottom: calc(100% + 3px); width: 8px; @@ -1911,6 +1918,7 @@ button.summary-card:hover { transform: translate(-50%, 2px) rotate(45deg); } +.ui-icon-button[data-tooltip]::after, .add-tile[data-tooltip]::after { bottom: calc(100% + 8px); border: 1px solid #334155; @@ -1927,6 +1935,10 @@ button.summary-card:hover { white-space: nowrap; } +.ui-icon-button[data-tooltip]:hover::before, +.ui-icon-button[data-tooltip]:hover::after, +.ui-icon-button[data-tooltip]:focus-visible::before, +.ui-icon-button[data-tooltip]:focus-visible::after, .add-tile[data-tooltip]:hover::before, .add-tile[data-tooltip]:hover::after, .add-tile[data-tooltip]:focus-visible::before, @@ -1934,11 +1946,15 @@ button.summary-card:hover { opacity: 1; } +.ui-icon-button[data-tooltip]:hover::before, +.ui-icon-button[data-tooltip]:focus-visible::before, .add-tile[data-tooltip]:hover::before, .add-tile[data-tooltip]:focus-visible::before { transform: translate(-50%, 0) rotate(45deg); } +.ui-icon-button[data-tooltip]:hover::after, +.ui-icon-button[data-tooltip]:focus-visible::after, .add-tile[data-tooltip]:hover::after, .add-tile[data-tooltip]:focus-visible::after { transform: translate(-50%, 0); @@ -2058,8 +2074,7 @@ button.summary-card:hover { flex: 0 0 auto; } -.app-row, -.empty-state { +.app-row { border: 1px solid #2b3342; border-radius: 4px; background: #131720; @@ -2072,7 +2087,8 @@ button.summary-card:hover { .empty-state { color: #8d99ae; - min-height: 48px; + min-height: 30px; + padding: 6px 2px 2px; } .list-skeleton { @@ -2551,6 +2567,7 @@ button.summary-card:hover { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .add-toolbar .ui-icon-button, .add-tile { width: 100%; } diff --git a/src/ui/IconButton.tsx b/src/ui/IconButton.tsx index bd553b7..0576ed9 100644 --- a/src/ui/IconButton.tsx +++ b/src/ui/IconButton.tsx @@ -19,6 +19,7 @@ export function IconButton({ title, ...props }: IconButtonProps) { + const tooltip = title === '' ? undefined : title ?? label; const classes = [ 'ui-icon-button', `ui-icon-button--${variant}`, @@ -32,7 +33,7 @@ export function IconButton({ type={props.type ?? 'button'} className={classes} aria-label={label} - title={title ?? label} + data-tooltip={tooltip} disabled={disabled || loading} > {loading ?