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
+1 -1
View File
@@ -2314,7 +2314,7 @@ dependencies = [
[[package]]
name = "proxywarden"
version = "1.0.2"
version = "1.0.3"
dependencies = [
"base64 0.22.1",
"percent-encoding",
+1 -1
View File
@@ -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"
@@ -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.
+671 -66
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},
};
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<PathBuf>,
pub running: bool,
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)]
@@ -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<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>;
}
@@ -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<String> {
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<RegistryInstallEntry> {
@@ -95,16 +111,53 @@ pub fn detect_proxyfier_install() -> Option<DetectedProxyfier> {
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(
host: &impl ProxyfierDetectionHost,
) -> Option<DetectedProxyfier> {
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<DetectedProxyfier> {
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<ProxyfierCand
"ProxiFyre",
"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() {
if let Some(engine) = engine_from_name(&entry.display_name) {
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> {
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<PathBuf> {
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<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> {
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<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 {
command_no_window("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
+1
View File
@@ -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,
+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_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<String>,
pub path: Option<String>,
#[serde(default)]
pub service_name: Option<String>,
#[serde(default)]
pub service_status: Option<String>,
#[serde(default)]
pub problems: Vec<String>,
#[serde(default)]
pub actions: Vec<String>,
+16 -5
View File
@@ -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(());
}
+12 -2
View File
@@ -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",
+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 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<String> {
None
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
@@ -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()],
}
+56 -7
View File
@@ -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<String, String>,
paths: HashSet<String>,
processes: HashSet<String>,
services: HashSet<String>,
services: HashMap<String, String>,
registry: Vec<RegistryInstallEntry>,
}
@@ -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<String> {
self.services
.get(&service_name.to_ascii_lowercase())
.cloned()
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
+5 -1
View File
@@ -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()],
}
+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 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()],
}
+13 -11
View File
@@ -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,
+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_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()],
}