From c5bdb104451ee40f6756313e8b06a9b896c4f31a Mon Sep 17 00:00:00 2001 From: Dokril Date: Tue, 7 Jul 2026 22:33:15 +0300 Subject: [PATCH] Add VPN proxy connection handling --- apps/windows-client/src-tauri/src/commands.rs | 737 +++++++++++++++++- apps/windows-client/src-tauri/src/main.rs | 5 +- apps/windows-client/src-tauri/tauri.conf.json | 2 +- .../src-tauri/tests/command_tests.rs | 36 + apps/windows-client/src/api/tauriCommands.ts | 26 + apps/windows-client/src/app/App.tsx | 184 ++++- apps/windows-client/src/styles/app.css | 144 +++- apps/windows-client/vite.config.ts | 3 +- 8 files changed, 1108 insertions(+), 29 deletions(-) diff --git a/apps/windows-client/src-tauri/src/commands.rs b/apps/windows-client/src-tauri/src/commands.rs index 10db9c7..15a2e5a 100644 --- a/apps/windows-client/src-tauri/src/commands.rs +++ b/apps/windows-client/src-tauri/src/commands.rs @@ -28,9 +28,17 @@ use serde::{Deserialize, Serialize}; use std::env; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Output}; use std::time::{SystemTime, UNIX_EPOCH}; +const PROXIFYRE_INSTALL_DIR: &str = r"C:\Tools\ProxiFyre"; +const PROXIFYRE_RELEASE_API_URL: &str = + "https://api.github.com/repos/wiresock/proxifyre/releases/latest"; +const NDISAPI_RELEASE_API_URL: &str = + "https://api.github.com/repos/wiresock/ndisapi/releases/latest"; +const VC_REDIST_X64_URL: &str = "https://aka.ms/vc14/vc_redist.x64.exe"; +const VC_REDIST_X86_URL: &str = "https://aka.ms/vc14/vc_redist.x86.exe"; + #[derive(Debug, Clone)] pub struct CommandState { root: PathBuf, @@ -110,6 +118,24 @@ pub struct SavedStateResponse { pub generated_config_path: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxiFyreSetupStatusDto { + pub ready: bool, + pub missing_count: usize, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxiFyreSetupItemDto { + pub id: String, + pub name: String, + pub installed: bool, + pub version: Option, + pub details: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProfileInputDto { @@ -384,6 +410,13 @@ pub async fn get_components( .map_err(background_task_error)? } +#[tauri::command] +pub async fn get_proxifyre_setup_status() -> Result { + tauri::async_runtime::spawn_blocking(build_proxifyre_setup_status) + .await + .map_err(background_task_error) +} + #[tauri::command] pub fn resolve_profile_preview( input: ProfileInputDto, @@ -444,6 +477,23 @@ pub async fn stop_proxifyre_service() -> Result, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || install_proxifyre_component(&storage)) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn uninstall_proxifyre() -> Result { + tauri::async_runtime::spawn_blocking(uninstall_proxifyre_component) + .await + .map_err(background_task_error)? +} + pub fn build_status(storage: &JsonStorage) -> Result { let profiles = storage.read_profiles().map_err(storage_error)?; let targets = storage.read_targets().map_err(storage_error)?; @@ -1063,7 +1113,7 @@ fn write_elevated_service_script( let script_path = env::temp_dir().join(format!("vpn-proxy-proxifyre-service-{nonce}.ps1")); let script = elevated_service_script(action, service_names); - fs::write(&script_path, script).map_err(|error| { + write_powershell_script(&script_path, &script).map_err(|error| { CommandError::new( action.error_code(), format!( @@ -1161,6 +1211,640 @@ exit 4 ) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProxiFyrePackageAction { + Install, + Uninstall, +} + +impl ProxiFyrePackageAction { + fn error_code(self) -> &'static str { + match self { + ProxiFyrePackageAction::Install => "proxifyre_install_failed", + ProxiFyrePackageAction::Uninstall => "proxifyre_uninstall_failed", + } + } + + fn label(self) -> &'static str { + match self { + ProxiFyrePackageAction::Install => "установить", + ProxiFyrePackageAction::Uninstall => "удалить", + } + } + + fn file_label(self) -> &'static str { + match self { + ProxiFyrePackageAction::Install => "install", + ProxiFyrePackageAction::Uninstall => "uninstall", + } + } +} + +fn install_proxifyre_component(storage: &JsonStorage) -> Result { + let generated_config_path = storage + .paths() + .generated_dir + .join("proxifyre-app-config.json"); + let script = install_proxifyre_script(&generated_config_path); + + run_elevated_package_script( + ProxiFyrePackageAction::Install, + script, + &storage.paths().state_dir, + )?; + + let refreshed = detect_proxyfier_install(); + let Some(detected) = refreshed.as_ref() else { + return Err(CommandError::new( + ProxiFyrePackageAction::Install.error_code(), + "Установка ProxiFyre завершилась, но приложение не найдено после проверки.", + )); + }; + + Ok(ComponentStatusDto::from( + &proxyfier_component_from_detection(Some(detected)), + )) +} + +fn uninstall_proxifyre_component() -> Result { + let Some(detected) = detect_proxyfier_install() else { + let component = proxyfier_component_from_detection(None); + return Ok(ComponentStatusDto::from(&component)); + }; + + ensure_safe_proxifyre_install_dir(&detected.install_dir)?; + let script = uninstall_proxifyre_script(&detected.install_dir, &detected.executable_path); + + let artifact_dir = default_config_root().join("state"); + run_elevated_package_script(ProxiFyrePackageAction::Uninstall, script, &artifact_dir)?; + + let refreshed = detect_proxyfier_install(); + if refreshed.is_some() { + return Err(CommandError::new( + ProxiFyrePackageAction::Uninstall.error_code(), + "Удаление ProxiFyre завершилось, но приложение все еще найдено на компьютере.", + )); + } + + let component = proxyfier_component_from_detection(None); + Ok(ComponentStatusDto::from(&component)) +} + +fn build_proxifyre_setup_status() -> ProxiFyreSetupStatusDto { + let vc_runtime = detect_vc_runtime(); + let packet_filter = detect_windows_packet_filter(); + let proxifyre = detect_proxyfier_install(); + + let vc_runtime_item = setup_item_from_program( + "vc-runtime", + &format!("Microsoft Visual C++ Runtime ({})", runtime_arch_label()), + vc_runtime, + "Нужен для запуска ProxiFyre.exe. Установщик скачает официальный vc_redist от Microsoft.", + ); + let packet_filter_item = setup_item_from_program( + "packet-filter", + "Windows Packet Filter", + packet_filter, + "Сетевой драйвер NT Kernel/WireSock, через который ProxiFyre перехватывает трафик приложений.", + ); + let proxifyre_item = match proxifyre { + Some(detected) => ProxiFyreSetupItemDto { + id: "proxifyre".to_string(), + name: "ProxiFyre".to_string(), + installed: true, + version: Some(if detected.running { + "служба запущена".to_string() + } else { + "служба не запущена".to_string() + }), + details: detected.install_dir.display().to_string(), + }, + None => ProxiFyreSetupItemDto { + id: "proxifyre".to_string(), + name: "ProxiFyre".to_string(), + installed: false, + version: None, + details: format!( + "Будет скачан с GitHub и распакован в {}.", + PROXIFYRE_INSTALL_DIR + ), + }, + }; + + let items = vec![vc_runtime_item, packet_filter_item, proxifyre_item]; + let missing_count = items.iter().filter(|item| !item.installed).count(); + + ProxiFyreSetupStatusDto { + ready: missing_count == 0, + missing_count, + items, + } +} + +fn setup_item_from_program( + id: &str, + name: &str, + program: Option, + missing_details: &str, +) -> ProxiFyreSetupItemDto { + match program { + Some(program) => ProxiFyreSetupItemDto { + id: id.to_string(), + name: name.to_string(), + installed: true, + version: program.display_version, + details: program.display_name, + }, + None => ProxiFyreSetupItemDto { + id: id.to_string(), + name: name.to_string(), + installed: false, + version: None, + details: missing_details.to_string(), + }, + } +} + +#[derive(Debug, Clone)] +struct InstalledProgram { + display_name: String, + display_version: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct InstalledProgramJson { + display_name: Option, + display_version: Option, +} + +fn detect_vc_runtime() -> Option { + installed_program(&vc_runtime_registry_pattern()) +} + +fn detect_windows_packet_filter() -> Option { + installed_program("Windows Packet Filter|WinpkFilter|NDISAPI") +} + +fn installed_program(pattern: &str) -> Option { + let script = format!( + r#" +$paths = @( + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' +) +$program = Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | + Where-Object {{ $_.DisplayName -match '{}' }} | + Select-Object -First 1 DisplayName, DisplayVersion +if ($null -ne $program) {{ + $program | ConvertTo-Json -Compress +}} +"#, + escape_powershell_single(pattern) + ); + + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script.as_str(), + ]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let payload = stdout.trim(); + if payload.is_empty() || payload.eq_ignore_ascii_case("null") { + return None; + } + + let parsed: InstalledProgramJson = serde_json::from_str(payload).ok()?; + let display_name = parsed.display_name?.trim().to_string(); + if display_name.is_empty() { + return None; + } + + Some(InstalledProgram { + display_name, + display_version: parsed + .display_version + .map(|version| version.trim().to_string()) + .filter(|version| !version.is_empty()), + }) +} + +fn vc_runtime_registry_pattern() -> String { + let arch = runtime_arch_label(); + if arch == "ARM64" { + return r"Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)".to_string(); + } + + format!(r"Microsoft Visual C\+\+.*Redistributable.*\({arch}\)") +} + +fn runtime_arch_label() -> &'static str { + if cfg!(target_arch = "aarch64") { + "ARM64" + } else if cfg!(target_arch = "x86") { + "x86" + } else { + "x64" + } +} + +fn run_elevated_package_script( + action: ProxiFyrePackageAction, + body: String, + artifact_dir: &Path, +) -> Result<(), CommandError> { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + fs::create_dir_all(artifact_dir).map_err(|error| { + CommandError::new( + action.error_code(), + format!( + "Не удалось создать папку для временных файлов ProxiFyre '{}': {error}", + artifact_dir.display() + ), + ) + })?; + let script_path = artifact_dir.join(format!( + "vpn-proxy-proxifyre-{}-{nonce}.ps1", + action.file_label() + )); + let result_path = artifact_dir.join(format!( + "vpn-proxy-proxifyre-{}-{nonce}.log", + action.file_label() + )); + let script = wrap_elevated_package_script(&body, &result_path); + + write_powershell_script(&script_path, &script).map_err(|error| { + CommandError::new( + action.error_code(), + format!( + "Не удалось подготовить временный скрипт, чтобы {} ProxiFyre '{}': {error}", + action.label(), + script_path.display() + ), + ) + })?; + + let launch_script = format!( + r#" +$ErrorActionPreference = 'Stop' +$resultPath = '{}' +try {{ + $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}') + if ($null -eq $p) {{ + Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8 + exit 1 + }} + exit $p.ExitCode +}} catch {{ + Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 + exit 1 +}} +"#, + escape_powershell_single(&result_path.display().to_string()), + escape_powershell_single(&script_path.display().to_string()) + ); + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + launch_script.as_str(), + ]) + .output(); + + let _ = fs::remove_file(&script_path); + + match output { + Ok(output) if output.status.success() => { + let _ = fs::remove_file(&result_path); + Ok(()) + } + Ok(output) => { + let details = package_failure_details(&result_path, &output); + let _ = fs::remove_file(&result_path); + Err(CommandError::new( + action.error_code(), + format!( + "Не удалось {} ProxiFyre. Код elevated-команды: {}. {details}", + action.label(), + output.status.code().unwrap_or(-1), + ), + )) + } + Err(error) => Err(CommandError::new( + action.error_code(), + format!( + "Не удалось запросить права администратора, чтобы {} ProxiFyre: {error}", + action.label() + ), + )), + } +} + +pub(crate) fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String { + let mut script = String::new(); + script.push_str("$ErrorActionPreference = 'Stop'\n"); + script.push_str(&format!( + "$resultPath = '{}'\n", + escape_powershell_single(&result_path.display().to_string()) + )); + script.push_str("try {\n"); + script.push_str(body); + script.push_str( + r#" + Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8 + exit 0 +} catch { + $message = ($_ | Out-String) + Set-Content -LiteralPath $resultPath -Value $message -Encoding UTF8 + exit 1 +} +"#, + ); + + script +} + +fn write_powershell_script(path: &Path, script: &str) -> std::io::Result<()> { + let mut bytes = Vec::with_capacity(script.len() + 3); + bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]); + bytes.extend_from_slice(script.as_bytes()); + fs::write(path, bytes) +} + +pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String { + let mut script = String::new(); + script.push_str(&format!( + "$targetDir = '{}'\n", + escape_powershell_single(PROXIFYRE_INSTALL_DIR) + )); + script.push_str(&format!( + "$generatedConfigPath = '{}'\n", + escape_powershell_single(&generated_config_path.display().to_string()) + )); + script.push_str(&format!( + "$proxifyreReleaseApi = '{}'\n", + escape_powershell_single(PROXIFYRE_RELEASE_API_URL) + )); + script.push_str(&format!( + "$ndisapiReleaseApi = '{}'\n", + escape_powershell_single(NDISAPI_RELEASE_API_URL) + )); + script.push_str(&format!( + "$vcRedistX64Url = '{}'\n", + escape_powershell_single(VC_REDIST_X64_URL) + )); + script.push_str(&format!( + "$vcRedistX86Url = '{}'\n", + escape_powershell_single(VC_REDIST_X86_URL) + )); + script.push_str( + r#" + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + function Get-NativeArchitecture { + $processor = Get-CimInstance Win32_Processor | Select-Object -First 1 + if ($null -ne $processor -and $processor.Architecture -eq 12) { return 'ARM64' } + if ([Environment]::Is64BitOperatingSystem) { return 'x64' } + return 'x86' + } + + function Invoke-Download([string]$uri, [string]$path) { + Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $path -Headers @{ 'User-Agent' = 'vpn-proxy-windows-client' } + } + + function Select-Asset($assets, [string]$pattern, [string]$label) { + $asset = $assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1 + if ($null -eq $asset) { throw "Не найден подходящий asset для $label ($pattern)." } + return $asset + } + + function Verify-AssetHash([string]$path, $asset) { + if ($asset.digest -match '^sha256:(.+)$') { + $expected = $Matches[1].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + throw "SHA256 не совпал для $($asset.name). Ожидалось $expected, получилось $actual." + } + } + } + + function Assert-ExitCode($process, [string]$label) { + if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) { + throw "$label завершился с кодом $($process.ExitCode)." + } + } + + function Get-InstalledProgram([string]$pattern) { + $paths = @( + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -match $pattern } | + Select-Object -First 1 + } + + function Test-VcRuntime([string]$arch) { + $pattern = if ($arch -eq 'ARM64') { + 'Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)' + } else { + "Microsoft Visual C\+\+.*Redistributable.*\($arch\)" + } + + return $null -ne (Get-InstalledProgram $pattern) + } + + function Test-WindowsPacketFilter { + return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI') + } + + function Get-LogTail([string]$path) { + if (-not (Test-Path -LiteralPath $path)) { return '' } + return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' ' + } + + $arch = Get-NativeArchitecture + $workDir = Join-Path ([IO.Path]::GetTempPath()) 'vpn-proxy-proxifyre-install' + $extractDir = Join-Path $workDir 'proxifyre' + Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $workDir, $extractDir, $targetDir | Out-Null + + if (-not (Test-VcRuntime $arch)) { + $vcRedistPath = Join-Path $workDir 'vc_redist.exe' + $vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url } + Invoke-Download $vcRedistUrl $vcRedistPath + $vcProcess = Start-Process -FilePath $vcRedistPath -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru -WindowStyle Hidden + if ($vcProcess.ExitCode -ne 0 -and $vcProcess.ExitCode -ne 3010 -and $vcProcess.ExitCode -ne 1638 -and -not (Test-VcRuntime $arch)) { + throw "Visual C++ Runtime завершился с кодом $($vcProcess.ExitCode)." + } + } + + if (-not (Test-WindowsPacketFilter)) { + $ndisRelease = Invoke-RestMethod -Uri $ndisapiReleaseApi -Headers @{ 'User-Agent' = 'vpn-proxy-windows-client' } + $ndisPattern = if ($arch -eq 'ARM64') { 'ARM64\.msi$' } elseif ($arch -eq 'x86') { 'x86\.msi$' } else { 'x64\.msi$' } + $ndisAsset = Select-Asset $ndisRelease.assets $ndisPattern 'Windows Packet Filter' + $ndisPath = Join-Path $workDir $ndisAsset.name + $ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log' + Invoke-Download $ndisAsset.browser_download_url $ndisPath + Verify-AssetHash $ndisPath $ndisAsset + $ndisProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $ndisPath, '/qn', '/norestart', '/L*v', $ndisLogPath) -Wait -PassThru -WindowStyle Hidden + if ($ndisProcess.ExitCode -ne 0 -and $ndisProcess.ExitCode -ne 3010 -and -not (Test-WindowsPacketFilter)) { + $ndisLogTail = Get-LogTail $ndisLogPath + throw "Windows Packet Filter завершился с кодом $($ndisProcess.ExitCode). MSI log: $ndisLogPath $ndisLogTail" + } + } + + $proxifyreRelease = Invoke-RestMethod -Uri $proxifyreReleaseApi -Headers @{ 'User-Agent' = 'vpn-proxy-windows-client' } + $proxifyrePattern = if ($arch -eq 'ARM64') { 'ARM64-signed\.zip$' } elseif ($arch -eq 'x86') { 'x86-signed\.zip$' } else { 'x64-signed\.zip$' } + $proxifyreAsset = Select-Asset $proxifyreRelease.assets $proxifyrePattern 'ProxiFyre' + $proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name + Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath + Verify-AssetHash $proxifyreZipPath $proxifyreAsset + + Expand-Archive -LiteralPath $proxifyreZipPath -DestinationPath $extractDir -Force + $proxifyreExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'ProxiFyre.exe' | Select-Object -First 1 + if ($null -eq $proxifyreExe) { throw 'В архиве ProxiFyre не найден ProxiFyre.exe.' } + + Copy-Item -Path (Join-Path $proxifyreExe.Directory.FullName '*') -Destination $targetDir -Recurse -Force + + $configTarget = Join-Path $targetDir 'app-config.json' + if (Test-Path -LiteralPath $generatedConfigPath) { + Copy-Item -LiteralPath $generatedConfigPath -Destination $configTarget -Force + } elseif (-not (Test-Path -LiteralPath $configTarget)) { + $emptyConfig = '{"logLevel":"Info","bypassLan":true,"proxies":[]}' + Set-Content -LiteralPath $configTarget -Value $emptyConfig -Encoding UTF8 + } + + Push-Location $targetDir + try { + & .\ProxiFyre.exe stop | Out-Null + & .\ProxiFyre.exe uninstall | Out-Null + & .\ProxiFyre.exe install + if ($LASTEXITCODE -ne 0) { throw "ProxiFyre.exe install завершился с кодом $LASTEXITCODE." } + & .\ProxiFyre.exe start + if ($LASTEXITCODE -ne 0) { + Start-Service -Name 'ProxiFyreService' -ErrorAction Stop + } + } finally { + Pop-Location + } +"#, + ); + + script +} + +fn uninstall_proxifyre_script(install_dir: &Path, executable_path: &Path) -> String { + let mut script = String::new(); + script.push_str(&format!( + "$installDir = '{}'\n", + escape_powershell_single(&install_dir.display().to_string()) + )); + script.push_str(&format!( + "$exePath = '{}'\n", + escape_powershell_single(&executable_path.display().to_string()) + )); + script.push_str( + r#" + function Find-ProxiFyreService { + foreach ($name in @('ProxiFyreService', 'ProxiFyre')) { + $candidate = Get-Service -Name $name -ErrorAction SilentlyContinue + if ($null -ne $candidate) { return $candidate } + } + + return Get-Service | + Where-Object { $_.Name -match 'ProxiFyre|Proxifyre' -or $_.DisplayName -match 'ProxiFyre|Proxifyre' } | + Select-Object -First 1 + } + + function Get-ServiceProcessId([string]$name) { + $escapedName = $name.Replace("'", "''") + $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue + if ($null -eq $record) { return 0 } + return [int]$record.ProcessId + } + + $service = Find-ProxiFyreService + if ($null -ne $service -and $service.Status -ne 'Stopped') { + try { + if ($service.CanStop) { Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue } + $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue + if ($null -ne $service) { $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) } + } catch {} + } + + $service = Find-ProxiFyreService + if ($null -ne $service -and $service.Status -ne 'Stopped') { + $processId = Get-ServiceProcessId $service.Name + if ($processId -gt 0) { + taskkill.exe /PID $processId /F | Out-Null + Start-Sleep -Milliseconds 700 + } + } + + if (Test-Path -LiteralPath $exePath) { + Push-Location (Split-Path -Parent $exePath) + try { + & $exePath uninstall | Out-Null + } finally { + Pop-Location + } + } + + $service = Find-ProxiFyreService + if ($null -ne $service) { + sc.exe delete $service.Name | Out-Null + } + + Get-Process -Name 'ProxiFyre' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + + if (Test-Path -LiteralPath $installDir) { + Remove-Item -LiteralPath $installDir -Recurse -Force + } +"#, + ); + + script +} + +fn ensure_safe_proxifyre_install_dir(path: &Path) -> Result<(), CommandError> { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default(); + + if path.parent().is_some() && name.contains("proxifyre") { + return Ok(()); + } + + Err(CommandError::new( + ProxiFyrePackageAction::Uninstall.error_code(), + format!( + "Отказываюсь рекурсивно удалять папку ProxiFyre с небезопасным путем: {}", + path.display() + ), + )) +} + fn apply_to_detected_proxyfier( request: HelperApplyRequest<'_>, detected: &DetectedProxyfier, @@ -1280,6 +1964,55 @@ fn background_task_error(error: impl std::fmt::Display) -> CommandError { ) } +fn package_failure_details(result_path: &Path, output: &Output) -> String { + let mut parts = Vec::new(); + + if let Ok(contents) = fs::read_to_string(result_path) { + let details = compact_error_text(&contents); + if !details.is_empty() && !details.eq_ignore_ascii_case("ok") { + parts.push(details); + } + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stdout = compact_error_text(&stdout); + if !stdout.is_empty() { + parts.push(format!("stdout: {stdout}")); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = compact_error_text(&stderr); + if !stderr.is_empty() { + parts.push(format!("stderr: {stderr}")); + } + + if parts.is_empty() { + parts.push( + "Лог elevated-скрипта не создан. Обычно это значит, что окно UAC было отменено или Windows не дала запустить elevated PowerShell." + .to_string(), + ); + } + + parts.join(" ") +} + +fn compact_error_text(value: &str) -> String { + let text = value + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" "); + + let max_chars = 1400; + if text.chars().count() <= max_chars { + return text; + } + + let truncated = text.chars().take(max_chars).collect::(); + format!("{truncated}...") +} + fn parse_service_command_output(stdout: &[u8]) -> Option { let stdout = String::from_utf8_lossy(stdout); let payload = stdout diff --git a/apps/windows-client/src-tauri/src/main.rs b/apps/windows-client/src-tauri/src/main.rs index 3a41b84..292c677 100644 --- a/apps/windows-client/src-tauri/src/main.rs +++ b/apps/windows-client/src-tauri/src/main.rs @@ -34,12 +34,15 @@ fn main() { commands::get_targets, commands::save_target, commands::get_components, + commands::get_proxifyre_setup_status, commands::resolve_profile_preview, commands::apply_profiles, commands::get_logs, commands::open_config_location, commands::start_proxifyre_service, - commands::stop_proxifyre_service + commands::stop_proxifyre_service, + commands::install_proxifyre, + commands::uninstall_proxifyre ]) .run(tauri::generate_context!()) .expect("не удалось запустить клиент VPN Proxy для Windows"); diff --git a/apps/windows-client/src-tauri/tauri.conf.json b/apps/windows-client/src-tauri/tauri.conf.json index 6dc1e79..77ccfed 100644 --- a/apps/windows-client/src-tauri/tauri.conf.json +++ b/apps/windows-client/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "npm run dev", "beforeBuildCommand": "npm run build", - "devUrl": "http://localhost:5173", + "devUrl": "http://127.0.0.1:5173", "frontendDist": "../dist" }, "app": { diff --git a/apps/windows-client/src-tauri/tests/command_tests.rs b/apps/windows-client/src-tauri/tests/command_tests.rs index 32f5dc9..d73c12e 100644 --- a/apps/windows-client/src-tauri/tests/command_tests.rs +++ b/apps/windows-client/src-tauri/tests/command_tests.rs @@ -32,6 +32,8 @@ use proxifyre::ProxiFyreAdapter; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::process::Command as ProcessCommand; use std::time::{SystemTime, UNIX_EPOCH}; use storage::JsonStorage; @@ -119,6 +121,40 @@ fn resolve_preview_returns_structured_apps_without_filesystem_scan() { .any(|warning| warning.contains("Сканирование папок отложено"))); } +#[test] +#[cfg(windows)] +fn proxifyre_install_script_parses_as_powershell() { + let root = test_root("proxifyre-install-script"); + fs::create_dir_all(&root).expect("test root should be created"); + + let script = commands::wrap_elevated_package_script( + &commands::install_proxifyre_script(&root.join("proxifyre-app-config.json")), + &root.join("install.log"), + ); + let script_path = root.join("install.ps1"); + let mut script_bytes = vec![0xEF, 0xBB, 0xBF]; + script_bytes.extend_from_slice(script.as_bytes()); + fs::write(&script_path, script_bytes).expect("script should be written"); + + let escaped_path = script_path.display().to_string().replace('\'', "''"); + let parser = format!( + "$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}" + ); + let output = ProcessCommand::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &parser]) + .output() + .expect("powershell parser should run"); + + assert!( + output.status.success(), + "install script should parse\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + cleanup(&root); +} + #[test] fn apply_generates_derived_config_and_records_activity_with_mock_helper() { let root = test_root("apply"); diff --git a/apps/windows-client/src/api/tauriCommands.ts b/apps/windows-client/src/api/tauriCommands.ts index 45c87fa..7a106e5 100644 --- a/apps/windows-client/src/api/tauriCommands.ts +++ b/apps/windows-client/src/api/tauriCommands.ts @@ -33,6 +33,20 @@ export interface SavedStateResponse { generatedConfigPath: string; } +export interface ProxiFyreSetupItem { + id: string; + name: string; + installed: boolean; + version?: string; + details: string; +} + +export interface ProxiFyreSetupStatus { + ready: boolean; + missingCount: number; + items: ProxiFyreSetupItem[]; +} + export interface HelperApplyResult { success: boolean; changed: boolean; @@ -80,6 +94,10 @@ export function getComponents(): Promise { return invoke('get_components'); } +export function getProxiFyreSetupStatus(): Promise { + return invoke('get_proxifyre_setup_status'); +} + export function applyProfiles(): Promise { return invoke('apply_profiles'); } @@ -95,3 +113,11 @@ export function startProxiFyreService(): Promise { export function stopProxiFyreService(): Promise { return invoke('stop_proxifyre_service'); } + +export function installProxiFyre(): Promise { + return invoke('install_proxifyre'); +} + +export function uninstallProxiFyre(): Promise { + return invoke('uninstall_proxifyre'); +} diff --git a/apps/windows-client/src/app/App.tsx b/apps/windows-client/src/app/App.tsx index c24dbe5..2f67f63 100644 --- a/apps/windows-client/src/app/App.tsx +++ b/apps/windows-client/src/app/App.tsx @@ -1,20 +1,25 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { open } from '@tauri-apps/plugin-dialog'; -import { Cpu, FileCode2, FolderOpen } from 'lucide-react'; +import { Cpu, FileCode2, FolderOpen, MoreHorizontal } from 'lucide-react'; import { applyProfiles, getComponents, + getProxiFyreSetupStatus, getSavedState, + installProxiFyre, openConfigLocation, saveProfile, saveTarget, startProxiFyreService, stopProxiFyreService, + uninstallProxiFyre, type ApplyProfilesResponse, + type ProxiFyreSetupStatus, } from '../api/tauriCommands'; import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, Target } from '../domain/types'; type DraftItemType = Extract; +type ProxiFyreAction = 'start' | 'stop' | 'install' | 'uninstall'; type ServiceVisualState = 'active' | 'settling' | null; interface DraftItem { @@ -60,6 +65,8 @@ export function App() { const [processInput, setProcessInput] = useState(''); const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null); const [components, setComponents] = useState(fallbackComponents); + const [setupStatus, setSetupStatus] = useState(null); + const [isSetupOpen, setIsSetupOpen] = useState(false); const [generatedConfigPath, setGeneratedConfigPath] = useState(''); const [logEntries, setLogEntries] = useState([]); const [activeLogId, setActiveLogId] = useState(null); @@ -68,7 +75,8 @@ export function App() { const [isDetectingComponents, setIsDetectingComponents] = useState(true); const [isApplying, setIsApplying] = useState(false); const [isOpeningConfig, setIsOpeningConfig] = useState(false); - const [serviceAction, setServiceAction] = useState<'start' | 'stop' | null>(null); + const [serviceAction, setServiceAction] = useState(null); + const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false); const [serviceVisualState, setServiceVisualState] = useState(null); const serviceVisualTimerRef = useRef(null); @@ -127,8 +135,12 @@ export function App() { async function refreshComponents() { setIsDetectingComponents(true); try { - const detectedComponents = await getComponents(); + const [detectedComponents, detectedSetupStatus] = await Promise.all([ + getComponents(), + getProxiFyreSetupStatus(), + ]); setComponents(detectedComponents); + setSetupStatus(detectedSetupStatus); } catch (error) { showNotice({ kind: 'error', @@ -254,13 +266,15 @@ export function App() { ); const result = await applyProfiles(); - const [saved, detectedComponents] = await Promise.all([ + const [saved, detectedComponents, detectedSetupStatus] = await Promise.all([ getSavedState(), getComponents(), + getProxiFyreSetupStatus(), ]); applySavedState(saved.profiles, saved.targets, result.generatedConfigPath); setComponents(detectedComponents); + setSetupStatus(detectedSetupStatus); showNotice(noticeFromApply(result)); } catch (error) { showNotice({ @@ -296,6 +310,7 @@ export function App() { async function setProxiFyreServiceRunning(shouldRun: boolean) { const action = shouldRun ? 'start' : 'stop'; setServiceAction(action); + setIsServiceMenuOpen(false); startServiceVisual(); try { await nextFrame(); @@ -321,6 +336,65 @@ export function App() { } } + async function installProxiFyrePackage() { + setServiceAction('install'); + setIsServiceMenuOpen(false); + startServiceVisual(); + try { + await nextFrame(); + const component = await installProxiFyre(); + const detectedSetupStatus = await getProxiFyreSetupStatus(); + setComponents((current) => upsertComponent(current, component)); + setSetupStatus(detectedSetupStatus); + showNotice({ + kind: 'success', + title: 'ProxiFyre установлен', + text: proxyfierDetails(component, false), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'ProxiFyre не установлен', + text: errorMessage(error), + }); + } finally { + setServiceAction(null); + settleServiceVisual(); + } + } + + async function uninstallProxiFyrePackage() { + const confirmed = window.confirm( + 'Удалить ProxiFyre с компьютера? Будет удалена служба и папка установки ProxiFyre.', + ); + if (!confirmed) return; + + setServiceAction('uninstall'); + setIsServiceMenuOpen(false); + startServiceVisual(); + try { + await nextFrame(); + const component = await uninstallProxiFyre(); + const detectedSetupStatus = await getProxiFyreSetupStatus(); + setComponents((current) => upsertComponent(current, component)); + setSetupStatus(detectedSetupStatus); + showNotice({ + kind: 'success', + title: 'ProxiFyre удален', + text: 'Служба и папка установки ProxiFyre удалены.', + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'ProxiFyre не удален', + text: errorMessage(error), + }); + } finally { + setServiceAction(null); + settleServiceVisual(); + } + } + function startServiceVisual() { if (serviceVisualTimerRef.current !== null) { window.clearTimeout(serviceVisualTimerRef.current); @@ -382,25 +456,89 @@ export function App() {
{proxyfierTitle(proxyfier, isDetectingComponents)} {proxyfierDetails(proxyfier, isDetectingComponents)} +
- - + {proxyfier?.installed ? ( + <> + +
+ + {isServiceMenuOpen ? ( +
+ +
+ ) : null} +
+ + ) : ( + + )}
+ {isSetupOpen ? ( +
+ {setupStatus ? ( + setupStatus.items.map((item) => ( +
+
+ )) + ) : ( +
+
+ )} +
+ ) : null}