Refine ProxyWarden routing and config flows

This commit is contained in:
2026-07-10 21:15:17 +03:00
parent 9fd0a8c0b9
commit dbba3806cc
31 changed files with 1823 additions and 191 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "proxywarden", "name": "proxywarden",
"version": "1.0.2", "version": "1.0.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxywarden", "name": "proxywarden",
"version": "1.0.2", "version": "1.0.3",
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxywarden", "name": "proxywarden",
"version": "1.0.2", "version": "1.0.3",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Standalone Windows desktop proxy management app for ProxyWarden.", "description": "Standalone Windows desktop proxy management app for ProxyWarden.",
+1 -1
View File
@@ -1,5 +1,5 @@
param( param(
[string]$InstallRoot = "C:\Tools\ProxiFyre", [string]$InstallRoot = "C:\Program Files\ProxyWarden\components\ProxiFyre",
[string]$PackagePath = "", [string]$PackagePath = "",
[string]$ServiceName = "ProxiFyreService", [string]$ServiceName = "ProxiFyreService",
[switch]$PlanOnly, [switch]$PlanOnly,
+2 -2
View File
@@ -1,5 +1,5 @@
param( param(
[string]$InstallRoot = "C:\Program Files\ProxyWarden\sing-box", [string]$InstallRoot = "C:\Program Files\ProxyWarden\components\sing-box",
[string]$ServiceName = "ProxyWardenSingBox", [string]$ServiceName = "ProxyWardenSingBox",
[string]$ConfigSource = "C:\ProgramData\ProxyWarden\generated\sing-box-config.json", [string]$ConfigSource = "C:\ProgramData\ProxyWarden\generated\sing-box-config.json",
[switch]$PlanOnly, [switch]$PlanOnly,
@@ -77,7 +77,7 @@ function Test-SafeInstallRoot {
$leaf = Split-Path -Leaf $full $leaf = Split-Path -Leaf $full
$parent = Split-Path -Parent $full $parent = Split-Path -Parent $full
if ($leaf -ne "sing-box") { return $false } if ($leaf -ne "sing-box") { return $false }
return $parent -match "\\ProxyWarden$|\\proxywarden$" return $parent -match "\\ProxyWarden\\components$|\\proxywarden\\components$|\\ProxyWarden$|\\proxywarden$"
} }
function Backup-File { function Backup-File {
+143
View File
@@ -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"
+1 -1
View File
@@ -2314,7 +2314,7 @@ dependencies = [
[[package]] [[package]]
name = "proxywarden" name = "proxywarden"
version = "1.0.2" version = "1.0.3"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"percent-encoding", "percent-encoding",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "proxywarden" name = "proxywarden"
version = "1.0.2" version = "1.0.3"
description = "Standalone Windows desktop proxy management app for ProxyWarden." description = "Standalone Windows desktop proxy management app for ProxyWarden."
authors = ["ProxyWarden"] authors = ["ProxyWarden"]
edition = "2021" edition = "2021"
@@ -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
}
@@ -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
+31
View File
@@ -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"
}
]
}
Binary file not shown.
+664 -59
View File
File diff suppressed because it is too large Load Diff
+156 -26
View File
@@ -9,6 +9,10 @@ use std::{
path::{Path, PathBuf}, 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyfierEngine { pub enum ProxyfierEngine {
ProxiFyre, ProxiFyre,
@@ -23,6 +27,13 @@ pub struct DetectedProxyfier {
pub config_path: Option<PathBuf>, pub config_path: Option<PathBuf>,
pub running: bool, pub running: bool,
pub service_name: Option<String>, pub service_name: Option<String>,
pub service_status: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectedService {
pub name: String,
pub status: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -50,7 +61,12 @@ pub trait ProxyfierDetectionHost {
fn process_running(&self, process_name: &str) -> bool; fn process_running(&self, process_name: &str) -> bool;
fn service_running(&self, service_name: &str) -> bool; fn service_status(&self, service_name: &str) -> Option<String>;
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<RegistryInstallEntry>; fn registry_install_entries(&self) -> Vec<RegistryInstallEntry>;
} }
@@ -77,13 +93,13 @@ impl ProxyfierDetectionHost for SystemProxyfierDetectionHost {
powershell_bool(&script) powershell_bool(&script)
} }
fn service_running(&self, service_name: &str) -> bool { fn service_status(&self, service_name: &str) -> Option<String> {
let script = format!( 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) escape_powershell_single(service_name)
); );
powershell_bool(&script) powershell_text(&script).map(|status| status.to_ascii_lowercase())
} }
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> { fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
@@ -95,16 +111,53 @@ pub fn detect_proxyfier_install() -> Option<DetectedProxyfier> {
detect_proxyfier_install_with_host(&SystemProxyfierDetectionHost) detect_proxyfier_install_with_host(&SystemProxyfierDetectionHost)
} }
pub fn app_install_dir_from_current_exe() -> Option<PathBuf> {
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( pub fn detect_proxyfier_install_with_host(
host: &impl ProxyfierDetectionHost, host: &impl ProxyfierDetectionHost,
) -> Option<DetectedProxyfier> { ) -> Option<DetectedProxyfier> {
let proxifyre_running = host.process_running("ProxiFyre.exe") let detected_service = detect_proxifyre_service(host);
|| host.service_running("ProxiFyreService") let proxifyre_running = detected_service
|| host.service_running("ProxiFyre"); .as_ref()
.is_some_and(|service| service_status_is_running(&service.status));
proxyfier_candidates(host) proxyfier_candidates(host)
.into_iter() .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() .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 { ComponentStatus {
id: ComponentId::Proxyfier, id: ComponentId::Proxyfier,
@@ -169,10 +231,16 @@ fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatu
installed: true, installed: true,
running: proxyfier.running, running: proxyfier.running,
version: Some(match proxyfier.engine { 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()), path: Some(proxyfier.install_dir.display().to_string()),
problems: Vec::new(), service_name,
service_status,
problems,
actions, actions,
} }
} }
@@ -186,6 +254,8 @@ fn missing_proxyfier_component() -> ComponentStatus {
running: false, running: false,
version: None, version: None,
path: None, path: None,
service_name: service_name(&ProxyfierEngine::ProxiFyre).map(str::to_string),
service_status: None,
problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()], problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()],
actions: vec!["Установить ProxiFyre".to_string()], actions: vec!["Установить ProxiFyre".to_string()],
} }
@@ -224,6 +294,15 @@ fn detected_singbox_component(singbox: &DetectedSingBox) -> ComponentStatus {
running: singbox.running, running: singbox.running,
version: Some("sing-box найден".to_string()), version: Some("sing-box найден".to_string()),
path: Some(singbox.executable_path.display().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, problems,
actions, actions,
} }
@@ -238,6 +317,8 @@ fn missing_singbox_component() -> ComponentStatus {
running: false, running: false,
version: None, version: None,
path: None, path: None,
service_name: Some(DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()),
service_status: None,
problems: Vec::new(), problems: Vec::new(),
actions: vec!["Установить Local sing-box".to_string()], actions: vec!["Установить Local sing-box".to_string()],
} }
@@ -255,21 +336,19 @@ impl ProxyfierCandidate {
self, self,
host: &impl ProxyfierDetectionHost, host: &impl ProxyfierDetectionHost,
proxifyre_running: bool, proxifyre_running: bool,
detected_service: Option<&DetectedService>,
) -> Option<DetectedProxyfier> { ) -> Option<DetectedProxyfier> {
let executable_path = self.install_dir.join(executable_name(&self.engine)); let executable_path = self.install_dir.join(executable_name(&self.engine));
let config_path = config_path(&self.engine, &self.install_dir); let config_path = config_path(&self.engine, &self.install_dir);
let exists = host.path_exists(&self.install_dir) if !host.path_exists(&executable_path) {
|| host.path_exists(&executable_path)
|| config_path
.as_ref()
.is_some_and(|path| host.path_exists(path));
if !exists {
return None; return None;
} }
Some(DetectedProxyfier { 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, engine: self.engine,
name: self.name, name: self.name,
install_dir: self.install_dir, install_dir: self.install_dir,
@@ -290,6 +369,14 @@ fn proxyfier_candidates(host: &impl ProxyfierDetectionHost) -> Vec<ProxyfierCand
"ProxiFyre", "ProxiFyre",
"PROXYWARDEN_PROXIFYRE_ROOT", "PROXYWARDEN_PROXIFYRE_ROOT",
); );
push_candidate(
&mut candidates,
ProxyfierCandidate {
engine: ProxyfierEngine::ProxiFyre,
name: "ProxiFyre".to_string(),
install_dir: default_proxifyre_install_dir(),
},
);
for entry in host.registry_install_entries() { for entry in host.registry_install_entries() {
if let Some(engine) = engine_from_name(&entry.display_name) { if let Some(engine) = engine_from_name(&entry.display_name) {
let install_dir = entry let install_dir = entry
@@ -350,11 +437,17 @@ fn push_candidate(candidates: &mut Vec<ProxyfierCandidate>, candidate: Proxyfier
} }
fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) -> Vec<PathBuf> { fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) -> Vec<PathBuf> {
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"] { for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
if let Some(root) = host.env_var(env_name) { 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<PathBuf> {
if let Some(path) = host.env_var("PROXYWARDEN_SINGBOX_ROOT") { if let Some(path) = host.env_var("PROXYWARDEN_SINGBOX_ROOT") {
push_path_candidate(&mut candidates, PathBuf::from(path)); push_path_candidate(&mut candidates, PathBuf::from(path));
} }
push_path_candidate(&mut candidates, default_singbox_install_dir());
push_path_candidate( push_path_candidate(
&mut candidates, &mut candidates,
PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT), 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"] { for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
if let Some(root) = host.env_var(env_name) { if let Some(root) = host.env_var(env_name) {
push_path_candidate( push_path_candidate(
&mut candidates, &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<DetectedService> {
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<ProxyfierEngine> { fn engine_from_name(name: &str) -> Option<ProxyfierEngine> {
let normalized = name.to_ascii_lowercase(); let normalized = name.to_ascii_lowercase();
if normalized.contains("proxifyre") { if normalized.contains("proxifyre") {
@@ -459,6 +578,17 @@ fn same_path(left: &Path, right: &Path) -> bool {
.eq_ignore_ascii_case(&right.to_string_lossy()) .eq_ignore_ascii_case(&right.to_string_lossy())
} }
fn powershell_text(script: &str) -> Option<String> {
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 { fn powershell_bool(script: &str) -> bool {
command_no_window("powershell") command_no_window("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script]) .args(["-NoProfile", "-NonInteractive", "-Command", script])
+1
View File
@@ -33,6 +33,7 @@ pub fn run() {
commands::save_target, commands::save_target,
commands::get_components, commands::get_components,
commands::get_proxifyre_setup_status, commands::get_proxifyre_setup_status,
commands::get_proxifyre_setup_progress,
commands::get_singbox_status, commands::get_singbox_status,
commands::get_singbox_setup_status, commands::get_singbox_setup_status,
commands::resolve_profile_preview, commands::resolve_profile_preview,
+6 -1
View File
@@ -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_HOST: &str = "127.0.0.1";
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080; pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
pub const DEFAULT_LOCAL_SINGBOX_SERVICE_NAME: &str = "ProxyWardenSingBox"; 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
@@ -131,6 +132,10 @@ pub struct ComponentStatus {
pub version: Option<String>, pub version: Option<String>,
pub path: Option<String>, pub path: Option<String>,
#[serde(default)] #[serde(default)]
pub service_name: Option<String>,
#[serde(default)]
pub service_status: Option<String>,
#[serde(default)]
pub problems: Vec<String>, pub problems: Vec<String>,
#[serde(default)] #[serde(default)]
pub actions: Vec<String>, pub actions: Vec<String>,
+16 -5
View File
@@ -1,7 +1,7 @@
use crate::component_detection::DetectedSingBox; use crate::component_detection::DetectedSingBox;
use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME}; use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::Path; use std::path::{Path, PathBuf};
pub const WINSW_WRAPPER_FILE: &str = "ProxyWardenSingBox.exe"; 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 { 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 let install_root = detected
.map(|singbox| singbox.install_dir.display().to_string()) .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 { let binary_item = match detected {
Some(singbox) if singbox.binary_exists => SingBoxSetupItem { Some(singbox) if singbox.binary_exists => SingBoxSetupItem {
id: "sing-box-binary".to_string(), id: "sing-box-binary".to_string(),
@@ -152,9 +162,10 @@ pub fn ensure_safe_singbox_install_dir(path: &Path) -> Result<(), String> {
.unwrap_or_default() .unwrap_or_default()
.to_ascii_lowercase(); .to_ascii_lowercase();
if file_name == "sing-box" let is_proxywarden_component = normalized.contains("\\proxywarden\\components\\");
&& (normalized.contains("\\proxywarden\\") || normalized.contains("\\proxywarden\\")) 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(()); return Ok(());
} }
+12 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyWarden", "productName": "ProxyWarden",
"version": "1.0.2", "version": "1.0.3",
"identifier": "ru.dokops.proxywarden.windows", "identifier": "ru.dokops.proxywarden.windows",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -27,7 +27,17 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "nsis",
"resources": [
"bundled/proxifyre",
"bundled/cleanup"
],
"windows": {
"nsis": {
"installMode": "perMachine",
"installerHooks": "bundled/installer-hooks/proxywarden-hooks.nsh"
}
},
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+146 -7
View File
@@ -258,6 +258,9 @@ fn proxifyre_install_script_uses_resilient_download_helpers() {
assert!(script.contains("function Get-SafeUriForLog([string]$uri)")); assert!(script.contains("function Get-SafeUriForLog([string]$uri)"));
assert!(script.contains("function Invoke-ReleaseApi([string]$uri, [string]$label)")); 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!( assert!(
script.contains("function Invoke-Download([string]$uri, [string]$path, [string]$label)") 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("Invoke-CurlDownload $uri $partialPath"));
assert!(script.contains("--user-agent 'proxywarden' --output $partialPath --url $uri")); assert!(script.contains("--user-agent 'proxywarden' --output $partialPath --url $uri"));
assert!(script.contains("Move-Item -LiteralPath $partialPath -Destination $path -Force")); 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 assert!(script
.contains("Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'")); .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( assert!(script.contains(
"Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'" "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( assert!(script.contains(
"Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'" "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); 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] #[test]
fn singbox_runner_preserves_installer_args_with_spaces() { fn singbox_runner_preserves_installer_args_with_spaces() {
let script = commands::singbox_installer_runner_script( 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"), Path::new(r"C:\ProgramData\ProxyWarden\state\install.log"),
&[ &[
"-InstallRoot".to_string(), "-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(), "-ServiceName".to_string(),
"ProxyWardenSingBox".to_string(), "ProxyWardenSingBox".to_string(),
"-Uninstall".to_string(), "-Uninstall".to_string(),
], ],
); );
assert!(script assert!(script.contains(
.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'")); "$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\components\\sing-box'"
));
assert!(script.contains( assert!(script.contains(
"& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs" "& 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")), config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")),
running: true, running: true,
service_name: Some("ProxiFyreService".to_string()), service_name: Some("ProxiFyreService".to_string()),
service_status: Some("running".to_string()),
}), }),
None, None,
); );
@@ -540,8 +675,8 @@ impl ProxyfierDetectionHost for DetectionHost {
false false
} }
fn service_running(&self, _service_name: &str) -> bool { fn service_status(&self, _service_name: &str) -> Option<String> {
false None
} }
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> { fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
@@ -625,6 +760,8 @@ fn proxyfier_running() -> ComponentStatus {
running: true, running: true,
version: Some("2.2.1".to_string()), version: Some("2.2.1".to_string()),
path: Some(r"C:\Tools\ProxiFyre".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(), problems: Vec::new(),
actions: vec!["Restart".to_string()], actions: vec!["Restart".to_string()],
} }
@@ -639,6 +776,8 @@ fn singbox_missing() -> ComponentStatus {
running: false, running: false,
version: None, version: None,
path: None, path: None,
service_name: Some("ProxyWardenSingBox".to_string()),
service_status: None,
problems: vec!["Локальный sing-box не установлен".to_string()], problems: vec!["Локальный sing-box не установлен".to_string()],
actions: vec!["Установить локальный sing-box".to_string()], actions: vec!["Установить локальный sing-box".to_string()],
} }
+56 -7
View File
@@ -14,6 +14,7 @@ fn detects_existing_proxifyre_from_registry_install_location() {
let host = MockHost::new() let host = MockHost::new()
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre") .with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre") .with_path(r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
.with_service("ProxiFyreService"); .with_service("ProxiFyreService");
let detected = detect_proxyfier_install_with_host(&host) 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")) Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json"))
); );
assert!(detected.running); 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)); let component = proxyfier_component_from_detection(Some(&detected));
assert_eq!(component.state, ComponentState::Running); assert_eq!(component.state, ComponentState::Running);
assert!(component.installed); assert!(component.installed);
assert!(component.running); assert!(component.running);
assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string())); 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()); 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] #[test]
fn ignores_plain_proxifier_install() { fn ignores_plain_proxifier_install() {
let host = MockHost::new() 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] #[test]
fn missing_proxyfier_returns_install_action_status() { fn missing_proxyfier_returns_install_action_status() {
let component = proxyfier_component_from_detection(None); let component = proxyfier_component_from_detection(None);
@@ -73,7 +108,7 @@ fn missing_proxyfier_returns_install_action_status() {
#[test] #[test]
fn detects_running_local_singbox_from_default_install_root_and_service() { fn detects_running_local_singbox_from_default_install_root_and_service() {
let host = MockHost::new() 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"); .with_service("ProxyWardenSingBox");
let detected = let detected =
@@ -81,7 +116,7 @@ fn detects_running_local_singbox_from_default_install_root_and_service() {
assert_eq!( assert_eq!(
detected.executable_path, 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_eq!(detected.service_name, "ProxyWardenSingBox");
assert!(detected.running); assert!(detected.running);
@@ -92,7 +127,7 @@ fn detects_running_local_singbox_from_default_install_root_and_service() {
assert!(component.running); assert!(component.running);
assert_eq!( assert_eq!(
component.path, 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()); assert!(component.problems.is_empty());
} }
@@ -113,6 +148,11 @@ fn detects_stopped_local_singbox_from_env_override() {
assert_eq!(component.state, ComponentState::Stopped); assert_eq!(component.state, ComponentState::Stopped);
assert!(component.installed); assert!(component.installed);
assert!(!component.running); assert!(!component.running);
assert_eq!(
component.service_name,
Some("ProxyWardenSingBox".to_string())
);
assert_eq!(component.service_status, Some("stopped".to_string()));
assert!(component assert!(component
.problems .problems
.iter() .iter()
@@ -135,7 +175,7 @@ struct MockHost {
env: HashMap<String, String>, env: HashMap<String, String>,
paths: HashSet<String>, paths: HashSet<String>,
processes: HashSet<String>, processes: HashSet<String>,
services: HashSet<String>, services: HashMap<String, String>,
registry: Vec<RegistryInstallEntry>, registry: Vec<RegistryInstallEntry>,
} }
@@ -160,7 +200,14 @@ impl MockHost {
} }
fn with_service(mut self, service: &str) -> Self { 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 self
} }
@@ -188,8 +235,10 @@ impl ProxyfierDetectionHost for MockHost {
self.processes.contains(&process_name.to_ascii_lowercase()) self.processes.contains(&process_name.to_ascii_lowercase())
} }
fn service_running(&self, service_name: &str) -> bool { fn service_status(&self, service_name: &str) -> Option<String> {
self.services.contains(&service_name.to_ascii_lowercase()) self.services
.get(&service_name.to_ascii_lowercase())
.cloned()
} }
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> { fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
+5 -1
View File
@@ -184,6 +184,8 @@ fn missing_singbox_component() -> ComponentStatus {
running: false, running: false,
version: None, version: None,
path: None, path: None,
service_name: Some("ProxyWardenSingBox".to_string()),
service_status: None,
problems: vec!["Local sing-box is not installed".to_string()], problems: vec!["Local sing-box is not installed".to_string()],
actions: vec!["Install Local sing-box".to_string()], actions: vec!["Install Local sing-box".to_string()],
} }
@@ -197,7 +199,9 @@ fn running_singbox_component() -> ComponentStatus {
installed: true, installed: true,
running: true, running: true,
version: Some("1.11.0".to_string()), 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(), problems: Vec::new(),
actions: vec!["Restart".to_string(), "Stop".to_string()], actions: vec!["Restart".to_string(), "Stop".to_string()],
} }
+4 -2
View File
@@ -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 config = local_singbox_config("nl-1");
let cache = subscription_cache(); let cache = subscription_cache();
let checker = RecordingChecker::ok("configuration OK"); 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 let generated = adapter
.generate_config( .generate_config(
@@ -211,7 +211,7 @@ fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
listen_host: "127.0.0.1".to_string(), listen_host: "127.0.0.1".to_string(),
listen_port: 1080, listen_port: 1080,
service_name: "ProxyWardenSingBox".to_string(), 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()), updated_at: Some("2026-07-07T10:00:00Z".to_string()),
} }
} }
@@ -280,6 +280,8 @@ fn missing_singbox_component() -> ComponentStatus {
running: false, running: false,
version: None, version: None,
path: None, path: None,
service_name: Some("ProxyWardenSingBox".to_string()),
service_status: None,
problems: vec!["Local sing-box is not installed".to_string()], problems: vec!["Local sing-box is not installed".to_string()],
actions: vec!["Install Local sing-box".to_string()], actions: vec!["Install Local sing-box".to_string()],
} }
+13 -11
View File
@@ -47,10 +47,10 @@ noise
#[test] #[test]
fn safe_install_dir_allows_only_proxywarden_singbox_folder() { fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
assert!( assert!(ensure_safe_singbox_install_dir(Path::new(
ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\ProxyWarden\sing-box")) r"C:\Program Files\ProxyWarden\components\sing-box"
.is_ok() ))
); .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:\Windows")).is_err());
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).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] #[test]
fn service_control_script_syncs_generated_config_before_start() { fn service_control_script_syncs_generated_config_before_start() {
let source = Path::new(r"C:\ProgramData\ProxyWarden\generated\sing-box-config.json"); 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( let script = service_control_script(
SingBoxServiceAction::Start, SingBoxServiceAction::Start,
"ProxyWardenSingBox", "ProxyWardenSingBox",
@@ -83,9 +83,9 @@ fn service_control_script_syncs_generated_config_before_start() {
assert!(script.contains( assert!(script.contains(
"$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'" "$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'"
)); ));
assert!( assert!(script.contains(
script.contains("$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'") "$configTarget = 'C:\\Program Files\\ProxyWarden\\components\\sing-box\\config.json'"
); ));
assert!(script.contains("Copy-Item -LiteralPath $configSource")); assert!(script.contains("Copy-Item -LiteralPath $configSource"));
assert!(script.contains("'config_sync_failed'")); 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 { fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> DetectedSingBox {
DetectedSingBox { DetectedSingBox {
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box"), install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box"),
executable_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe"), executable_path: PathBuf::from(
r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe",
),
wrapper_path: PathBuf::from( 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, binary_exists,
wrapper_exists, wrapper_exists,
+3 -1
View File
@@ -57,7 +57,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
listen_host: "127.0.0.1".to_string(), listen_host: "127.0.0.1".to_string(),
listen_port: 1080, listen_port: 1080,
service_name: "ProxyWardenSingBox".to_string(), 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()), updated_at: Some("2026-07-07T10:00:00Z".to_string()),
}; };
let cache = sample_subscription_cache(); let cache = sample_subscription_cache();
@@ -370,6 +370,8 @@ fn sample_component() -> ComponentStatus {
running: false, running: false,
version: None, version: None,
path: None, path: None,
service_name: Some("ProxiFyreService".to_string()),
service_status: None,
problems: vec!["ProxiFyre не установлен".to_string()], problems: vec!["ProxiFyre не установлен".to_string()],
actions: vec!["Установить ProxiFyre".to_string()], actions: vec!["Установить ProxiFyre".to_string()],
} }
+13
View File
@@ -66,6 +66,15 @@ export interface ProxiFyreSetupStatus {
items: ProxiFyreSetupItem[]; 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 type SingBoxSetupItem = ProxiFyreSetupItem;
export interface SingBoxSetupStatus { export interface SingBoxSetupStatus {
@@ -201,6 +210,10 @@ export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status'); return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status');
} }
export function getProxiFyreSetupProgress(): Promise<ProxiFyreSetupProgress> {
return invoke<ProxiFyreSetupProgress>('get_proxifyre_setup_progress');
}
export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> { export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('get_singbox_status'); return invoke<LocalSingBoxStatusResponse>('get_singbox_status');
} }
+122 -54
View File
@@ -7,6 +7,7 @@ import {
forgetSingBoxSubscription, forgetSingBoxSubscription,
generateSingBoxConfig, generateSingBoxConfig,
getComponents, getComponents,
getProxiFyreSetupProgress,
getProxiFyreSetupStatus, getProxiFyreSetupStatus,
getSavedState, getSavedState,
getSingBoxSetupStatus, getSingBoxSetupStatus,
@@ -34,11 +35,13 @@ import {
type PingServerResponse, type PingServerResponse,
type ProxyProbeResponse, type ProxyProbeResponse,
type ProxyTargetCheckResponse, type ProxyTargetCheckResponse,
type ProxiFyreSetupProgress,
type ProxiFyreSetupStatus, type ProxiFyreSetupStatus,
type SingBoxSetupStatus, type SingBoxSetupStatus,
} from '../api/tauriCommands'; } from '../api/tauriCommands';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types'; import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
import { BusyRing, Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui'; import { BusyRing, Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
import { ProxiFyreSetupStrip } from './components/ProxiFyreSetupStrip';
import { parseProxy, type ParsedProxy } from './lib/parseProxy'; import { parseProxy, type ParsedProxy } from './lib/parseProxy';
import { getApplyReadiness } from './readiness'; import { getApplyReadiness } from './readiness';
import { serviceControlState } from './viewModel'; import { serviceControlState } from './viewModel';
@@ -183,6 +186,7 @@ export function App() {
const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null); const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null);
const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents); const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
const [setupStatus, setSetupStatus] = useState<ProxiFyreSetupStatus | null>(null); const [setupStatus, setSetupStatus] = useState<ProxiFyreSetupStatus | null>(null);
const [setupProgress, setSetupProgress] = useState<ProxiFyreSetupProgress | null>(null);
const [singBoxStatus, setSingBoxStatus] = useState<LocalSingBoxStatusResponse | null>(null); const [singBoxStatus, setSingBoxStatus] = useState<LocalSingBoxStatusResponse | null>(null);
const [singBoxSetupStatus, setSingBoxSetupStatus] = useState<SingBoxSetupStatus | null>(null); const [singBoxSetupStatus, setSingBoxSetupStatus] = useState<SingBoxSetupStatus | null>(null);
const [subscriptionInput, setSubscriptionInput] = useState(''); const [subscriptionInput, setSubscriptionInput] = useState('');
@@ -269,14 +273,39 @@ export function App() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [activeLogId]); }, [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() { async function refresh() {
setIsLoading(true); setIsLoading(true);
setIsDetectingComponents(true); setIsDetectingComponents(true);
try { try {
const snapshot = await getStartupSnapshot(); const [snapshot, progress] = await Promise.all([
getStartupSnapshot(),
getProxiFyreSetupProgress(),
]);
setAdminStatus(snapshot.adminStatus); setAdminStatus(snapshot.adminStatus);
setComponents(snapshot.components); setComponents(snapshot.components);
setSetupStatus(snapshot.proxifyreSetupStatus); setSetupStatus(snapshot.proxifyreSetupStatus);
setSetupProgress(progress);
setSingBoxStatus(snapshot.singboxStatus); setSingBoxStatus(snapshot.singboxStatus);
setSingBoxSetupStatus(snapshot.singboxSetupStatus); setSingBoxSetupStatus(snapshot.singboxSetupStatus);
applySavedState( applySavedState(
@@ -562,19 +591,25 @@ export function App() {
async function installProxiFyrePackage() { async function installProxiFyrePackage() {
setServiceAction('install'); setServiceAction('install');
setIsServiceMenuOpen(false); setIsServiceMenuOpen(false);
setSetupProgress(localSetupProgress('install', 'packet-filter', 1, 'Готовлю установку сетевого драйвера.'));
startServiceVisual(); startServiceVisual();
try { try {
await nextFrame(); await nextFrame();
const component = await installProxiFyre(); const component = await installProxiFyre();
const detectedSetupStatus = await getProxiFyreSetupStatus(); const [detectedSetupStatus, detectedProgress] = await Promise.all([
getProxiFyreSetupStatus(),
getProxiFyreSetupProgress(),
]);
setComponents((current) => upsertComponent(current, component)); setComponents((current) => upsertComponent(current, component));
setSetupStatus(detectedSetupStatus); setSetupStatus(detectedSetupStatus);
setSetupProgress(detectedProgress);
showNotice({ showNotice({
kind: 'success', kind: 'success',
title: 'ProxiFyre установлен', title: 'ProxiFyre установлен',
text: proxyfierDetails(component, false), text: proxyfierDetails(component, false),
}); });
} catch (error) { } catch (error) {
void getProxiFyreSetupProgress().then(setSetupProgress).catch(() => undefined);
showNotice({ showNotice({
kind: 'error', kind: 'error',
title: 'ProxiFyre не установлен', title: 'ProxiFyre не установлен',
@@ -588,25 +623,31 @@ export function App() {
async function uninstallProxiFyrePackage() { async function uninstallProxiFyrePackage() {
const confirmed = window.confirm( const confirmed = window.confirm(
'Удалить ProxiFyre с компьютера? Будет удалена служба и папка установки ProxiFyre.', 'Удалить ProxiFyre и Windows Packet Filter с компьютера? Это остановит службу, удалит папку ProxiFyre и сетевой драйвер. Другие программы WireSock могут перестать работать до повторной установки драйвера.',
); );
if (!confirmed) return; if (!confirmed) return;
setServiceAction('uninstall'); setServiceAction('uninstall');
setIsServiceMenuOpen(false); setIsServiceMenuOpen(false);
setSetupProgress(localSetupProgress('uninstall', 'proxifyre', 1, 'Готовлю удаление ProxiFyre и сетевого драйвера.'));
startServiceVisual(); startServiceVisual();
try { try {
await nextFrame(); await nextFrame();
const component = await uninstallProxiFyre(); const component = await uninstallProxiFyre();
const detectedSetupStatus = await getProxiFyreSetupStatus(); const [detectedSetupStatus, detectedProgress] = await Promise.all([
getProxiFyreSetupStatus(),
getProxiFyreSetupProgress(),
]);
setComponents((current) => upsertComponent(current, component)); setComponents((current) => upsertComponent(current, component));
setSetupStatus(detectedSetupStatus); setSetupStatus(detectedSetupStatus);
setSetupProgress(detectedProgress);
showNotice({ showNotice({
kind: 'success', kind: 'success',
title: 'ProxiFyre удален', title: 'ProxiFyre удален',
text: 'Служба и папка установки ProxiFyre удалены.', text: 'Служба, папка установки ProxiFyre и Windows Packet Filter удалены.',
}); });
} catch (error) { } catch (error) {
void getProxiFyreSetupProgress().then(setSetupProgress).catch(() => undefined);
showNotice({ showNotice({
kind: 'error', kind: 'error',
title: 'ProxiFyre не удален', title: 'ProxiFyre не удален',
@@ -995,8 +1036,21 @@ export function App() {
function renderProxiFyreCard() { function renderProxiFyreCard() {
const state = serviceControlState(proxyfier, isDetectingComponents); const state = serviceControlState(proxyfier, isDetectingComponents);
const visualState = serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : null; 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 ? 'Остановить' : 'Запустить', label: proxyfier.running ? 'Остановить' : 'Запустить',
onClick: () => void setProxiFyreServiceRunning(!proxyfier.running), onClick: () => void setProxiFyreServiceRunning(!proxyfier.running),
variant: proxyfier.running ? 'danger' as const : 'neutral' as const, variant: proxyfier.running ? 'danger' as const : 'neutral' as const,
@@ -1005,14 +1059,6 @@ export function App() {
? 'Перезапускаю' ? 'Перезапускаю'
: serviceAction === 'start' ? 'Запускаю' : 'Останавливаю', : serviceAction === 'start' ? 'Запускаю' : 'Останавливаю',
disabled: isDetectingComponents || Boolean(serviceAction), disabled: isDetectingComponents || Boolean(serviceAction),
}
: {
label: 'Установить',
onClick: () => void installProxiFyrePackage(),
variant: 'primary' as const,
loading: serviceAction === 'install',
loadingLabel: 'Устанавливаю',
disabled: isDetectingComponents || Boolean(serviceAction),
}; };
return ( return (
@@ -1023,13 +1069,13 @@ export function App() {
title={proxyfierTitle(proxyfier, isDetectingComponents)} title={proxyfierTitle(proxyfier, isDetectingComponents)}
detail={proxyfierDetails(proxyfier, isDetectingComponents)} detail={proxyfierDetails(proxyfier, isDetectingComponents)}
primaryAction={primaryAction} primaryAction={primaryAction}
menu={proxyfier?.installed ? { menu={canCleanupSetup ? {
label: 'Дополнительные действия ProxiFyre', label: 'Дополнительные действия ProxiFyre',
open: isServiceMenuOpen, open: isServiceMenuOpen,
onOpenChange: setIsServiceMenuOpen, onOpenChange: setIsServiceMenuOpen,
disabled: isDetectingComponents || Boolean(serviceAction), disabled: isDetectingComponents || Boolean(serviceAction),
items: [{ items: [{
label: serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre', label: serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre и драйвер',
danger: true, danger: true,
disabled: Boolean(serviceAction), disabled: Boolean(serviceAction),
onClick: () => void uninstallProxiFyrePackage(), onClick: () => void uninstallProxiFyrePackage(),
@@ -1040,22 +1086,7 @@ export function App() {
} }
function renderProxiFyreSetupStrip() { function renderProxiFyreSetupStrip() {
const stripItems = setupStatus?.items ?? proxifyreSetupPlaceholders(); return <ProxiFyreSetupStrip setupStatus={setupStatus} progress={setupProgress} />;
return (
<div className={`setup-strip ${setupStatus?.ready ? 'ready' : 'attention'}`} aria-label="Состав ProxiFyre">
<span className="setup-strip-title">Состав</span>
<div className="setup-strip-items">
{stripItems.map((item) => (
<div className={`setup-strip-item ${item.installed ? 'installed' : 'missing'}`} key={item.id}>
<span className="setup-strip-dot" aria-hidden="true" />
<strong>{setupItemUserName(item.id, item.name)}</strong>
<span>{setupItemShortStatus(item)}</span>
</div>
))}
</div>
</div>
);
} }
function renderAppsSection() { function renderAppsSection() {
@@ -2096,9 +2127,7 @@ function summaryRouteChainSegments(input: RouteChainInput, flow: SummaryRouteFlo
value: 'напрямую', value: 'напрямую',
tone: 'warning', tone: 'warning',
details: [ details: [
input.proxyfier?.installed proxyfierBypassReason(input.proxyfier),
? 'ProxiFyre остановлен, поэтому выбранные приложения не перехватываются.'
: 'ProxiFyre не найден, поэтому выбранные приложения не перехватываются.',
'Пакеты идут обычным системным маршрутом без SOCKS5.', 'Пакеты идут обычным системным маршрутом без SOCKS5.',
], ],
}, },
@@ -2136,7 +2165,7 @@ function routeChainSegments(input: RouteChainInput): RouteChainSegment[] {
{ {
id: 'proxifyre', id: 'proxifyre',
label: 'ProxiFyre', label: 'ProxiFyre',
value: input.isDetectingComponents ? 'проверяю' : input.proxyfier?.running ? 'запущен' : input.proxyfier?.installed ? 'остановлен' : 'не найден', value: proxyfierCompactStatus(input.proxyfier, input.isDetectingComponents),
tone: componentChainTone(input.proxyfier, input.isDetectingComponents), tone: componentChainTone(input.proxyfier, input.isDetectingComponents),
details: [ details: [
proxyfierTitle(input.proxyfier, input.isDetectingComponents), proxyfierTitle(input.proxyfier, input.isDetectingComponents),
@@ -2449,25 +2478,26 @@ function itemIcon(type: DraftItemType) {
return <FileCode2 size={18} strokeWidth={1.9} />; return <FileCode2 size={18} strokeWidth={1.9} />;
} }
function proxifyreSetupPlaceholders(): ProxiFyreSetupStatus['items'] { function localSetupProgress(
return [ operation: 'install' | 'uninstall',
{ id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' }, activeStep: string,
{ id: 'packet-filter', name: 'Сетевой драйвер', installed: false, details: 'Проверяю' }, percent: number,
{ id: 'proxifyre', name: 'Клиент ProxiFyre', installed: false, details: 'Проверяю' }, message: string,
]; ): ProxiFyreSetupProgress {
} return {
operation,
function setupItemUserName(id: string, fallbackName: string) { status: 'running',
if (id === 'vc-runtime') return 'Среда запуска'; activeStep,
if (id === 'packet-filter') return 'Сетевой драйвер'; percent,
if (id === 'proxifyre') return 'Клиент ProxiFyre'; message,
return fallbackName; updatedAt: new Date().toISOString(),
};
} }
function setupItemShortStatus(item: ProxiFyreSetupStatus['items'][number]) { function setupItemShortStatus(item: ProxiFyreSetupStatus['items'][number]) {
if (item.details === 'Проверяю') return 'проверяю'; if (item.details === 'Проверяю') return 'проверяю';
if (!item.installed) return 'нужно установить'; if (!item.installed) return 'нужно установить';
if (item.id === 'proxifyre') return item.version?.includes('не запущена') ? 'остановлен' : 'запущен'; if (item.id === 'proxifyre') return proxifyreSetupServiceSummary(item.version);
return 'готово'; return 'готово';
} }
@@ -2489,16 +2519,54 @@ function profileInputFromProfile(profile: Profile, enabled: boolean) {
function proxyfierTitle(component: ComponentStatus | undefined, checking: boolean) { function proxyfierTitle(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Проверяю ProxiFyre'; if (checking) return 'Проверяю ProxiFyre';
if (!component) return 'ProxiFyre не проверен'; if (!component) return 'ProxiFyre не проверен';
if (component.running) return 'ProxiFyre найден и запущен'; if (component.installed) return `ProxiFyre найден, служба ${serviceStatusLabel(component.serviceStatus)}`;
if (component.installed) return 'ProxiFyre найден';
return 'ProxiFyre не найден'; return 'ProxiFyre не найден';
} }
function proxyfierDetails(component: ComponentStatus | undefined, checking: boolean) { function proxyfierDetails(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Ищу установленный клиент и состояние службы.'; if (checking) return 'Ищу установленный клиент и состояние службы.';
if (!component) return 'Нажми «Обновить», чтобы проверить компьютер.'; if (!component) return 'Нажми «Обновить», чтобы проверить компьютер.';
if (component.path) return component.path; const service = proxyfierServiceDetails(component);
return component.problems[0] ?? 'Путь установки не найден.'; 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( function singBoxDetails(
+109
View File
@@ -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 (
<div
className={`setup-strip ${setupStatus?.ready ? 'ready' : 'attention'} ${visibleProgress ? 'with-progress' : ''}`}
aria-label="Состав ProxiFyre"
>
<span className="setup-strip-title">Состав</span>
<div className="setup-strip-items">
{stripItems.map((item) => (
<div
className={`setup-strip-item ${setupItemClass(item, visibleProgress)}`}
key={item.id}
>
<span className="setup-strip-dot" aria-hidden="true" />
<strong>{setupItemUserName(item.id, item.name)}</strong>
<span>{setupItemShortStatus(item, visibleProgress)}</span>
</div>
))}
</div>
{visibleProgress ? (
<div className={`setup-progress setup-progress--${progressTone}`}>
<div
className="setup-progress-track"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
aria-label={visibleProgress.message}
>
<span className="setup-progress-fill" style={{ width: `${percent}%` }} />
</div>
<span className="setup-progress-message">{visibleProgress.message}</span>
</div>
) : null}
</div>
);
}
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)));
}
+1
View File
@@ -9,6 +9,7 @@ export function serviceControlState(
if (!component) return 'missing'; if (!component) return 'missing';
if (component.state === 'error') return 'error'; if (component.state === 'error') return 'error';
if (component.running) return 'running'; if (component.running) return 'running';
if (component.installed && !component.serviceStatus) return 'installed';
if (component.installed) return 'stopped'; if (component.installed) return 'stopped';
return 'missing'; return 'missing';
} }
+2
View File
@@ -64,6 +64,8 @@ export interface ComponentStatus {
running: boolean; running: boolean;
version?: string; version?: string;
path?: string; path?: string;
serviceName?: string;
serviceStatus?: string;
problems: string[]; problems: string[];
actions: string[]; actions: string[];
} }
+61
View File
@@ -1514,6 +1514,10 @@ button.summary-card:hover {
padding: 7px 10px; padding: 7px 10px;
} }
.setup-strip.with-progress {
row-gap: 8px;
}
.setup-strip-title { .setup-strip-title {
color: #8d99ae; color: #8d99ae;
font-size: 12px; font-size: 12px;
@@ -1554,6 +1558,26 @@ button.summary-card:hover {
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.11); 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 { .setup-strip-item strong {
font-size: 12px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
@@ -1565,6 +1589,43 @@ button.summary-card:hover {
white-space: nowrap; 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 { .service-actions {
position: relative; position: relative;
display: flex; display: flex;