diff --git a/package-lock.json b/package-lock.json index 07a5fa1..0f3f29a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxywarden", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxywarden", - "version": "1.0.2", + "version": "1.0.3", "dependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", "@tauri-apps/api": "^2.0.0", diff --git a/package.json b/package.json index e1f48ab..db17b25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxywarden", - "version": "1.0.2", + "version": "1.0.3", "private": true, "type": "module", "description": "Standalone Windows desktop proxy management app for ProxyWarden.", diff --git a/scripts/install-proxyfier.ps1 b/scripts/install-proxyfier.ps1 index 521c438..74cdc63 100644 --- a/scripts/install-proxyfier.ps1 +++ b/scripts/install-proxyfier.ps1 @@ -1,5 +1,5 @@ param( - [string]$InstallRoot = "C:\Tools\ProxiFyre", + [string]$InstallRoot = "C:\Program Files\ProxyWarden\components\ProxiFyre", [string]$PackagePath = "", [string]$ServiceName = "ProxiFyreService", [switch]$PlanOnly, diff --git a/scripts/install-singbox.ps1 b/scripts/install-singbox.ps1 index 594eaf8..f2e6cb3 100644 --- a/scripts/install-singbox.ps1 +++ b/scripts/install-singbox.ps1 @@ -1,5 +1,5 @@ param( - [string]$InstallRoot = "C:\Program Files\ProxyWarden\sing-box", + [string]$InstallRoot = "C:\Program Files\ProxyWarden\components\sing-box", [string]$ServiceName = "ProxyWardenSingBox", [string]$ConfigSource = "C:\ProgramData\ProxyWarden\generated\sing-box-config.json", [switch]$PlanOnly, @@ -77,7 +77,7 @@ function Test-SafeInstallRoot { $leaf = Split-Path -Leaf $full $parent = Split-Path -Parent $full if ($leaf -ne "sing-box") { return $false } - return $parent -match "\\ProxyWarden$|\\proxywarden$" + return $parent -match "\\ProxyWarden\\components$|\\proxywarden\\components$|\\ProxyWarden$|\\proxywarden$" } function Backup-File { diff --git a/scripts/update-proxifyre-bundle.ps1 b/scripts/update-proxifyre-bundle.ps1 new file mode 100644 index 0000000..c050d71 --- /dev/null +++ b/scripts/update-proxifyre-bundle.ps1 @@ -0,0 +1,143 @@ +param( + [string]$OutputDir = (Join-Path $PSScriptRoot '..\src-tauri\bundled\proxifyre'), + [ValidateSet('x64', 'x86', 'ARM64')] + [string[]]$Architectures = @('x64'), + [switch]$SkipVcRuntime +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$ProgressPreference = 'SilentlyContinue' + +function Invoke-JsonApi([string]$Uri) { + Invoke-RestMethod -Uri $Uri -Headers @{ + 'User-Agent' = 'proxywarden-bundle-updater' + 'Accept' = 'application/vnd.github+json' + } -TimeoutSec 60 -MaximumRedirection 10 +} + +function Invoke-FileDownload([string]$Uri, [string]$Path) { + $partialPath = "$Path.part" + Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue + + try { + Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $partialPath -Headers @{ + 'User-Agent' = 'proxywarden-bundle-updater' + 'Accept' = 'application/octet-stream,*/*' + } -TimeoutSec 240 -MaximumRedirection 10 + } catch { + Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue + throw + } + + $item = Get-Item -LiteralPath $partialPath + if ($item.Length -le 0) { + Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue + throw "Downloaded file is empty: $Uri" + } + + Move-Item -LiteralPath $partialPath -Destination $Path -Force +} + +function Select-ReleaseAsset($Release, [string]$Pattern, [string]$Label) { + $asset = $Release.assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1 + if ($null -eq $asset) { + throw "No asset found for $Label using pattern $Pattern" + } + + $asset +} + +function Save-Asset([string]$Id, [string]$Name, [string]$Url, [string]$ExpectedDigest = '') { + $path = Join-Path $OutputDir $Name + if (Test-Path -LiteralPath $path) { + $existing = Get-Item -LiteralPath $path + if ($existing.Length -gt 0) { + $existingHash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + $expectedHash = '' + if (-not [string]::IsNullOrWhiteSpace($ExpectedDigest) -and $ExpectedDigest -match '^sha256:(.+)$') { + $expectedHash = $Matches[1].ToLowerInvariant() + } + + if ([string]::IsNullOrWhiteSpace($expectedHash) -or $existingHash -eq $expectedHash) { + Write-Host "Using existing $Name" + return [PSCustomObject]@{ + id = $Id + name = $Name + sha256 = $existingHash + size = $existing.Length + sourceUrl = $Url + } + } + } + } + + Write-Host "Downloading $Name" + Invoke-FileDownload $Url $path + + $hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + if (-not [string]::IsNullOrWhiteSpace($ExpectedDigest) -and $ExpectedDigest -match '^sha256:(.+)$') { + $expected = $Matches[1].ToLowerInvariant() + if ($hash -ne $expected) { + throw "SHA256 mismatch for $Name. Expected $expected, got $hash." + } + } + + [PSCustomObject]@{ + id = $Id + name = $Name + sha256 = $hash + size = (Get-Item -LiteralPath $path).Length + sourceUrl = $Url + } +} + +$resolvedOutputDir = [System.IO.Path]::GetFullPath($OutputDir) +New-Item -ItemType Directory -Force -Path $resolvedOutputDir | Out-Null +$OutputDir = $resolvedOutputDir + +$selectedArchitectures = $Architectures | + ForEach-Object { + if ($_ -eq 'ARM64') { 'ARM64' } elseif ($_ -eq 'x86') { 'x86' } else { 'x64' } + } | + Select-Object -Unique + +$proxifyreRelease = Invoke-JsonApi 'https://api.github.com/repos/wiresock/proxifyre/releases/latest' +$ndisapiRelease = Invoke-JsonApi 'https://api.github.com/repos/wiresock/ndisapi/releases/latest' + +$files = New-Object System.Collections.Generic.List[object] + +foreach ($arch in $selectedArchitectures) { + $proxifyreAsset = Select-ReleaseAsset $proxifyreRelease "ProxiFyre-.*-$arch-signed\.zip$" "ProxiFyre $arch" + $files.Add((Save-Asset "proxifyre-$($arch.ToLowerInvariant())" $proxifyreAsset.name $proxifyreAsset.browser_download_url $proxifyreAsset.digest)) + + $ndisAsset = Select-ReleaseAsset $ndisapiRelease "Windows\.Packet\.Filter\..*\.$arch\.msi$" "Windows Packet Filter $arch" + $files.Add((Save-Asset "packet-filter-$($arch.ToLowerInvariant())" $ndisAsset.name $ndisAsset.browser_download_url $ndisAsset.digest)) +} + +if (-not $SkipVcRuntime) { + if ($selectedArchitectures | Where-Object { $_ -ne 'x86' }) { + $files.Add((Save-Asset 'vc-runtime-x64' 'vc_redist.x64.exe' 'https://aka.ms/vc14/vc_redist.x64.exe')) + } + if ($selectedArchitectures -contains 'x86') { + $files.Add((Save-Asset 'vc-runtime-x86' 'vc_redist.x86.exe' 'https://aka.ms/vc14/vc_redist.x86.exe')) + } +} + +$manifest = [PSCustomObject]@{ + generatedAt = (Get-Date).ToUniversalTime().ToString('o') + architectures = @($selectedArchitectures) + proxifyreRelease = $proxifyreRelease.tag_name + windowsPacketFilterRelease = $ndisapiRelease.tag_name + files = $files +} + +$manifestPath = Join-Path $OutputDir 'manifest.json' +$manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 + +$keepNames = @($files | ForEach-Object { $_.name }) + 'manifest.json' +Get-ChildItem -LiteralPath $OutputDir -File | + Where-Object { $keepNames -notcontains $_.Name } | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } + +Write-Host "Bundle updated: $OutputDir" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9a9c7fd..9546894 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2314,7 +2314,7 @@ dependencies = [ [[package]] name = "proxywarden" -version = "1.0.2" +version = "1.0.3" dependencies = [ "base64 0.22.1", "percent-encoding", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c751914..d7b6f37 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxywarden" -version = "1.0.2" +version = "1.0.3" description = "Standalone Windows desktop proxy management app for ProxyWarden." authors = ["ProxyWarden"] edition = "2021" diff --git a/src-tauri/bundled/cleanup/uninstall-managed-components.ps1 b/src-tauri/bundled/cleanup/uninstall-managed-components.ps1 new file mode 100644 index 0000000..c1dc31b --- /dev/null +++ b/src-tauri/bundled/cleanup/uninstall-managed-components.ps1 @@ -0,0 +1,238 @@ +param( + [string]$InstallRoot = "", + [switch]$ForceRemoveWindowsPacketFilter +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function New-Result { + param( + [bool]$Success, + [string]$Message, + [hashtable]$Details = @{} + ) + + [ordered]@{ + success = $Success + message = $Message + details = $Details + } | ConvertTo-Json -Depth 8 -Compress +} + +function Get-FullPath([string]$Path) { + return [System.IO.Path]::GetFullPath($Path).TrimEnd("\") +} + +function Test-PathInside([string]$Path, [string]$Root) { + if ([string]::IsNullOrWhiteSpace($Path)) { return $false } + try { + $fullPath = Get-FullPath $Path + $fullRoot = Get-FullPath $Root + return $fullPath.StartsWith($fullRoot + "\", [StringComparison]::OrdinalIgnoreCase) + } catch { + return $false + } +} + +function Assert-SafeInstallRoot([string]$Root) { + if ([string]::IsNullOrWhiteSpace($Root)) { + throw "InstallRoot is empty." + } + + $full = Get-FullPath $Root + if ($full -match "^[A-Za-z]:\\?$") { + throw "Refusing to use drive root as InstallRoot: $full" + } + if ($full -match "\\Windows($|\\)" -or $full -match "\\ProgramData$" -or $full -match "\\Users$") { + throw "Refusing unsafe InstallRoot: $full" + } + + $knownAppFiles = @( + (Join-Path $full "proxywarden.exe"), + (Join-Path $full "uninstall.exe"), + (Join-Path $full "bundled\cleanup\uninstall-managed-components.ps1") + ) + foreach ($candidate in $knownAppFiles) { + if (Test-Path -LiteralPath $candidate) { return $full } + } + + throw "InstallRoot does not look like a ProxyWarden install directory: $full" +} + +function Resolve-SafeComponentDir([string]$Root, [string]$Leaf) { + $componentRoot = Join-Path $Root "components" + $path = Join-Path $componentRoot $Leaf + $full = Get-FullPath $path + $expectedParent = Get-FullPath $componentRoot + $actualLeaf = Split-Path -Leaf $full + + if ($actualLeaf -ne $Leaf) { + throw "Unexpected component directory leaf: $full" + } + if (-not $full.StartsWith($expectedParent + "\", [StringComparison]::OrdinalIgnoreCase)) { + throw "Component directory is outside ProxyWarden components root: $full" + } + + return $full +} + +function Read-ComponentMarker([string]$Dir) { + $markerPath = Join-Path $Dir "proxywarden-component.json" + if (-not (Test-Path -LiteralPath $markerPath)) { return $null } + try { + return Get-Content -LiteralPath $markerPath -Raw -Encoding UTF8 | ConvertFrom-Json + } catch { + return $null + } +} + +function Get-MarkerBool($Marker, [string]$Name) { + if ($null -eq $Marker) { return $false } + $property = $Marker.PSObject.Properties[$Name] + if ($null -eq $property) { return $false } + return [bool]$property.Value +} + +function Get-ServiceRecord([string]$Name) { + $escaped = $Name.Replace("'", "''") + return Get-CimInstance Win32_Service -Filter "Name='$escaped'" -ErrorAction SilentlyContinue +} + +function Get-ServiceImagePath($Record) { + if ($null -eq $Record -or [string]::IsNullOrWhiteSpace([string]$Record.PathName)) { + return $null + } + + $pathName = ([string]$Record.PathName).Trim() + if ($pathName -match '^"([^"]+)"') { return $Matches[1] } + if ($pathName -match '^(.+?\.exe)\b') { return $Matches[1].Trim() } + return $pathName +} + +function Stop-ServiceRecord($Record) { + if ($null -eq $Record) { return } + + $service = Get-Service -Name $Record.Name -ErrorAction SilentlyContinue + if ($null -ne $service -and $service.Status -ne "Stopped") { + Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue + $service = Get-Service -Name $Record.Name -ErrorAction SilentlyContinue + if ($null -ne $service) { + try { $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(12)) } catch {} + } + } + + $recordAfterStop = Get-ServiceRecord $Record.Name + if ($null -ne $recordAfterStop -and [int]$recordAfterStop.ProcessId -gt 0) { + taskkill.exe /PID ([int]$recordAfterStop.ProcessId) /F | Out-Null + Start-Sleep -Milliseconds 500 + } +} + +function Remove-ManagedService { + param( + [string[]]$Names, + [string]$InstallRoot, + [string]$UninstallExe = "" + ) + + $removed = @() + foreach ($name in $Names) { + $record = Get-ServiceRecord $name + if ($null -eq $record) { continue } + + $imagePath = Get-ServiceImagePath $record + if (-not [string]::IsNullOrWhiteSpace($imagePath) -and -not (Test-PathInside $imagePath $InstallRoot)) { + continue + } + + Stop-ServiceRecord $record + + if (-not [string]::IsNullOrWhiteSpace($UninstallExe) -and (Test-Path -LiteralPath $UninstallExe)) { + Push-Location (Split-Path -Parent $UninstallExe) + try { & $UninstallExe uninstall | Out-Null } finally { Pop-Location } + } + + $record = Get-ServiceRecord $name + if ($null -ne $record) { + sc.exe delete $name | Out-Null + } + $removed += $name + } + + return $removed +} + +function Remove-SafeDirectory([string]$Path, [string]$Root) { + if (-not (Test-Path -LiteralPath $Path)) { return $false } + if (-not (Test-PathInside $Path $Root)) { + throw "Refusing to remove directory outside InstallRoot: $Path" + } + Remove-Item -LiteralPath $Path -Recurse -Force + return $true +} + +function Get-InstalledProgram([string]$Pattern) { + $paths = @( + "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -match $Pattern } | + Select-Object -First 1 DisplayName, DisplayVersion, PSChildName, UninstallString, QuietUninstallString +} + +function Resolve-MsiProductCode($Program, [string]$Label) { + if ($null -eq $Program) { return $null } + if ($Program.PSChildName -match "^\{[0-9A-Fa-f-]{36}\}$") { + return $Program.PSChildName + } + foreach ($candidate in @($Program.QuietUninstallString, $Program.UninstallString)) { + if ($candidate -match "\{[0-9A-Fa-f-]{36}\}") { + return $Matches[0] + } + } + throw "Could not resolve MSI product code for $Label." +} + +function Uninstall-MsiProgram($Program, [string]$Label) { + $productCode = Resolve-MsiProductCode $Program $Label + if ([string]::IsNullOrWhiteSpace($productCode)) { return $false } + + $logPath = Join-Path ([System.IO.Path]::GetTempPath()) "proxywarden-$Label-uninstall.log" + $process = Start-Process -FilePath "msiexec.exe" -ArgumentList @("/x", $productCode, "/qn", "/norestart", "/L*v", $logPath) -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010 -and $process.ExitCode -ne 1605) { + throw "$Label uninstall exited with code $($process.ExitCode). MSI log: $logPath" + } + + return $true +} + +try { + $details = @{} + $root = Assert-SafeInstallRoot $InstallRoot + $details.installRoot = $root + + $proxifyreDir = Resolve-SafeComponentDir $root "ProxiFyre" + $singboxDir = Resolve-SafeComponentDir $root "sing-box" + $proxifyreMarker = Read-ComponentMarker $proxifyreDir + $removePacketFilter = [bool]$ForceRemoveWindowsPacketFilter -or (Get-MarkerBool $proxifyreMarker "packetFilterInstalledByProxyWarden") + + $details.removedProxiFyreServices = Remove-ManagedService -Names @("ProxiFyreService", "ProxiFyre") -InstallRoot $root -UninstallExe (Join-Path $proxifyreDir "ProxiFyre.exe") + $details.removedSingBoxServices = Remove-ManagedService -Names @("ProxyWardenSingBox") -InstallRoot $root -UninstallExe (Join-Path $singboxDir "ProxyWardenSingBox.exe") + $details.removedProxiFyreDir = Remove-SafeDirectory $proxifyreDir $root + $details.removedSingBoxDir = Remove-SafeDirectory $singboxDir $root + + if ($removePacketFilter) { + $packetFilter = Get-InstalledProgram "Windows Packet Filter|WinpkFilter|NDISAPI" + $details.removedWindowsPacketFilter = Uninstall-MsiProgram $packetFilter "windows-packet-filter" + } else { + $details.removedWindowsPacketFilter = $false + } + + New-Result -Success $true -Message "ProxyWarden managed components cleanup completed." -Details $details + exit 0 +} catch { + New-Result -Success $false -Message $_.Exception.Message -Details @{} + exit 1 +} diff --git a/src-tauri/bundled/installer-hooks/proxywarden-hooks.nsh b/src-tauri/bundled/installer-hooks/proxywarden-hooks.nsh new file mode 100644 index 0000000..15240a2 --- /dev/null +++ b/src-tauri/bundled/installer-hooks/proxywarden-hooks.nsh @@ -0,0 +1,6 @@ +!macro NSIS_HOOK_PREUNINSTALL + DetailPrint "ProxyWarden: cleaning managed components" + nsExec::ExecToLog 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\bundled\cleanup\uninstall-managed-components.ps1" -InstallRoot "$INSTDIR"' + Pop $0 + DetailPrint "ProxyWarden cleanup exit code: $0" +!macroend diff --git a/src-tauri/bundled/proxifyre/ProxiFyre-v2.2.1-x64-signed.zip b/src-tauri/bundled/proxifyre/ProxiFyre-v2.2.1-x64-signed.zip new file mode 100644 index 0000000..5abd3f4 Binary files /dev/null and b/src-tauri/bundled/proxifyre/ProxiFyre-v2.2.1-x64-signed.zip differ diff --git a/src-tauri/bundled/proxifyre/Windows.Packet.Filter.3.6.2.1.x64.msi b/src-tauri/bundled/proxifyre/Windows.Packet.Filter.3.6.2.1.x64.msi new file mode 100644 index 0000000..75ebaaa Binary files /dev/null and b/src-tauri/bundled/proxifyre/Windows.Packet.Filter.3.6.2.1.x64.msi differ diff --git a/src-tauri/bundled/proxifyre/manifest.json b/src-tauri/bundled/proxifyre/manifest.json new file mode 100644 index 0000000..d0fbe01 --- /dev/null +++ b/src-tauri/bundled/proxifyre/manifest.json @@ -0,0 +1,31 @@ +{ + "generatedAt": "2026-07-09T16:13:27.3087159Z", + "architectures": [ + "x64" + ], + "proxifyreRelease": "v2.2.1", + "windowsPacketFilterRelease": "v3.6.2", + "files": [ + { + "id": "proxifyre-x64", + "name": "ProxiFyre-v2.2.1-x64-signed.zip", + "sha256": "c38ca1caa68cd730712f5c0911e4240711bf9e7684988ae64ed04ec693cce899", + "size": 1372483, + "sourceUrl": "https://github.com/wiresock/proxifyre/releases/download/v2.2.1/ProxiFyre-v2.2.1-x64-signed.zip" + }, + { + "id": "packet-filter-x64", + "name": "Windows.Packet.Filter.3.6.2.1.x64.msi", + "sha256": "9c388c0b7f189f7fa98720bae2caecf7d64f30910838b80b438ecf8956b8502c", + "size": 819200, + "sourceUrl": "https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi" + }, + { + "id": "vc-runtime-x64", + "name": "vc_redist.x64.exe", + "sha256": "843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c", + "size": 18731856, + "sourceUrl": "https://aka.ms/vc14/vc_redist.x64.exe" + } + ] +} diff --git a/src-tauri/bundled/proxifyre/vc_redist.x64.exe b/src-tauri/bundled/proxifyre/vc_redist.x64.exe new file mode 100644 index 0000000..be4e202 Binary files /dev/null and b/src-tauri/bundled/proxifyre/vc_redist.x64.exe differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 059faa4..4d8395e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,9 +8,11 @@ use crate::adapters::singbox::{ SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest, }; use crate::component_detection::{ - detect_proxyfier_install, detect_proxyfier_install_with_host, detect_singbox_install, - proxyfier_component_from_detection, singbox_component_from_detection, DetectedProxyfier, - DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost, + default_proxifyre_install_dir, default_singbox_install_dir, detect_proxyfier_install, + detect_proxyfier_install_with_host, detect_singbox_install, proxifyre_install_dir_from_app_dir, + proxyfier_component_from_detection, singbox_component_from_detection, + singbox_install_dir_from_app_dir, DetectedProxyfier, DetectedSingBox, ProxyfierDetectionHost, + SystemProxyfierDetectionHost, }; use crate::elevated_scripts; use crate::models::{ @@ -21,7 +23,7 @@ use crate::models::{ use crate::process::command_no_window; use crate::safe_fs; use crate::singbox_service::{ - build_singbox_setup_status, ensure_safe_singbox_install_dir, + build_singbox_setup_status_with_install_root, ensure_safe_singbox_install_dir, parse_service_command_output as parse_singbox_service_command_output, service_control_script, ServiceCommandOutput as SingBoxServiceCommandOutput, SingBoxServiceAction, SingBoxSetupStatus, }; @@ -36,14 +38,17 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::time::{Duration, Instant}; use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::Manager; -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 = "https://api.github.com/repos/wiresock/ndisapi/releases/latest"; +const PROXIFYRE_PINNED_RELEASE_TAG: &str = "v2.2.1"; +const NDISAPI_PINNED_RELEASE_TAG: &str = "v3.6.2"; +const NDISAPI_PINNED_INSTALLER_VERSION: &str = "3.6.2.1"; 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"; const PROXY_CHECK_TIMEOUT: Duration = Duration::from_secs(4); @@ -202,6 +207,17 @@ pub struct ProxiFyreSetupItemDto { pub details: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxiFyreSetupProgressDto { + pub operation: String, + pub status: String, + pub active_step: Option, + pub percent: u8, + pub message: String, + pub updated_at: Option, +} + pub type SingBoxSetupStatusDto = SingBoxSetupStatus; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -424,6 +440,10 @@ pub struct ComponentStatusDto { pub version: Option, #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_status: Option, pub problems: Vec, pub actions: Vec, } @@ -652,10 +672,25 @@ pub async fn get_components( } #[tauri::command] -pub async fn get_proxifyre_setup_status() -> Result { - tauri::async_runtime::spawn_blocking(build_proxifyre_setup_status) +pub async fn get_proxifyre_setup_status( + app: tauri::AppHandle, +) -> Result { + let install_dir = proxifyre_install_dir_for_app(&app)?; + tauri::async_runtime::spawn_blocking(move || { + build_proxifyre_setup_status_for_install_dir(&install_dir) + }) + .await + .map_err(background_task_error) +} + +#[tauri::command] +pub async fn get_proxifyre_setup_progress( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || read_proxifyre_setup_progress(&storage)) .await - .map_err(background_task_error) + .map_err(background_task_error)? } #[tauri::command] @@ -669,9 +704,15 @@ pub async fn get_singbox_status( } #[tauri::command] -pub async fn get_singbox_setup_status() -> Result { - tauri::async_runtime::spawn_blocking(|| { - build_singbox_setup_status(detect_singbox_install().as_ref()) +pub async fn get_singbox_setup_status( + app: tauri::AppHandle, +) -> Result { + let install_dir = singbox_install_dir_for_app(&app)?; + tauri::async_runtime::spawn_blocking(move || { + build_singbox_setup_status_with_install_root( + detect_singbox_install().as_ref(), + &install_dir, + ) }) .await .map_err(background_task_error) @@ -830,10 +871,11 @@ pub async fn stop_proxifyre_service() -> Result, ) -> Result { let storage = state.storage(); - tauri::async_runtime::spawn_blocking(move || install_proxifyre_component(&storage)) + tauri::async_runtime::spawn_blocking(move || install_proxifyre_component(&storage, &app)) .await .map_err(background_task_error)? } @@ -880,10 +922,12 @@ pub async fn stop_singbox_service() -> Result #[tauri::command] pub async fn install_singbox( + app: tauri::AppHandle, state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); - tauri::async_runtime::spawn_blocking(move || install_singbox_component(&storage)) + let install_dir = singbox_install_dir_for_app(&app)?; + tauri::async_runtime::spawn_blocking(move || install_singbox_component(&storage, &install_dir)) .await .map_err(background_task_error)? } @@ -1091,10 +1135,15 @@ pub fn read_startup_snapshot( .iter() .map(ComponentStatusDto::from) .collect(); - let proxifyre_setup_status = - build_proxifyre_setup_status_with_detection(detected_proxyfier.as_ref()); + let proxifyre_setup_status = build_proxifyre_setup_status_with_detection( + detected_proxyfier.as_ref(), + &default_proxifyre_install_dir(), + ); let singbox_status = read_singbox_status_with_detection(storage, detected_singbox.as_ref())?; - let singbox_setup_status = build_singbox_setup_status(detected_singbox.as_ref()); + let singbox_setup_status = build_singbox_setup_status_with_install_root( + detected_singbox.as_ref(), + &default_singbox_install_dir(), + ); Ok(StartupSnapshotResponse { admin_status: admin_status(), @@ -2294,12 +2343,17 @@ exit 4 ) } -fn install_singbox_component(storage: &JsonStorage) -> Result { +fn install_singbox_component( + storage: &JsonStorage, + install_dir: &Path, +) -> Result { let generated_config_path = storage.paths().generated_dir.join("sing-box-config.json"); run_elevated_singbox_package_script( SingBoxPackageAction::Install, include_str!("../../scripts/install-singbox.ps1"), vec![ + "-InstallRoot".to_string(), + install_dir.display().to_string(), "-ConfigSource".to_string(), generated_config_path.display().to_string(), ], @@ -2660,6 +2714,8 @@ fn default_components() -> Vec { running: true, version: None, path: None, + service_name: None, + service_status: None, problems: Vec::new(), actions: vec![ "Открыть журнал".to_string(), @@ -2674,6 +2730,8 @@ fn default_components() -> Vec { running: false, version: None, path: None, + service_name: Some("ProxiFyreService".to_string()), + service_status: None, problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()], actions: vec!["Установить ProxiFyre".to_string()], }, @@ -2685,6 +2743,8 @@ fn default_components() -> Vec { running: false, version: None, path: None, + service_name: Some(crate::models::DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()), + service_status: None, problems: Vec::new(), actions: vec!["Установить локальный sing-box".to_string()], }, @@ -3171,14 +3231,44 @@ impl ProxiFyrePackageAction { ProxiFyrePackageAction::Uninstall => "uninstall", } } + + fn operation(self) -> &'static str { + match self { + ProxiFyrePackageAction::Install => "install", + ProxiFyrePackageAction::Uninstall => "uninstall", + } + } + + fn start_message(self) -> &'static str { + match self { + ProxiFyrePackageAction::Install => "Готовлю установку ProxiFyre.", + ProxiFyrePackageAction::Uninstall => "Готовлю удаление ProxiFyre и сетевого драйвера.", + } + } + + fn success_message(self) -> &'static str { + match self { + ProxiFyrePackageAction::Install => "ProxiFyre и сетевой драйвер готовы.", + ProxiFyrePackageAction::Uninstall => "ProxiFyre и сетевой драйвер удалены.", + } + } } -fn install_proxifyre_component(storage: &JsonStorage) -> Result { +fn install_proxifyre_component( + storage: &JsonStorage, + app: &tauri::AppHandle, +) -> Result { let generated_config_path = storage .paths() .generated_dir .join("proxifyre-app-config.json"); - let script = install_proxifyre_script(&generated_config_path); + let bundled_asset_dir = bundled_proxifyre_asset_dir(app); + let install_dir = proxifyre_install_dir_for_app(app)?; + let script = install_proxifyre_script_for_target( + &generated_config_path, + bundled_asset_dir.as_deref(), + &install_dir, + ); run_elevated_package_script( ProxiFyrePackageAction::Install, @@ -3186,6 +3276,13 @@ fn install_proxifyre_component(storage: &JsonStorage) -> Result Result Option { + let mut candidates = Vec::new(); + if let Ok(resource_dir) = app.path().resource_dir() { + candidates.push(resource_dir.join("bundled").join("proxifyre")); + } + candidates.push( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("bundled") + .join("proxifyre"), + ); + + candidates.into_iter().find(|path| path.is_dir()) +} + +fn app_install_dir(app: &tauri::AppHandle) -> Result { + if let Ok(exe_path) = env::current_exe() { + if let Some(parent) = exe_path.parent() { + return Ok(parent.to_path_buf()); + } + } + + app.path().resource_dir().map_err(|error| { + CommandError::new( + "app_install_dir_unavailable", + format!("Не удалось определить папку установки ProxyWarden: {error}"), + ) + }) +} + +fn proxifyre_install_dir_for_app(app: &tauri::AppHandle) -> Result { + Ok(proxifyre_install_dir_from_app_dir(&app_install_dir(app)?)) +} + +fn singbox_install_dir_for_app(app: &tauri::AppHandle) -> Result { + Ok(singbox_install_dir_from_app_dir(&app_install_dir(app)?)) +} + fn uninstall_proxifyre_component() -> Result { - let Some(detected) = detect_proxyfier_install() else { + let detected = detect_proxyfier_install(); + let packet_filter = detect_windows_packet_filter(); + if detected.is_none() && packet_filter.is_none() { 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); + if let Some(detected) = detected.as_ref() { + ensure_safe_proxifyre_install_dir(&detected.install_dir)?; + } + let script = uninstall_proxifyre_script(detected.as_ref()); let artifact_dir = default_config_root().join("state"); run_elevated_package_script(ProxiFyrePackageAction::Uninstall, script, &artifact_dir)?; @@ -3218,18 +3356,25 @@ fn uninstall_proxifyre_component() -> Result { "Удаление ProxiFyre завершилось, но приложение все еще найдено на компьютере.", )); } + if detect_windows_packet_filter().is_some() { + return Err(CommandError::new( + ProxiFyrePackageAction::Uninstall.error_code(), + "Удаление ProxiFyre завершилось, но Windows Packet Filter все еще найден на компьютере.", + )); + } let component = proxyfier_component_from_detection(None); Ok(ComponentStatusDto::from(&component)) } -fn build_proxifyre_setup_status() -> ProxiFyreSetupStatusDto { +fn build_proxifyre_setup_status_for_install_dir(install_dir: &Path) -> ProxiFyreSetupStatusDto { let proxifyre = detect_proxyfier_install(); - build_proxifyre_setup_status_with_detection(proxifyre.as_ref()) + build_proxifyre_setup_status_with_detection(proxifyre.as_ref(), install_dir) } fn build_proxifyre_setup_status_with_detection( proxifyre: Option<&DetectedProxyfier>, + default_install_dir: &Path, ) -> ProxiFyreSetupStatusDto { let vc_runtime = detect_vc_runtime(); let packet_filter = detect_windows_packet_filter(); @@ -3251,11 +3396,7 @@ fn build_proxifyre_setup_status_with_detection( id: "proxifyre".to_string(), name: "ProxiFyre".to_string(), installed: true, - version: Some(if detected.running { - "служба запущена".to_string() - } else { - "служба не запущена".to_string() - }), + version: Some(proxifyre_service_setup_version(detected)), details: detected.install_dir.display().to_string(), }, None => ProxiFyreSetupItemDto { @@ -3264,8 +3405,8 @@ fn build_proxifyre_setup_status_with_detection( installed: false, version: None, details: format!( - "Будет скачан с GitHub и распакован в {}.", - PROXIFYRE_INSTALL_DIR + "Будет установлен рядом с ProxyWarden в {}.", + default_install_dir.display() ), }, }; @@ -3280,6 +3421,115 @@ fn build_proxifyre_setup_status_with_detection( } } +fn proxifyre_service_setup_version(detected: &DetectedProxyfier) -> String { + match detected.service_status.as_deref() { + Some(status) if status.eq_ignore_ascii_case("running") => "служба запущена".to_string(), + Some(_) => "служба остановлена".to_string(), + None => "служба не установлена".to_string(), + } +} + +fn proxifyre_progress_path(state_dir: &Path) -> PathBuf { + state_dir.join("proxifyre-setup-progress.json") +} + +fn idle_proxifyre_setup_progress() -> ProxiFyreSetupProgressDto { + ProxiFyreSetupProgressDto { + operation: "idle".to_string(), + status: "idle".to_string(), + active_step: None, + percent: 0, + message: "Ожидаю действия пользователя.".to_string(), + updated_at: None, + } +} + +fn read_proxifyre_setup_progress( + storage: &JsonStorage, +) -> Result { + let path = proxifyre_progress_path(&storage.paths().state_dir); + if !path.exists() { + return Ok(idle_proxifyre_setup_progress()); + } + + let contents = fs::read_to_string(&path).map_err(|error| { + CommandError::new( + "proxifyre_setup_progress_read_failed", + format!( + "Не удалось прочитать прогресс установки ProxiFyre '{}': {error}", + path.display() + ), + ) + })?; + + serde_json::from_str(&contents).map_err(|error| { + CommandError::new( + "proxifyre_setup_progress_parse_failed", + format!( + "Не удалось разобрать прогресс установки ProxiFyre '{}': {error}", + path.display() + ), + ) + }) +} + +fn write_proxifyre_setup_progress( + path: &Path, + operation: &str, + active_step: Option<&str>, + status: &str, + percent: u8, + message: &str, +) -> Result<(), CommandError> { + let progress = ProxiFyreSetupProgressDto { + operation: operation.to_string(), + status: status.to_string(), + active_step: active_step.map(str::to_string), + percent: percent.min(100), + message: message.to_string(), + updated_at: Some(SystemClock.now()), + }; + let bytes = serde_json::to_vec_pretty(&progress).map_err(|error| { + CommandError::new( + "proxifyre_setup_progress_write_failed", + format!("Не удалось подготовить прогресс установки ProxiFyre: {error}"), + ) + })?; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + CommandError::new( + "proxifyre_setup_progress_write_failed", + format!( + "Не удалось создать папку прогресса установки ProxiFyre '{}': {error}", + parent.display() + ), + ) + })?; + } + + let temp_path = safe_fs::temp_path(path); + fs::write(&temp_path, bytes).map_err(|error| { + CommandError::new( + "proxifyre_setup_progress_write_failed", + format!( + "Не удалось записать прогресс установки ProxiFyre '{}': {error}", + temp_path.display() + ), + ) + })?; + fs::rename(&temp_path, path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + CommandError::new( + "proxifyre_setup_progress_write_failed", + format!( + "Не удалось обновить прогресс установки ProxiFyre '{}': {error}", + path.display() + ), + ) + }) +} + fn setup_item_from_program( id: &str, name: &str, @@ -3416,7 +3666,17 @@ fn run_elevated_package_script( let script_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1"); let result_path = elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log"); - let script = wrap_elevated_package_script(&body, &result_path); + let progress_path = proxifyre_progress_path(artifact_dir); + let _ = write_proxifyre_setup_progress( + &progress_path, + action.operation(), + None, + "running", + 1, + action.start_message(), + ); + let script = + wrap_elevated_package_script_for_action(&body, &result_path, Some(&progress_path), action); write_powershell_script(&script_path, &script).map_err(|error| { CommandError::new( @@ -3459,11 +3719,27 @@ try {{ match output { Ok(output) if output.status.success() => { let _ = fs::remove_file(&result_path); + let _ = write_proxifyre_setup_progress( + &progress_path, + action.operation(), + None, + "succeeded", + 100, + action.success_message(), + ); Ok(()) } Ok(output) => { let details = package_failure_details(&result_path, &output); let _ = fs::remove_file(&result_path); + let _ = write_proxifyre_setup_progress( + &progress_path, + action.operation(), + None, + "failed", + 100, + &details, + ); Err(CommandError::new( action.error_code(), format!( @@ -3473,23 +3749,81 @@ try {{ ), )) } - Err(error) => Err(CommandError::new( - action.error_code(), - format!( + Err(error) => { + let message = format!( "Не удалось запросить права администратора, чтобы {} ProxiFyre: {error}", action.label() - ), - )), + ); + let _ = write_proxifyre_setup_progress( + &progress_path, + action.operation(), + None, + "failed", + 100, + &message, + ); + Err(CommandError::new(action.error_code(), message)) + } } } pub fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String { + wrap_elevated_package_script_for_action( + body, + result_path, + None, + ProxiFyrePackageAction::Install, + ) +} + +fn wrap_elevated_package_script_for_action( + body: &str, + result_path: &Path, + progress_path: Option<&Path>, + action: ProxiFyrePackageAction, +) -> 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(&format!( + "$script:progressOperation = '{}'\n", + escape_powershell_single(action.operation()) + )); + script.push_str("$script:progressActiveStep = $null\n"); + if let Some(progress_path) = progress_path { + script.push_str(&format!( + "$progressPath = '{}'\n", + escape_powershell_single(&progress_path.display().to_string()) + )); + script.push_str( + r#" +function Write-ProxyWardenProgress([string]$operation, [string]$activeStep, [string]$status, [int]$percent, [string]$message) { + $script:progressOperation = $operation + $script:progressActiveStep = if ([string]::IsNullOrWhiteSpace($activeStep)) { $null } else { $activeStep } + $payload = [ordered]@{ + operation = $operation + status = $status + activeStep = $script:progressActiveStep + percent = [Math]::Max(0, [Math]::Min(100, $percent)) + message = $message + updatedAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + $progressTempPath = "$progressPath.tmp" + Set-Content -LiteralPath $progressTempPath -Value $payload -Encoding UTF8 + Move-Item -LiteralPath $progressTempPath -Destination $progressPath -Force +} +"#, + ); + } else { + script.push_str( + r#" +function Write-ProxyWardenProgress([string]$operation, [string]$activeStep, [string]$status, [int]$percent, [string]$message) {} +"#, + ); + } script.push_str("try {\n"); script.push_str(body); script.push_str( @@ -3498,6 +3832,7 @@ pub fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String { exit 0 } catch { $message = ($_ | Out-String) + Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'failed' 100 $message Set-Content -LiteralPath $resultPath -Value $message -Encoding UTF8 exit 1 } @@ -3571,15 +3906,43 @@ fn powershell_output_message(output: &Output, fallback: &str) -> String { } pub fn install_proxifyre_script(generated_config_path: &Path) -> String { + install_proxifyre_script_with_bundle(generated_config_path, None) +} + +pub fn install_proxifyre_script_with_bundle( + generated_config_path: &Path, + bundled_asset_dir: Option<&Path>, +) -> String { + install_proxifyre_script_for_target( + generated_config_path, + bundled_asset_dir, + &default_proxifyre_install_dir(), + ) +} + +pub fn install_proxifyre_script_for_target( + generated_config_path: &Path, + bundled_asset_dir: Option<&Path>, + target_dir: &Path, +) -> String { let mut script = String::new(); script.push_str(&format!( "$targetDir = '{}'\n", - escape_powershell_single(PROXIFYRE_INSTALL_DIR) + escape_powershell_single(&target_dir.display().to_string()) )); script.push_str(&format!( "$generatedConfigPath = '{}'\n", escape_powershell_single(&generated_config_path.display().to_string()) )); + script.push_str(&format!( + "$bundledAssetDir = '{}'\n", + escape_powershell_single( + &bundled_asset_dir + .map(|path| path.display().to_string()) + .unwrap_or_default() + ) + )); + script.push_str("$script:bundledAssetDir = [string]$bundledAssetDir\n"); script.push_str(&format!( "$proxifyreReleaseApi = '{}'\n", escape_powershell_single(PROXIFYRE_RELEASE_API_URL) @@ -3588,6 +3951,18 @@ pub fn install_proxifyre_script(generated_config_path: &Path) -> String { "$ndisapiReleaseApi = '{}'\n", escape_powershell_single(NDISAPI_RELEASE_API_URL) )); + script.push_str(&format!( + "$proxifyrePinnedReleaseTag = '{}'\n", + escape_powershell_single(PROXIFYRE_PINNED_RELEASE_TAG) + )); + script.push_str(&format!( + "$ndisapiPinnedReleaseTag = '{}'\n", + escape_powershell_single(NDISAPI_PINNED_RELEASE_TAG) + )); + script.push_str(&format!( + "$ndisapiPinnedInstallerVersion = '{}'\n", + escape_powershell_single(NDISAPI_PINNED_INSTALLER_VERSION) + )); script.push_str(&format!( "$vcRedistX64Url = '{}'\n", escape_powershell_single(VC_REDIST_X64_URL) @@ -3636,6 +4011,39 @@ pub fn install_proxifyre_script(generated_config_path: &Path) -> String { throw "Не удалось получить metadata для $label ($safeUri): $lastError" } + function New-ReleaseAsset([string]$name, [string]$url) { + [PSCustomObject]@{ + name = $name + browser_download_url = $url + digest = $null + } + } + + function Resolve-ReleaseAsset([string]$apiUri, [string]$pattern, [string]$label, $fallbackAsset, [int]$fallbackPercent) { + try { + $release = Invoke-ReleaseApi $apiUri $label + return Select-Asset $release.assets $pattern $label + } catch { + $fallbackUri = Get-SafeUriForLog $fallbackAsset.browser_download_url + Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'running' $fallbackPercent "GitHub API недоступен для $label. Пробую прямую ссылку: $fallbackUri" + return $fallbackAsset + } + } + + function Get-PinnedProxiFyreAsset([string]$arch) { + $archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' } + $name = "ProxiFyre-$proxifyrePinnedReleaseTag-$archLabel-signed.zip" + $url = "https://github.com/wiresock/proxifyre/releases/download/$proxifyrePinnedReleaseTag/$name" + return New-ReleaseAsset $name $url + } + + function Get-PinnedWindowsPacketFilterAsset([string]$arch) { + $archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' } + $name = "Windows.Packet.Filter.$ndisapiPinnedInstallerVersion.$archLabel.msi" + $url = "https://github.com/wiresock/ndisapi/releases/download/$ndisapiPinnedReleaseTag/$name" + return New-ReleaseAsset $name $url + } + function Complete-Download([string]$partialPath, [string]$path, [string]$label) { if (-not (Test-Path -LiteralPath $partialPath)) { throw "${label}: файл не был создан." @@ -3781,48 +4189,149 @@ pub fn install_proxifyre_script(generated_config_path: &Path) -> String { return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' ' } + function Get-BundledAssetDir { + $dir = [string]$script:bundledAssetDir + if ([string]::IsNullOrWhiteSpace($dir)) { return $null } + if (-not (Test-Path -LiteralPath $dir -PathType Container)) { return $null } + return $dir + } + + function Get-BundledAssetManifest { + $assetDir = Get-BundledAssetDir + if ($null -eq $assetDir) { return $null } + $manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json') + if (-not (Test-Path -LiteralPath $manifestPath)) { return $null } + + try { + return Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json + } catch { + throw "Не удалось прочитать manifest встроенных пакетов ProxiFyre: $($_.Exception.Message)" + } + } + + $script:bundledAssetManifest = Get-BundledAssetManifest + + function Get-BundledAssetHash([string]$name) { + if ($null -eq $script:bundledAssetManifest -or $null -eq $script:bundledAssetManifest.files) { + return $null + } + + $entry = $script:bundledAssetManifest.files | + Where-Object { $_.name -eq $name } | + Select-Object -First 1 + if ($null -eq $entry) { return $null } + return [string]$entry.sha256 + } + + function Verify-BundledAssetHash([string]$path, [string]$label) { + $name = [IO.Path]::GetFileName($path) + $expected = Get-BundledAssetHash $name + if ([string]::IsNullOrWhiteSpace($expected)) { + throw "Во встроенном manifest нет SHA256 для $label ($name)." + } + + $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected.ToLowerInvariant()) { + throw "SHA256 не совпал для встроенного $label ($name). Ожидалось $expected, получилось $actual." + } + } + + function Get-BundledAsset([string]$pattern, [string]$label) { + $assetDir = Get-BundledAssetDir + if ($null -eq $assetDir) { return $null } + + $asset = Get-ChildItem -LiteralPath $assetDir -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match $pattern } | + Select-Object -First 1 + if ($null -eq $asset) { return $null } + + Verify-BundledAssetHash $asset.FullName $label + return $asset.FullName + } + + function Copy-BundledAsset([string]$sourcePath, [string]$targetPath, [string]$label) { + Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Force + $item = Get-Item -LiteralPath $targetPath + if ($item.Length -le 0) { + throw "${label}: встроенный файл пустой." + } + } + $arch = Get-NativeArchitecture $workDir = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-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 'Microsoft Visual C++ Runtime' - $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-ReleaseApi $ndisapiReleaseApi 'Windows Packet Filter' + Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 8 'Проверяю сетевой драйвер Windows Packet Filter.' + $packetFilterAlreadyInstalled = Test-WindowsPacketFilter + if (-not $packetFilterAlreadyInstalled) { + Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 14 'Готовлю Windows Packet Filter.' $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 + $bundledNdisPath = Get-BundledAsset $ndisPattern 'Windows Packet Filter' + if ($null -ne $bundledNdisPath) { + Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Использую встроенный Windows Packet Filter.' + $ndisPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledNdisPath)) + Copy-BundledAsset $bundledNdisPath $ndisPath 'Windows Packet Filter' + } else { + Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Скачиваю Windows Packet Filter.' + $ndisAsset = Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter' (Get-PinnedWindowsPacketFilterAsset $arch) 16 + $ndisPath = Join-Path $workDir $ndisAsset.name + Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter' + Verify-AssetHash $ndisPath $ndisAsset + } $ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log' - Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter' - Verify-AssetHash $ndisPath $ndisAsset + Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 26 'Устанавливаю Windows Packet Filter.' $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" } } + Write-ProxyWardenProgress 'install' 'packet-filter' 'succeeded' 36 'Сетевой драйвер готов.' - $proxifyreRelease = Invoke-ReleaseApi $proxifyreReleaseApi 'ProxiFyre' + Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 40 'Проверяю Microsoft Visual C++ Runtime.' + if (-not (Test-VcRuntime $arch)) { + $vcBundledPattern = if ($arch -eq 'x86') { '^vc_redist\.x86\.exe$' } else { '^vc_redist\.x64\.exe$' } + $vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url } + $bundledVcPath = Get-BundledAsset $vcBundledPattern 'Microsoft Visual C++ Runtime' + $vcRedistPath = Join-Path $workDir 'vc_redist.exe' + if ($null -ne $bundledVcPath) { + Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Использую встроенный Microsoft Visual C++ Runtime.' + Copy-BundledAsset $bundledVcPath $vcRedistPath 'Microsoft Visual C++ Runtime' + } else { + Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Скачиваю Microsoft Visual C++ Runtime.' + Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime' + } + Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 54 'Устанавливаю Microsoft Visual C++ Runtime.' + $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)." + } + } + Write-ProxyWardenProgress 'install' 'vc-runtime' 'succeeded' 62 'Среда запуска готова.' + + Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 66 'Готовлю ProxiFyre.' $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 'ProxiFyre' - Verify-AssetHash $proxifyreZipPath $proxifyreAsset + $bundledProxiFyrePath = Get-BundledAsset $proxifyrePattern 'ProxiFyre' + if ($null -ne $bundledProxiFyrePath) { + Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Использую встроенный ProxiFyre.' + $proxifyreZipPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledProxiFyrePath)) + Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath 'ProxiFyre' + } else { + Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Скачиваю ProxiFyre.' + $proxifyreAsset = Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre' (Get-PinnedProxiFyreAsset $arch) 68 + $proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name + Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre' + Verify-AssetHash $proxifyreZipPath $proxifyreAsset + } + Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 76 'Распаковываю ProxiFyre.' 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.' } + Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 82 'Копирую ProxiFyre в папку установки.' Copy-Item -Path (Join-Path $proxifyreExe.Directory.FullName '*') -Destination $targetDir -Recurse -Force $configTarget = Join-Path $targetDir 'app-config.json' @@ -3833,6 +4342,17 @@ pub fn install_proxifyre_script(generated_config_path: &Path) -> String { Set-Content -LiteralPath $configTarget -Value $emptyConfig -Encoding UTF8 } + $markerPath = Join-Path $targetDir 'proxywarden-component.json' + [ordered]@{ + manager = 'ProxyWarden' + component = 'proxifyre' + serviceName = 'ProxiFyreService' + installedAt = (Get-Date).ToString('o') + installRoot = $targetDir + packetFilterInstalledByProxyWarden = (-not $packetFilterAlreadyInstalled) + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8 + + Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 90 'Устанавливаю и запускаю службу ProxiFyre.' Push-Location $targetDir try { & .\ProxiFyre.exe stop | Out-Null @@ -3846,24 +4366,73 @@ pub fn install_proxifyre_script(generated_config_path: &Path) -> String { } finally { Pop-Location } + Write-ProxyWardenProgress 'install' 'proxifyre' 'succeeded' 100 'ProxiFyre и сетевой драйвер готовы.' "#, ); script } -fn uninstall_proxifyre_script(install_dir: &Path, executable_path: &Path) -> String { +pub fn uninstall_proxifyre_script(detected: Option<&DetectedProxyfier>) -> String { let mut script = String::new(); + let install_dir = detected + .map(|detected| detected.install_dir.display().to_string()) + .unwrap_or_default(); + let executable_path = detected + .map(|detected| detected.executable_path.display().to_string()) + .unwrap_or_default(); script.push_str(&format!( "$installDir = '{}'\n", - escape_powershell_single(&install_dir.display().to_string()) + escape_powershell_single(&install_dir) )); script.push_str(&format!( "$exePath = '{}'\n", - escape_powershell_single(&executable_path.display().to_string()) + escape_powershell_single(&executable_path) )); script.push_str( r#" + function Get-InstalledProgram([string]$pattern) { + $paths = @( + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -match $pattern } | + Select-Object -First 1 DisplayName, DisplayVersion, PSChildName, UninstallString, QuietUninstallString + } + + 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 ' ' + } + + function Resolve-MsiProductCode($program, [string]$label) { + if ($null -eq $program) { return $null } + if ($program.PSChildName -match '^\{[0-9A-Fa-f-]{36}\}$') { + return $program.PSChildName + } + foreach ($candidate in @($program.QuietUninstallString, $program.UninstallString)) { + if ($candidate -match '\{[0-9A-Fa-f-]{36}\}') { + return $Matches[0] + } + } + throw "Не удалось найти MSI product code для $label. Отказываюсь запускать произвольный UninstallString." + } + + function Uninstall-MsiProgram($program, [string]$label, [string]$logPath) { + $productCode = Resolve-MsiProductCode $program $label + if ([string]::IsNullOrWhiteSpace($productCode)) { return } + $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/x', $productCode, '/qn', '/norestart', '/L*v', $logPath) -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010 -and $process.ExitCode -ne 1605) { + $logTail = Get-LogTail $logPath + throw "$label uninstall завершился с кодом $($process.ExitCode). MSI log: $logPath $logTail" + } + } + function Find-ProxiFyreService { foreach ($name in @('ProxiFyreService', 'ProxiFyre')) { $candidate = Get-Service -Name $name -ErrorAction SilentlyContinue @@ -3882,6 +4451,7 @@ fn uninstall_proxifyre_script(install_dir: &Path, executable_path: &Path) -> Str return [int]$record.ProcessId } + Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 10 'Останавливаю службу ProxiFyre.' $service = Find-ProxiFyreService if ($null -ne $service -and $service.Status -ne 'Stopped') { try { @@ -3900,7 +4470,8 @@ fn uninstall_proxifyre_script(install_dir: &Path, executable_path: &Path) -> Str } } - if (Test-Path -LiteralPath $exePath) { + Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 34 'Удаляю службу и файлы ProxiFyre.' + if (-not [string]::IsNullOrWhiteSpace($exePath) -and (Test-Path -LiteralPath $exePath)) { Push-Location (Split-Path -Parent $exePath) try { & $exePath uninstall | Out-Null @@ -3916,9 +4487,24 @@ fn uninstall_proxifyre_script(install_dir: &Path, executable_path: &Path) -> Str Get-Process -Name 'ProxiFyre' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue - if (Test-Path -LiteralPath $installDir) { + if (-not [string]::IsNullOrWhiteSpace($installDir) -and (Test-Path -LiteralPath $installDir)) { Remove-Item -LiteralPath $installDir -Recurse -Force } + + Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'succeeded' 58 'ProxiFyre удален.' + + Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 68 'Проверяю Windows Packet Filter.' + $packetFilter = Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI' + if ($null -ne $packetFilter) { + Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 78 'Удаляю Windows Packet Filter.' + $driverLogPath = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-windows-packet-filter-uninstall.log' + Uninstall-MsiProgram $packetFilter 'Windows Packet Filter' $driverLogPath + } + if (Test-WindowsPacketFilter) { + throw 'Windows Packet Filter все еще найден после удаления. Возможно, Windows требует перезагрузку.' + } + + Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'succeeded' 100 'ProxiFyre и Windows Packet Filter удалены.' "#, ); @@ -3926,13 +4512,30 @@ fn uninstall_proxifyre_script(install_dir: &Path, executable_path: &Path) -> Str } fn ensure_safe_proxifyre_install_dir(path: &Path) -> Result<(), CommandError> { + let normalized = path + .display() + .to_string() + .replace('/', "\\") + .to_ascii_lowercase(); let name = path .file_name() .and_then(|value| value.to_str()) .map(|value| value.to_ascii_lowercase()) .unwrap_or_default(); + let marker_path = path.join("proxywarden-component.json"); + let is_proxywarden_component = + name == "proxifyre" && normalized.contains("\\proxywarden\\components\\"); + let is_legacy_proxywarden_child = + name == "proxifyre" && normalized.ends_with("\\proxywarden\\proxifyre"); + let is_legacy_tools_proxifyre = normalized == r"c:\tools\proxifyre"; + let has_proxywarden_marker = marker_path.exists(); - if path.parent().is_some() && name.contains("proxifyre") { + if path.parent().is_some() + && (is_proxywarden_component + || is_legacy_proxywarden_child + || is_legacy_tools_proxifyre + || has_proxywarden_marker) + { return Ok(()); } @@ -4295,6 +4898,8 @@ impl From<&ComponentStatus> for ComponentStatusDto { running: component.running, version: component.version.clone(), path: component.path.clone(), + service_name: component.service_name.clone(), + service_status: component.service_status.clone(), problems: component.problems.clone(), actions: component.actions.clone(), } diff --git a/src-tauri/src/component_detection.rs b/src-tauri/src/component_detection.rs index 5b2a859..a77fcf3 100644 --- a/src-tauri/src/component_detection.rs +++ b/src-tauri/src/component_detection.rs @@ -9,6 +9,10 @@ use std::{ path::{Path, PathBuf}, }; +pub const PROXYWARDEN_COMPONENTS_DIR_NAME: &str = "components"; +pub const PROXIFYRE_COMPONENT_DIR_NAME: &str = "ProxiFyre"; +pub const SINGBOX_COMPONENT_DIR_NAME: &str = "sing-box"; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProxyfierEngine { ProxiFyre, @@ -23,6 +27,13 @@ pub struct DetectedProxyfier { pub config_path: Option, pub running: bool, pub service_name: Option, + pub service_status: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedService { + pub name: String, + pub status: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -50,7 +61,12 @@ pub trait ProxyfierDetectionHost { fn process_running(&self, process_name: &str) -> bool; - fn service_running(&self, service_name: &str) -> bool; + fn service_status(&self, service_name: &str) -> Option; + + fn service_running(&self, service_name: &str) -> bool { + self.service_status(service_name) + .is_some_and(|status| service_status_is_running(&status)) + } fn registry_install_entries(&self) -> Vec; } @@ -77,13 +93,13 @@ impl ProxyfierDetectionHost for SystemProxyfierDetectionHost { powershell_bool(&script) } - fn service_running(&self, service_name: &str) -> bool { + fn service_status(&self, service_name: &str) -> Option { let script = format!( - "$s = Get-Service -Name '{}' -ErrorAction SilentlyContinue; if ($s -and $s.Status -eq 'Running') {{ 'true' }} else {{ 'false' }}", + "$s = Get-Service -Name '{}' -ErrorAction SilentlyContinue; if ($s) {{ $s.Status.ToString() }}", escape_powershell_single(service_name) ); - powershell_bool(&script) + powershell_text(&script).map(|status| status.to_ascii_lowercase()) } fn registry_install_entries(&self) -> Vec { @@ -95,16 +111,53 @@ pub fn detect_proxyfier_install() -> Option { detect_proxyfier_install_with_host(&SystemProxyfierDetectionHost) } +pub fn app_install_dir_from_current_exe() -> Option { + env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)) +} + +pub fn component_root_from_app_dir(app_dir: &Path) -> PathBuf { + app_dir.join(PROXYWARDEN_COMPONENTS_DIR_NAME) +} + +pub fn proxifyre_install_dir_from_app_dir(app_dir: &Path) -> PathBuf { + component_root_from_app_dir(app_dir).join(PROXIFYRE_COMPONENT_DIR_NAME) +} + +pub fn singbox_install_dir_from_app_dir(app_dir: &Path) -> PathBuf { + component_root_from_app_dir(app_dir).join(SINGBOX_COMPONENT_DIR_NAME) +} + +pub fn default_proxifyre_install_dir() -> PathBuf { + app_install_dir_from_current_exe() + .map(|app_dir| proxifyre_install_dir_from_app_dir(&app_dir)) + .unwrap_or_else(|| { + PathBuf::from(r"C:\Program Files\ProxyWarden") + .join(PROXYWARDEN_COMPONENTS_DIR_NAME) + .join(PROXIFYRE_COMPONENT_DIR_NAME) + }) +} + +pub fn default_singbox_install_dir() -> PathBuf { + app_install_dir_from_current_exe() + .map(|app_dir| singbox_install_dir_from_app_dir(&app_dir)) + .unwrap_or_else(|| PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT)) +} + pub fn detect_proxyfier_install_with_host( host: &impl ProxyfierDetectionHost, ) -> Option { - let proxifyre_running = host.process_running("ProxiFyre.exe") - || host.service_running("ProxiFyreService") - || host.service_running("ProxiFyre"); + let detected_service = detect_proxifyre_service(host); + let proxifyre_running = detected_service + .as_ref() + .is_some_and(|service| service_status_is_running(&service.status)); proxyfier_candidates(host) .into_iter() - .filter_map(|candidate| candidate.into_detected(host, proxifyre_running)) + .filter_map(|candidate| { + candidate.into_detected(host, proxifyre_running, detected_service.as_ref()) + }) .next() } @@ -161,6 +214,15 @@ fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatu } } }; + let service_name = proxyfier + .service_name + .clone() + .or_else(|| service_name(&proxyfier.engine).map(str::to_string)); + let service_status = proxyfier.service_status.clone(); + let mut problems = Vec::new(); + if service_status.is_none() { + problems.push("Служба ProxiFyre не установлена".to_string()); + } ComponentStatus { id: ComponentId::Proxyfier, @@ -169,10 +231,16 @@ fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatu installed: true, running: proxyfier.running, version: Some(match proxyfier.engine { - ProxyfierEngine::ProxiFyre => "ProxiFyre найден".to_string(), + ProxyfierEngine::ProxiFyre => match service_status.as_deref() { + Some(status) if service_status_is_running(status) => "служба запущена".to_string(), + Some(_) => "служба остановлена".to_string(), + None => "служба не установлена".to_string(), + }, }), path: Some(proxyfier.install_dir.display().to_string()), - problems: Vec::new(), + service_name, + service_status, + problems, actions, } } @@ -186,6 +254,8 @@ fn missing_proxyfier_component() -> ComponentStatus { running: false, version: None, path: None, + service_name: service_name(&ProxyfierEngine::ProxiFyre).map(str::to_string), + service_status: None, problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()], actions: vec!["Установить ProxiFyre".to_string()], } @@ -224,6 +294,15 @@ fn detected_singbox_component(singbox: &DetectedSingBox) -> ComponentStatus { running: singbox.running, version: Some("sing-box найден".to_string()), path: Some(singbox.executable_path.display().to_string()), + service_name: Some(singbox.service_name.clone()), + service_status: Some( + if singbox.running { + "running" + } else { + "stopped" + } + .to_string(), + ), problems, actions, } @@ -238,6 +317,8 @@ fn missing_singbox_component() -> ComponentStatus { running: false, version: None, path: None, + service_name: Some(DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()), + service_status: None, problems: Vec::new(), actions: vec!["Установить Local sing-box".to_string()], } @@ -255,21 +336,19 @@ impl ProxyfierCandidate { self, host: &impl ProxyfierDetectionHost, proxifyre_running: bool, + detected_service: Option<&DetectedService>, ) -> Option { let executable_path = self.install_dir.join(executable_name(&self.engine)); let config_path = config_path(&self.engine, &self.install_dir); - let exists = host.path_exists(&self.install_dir) - || host.path_exists(&executable_path) - || config_path - .as_ref() - .is_some_and(|path| host.path_exists(path)); - - if !exists { + if !host.path_exists(&executable_path) { return None; } Some(DetectedProxyfier { - service_name: service_name(&self.engine).map(str::to_string), + service_name: detected_service + .map(|service| service.name.clone()) + .or_else(|| service_name(&self.engine).map(str::to_string)), + service_status: detected_service.map(|service| service.status.clone()), engine: self.engine, name: self.name, install_dir: self.install_dir, @@ -290,6 +369,14 @@ fn proxyfier_candidates(host: &impl ProxyfierDetectionHost) -> Vec, candidate: Proxyfier } fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) -> Vec { - let mut dirs = vec![PathBuf::from(format!(r"C:\Tools\{folder_name}"))]; + let mut dirs = Vec::new(); for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] { if let Some(root) = host.env_var(env_name) { - dirs.push(PathBuf::from(root).join(folder_name)); + let proxywarden_root = PathBuf::from(root).join("ProxyWarden"); + dirs.push( + proxywarden_root + .join(PROXYWARDEN_COMPONENTS_DIR_NAME) + .join(folder_name), + ); + dirs.push(proxywarden_root.join(folder_name)); } } @@ -367,21 +460,26 @@ fn singbox_candidates(host: &impl ProxyfierDetectionHost) -> Vec { if let Some(path) = host.env_var("PROXYWARDEN_SINGBOX_ROOT") { push_path_candidate(&mut candidates, PathBuf::from(path)); } + push_path_candidate(&mut candidates, default_singbox_install_dir()); push_path_candidate( &mut candidates, PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT), ); - push_path_candidate( - &mut candidates, - PathBuf::from(r"C:\Tools\ProxyWarden\sing-box"), - ); for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] { if let Some(root) = host.env_var(env_name) { push_path_candidate( &mut candidates, - PathBuf::from(&root).join("ProxyWarden").join("sing-box"), + PathBuf::from(&root) + .join("ProxyWarden") + .join(PROXYWARDEN_COMPONENTS_DIR_NAME) + .join(SINGBOX_COMPONENT_DIR_NAME), + ); + push_path_candidate( + &mut candidates, + PathBuf::from(&root) + .join("ProxyWarden") + .join(SINGBOX_COMPONENT_DIR_NAME), ); - push_path_candidate(&mut candidates, PathBuf::from(root).join("sing-box")); } } @@ -441,6 +539,27 @@ fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> { } } +fn detect_proxifyre_service(host: &impl ProxyfierDetectionHost) -> Option { + for name in ["ProxiFyreService", "ProxiFyre"] { + if let Some(status) = host.service_status(name) { + return Some(DetectedService { + name: name.to_string(), + status: normalize_service_status(&status), + }); + } + } + + None +} + +fn normalize_service_status(status: &str) -> String { + status.trim().to_ascii_lowercase() +} + +fn service_status_is_running(status: &str) -> bool { + status.trim().eq_ignore_ascii_case("running") +} + fn engine_from_name(name: &str) -> Option { let normalized = name.to_ascii_lowercase(); if normalized.contains("proxifyre") { @@ -459,6 +578,17 @@ fn same_path(left: &Path, right: &Path) -> bool { .eq_ignore_ascii_case(&right.to_string_lossy()) } +fn powershell_text(script: &str) -> Option { + command_no_window("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|stdout| stdout.trim().to_string()) + .filter(|stdout| !stdout.is_empty()) +} + fn powershell_bool(script: &str) -> bool { command_no_window("powershell") .args(["-NoProfile", "-NonInteractive", "-Command", script]) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7f4ad2b..7e8c2d9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,6 +33,7 @@ pub fn run() { commands::save_target, commands::get_components, commands::get_proxifyre_setup_status, + commands::get_proxifyre_setup_progress, commands::get_singbox_status, commands::get_singbox_setup_status, commands::resolve_profile_preview, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 479af77..c114c87 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -6,7 +6,8 @@ use url::Url; pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1"; pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080; pub const DEFAULT_LOCAL_SINGBOX_SERVICE_NAME: &str = "ProxyWardenSingBox"; -pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str = r"C:\Program Files\ProxyWarden\sing-box"; +pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str = + r"C:\Program Files\ProxyWarden\components\sing-box"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] @@ -131,6 +132,10 @@ pub struct ComponentStatus { pub version: Option, pub path: Option, #[serde(default)] + pub service_name: Option, + #[serde(default)] + pub service_status: Option, + #[serde(default)] pub problems: Vec, #[serde(default)] pub actions: Vec, diff --git a/src-tauri/src/singbox_service.rs b/src-tauri/src/singbox_service.rs index f8ca240..5a21c0c 100644 --- a/src-tauri/src/singbox_service.rs +++ b/src-tauri/src/singbox_service.rs @@ -1,7 +1,7 @@ use crate::component_detection::DetectedSingBox; use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME}; use serde::{Deserialize, Serialize}; -use std::path::Path; +use std::path::{Path, PathBuf}; pub const WINSW_WRAPPER_FILE: &str = "ProxyWardenSingBox.exe"; @@ -56,9 +56,19 @@ pub struct ServiceCommandOutput { } pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBoxSetupStatus { + build_singbox_setup_status_with_install_root( + detected, + &PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT), + ) +} + +pub fn build_singbox_setup_status_with_install_root( + detected: Option<&DetectedSingBox>, + default_install_root: &Path, +) -> SingBoxSetupStatus { let install_root = detected .map(|singbox| singbox.install_dir.display().to_string()) - .unwrap_or_else(|| DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string()); + .unwrap_or_else(|| default_install_root.display().to_string()); let binary_item = match detected { Some(singbox) if singbox.binary_exists => SingBoxSetupItem { id: "sing-box-binary".to_string(), @@ -152,9 +162,10 @@ pub fn ensure_safe_singbox_install_dir(path: &Path) -> Result<(), String> { .unwrap_or_default() .to_ascii_lowercase(); - if file_name == "sing-box" - && (normalized.contains("\\proxywarden\\") || normalized.contains("\\proxywarden\\")) - { + let is_proxywarden_component = normalized.contains("\\proxywarden\\components\\"); + let is_legacy_proxywarden_child = normalized.ends_with("\\proxywarden\\sing-box"); + + if file_name == "sing-box" && (is_proxywarden_component || is_legacy_proxywarden_child) { return Ok(()); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 125947f..5851969 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyWarden", - "version": "1.0.2", + "version": "1.0.3", "identifier": "ru.dokops.proxywarden.windows", "build": { "beforeDevCommand": "npm run dev", @@ -27,7 +27,17 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": "nsis", + "resources": [ + "bundled/proxifyre", + "bundled/cleanup" + ], + "windows": { + "nsis": { + "installMode": "perMachine", + "installerHooks": "bundled/installer-hooks/proxywarden-hooks.nsh" + } + }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src-tauri/tests/command_tests.rs b/src-tauri/tests/command_tests.rs index 38f6556..478f91c 100644 --- a/src-tauri/tests/command_tests.rs +++ b/src-tauri/tests/command_tests.rs @@ -258,6 +258,9 @@ fn proxifyre_install_script_uses_resilient_download_helpers() { assert!(script.contains("function Get-SafeUriForLog([string]$uri)")); assert!(script.contains("function Invoke-ReleaseApi([string]$uri, [string]$label)")); + assert!(script.contains("function Resolve-ReleaseAsset(")); + assert!(script.contains("function Get-PinnedWindowsPacketFilterAsset([string]$arch)")); + assert!(script.contains("function Get-PinnedProxiFyreAsset([string]$arch)")); assert!( script.contains("function Invoke-Download([string]$uri, [string]$path, [string]$label)") ); @@ -266,20 +269,150 @@ fn proxifyre_install_script_uses_resilient_download_helpers() { assert!(script.contains("Invoke-CurlDownload $uri $partialPath")); assert!(script.contains("--user-agent 'proxywarden' --output $partialPath --url $uri")); assert!(script.contains("Move-Item -LiteralPath $partialPath -Destination $path -Force")); + assert!(script.contains("function Get-BundledAsset([string]$pattern, [string]$label)")); + assert!(script.contains("function Verify-BundledAssetHash([string]$path, [string]$label)")); assert!(script .contains("Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'")); - assert!(script.contains("Invoke-ReleaseApi $ndisapiReleaseApi 'Windows Packet Filter'")); + assert!(script + .contains("Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter'")); assert!(script.contains( "Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'" )); - assert!(script.contains("Invoke-ReleaseApi $proxifyreReleaseApi 'ProxiFyre'")); + assert!( + script.contains("Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre'") + ); assert!(script.contains( "Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'" )); + assert!(script.contains("github.com/wiresock/ndisapi/releases/download")); + assert!(script.contains("github.com/wiresock/proxifyre/releases/download")); + let packet_filter_step = script + .find("Write-ProxyWardenProgress 'install' 'packet-filter'") + .expect("packet filter install step should be present"); + let vc_runtime_step = script + .find("Write-ProxyWardenProgress 'install' 'vc-runtime'") + .expect("runtime install step should be present"); + let proxifyre_step = script + .find("Write-ProxyWardenProgress 'install' 'proxifyre'") + .expect("proxifyre install step should be present"); + assert!(packet_filter_step < vc_runtime_step); + assert!(vc_runtime_step < proxifyre_step); cleanup(&root); } +#[test] +fn proxifyre_install_script_prefers_bundled_assets_before_downloads() { + let root = test_root("proxifyre-install-script-bundled-assets"); + let bundle_dir = root.join("bundle"); + let script = commands::install_proxifyre_script_with_bundle( + &root.join("proxifyre-app-config.json"), + Some(&bundle_dir), + ); + + assert!(script.contains(&format!( + "$bundledAssetDir = '{}'", + bundle_dir.display().to_string().replace('\'', "''") + ))); + assert!(script.contains("$script:bundledAssetDir = [string]$bundledAssetDir")); + assert!(script.contains("function Get-BundledAssetDir")); + assert!(script.contains("$manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json')")); + assert!(script.contains("$script:bundledAssetManifest = Get-BundledAssetManifest")); + assert!(script.contains("Copy-BundledAsset $bundledNdisPath $ndisPath")); + assert!(script.contains("Copy-BundledAsset $bundledVcPath $vcRedistPath")); + assert!(script.contains("Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath")); + + let bundled_ndis = script + .find("Get-BundledAsset $ndisPattern 'Windows Packet Filter'") + .expect("ndis bundle check should be present"); + let online_ndis = script + .find("Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter'") + .expect("ndis online fallback should be present"); + assert!(bundled_ndis < online_ndis); + + let bundled_proxifyre = script + .find("Get-BundledAsset $proxifyrePattern 'ProxiFyre'") + .expect("proxifyre bundle check should be present"); + let online_proxifyre = script + .find("Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre'") + .expect("proxifyre online fallback should be present"); + assert!(bundled_proxifyre < online_proxifyre); + + cleanup(&root); +} + +#[test] +#[cfg(windows)] +fn proxifyre_uninstall_script_parses_as_powershell() { + let root = test_root("proxifyre-uninstall-script"); + fs::create_dir_all(&root).expect("test root should be created"); + let detected = DetectedProxyfier { + engine: ProxyfierEngine::ProxiFyre, + name: "ProxiFyre".to_string(), + install_dir: root.join("ProxiFyre"), + executable_path: root.join("ProxiFyre").join("ProxiFyre.exe"), + config_path: Some(root.join("ProxiFyre").join("app-config.json")), + running: false, + service_name: Some("ProxiFyreService".to_string()), + service_status: Some("stopped".to_string()), + }; + + let script = commands::wrap_elevated_package_script( + &commands::uninstall_proxifyre_script(Some(&detected)), + &root.join("uninstall.log"), + ); + let script_path = root.join("uninstall.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(), + "uninstall script should parse\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + cleanup(&root); +} + +#[test] +fn proxifyre_uninstall_script_removes_packet_filter_after_proxifyre() { + let detected = DetectedProxyfier { + engine: ProxyfierEngine::ProxiFyre, + name: "ProxiFyre".to_string(), + install_dir: PathBuf::from(r"C:\Tools\ProxiFyre"), + executable_path: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"), + config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")), + running: true, + service_name: Some("ProxiFyreService".to_string()), + service_status: Some("running".to_string()), + }; + let script = commands::uninstall_proxifyre_script(Some(&detected)); + + assert!(script.contains("function Resolve-MsiProductCode($program, [string]$label)")); + assert!(script.contains("Отказываюсь запускать произвольный UninstallString")); + assert!(script.contains("Start-Process -FilePath 'msiexec.exe'")); + assert!(script.contains("ArgumentList @('/x', $productCode, '/qn', '/norestart'")); + assert!(script.contains("Uninstall-MsiProgram $packetFilter 'Windows Packet Filter'")); + let proxifyre_step = script + .find("Write-ProxyWardenProgress 'uninstall' 'proxifyre'") + .expect("proxifyre uninstall step should be present"); + let packet_filter_step = script + .find("Write-ProxyWardenProgress 'uninstall' 'packet-filter'") + .expect("packet filter uninstall step should be present"); + assert!(proxifyre_step < packet_filter_step); +} + #[test] fn singbox_runner_preserves_installer_args_with_spaces() { let script = commands::singbox_installer_runner_script( @@ -287,15 +420,16 @@ fn singbox_runner_preserves_installer_args_with_spaces() { Path::new(r"C:\ProgramData\ProxyWarden\state\install.log"), &[ "-InstallRoot".to_string(), - r"C:\Program Files\ProxyWarden\sing-box".to_string(), + r"C:\Program Files\ProxyWarden\components\sing-box".to_string(), "-ServiceName".to_string(), "ProxyWardenSingBox".to_string(), "-Uninstall".to_string(), ], ); - assert!(script - .contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'")); + assert!(script.contains( + "$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\components\\sing-box'" + )); assert!(script.contains( "& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs" )); @@ -390,6 +524,7 @@ fn component_status_merges_detected_existing_proxifyre() { config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")), running: true, service_name: Some("ProxiFyreService".to_string()), + service_status: Some("running".to_string()), }), None, ); @@ -540,8 +675,8 @@ impl ProxyfierDetectionHost for DetectionHost { false } - fn service_running(&self, _service_name: &str) -> bool { - false + fn service_status(&self, _service_name: &str) -> Option { + None } fn registry_install_entries(&self) -> Vec { @@ -625,6 +760,8 @@ fn proxyfier_running() -> ComponentStatus { running: true, version: Some("2.2.1".to_string()), path: Some(r"C:\Tools\ProxiFyre".to_string()), + service_name: Some("ProxiFyreService".to_string()), + service_status: Some("running".to_string()), problems: Vec::new(), actions: vec!["Restart".to_string()], } @@ -639,6 +776,8 @@ fn singbox_missing() -> ComponentStatus { running: false, version: None, path: None, + service_name: Some("ProxyWardenSingBox".to_string()), + service_status: None, problems: vec!["Локальный sing-box не установлен".to_string()], actions: vec!["Установить локальный sing-box".to_string()], } diff --git a/src-tauri/tests/component_detection_tests.rs b/src-tauri/tests/component_detection_tests.rs index 30efbe2..a91d4fd 100644 --- a/src-tauri/tests/component_detection_tests.rs +++ b/src-tauri/tests/component_detection_tests.rs @@ -14,6 +14,7 @@ fn detects_existing_proxifyre_from_registry_install_location() { let host = MockHost::new() .with_registry("ProxiFyre", r"C:\Tools\ProxiFyre") .with_path(r"C:\Tools\ProxiFyre") + .with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe") .with_service("ProxiFyreService"); let detected = detect_proxyfier_install_with_host(&host) @@ -26,15 +27,30 @@ fn detects_existing_proxifyre_from_registry_install_location() { Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")) ); assert!(detected.running); + assert_eq!(detected.service_name, Some("ProxiFyreService".to_string())); + assert_eq!(detected.service_status, Some("running".to_string())); let component = proxyfier_component_from_detection(Some(&detected)); assert_eq!(component.state, ComponentState::Running); assert!(component.installed); assert!(component.running); assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string())); + assert_eq!(component.service_name, Some("ProxiFyreService".to_string())); + assert_eq!(component.service_status, Some("running".to_string())); assert!(component.problems.is_empty()); } +#[test] +fn ignores_empty_common_proxifyre_folder_without_executable() { + let host = MockHost::new().with_path(r"C:\Tools\ProxiFyre"); + + assert!(detect_proxyfier_install_with_host(&host).is_none()); + + let component = proxyfier_component_from_detection(None); + assert_eq!(component.state, ComponentState::Missing); + assert!(!component.installed); +} + #[test] fn ignores_plain_proxifier_install() { let host = MockHost::new() @@ -61,6 +77,25 @@ fn env_override_can_point_to_portable_proxifyre_install() { ); } +#[test] +fn reports_stopped_proxifyre_service_when_executable_exists() { + let host = MockHost::new() + .with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre") + .with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe") + .with_stopped_service("ProxiFyreService"); + + let detected = + detect_proxyfier_install_with_host(&host).expect("proxifyre executable should be detected"); + let component = proxyfier_component_from_detection(Some(&detected)); + + assert_eq!(component.state, ComponentState::Installed); + assert!(component.installed); + assert!(!component.running); + assert_eq!(component.service_name, Some("ProxiFyreService".to_string())); + assert_eq!(component.service_status, Some("stopped".to_string())); + assert!(component.problems.is_empty()); +} + #[test] fn missing_proxyfier_returns_install_action_status() { let component = proxyfier_component_from_detection(None); @@ -73,7 +108,7 @@ fn missing_proxyfier_returns_install_action_status() { #[test] fn detects_running_local_singbox_from_default_install_root_and_service() { let host = MockHost::new() - .with_path(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe") + .with_path(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe") .with_service("ProxyWardenSingBox"); let detected = @@ -81,7 +116,7 @@ fn detects_running_local_singbox_from_default_install_root_and_service() { assert_eq!( detected.executable_path, - PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe") + PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe") ); assert_eq!(detected.service_name, "ProxyWardenSingBox"); assert!(detected.running); @@ -92,7 +127,7 @@ fn detects_running_local_singbox_from_default_install_root_and_service() { assert!(component.running); assert_eq!( component.path, - Some(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe".to_string()) + Some(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe".to_string()) ); assert!(component.problems.is_empty()); } @@ -113,6 +148,11 @@ fn detects_stopped_local_singbox_from_env_override() { assert_eq!(component.state, ComponentState::Stopped); assert!(component.installed); assert!(!component.running); + assert_eq!( + component.service_name, + Some("ProxyWardenSingBox".to_string()) + ); + assert_eq!(component.service_status, Some("stopped".to_string())); assert!(component .problems .iter() @@ -135,7 +175,7 @@ struct MockHost { env: HashMap, paths: HashSet, processes: HashSet, - services: HashSet, + services: HashMap, registry: Vec, } @@ -160,7 +200,14 @@ impl MockHost { } fn with_service(mut self, service: &str) -> Self { - self.services.insert(service.to_ascii_lowercase()); + self.services + .insert(service.to_ascii_lowercase(), "running".to_string()); + self + } + + fn with_stopped_service(mut self, service: &str) -> Self { + self.services + .insert(service.to_ascii_lowercase(), "stopped".to_string()); self } @@ -188,8 +235,10 @@ impl ProxyfierDetectionHost for MockHost { self.processes.contains(&process_name.to_ascii_lowercase()) } - fn service_running(&self, service_name: &str) -> bool { - self.services.contains(&service_name.to_ascii_lowercase()) + fn service_status(&self, service_name: &str) -> Option { + self.services + .get(&service_name.to_ascii_lowercase()) + .cloned() } fn registry_install_entries(&self) -> Vec { diff --git a/src-tauri/tests/proxifyre_adapter_tests.rs b/src-tauri/tests/proxifyre_adapter_tests.rs index 6e03c94..d90718f 100644 --- a/src-tauri/tests/proxifyre_adapter_tests.rs +++ b/src-tauri/tests/proxifyre_adapter_tests.rs @@ -184,6 +184,8 @@ fn missing_singbox_component() -> ComponentStatus { running: false, version: None, path: None, + service_name: Some("ProxyWardenSingBox".to_string()), + service_status: None, problems: vec!["Local sing-box is not installed".to_string()], actions: vec!["Install Local sing-box".to_string()], } @@ -197,7 +199,9 @@ fn running_singbox_component() -> ComponentStatus { installed: true, running: true, version: Some("1.11.0".to_string()), - path: Some(r"C:\Tools\ProxyWarden\sing-box\sing-box.exe".to_string()), + path: Some(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe".to_string()), + service_name: Some("ProxyWardenSingBox".to_string()), + service_status: Some("running".to_string()), problems: Vec::new(), actions: vec!["Restart".to_string(), "Stop".to_string()], } diff --git a/src-tauri/tests/singbox_adapter_tests.rs b/src-tauri/tests/singbox_adapter_tests.rs index ade9e36..5a6c392 100644 --- a/src-tauri/tests/singbox_adapter_tests.rs +++ b/src-tauri/tests/singbox_adapter_tests.rs @@ -21,7 +21,7 @@ fn generates_selected_outbound_config_and_runs_check_when_binary_path_is_supplie let config = local_singbox_config("nl-1"); let cache = subscription_cache(); let checker = RecordingChecker::ok("configuration OK"); - let binary_path = Path::new(r"C:\Tools\ProxyWarden\sing-box\sing-box.exe"); + let binary_path = Path::new(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe"); let generated = adapter .generate_config( @@ -211,7 +211,7 @@ fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig { listen_host: "127.0.0.1".to_string(), listen_port: 1080, service_name: "ProxyWardenSingBox".to_string(), - install_root: r"C:\Program Files\ProxyWarden\sing-box".to_string(), + install_root: r"C:\Program Files\ProxyWarden\components\sing-box".to_string(), updated_at: Some("2026-07-07T10:00:00Z".to_string()), } } @@ -280,6 +280,8 @@ fn missing_singbox_component() -> ComponentStatus { running: false, version: None, path: None, + service_name: Some("ProxyWardenSingBox".to_string()), + service_status: None, problems: vec!["Local sing-box is not installed".to_string()], actions: vec!["Install Local sing-box".to_string()], } diff --git a/src-tauri/tests/singbox_service_tests.rs b/src-tauri/tests/singbox_service_tests.rs index 13ce026..6356930 100644 --- a/src-tauri/tests/singbox_service_tests.rs +++ b/src-tauri/tests/singbox_service_tests.rs @@ -47,10 +47,10 @@ noise #[test] fn safe_install_dir_allows_only_proxywarden_singbox_folder() { - assert!( - ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\ProxyWarden\sing-box")) - .is_ok() - ); + assert!(ensure_safe_singbox_install_dir(Path::new( + r"C:\Program Files\ProxyWarden\components\sing-box" + )) + .is_ok()); assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Windows")).is_err()); assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err()); } @@ -72,7 +72,7 @@ fn service_control_script_targets_named_service_and_action() { #[test] fn service_control_script_syncs_generated_config_before_start() { let source = Path::new(r"C:\ProgramData\ProxyWarden\generated\sing-box-config.json"); - let target = Path::new(r"C:\Program Files\ProxyWarden\sing-box\config.json"); + let target = Path::new(r"C:\Program Files\ProxyWarden\components\sing-box\config.json"); let script = service_control_script( SingBoxServiceAction::Start, "ProxyWardenSingBox", @@ -83,9 +83,9 @@ fn service_control_script_syncs_generated_config_before_start() { assert!(script.contains( "$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'" )); - assert!( - script.contains("$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'") - ); + assert!(script.contains( + "$configTarget = 'C:\\Program Files\\ProxyWarden\\components\\sing-box\\config.json'" + )); assert!(script.contains("Copy-Item -LiteralPath $configSource")); assert!(script.contains("'config_sync_failed'")); } @@ -116,10 +116,12 @@ fn install_singbox_script_parses_as_powershell() { fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> DetectedSingBox { DetectedSingBox { - install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box"), - executable_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe"), + install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box"), + executable_path: PathBuf::from( + r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe", + ), wrapper_path: PathBuf::from( - r"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe", + r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe", ), binary_exists, wrapper_exists, diff --git a/src-tauri/tests/storage_tests.rs b/src-tauri/tests/storage_tests.rs index ba27dcb..de057db 100644 --- a/src-tauri/tests/storage_tests.rs +++ b/src-tauri/tests/storage_tests.rs @@ -57,7 +57,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() { listen_host: "127.0.0.1".to_string(), listen_port: 1080, service_name: "ProxyWardenSingBox".to_string(), - install_root: r"C:\Program Files\ProxyWarden\sing-box".to_string(), + install_root: r"C:\Program Files\ProxyWarden\components\sing-box".to_string(), updated_at: Some("2026-07-07T10:00:00Z".to_string()), }; let cache = sample_subscription_cache(); @@ -370,6 +370,8 @@ fn sample_component() -> ComponentStatus { running: false, version: None, path: None, + service_name: Some("ProxiFyreService".to_string()), + service_status: None, problems: vec!["ProxiFyre не установлен".to_string()], actions: vec!["Установить ProxiFyre".to_string()], } diff --git a/src/api/tauriCommands.ts b/src/api/tauriCommands.ts index 04e6c5c..f33c88e 100644 --- a/src/api/tauriCommands.ts +++ b/src/api/tauriCommands.ts @@ -66,6 +66,15 @@ export interface ProxiFyreSetupStatus { items: ProxiFyreSetupItem[]; } +export interface ProxiFyreSetupProgress { + operation: 'idle' | 'install' | 'uninstall' | string; + status: 'idle' | 'running' | 'succeeded' | 'failed' | string; + activeStep?: string; + percent: number; + message: string; + updatedAt?: string; +} + export type SingBoxSetupItem = ProxiFyreSetupItem; export interface SingBoxSetupStatus { @@ -201,6 +210,10 @@ export function getProxiFyreSetupStatus(): Promise { return invoke('get_proxifyre_setup_status'); } +export function getProxiFyreSetupProgress(): Promise { + return invoke('get_proxifyre_setup_progress'); +} + export function getSingBoxStatus(): Promise { return invoke('get_singbox_status'); } diff --git a/src/app/App.tsx b/src/app/App.tsx index 88ea504..f29a7c2 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,6 +7,7 @@ import { forgetSingBoxSubscription, generateSingBoxConfig, getComponents, + getProxiFyreSetupProgress, getProxiFyreSetupStatus, getSavedState, getSingBoxSetupStatus, @@ -34,11 +35,13 @@ import { type PingServerResponse, type ProxyProbeResponse, type ProxyTargetCheckResponse, + type ProxiFyreSetupProgress, type ProxiFyreSetupStatus, type SingBoxSetupStatus, } from '../api/tauriCommands'; import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types'; import { BusyRing, Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui'; +import { ProxiFyreSetupStrip } from './components/ProxiFyreSetupStrip'; import { parseProxy, type ParsedProxy } from './lib/parseProxy'; import { getApplyReadiness } from './readiness'; import { serviceControlState } from './viewModel'; @@ -183,6 +186,7 @@ export function App() { const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null); const [components, setComponents] = useState(fallbackComponents); const [setupStatus, setSetupStatus] = useState(null); + const [setupProgress, setSetupProgress] = useState(null); const [singBoxStatus, setSingBoxStatus] = useState(null); const [singBoxSetupStatus, setSingBoxSetupStatus] = useState(null); const [subscriptionInput, setSubscriptionInput] = useState(''); @@ -269,14 +273,39 @@ export function App() { return () => window.clearTimeout(timer); }, [activeLogId]); + useEffect(() => { + if (serviceAction !== 'install' && serviceAction !== 'uninstall') return undefined; + + let cancelled = false; + const pollProgress = async () => { + try { + const progress = await getProxiFyreSetupProgress(); + if (!cancelled) setSetupProgress(progress); + } catch { + // Progress is best-effort; the main install/uninstall action still reports the real error. + } + }; + + void pollProgress(); + const timer = window.setInterval(() => void pollProgress(), 650); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [serviceAction]); + async function refresh() { setIsLoading(true); setIsDetectingComponents(true); try { - const snapshot = await getStartupSnapshot(); + const [snapshot, progress] = await Promise.all([ + getStartupSnapshot(), + getProxiFyreSetupProgress(), + ]); setAdminStatus(snapshot.adminStatus); setComponents(snapshot.components); setSetupStatus(snapshot.proxifyreSetupStatus); + setSetupProgress(progress); setSingBoxStatus(snapshot.singboxStatus); setSingBoxSetupStatus(snapshot.singboxSetupStatus); applySavedState( @@ -562,19 +591,25 @@ export function App() { async function installProxiFyrePackage() { setServiceAction('install'); setIsServiceMenuOpen(false); + setSetupProgress(localSetupProgress('install', 'packet-filter', 1, 'Готовлю установку сетевого драйвера.')); startServiceVisual(); try { await nextFrame(); const component = await installProxiFyre(); - const detectedSetupStatus = await getProxiFyreSetupStatus(); + const [detectedSetupStatus, detectedProgress] = await Promise.all([ + getProxiFyreSetupStatus(), + getProxiFyreSetupProgress(), + ]); setComponents((current) => upsertComponent(current, component)); setSetupStatus(detectedSetupStatus); + setSetupProgress(detectedProgress); showNotice({ kind: 'success', title: 'ProxiFyre установлен', text: proxyfierDetails(component, false), }); } catch (error) { + void getProxiFyreSetupProgress().then(setSetupProgress).catch(() => undefined); showNotice({ kind: 'error', title: 'ProxiFyre не установлен', @@ -588,25 +623,31 @@ export function App() { async function uninstallProxiFyrePackage() { const confirmed = window.confirm( - 'Удалить ProxiFyre с компьютера? Будет удалена служба и папка установки ProxiFyre.', + 'Удалить ProxiFyre и Windows Packet Filter с компьютера? Это остановит службу, удалит папку ProxiFyre и сетевой драйвер. Другие программы WireSock могут перестать работать до повторной установки драйвера.', ); if (!confirmed) return; setServiceAction('uninstall'); setIsServiceMenuOpen(false); + setSetupProgress(localSetupProgress('uninstall', 'proxifyre', 1, 'Готовлю удаление ProxiFyre и сетевого драйвера.')); startServiceVisual(); try { await nextFrame(); const component = await uninstallProxiFyre(); - const detectedSetupStatus = await getProxiFyreSetupStatus(); + const [detectedSetupStatus, detectedProgress] = await Promise.all([ + getProxiFyreSetupStatus(), + getProxiFyreSetupProgress(), + ]); setComponents((current) => upsertComponent(current, component)); setSetupStatus(detectedSetupStatus); + setSetupProgress(detectedProgress); showNotice({ kind: 'success', title: 'ProxiFyre удален', - text: 'Служба и папка установки ProxiFyre удалены.', + text: 'Служба, папка установки ProxiFyre и Windows Packet Filter удалены.', }); } catch (error) { + void getProxiFyreSetupProgress().then(setSetupProgress).catch(() => undefined); showNotice({ kind: 'error', title: 'ProxiFyre не удален', @@ -995,8 +1036,21 @@ export function App() { function renderProxiFyreCard() { const state = serviceControlState(proxyfier, isDetectingComponents); const visualState = serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : null; - const primaryAction = proxyfier?.installed + const packetFilterInstalled = Boolean( + setupStatus?.items.some((item) => item.id === 'packet-filter' && item.installed), + ); + const canCleanupSetup = Boolean(proxyfier?.installed || packetFilterInstalled); + const shouldInstallProxiFyre = !proxyfier?.installed || !proxyfier.serviceStatus; + const primaryAction = shouldInstallProxiFyre ? { + label: proxyfier?.installed ? 'Переустановить' : 'Установить', + onClick: () => void installProxiFyrePackage(), + variant: 'primary' as const, + loading: serviceAction === 'install', + loadingLabel: 'Устанавливаю', + disabled: isDetectingComponents || Boolean(serviceAction), + } + : { label: proxyfier.running ? 'Остановить' : 'Запустить', onClick: () => void setProxiFyreServiceRunning(!proxyfier.running), variant: proxyfier.running ? 'danger' as const : 'neutral' as const, @@ -1005,14 +1059,6 @@ export function App() { ? 'Перезапускаю' : serviceAction === 'start' ? 'Запускаю' : 'Останавливаю', disabled: isDetectingComponents || Boolean(serviceAction), - } - : { - label: 'Установить', - onClick: () => void installProxiFyrePackage(), - variant: 'primary' as const, - loading: serviceAction === 'install', - loadingLabel: 'Устанавливаю', - disabled: isDetectingComponents || Boolean(serviceAction), }; return ( @@ -1023,13 +1069,13 @@ export function App() { title={proxyfierTitle(proxyfier, isDetectingComponents)} detail={proxyfierDetails(proxyfier, isDetectingComponents)} primaryAction={primaryAction} - menu={proxyfier?.installed ? { + menu={canCleanupSetup ? { label: 'Дополнительные действия ProxiFyre', open: isServiceMenuOpen, onOpenChange: setIsServiceMenuOpen, disabled: isDetectingComponents || Boolean(serviceAction), items: [{ - label: serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre', + label: serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre и драйвер', danger: true, disabled: Boolean(serviceAction), onClick: () => void uninstallProxiFyrePackage(), @@ -1040,22 +1086,7 @@ export function App() { } function renderProxiFyreSetupStrip() { - const stripItems = setupStatus?.items ?? proxifyreSetupPlaceholders(); - - return ( -
- Состав -
- {stripItems.map((item) => ( -
-
- ))} -
-
- ); + return ; } function renderAppsSection() { @@ -2096,9 +2127,7 @@ function summaryRouteChainSegments(input: RouteChainInput, flow: SummaryRouteFlo value: 'напрямую', tone: 'warning', details: [ - input.proxyfier?.installed - ? 'ProxiFyre остановлен, поэтому выбранные приложения не перехватываются.' - : 'ProxiFyre не найден, поэтому выбранные приложения не перехватываются.', + proxyfierBypassReason(input.proxyfier), 'Пакеты идут обычным системным маршрутом без SOCKS5.', ], }, @@ -2136,7 +2165,7 @@ function routeChainSegments(input: RouteChainInput): RouteChainSegment[] { { id: 'proxifyre', label: 'ProxiFyre', - value: input.isDetectingComponents ? 'проверяю' : input.proxyfier?.running ? 'запущен' : input.proxyfier?.installed ? 'остановлен' : 'не найден', + value: proxyfierCompactStatus(input.proxyfier, input.isDetectingComponents), tone: componentChainTone(input.proxyfier, input.isDetectingComponents), details: [ proxyfierTitle(input.proxyfier, input.isDetectingComponents), @@ -2449,25 +2478,26 @@ function itemIcon(type: DraftItemType) { return ; } -function proxifyreSetupPlaceholders(): ProxiFyreSetupStatus['items'] { - return [ - { id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' }, - { id: 'packet-filter', name: 'Сетевой драйвер', installed: false, details: 'Проверяю' }, - { id: 'proxifyre', name: 'Клиент ProxiFyre', installed: false, details: 'Проверяю' }, - ]; -} - -function setupItemUserName(id: string, fallbackName: string) { - if (id === 'vc-runtime') return 'Среда запуска'; - if (id === 'packet-filter') return 'Сетевой драйвер'; - if (id === 'proxifyre') return 'Клиент ProxiFyre'; - return fallbackName; +function localSetupProgress( + operation: 'install' | 'uninstall', + activeStep: string, + percent: number, + message: string, +): ProxiFyreSetupProgress { + return { + operation, + status: 'running', + activeStep, + percent, + message, + updatedAt: new Date().toISOString(), + }; } function setupItemShortStatus(item: ProxiFyreSetupStatus['items'][number]) { if (item.details === 'Проверяю') return 'проверяю'; if (!item.installed) return 'нужно установить'; - if (item.id === 'proxifyre') return item.version?.includes('не запущена') ? 'остановлен' : 'запущен'; + if (item.id === 'proxifyre') return proxifyreSetupServiceSummary(item.version); return 'готово'; } @@ -2489,16 +2519,54 @@ function profileInputFromProfile(profile: Profile, enabled: boolean) { function proxyfierTitle(component: ComponentStatus | undefined, checking: boolean) { if (checking) return 'Проверяю ProxiFyre'; if (!component) return 'ProxiFyre не проверен'; - if (component.running) return 'ProxiFyre найден и запущен'; - if (component.installed) return 'ProxiFyre найден'; + if (component.installed) return `ProxiFyre найден, служба ${serviceStatusLabel(component.serviceStatus)}`; return 'ProxiFyre не найден'; } function proxyfierDetails(component: ComponentStatus | undefined, checking: boolean) { if (checking) return 'Ищу установленный клиент и состояние службы.'; if (!component) return 'Нажми «Обновить», чтобы проверить компьютер.'; - if (component.path) return component.path; - return component.problems[0] ?? 'Путь установки не найден.'; + const service = proxyfierServiceDetails(component); + if (component.path) return service ? `${component.path} · ${service}` : component.path; + const problem = component.problems[0] ?? 'Путь установки не найден.'; + return service ? `${problem} · ${service}` : problem; +} + +function proxyfierCompactStatus(component: ComponentStatus | undefined, checking: boolean) { + if (checking) return 'проверяю'; + if (!component?.installed) return 'не найден'; + return `служба ${serviceStatusLabel(component.serviceStatus)}`; +} + +function proxyfierBypassReason(component: ComponentStatus | undefined) { + if (!component?.installed) { + return 'ProxiFyre не найден, поэтому выбранные приложения не перехватываются.'; + } + return `Служба ProxiFyre ${serviceStatusLabel(component.serviceStatus)}, поэтому выбранные приложения не перехватываются.`; +} + +function proxyfierServiceDetails(component: ComponentStatus) { + const serviceName = component.serviceName ?? 'ProxiFyreService'; + return `служба ${serviceName}: ${serviceStatusLabel(component.serviceStatus)}`; +} + +function serviceStatusLabel(status: string | undefined) { + const normalized = status?.trim().toLowerCase(); + if (!normalized) return 'не установлена'; + if (normalized === 'running') return 'запущена'; + if (normalized === 'stopped') return 'остановлена'; + if (normalized === 'startpending' || normalized === 'start_pending') return 'запускается'; + if (normalized === 'stoppending' || normalized === 'stop_pending') return 'останавливается'; + if (normalized === 'paused') return 'на паузе'; + return status; +} + +function proxifyreSetupServiceSummary(version: string | undefined) { + const normalized = version?.trim().toLowerCase() ?? ''; + if (normalized.includes('не установлена')) return 'служба не установлена'; + if (normalized.includes('остановлена') || normalized.includes('не запущена')) return 'служба остановлена'; + if (normalized.includes('запущена')) return 'служба запущена'; + return 'готово'; } function singBoxDetails( diff --git a/src/app/components/ProxiFyreSetupStrip.tsx b/src/app/components/ProxiFyreSetupStrip.tsx new file mode 100644 index 0000000..edd97b3 --- /dev/null +++ b/src/app/components/ProxiFyreSetupStrip.tsx @@ -0,0 +1,109 @@ +import type { ProxiFyreSetupProgress, ProxiFyreSetupStatus } from '../../api/tauriCommands'; + +interface ProxiFyreSetupStripProps { + setupStatus: ProxiFyreSetupStatus | null; + progress: ProxiFyreSetupProgress | null; +} + +const SETUP_PLACEHOLDERS: ProxiFyreSetupStatus['items'] = [ + { id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' }, + { id: 'packet-filter', name: 'Сетевой драйвер', installed: false, details: 'Проверяю' }, + { id: 'proxifyre', name: 'Клиент ProxiFyre', installed: false, details: 'Проверяю' }, +]; + +export function ProxiFyreSetupStrip({ setupStatus, progress }: ProxiFyreSetupStripProps) { + const stripItems = setupStatus?.items ?? SETUP_PLACEHOLDERS; + const visibleProgress = isVisibleProgress(progress) ? progress : null; + const progressTone = visibleProgress?.status === 'failed' ? 'failed' : 'running'; + const percent = clampPercent(visibleProgress?.percent ?? 0); + + return ( +
+ Состав +
+ {stripItems.map((item) => ( +
+
+ ))} +
+ {visibleProgress ? ( +
+
+ +
+ {visibleProgress.message} +
+ ) : null} +
+ ); +} + +function setupItemClass( + item: ProxiFyreSetupStatus['items'][number], + progress: ProxiFyreSetupProgress | null, +) { + if (progress?.activeStep === item.id) { + if (progress.status === 'failed') return 'failed'; + return 'active'; + } + if (item.installed) return 'installed'; + return 'missing'; +} + +function setupItemUserName(id: string, fallbackName: string) { + if (id === 'vc-runtime') return 'Среда запуска'; + if (id === 'packet-filter') return 'Сетевой драйвер'; + if (id === 'proxifyre') return 'Клиент ProxiFyre'; + return fallbackName; +} + +function setupItemShortStatus( + item: ProxiFyreSetupStatus['items'][number], + progress: ProxiFyreSetupProgress | null, +) { + if (progress?.activeStep === item.id) { + if (progress.status === 'failed') return 'ошибка'; + if (progress.status === 'succeeded') return progress.operation === 'uninstall' ? 'удалено' : 'готово'; + return 'в процессе'; + } + if (item.details === 'Проверяю') return 'проверяю'; + if (!item.installed) return progress?.operation === 'uninstall' && progress.status === 'succeeded' + ? 'удалено' + : 'нужно установить'; + if (item.id === 'proxifyre') return proxifyreSetupServiceSummary(item.version); + return 'готово'; +} + +function proxifyreSetupServiceSummary(version: string | undefined) { + const normalized = version?.trim().toLowerCase() ?? ''; + if (normalized.includes('не установлена')) return 'служба не установлена'; + if (normalized.includes('остановлена') || normalized.includes('не запущена')) return 'служба остановлена'; + if (normalized.includes('запущена')) return 'служба запущена'; + return 'готово'; +} + +function isVisibleProgress(progress: ProxiFyreSetupProgress | null): progress is ProxiFyreSetupProgress { + if (!progress || progress.status === 'idle') return false; + return progress.status === 'running' || progress.status === 'failed'; +} + +function clampPercent(value: number) { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, Math.round(value))); +} diff --git a/src/app/viewModel.ts b/src/app/viewModel.ts index 2e337ac..5f24582 100644 --- a/src/app/viewModel.ts +++ b/src/app/viewModel.ts @@ -9,6 +9,7 @@ export function serviceControlState( if (!component) return 'missing'; if (component.state === 'error') return 'error'; if (component.running) return 'running'; + if (component.installed && !component.serviceStatus) return 'installed'; if (component.installed) return 'stopped'; return 'missing'; } diff --git a/src/domain/types.ts b/src/domain/types.ts index c0a446e..dd0fd6a 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -64,6 +64,8 @@ export interface ComponentStatus { running: boolean; version?: string; path?: string; + serviceName?: string; + serviceStatus?: string; problems: string[]; actions: string[]; } diff --git a/src/styles/app.css b/src/styles/app.css index fb9b98d..a76bfb6 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1514,6 +1514,10 @@ button.summary-card:hover { padding: 7px 10px; } +.setup-strip.with-progress { + row-gap: 8px; +} + .setup-strip-title { color: #8d99ae; font-size: 12px; @@ -1554,6 +1558,26 @@ button.summary-card:hover { box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.11); } +.setup-strip-item.active { + border-color: #60a5fa; + background: #122033; +} + +.setup-strip-item.active .setup-strip-dot { + background: #60a5fa; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.14); +} + +.setup-strip-item.failed { + border-color: #ef4444; + background: #26151a; +} + +.setup-strip-item.failed .setup-strip-dot { + background: #ef4444; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12); +} + .setup-strip-item strong { font-size: 12px; white-space: nowrap; @@ -1565,6 +1589,43 @@ button.summary-card:hover { white-space: nowrap; } +.setup-progress { + display: grid; + grid-column: 1 / -1; + grid-template-columns: minmax(96px, 160px) minmax(0, 1fr); + gap: 9px; + align-items: center; +} + +.setup-progress-track { + overflow: hidden; + height: 6px; + border-radius: 999px; + background: #202a38; +} + +.setup-progress-fill { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: #60a5fa; + transition: width 180ms ease; +} + +.setup-progress--failed .setup-progress-fill { + background: #ef4444; +} + +.setup-progress-message { + min-width: 0; + overflow: hidden; + color: #9aa8bd; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + .service-actions { position: relative; display: flex;