Add VPN proxy connection handling

This commit is contained in:
2026-07-07 22:33:15 +03:00
parent 7dbf786c56
commit c5bdb10445
8 changed files with 1108 additions and 29 deletions

View File

@@ -28,9 +28,17 @@ use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
const PROXIFYRE_INSTALL_DIR: &str = r"C:\Tools\ProxiFyre";
const PROXIFYRE_RELEASE_API_URL: &str =
"https://api.github.com/repos/wiresock/proxifyre/releases/latest";
const NDISAPI_RELEASE_API_URL: &str =
"https://api.github.com/repos/wiresock/ndisapi/releases/latest";
const VC_REDIST_X64_URL: &str = "https://aka.ms/vc14/vc_redist.x64.exe";
const VC_REDIST_X86_URL: &str = "https://aka.ms/vc14/vc_redist.x86.exe";
#[derive(Debug, Clone)]
pub struct CommandState {
root: PathBuf,
@@ -110,6 +118,24 @@ pub struct SavedStateResponse {
pub generated_config_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxiFyreSetupStatusDto {
pub ready: bool,
pub missing_count: usize,
pub items: Vec<ProxiFyreSetupItemDto>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxiFyreSetupItemDto {
pub id: String,
pub name: String,
pub installed: bool,
pub version: Option<String>,
pub details: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInputDto {
@@ -384,6 +410,13 @@ pub async fn get_components(
.map_err(background_task_error)?
}
#[tauri::command]
pub async fn get_proxifyre_setup_status() -> Result<ProxiFyreSetupStatusDto, CommandError> {
tauri::async_runtime::spawn_blocking(build_proxifyre_setup_status)
.await
.map_err(background_task_error)
}
#[tauri::command]
pub fn resolve_profile_preview(
input: ProfileInputDto,
@@ -444,6 +477,23 @@ pub async fn stop_proxifyre_service() -> Result<ComponentStatusDto, CommandError
.map_err(background_task_error)?
}
#[tauri::command]
pub async fn install_proxifyre(
state: tauri::State<'_, CommandState>,
) -> Result<ComponentStatusDto, CommandError> {
let storage = state.storage();
tauri::async_runtime::spawn_blocking(move || install_proxifyre_component(&storage))
.await
.map_err(background_task_error)?
}
#[tauri::command]
pub async fn uninstall_proxifyre() -> Result<ComponentStatusDto, CommandError> {
tauri::async_runtime::spawn_blocking(uninstall_proxifyre_component)
.await
.map_err(background_task_error)?
}
pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandError> {
let profiles = storage.read_profiles().map_err(storage_error)?;
let targets = storage.read_targets().map_err(storage_error)?;
@@ -1063,7 +1113,7 @@ fn write_elevated_service_script(
let script_path = env::temp_dir().join(format!("vpn-proxy-proxifyre-service-{nonce}.ps1"));
let script = elevated_service_script(action, service_names);
fs::write(&script_path, script).map_err(|error| {
write_powershell_script(&script_path, &script).map_err(|error| {
CommandError::new(
action.error_code(),
format!(
@@ -1161,6 +1211,640 @@ exit 4
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProxiFyrePackageAction {
Install,
Uninstall,
}
impl ProxiFyrePackageAction {
fn error_code(self) -> &'static str {
match self {
ProxiFyrePackageAction::Install => "proxifyre_install_failed",
ProxiFyrePackageAction::Uninstall => "proxifyre_uninstall_failed",
}
}
fn label(self) -> &'static str {
match self {
ProxiFyrePackageAction::Install => "установить",
ProxiFyrePackageAction::Uninstall => "удалить",
}
}
fn file_label(self) -> &'static str {
match self {
ProxiFyrePackageAction::Install => "install",
ProxiFyrePackageAction::Uninstall => "uninstall",
}
}
}
fn install_proxifyre_component(storage: &JsonStorage) -> Result<ComponentStatusDto, CommandError> {
let generated_config_path = storage
.paths()
.generated_dir
.join("proxifyre-app-config.json");
let script = install_proxifyre_script(&generated_config_path);
run_elevated_package_script(
ProxiFyrePackageAction::Install,
script,
&storage.paths().state_dir,
)?;
let refreshed = detect_proxyfier_install();
let Some(detected) = refreshed.as_ref() else {
return Err(CommandError::new(
ProxiFyrePackageAction::Install.error_code(),
"Установка ProxiFyre завершилась, но приложение не найдено после проверки.",
));
};
Ok(ComponentStatusDto::from(
&proxyfier_component_from_detection(Some(detected)),
))
}
fn uninstall_proxifyre_component() -> Result<ComponentStatusDto, CommandError> {
let Some(detected) = detect_proxyfier_install() else {
let component = proxyfier_component_from_detection(None);
return Ok(ComponentStatusDto::from(&component));
};
ensure_safe_proxifyre_install_dir(&detected.install_dir)?;
let script = uninstall_proxifyre_script(&detected.install_dir, &detected.executable_path);
let artifact_dir = default_config_root().join("state");
run_elevated_package_script(ProxiFyrePackageAction::Uninstall, script, &artifact_dir)?;
let refreshed = detect_proxyfier_install();
if refreshed.is_some() {
return Err(CommandError::new(
ProxiFyrePackageAction::Uninstall.error_code(),
"Удаление ProxiFyre завершилось, но приложение все еще найдено на компьютере.",
));
}
let component = proxyfier_component_from_detection(None);
Ok(ComponentStatusDto::from(&component))
}
fn build_proxifyre_setup_status() -> ProxiFyreSetupStatusDto {
let vc_runtime = detect_vc_runtime();
let packet_filter = detect_windows_packet_filter();
let proxifyre = detect_proxyfier_install();
let vc_runtime_item = setup_item_from_program(
"vc-runtime",
&format!("Microsoft Visual C++ Runtime ({})", runtime_arch_label()),
vc_runtime,
"Нужен для запуска ProxiFyre.exe. Установщик скачает официальный vc_redist от Microsoft.",
);
let packet_filter_item = setup_item_from_program(
"packet-filter",
"Windows Packet Filter",
packet_filter,
"Сетевой драйвер NT Kernel/WireSock, через который ProxiFyre перехватывает трафик приложений.",
);
let proxifyre_item = match proxifyre {
Some(detected) => ProxiFyreSetupItemDto {
id: "proxifyre".to_string(),
name: "ProxiFyre".to_string(),
installed: true,
version: Some(if detected.running {
"служба запущена".to_string()
} else {
"служба не запущена".to_string()
}),
details: detected.install_dir.display().to_string(),
},
None => ProxiFyreSetupItemDto {
id: "proxifyre".to_string(),
name: "ProxiFyre".to_string(),
installed: false,
version: None,
details: format!(
"Будет скачан с GitHub и распакован в {}.",
PROXIFYRE_INSTALL_DIR
),
},
};
let items = vec![vc_runtime_item, packet_filter_item, proxifyre_item];
let missing_count = items.iter().filter(|item| !item.installed).count();
ProxiFyreSetupStatusDto {
ready: missing_count == 0,
missing_count,
items,
}
}
fn setup_item_from_program(
id: &str,
name: &str,
program: Option<InstalledProgram>,
missing_details: &str,
) -> ProxiFyreSetupItemDto {
match program {
Some(program) => ProxiFyreSetupItemDto {
id: id.to_string(),
name: name.to_string(),
installed: true,
version: program.display_version,
details: program.display_name,
},
None => ProxiFyreSetupItemDto {
id: id.to_string(),
name: name.to_string(),
installed: false,
version: None,
details: missing_details.to_string(),
},
}
}
#[derive(Debug, Clone)]
struct InstalledProgram {
display_name: String,
display_version: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct InstalledProgramJson {
display_name: Option<String>,
display_version: Option<String>,
}
fn detect_vc_runtime() -> Option<InstalledProgram> {
installed_program(&vc_runtime_registry_pattern())
}
fn detect_windows_packet_filter() -> Option<InstalledProgram> {
installed_program("Windows Packet Filter|WinpkFilter|NDISAPI")
}
fn installed_program(pattern: &str) -> Option<InstalledProgram> {
let script = format!(
r#"
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
$program = Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue |
Where-Object {{ $_.DisplayName -match '{}' }} |
Select-Object -First 1 DisplayName, DisplayVersion
if ($null -ne $program) {{
$program | ConvertTo-Json -Compress
}}
"#,
escape_powershell_single(pattern)
);
let output = Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script.as_str(),
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let payload = stdout.trim();
if payload.is_empty() || payload.eq_ignore_ascii_case("null") {
return None;
}
let parsed: InstalledProgramJson = serde_json::from_str(payload).ok()?;
let display_name = parsed.display_name?.trim().to_string();
if display_name.is_empty() {
return None;
}
Some(InstalledProgram {
display_name,
display_version: parsed
.display_version
.map(|version| version.trim().to_string())
.filter(|version| !version.is_empty()),
})
}
fn vc_runtime_registry_pattern() -> String {
let arch = runtime_arch_label();
if arch == "ARM64" {
return r"Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)".to_string();
}
format!(r"Microsoft Visual C\+\+.*Redistributable.*\({arch}\)")
}
fn runtime_arch_label() -> &'static str {
if cfg!(target_arch = "aarch64") {
"ARM64"
} else if cfg!(target_arch = "x86") {
"x86"
} else {
"x64"
}
}
fn run_elevated_package_script(
action: ProxiFyrePackageAction,
body: String,
artifact_dir: &Path,
) -> Result<(), CommandError> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
fs::create_dir_all(artifact_dir).map_err(|error| {
CommandError::new(
action.error_code(),
format!(
"Не удалось создать папку для временных файлов ProxiFyre '{}': {error}",
artifact_dir.display()
),
)
})?;
let script_path = artifact_dir.join(format!(
"vpn-proxy-proxifyre-{}-{nonce}.ps1",
action.file_label()
));
let result_path = artifact_dir.join(format!(
"vpn-proxy-proxifyre-{}-{nonce}.log",
action.file_label()
));
let script = wrap_elevated_package_script(&body, &result_path);
write_powershell_script(&script_path, &script).map_err(|error| {
CommandError::new(
action.error_code(),
format!(
"Не удалось подготовить временный скрипт, чтобы {} ProxiFyre '{}': {error}",
action.label(),
script_path.display()
),
)
})?;
let launch_script = format!(
r#"
$ErrorActionPreference = 'Stop'
$resultPath = '{}'
try {{
$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}')
if ($null -eq $p) {{
Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8
exit 1
}}
exit $p.ExitCode
}} catch {{
Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8
exit 1
}}
"#,
escape_powershell_single(&result_path.display().to_string()),
escape_powershell_single(&script_path.display().to_string())
);
let output = Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
launch_script.as_str(),
])
.output();
let _ = fs::remove_file(&script_path);
match output {
Ok(output) if output.status.success() => {
let _ = fs::remove_file(&result_path);
Ok(())
}
Ok(output) => {
let details = package_failure_details(&result_path, &output);
let _ = fs::remove_file(&result_path);
Err(CommandError::new(
action.error_code(),
format!(
"Не удалось {} ProxiFyre. Код elevated-команды: {}. {details}",
action.label(),
output.status.code().unwrap_or(-1),
),
))
}
Err(error) => Err(CommandError::new(
action.error_code(),
format!(
"Не удалось запросить права администратора, чтобы {} ProxiFyre: {error}",
action.label()
),
)),
}
}
pub(crate) fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String {
let mut script = String::new();
script.push_str("$ErrorActionPreference = 'Stop'\n");
script.push_str(&format!(
"$resultPath = '{}'\n",
escape_powershell_single(&result_path.display().to_string())
));
script.push_str("try {\n");
script.push_str(body);
script.push_str(
r#"
Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8
exit 0
} catch {
$message = ($_ | Out-String)
Set-Content -LiteralPath $resultPath -Value $message -Encoding UTF8
exit 1
}
"#,
);
script
}
fn write_powershell_script(path: &Path, script: &str) -> std::io::Result<()> {
let mut bytes = Vec::with_capacity(script.len() + 3);
bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]);
bytes.extend_from_slice(script.as_bytes());
fs::write(path, bytes)
}
pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String {
let mut script = String::new();
script.push_str(&format!(
"$targetDir = '{}'\n",
escape_powershell_single(PROXIFYRE_INSTALL_DIR)
));
script.push_str(&format!(
"$generatedConfigPath = '{}'\n",
escape_powershell_single(&generated_config_path.display().to_string())
));
script.push_str(&format!(
"$proxifyreReleaseApi = '{}'\n",
escape_powershell_single(PROXIFYRE_RELEASE_API_URL)
));
script.push_str(&format!(
"$ndisapiReleaseApi = '{}'\n",
escape_powershell_single(NDISAPI_RELEASE_API_URL)
));
script.push_str(&format!(
"$vcRedistX64Url = '{}'\n",
escape_powershell_single(VC_REDIST_X64_URL)
));
script.push_str(&format!(
"$vcRedistX86Url = '{}'\n",
escape_powershell_single(VC_REDIST_X86_URL)
));
script.push_str(
r#"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
function Get-NativeArchitecture {
$processor = Get-CimInstance Win32_Processor | Select-Object -First 1
if ($null -ne $processor -and $processor.Architecture -eq 12) { return 'ARM64' }
if ([Environment]::Is64BitOperatingSystem) { return 'x64' }
return 'x86'
}
function Invoke-Download([string]$uri, [string]$path) {
Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $path -Headers @{ 'User-Agent' = 'vpn-proxy-windows-client' }
}
function Select-Asset($assets, [string]$pattern, [string]$label) {
$asset = $assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1
if ($null -eq $asset) { throw "Не найден подходящий asset для $label ($pattern)." }
return $asset
}
function Verify-AssetHash([string]$path, $asset) {
if ($asset.digest -match '^sha256:(.+)$') {
$expected = $Matches[1].ToLowerInvariant()
$actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected) {
throw "SHA256 не совпал для $($asset.name). Ожидалось $expected, получилось $actual."
}
}
}
function Assert-ExitCode($process, [string]$label) {
if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) {
throw "$label завершился с кодом $($process.ExitCode)."
}
}
function Get-InstalledProgram([string]$pattern) {
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -match $pattern } |
Select-Object -First 1
}
function Test-VcRuntime([string]$arch) {
$pattern = if ($arch -eq 'ARM64') {
'Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)'
} else {
"Microsoft Visual C\+\+.*Redistributable.*\($arch\)"
}
return $null -ne (Get-InstalledProgram $pattern)
}
function Test-WindowsPacketFilter {
return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI')
}
function Get-LogTail([string]$path) {
if (-not (Test-Path -LiteralPath $path)) { return '' }
return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' '
}
$arch = Get-NativeArchitecture
$workDir = Join-Path ([IO.Path]::GetTempPath()) 'vpn-proxy-proxifyre-install'
$extractDir = Join-Path $workDir 'proxifyre'
Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $workDir, $extractDir, $targetDir | Out-Null
if (-not (Test-VcRuntime $arch)) {
$vcRedistPath = Join-Path $workDir 'vc_redist.exe'
$vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url }
Invoke-Download $vcRedistUrl $vcRedistPath
$vcProcess = Start-Process -FilePath $vcRedistPath -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru -WindowStyle Hidden
if ($vcProcess.ExitCode -ne 0 -and $vcProcess.ExitCode -ne 3010 -and $vcProcess.ExitCode -ne 1638 -and -not (Test-VcRuntime $arch)) {
throw "Visual C++ Runtime завершился с кодом $($vcProcess.ExitCode)."
}
}
if (-not (Test-WindowsPacketFilter)) {
$ndisRelease = Invoke-RestMethod -Uri $ndisapiReleaseApi -Headers @{ 'User-Agent' = 'vpn-proxy-windows-client' }
$ndisPattern = if ($arch -eq 'ARM64') { 'ARM64\.msi$' } elseif ($arch -eq 'x86') { 'x86\.msi$' } else { 'x64\.msi$' }
$ndisAsset = Select-Asset $ndisRelease.assets $ndisPattern 'Windows Packet Filter'
$ndisPath = Join-Path $workDir $ndisAsset.name
$ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log'
Invoke-Download $ndisAsset.browser_download_url $ndisPath
Verify-AssetHash $ndisPath $ndisAsset
$ndisProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $ndisPath, '/qn', '/norestart', '/L*v', $ndisLogPath) -Wait -PassThru -WindowStyle Hidden
if ($ndisProcess.ExitCode -ne 0 -and $ndisProcess.ExitCode -ne 3010 -and -not (Test-WindowsPacketFilter)) {
$ndisLogTail = Get-LogTail $ndisLogPath
throw "Windows Packet Filter завершился с кодом $($ndisProcess.ExitCode). MSI log: $ndisLogPath $ndisLogTail"
}
}
$proxifyreRelease = Invoke-RestMethod -Uri $proxifyreReleaseApi -Headers @{ 'User-Agent' = 'vpn-proxy-windows-client' }
$proxifyrePattern = if ($arch -eq 'ARM64') { 'ARM64-signed\.zip$' } elseif ($arch -eq 'x86') { 'x86-signed\.zip$' } else { 'x64-signed\.zip$' }
$proxifyreAsset = Select-Asset $proxifyreRelease.assets $proxifyrePattern 'ProxiFyre'
$proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name
Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath
Verify-AssetHash $proxifyreZipPath $proxifyreAsset
Expand-Archive -LiteralPath $proxifyreZipPath -DestinationPath $extractDir -Force
$proxifyreExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'ProxiFyre.exe' | Select-Object -First 1
if ($null -eq $proxifyreExe) { throw 'В архиве ProxiFyre не найден ProxiFyre.exe.' }
Copy-Item -Path (Join-Path $proxifyreExe.Directory.FullName '*') -Destination $targetDir -Recurse -Force
$configTarget = Join-Path $targetDir 'app-config.json'
if (Test-Path -LiteralPath $generatedConfigPath) {
Copy-Item -LiteralPath $generatedConfigPath -Destination $configTarget -Force
} elseif (-not (Test-Path -LiteralPath $configTarget)) {
$emptyConfig = '{"logLevel":"Info","bypassLan":true,"proxies":[]}'
Set-Content -LiteralPath $configTarget -Value $emptyConfig -Encoding UTF8
}
Push-Location $targetDir
try {
& .\ProxiFyre.exe stop | Out-Null
& .\ProxiFyre.exe uninstall | Out-Null
& .\ProxiFyre.exe install
if ($LASTEXITCODE -ne 0) { throw "ProxiFyre.exe install завершился с кодом $LASTEXITCODE." }
& .\ProxiFyre.exe start
if ($LASTEXITCODE -ne 0) {
Start-Service -Name 'ProxiFyreService' -ErrorAction Stop
}
} finally {
Pop-Location
}
"#,
);
script
}
fn uninstall_proxifyre_script(install_dir: &Path, executable_path: &Path) -> String {
let mut script = String::new();
script.push_str(&format!(
"$installDir = '{}'\n",
escape_powershell_single(&install_dir.display().to_string())
));
script.push_str(&format!(
"$exePath = '{}'\n",
escape_powershell_single(&executable_path.display().to_string())
));
script.push_str(
r#"
function Find-ProxiFyreService {
foreach ($name in @('ProxiFyreService', 'ProxiFyre')) {
$candidate = Get-Service -Name $name -ErrorAction SilentlyContinue
if ($null -ne $candidate) { return $candidate }
}
return Get-Service |
Where-Object { $_.Name -match 'ProxiFyre|Proxifyre' -or $_.DisplayName -match 'ProxiFyre|Proxifyre' } |
Select-Object -First 1
}
function Get-ServiceProcessId([string]$name) {
$escapedName = $name.Replace("'", "''")
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
if ($null -eq $record) { return 0 }
return [int]$record.ProcessId
}
$service = Find-ProxiFyreService
if ($null -ne $service -and $service.Status -ne 'Stopped') {
try {
if ($service.CanStop) { Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue }
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
if ($null -ne $service) { $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }
} catch {}
}
$service = Find-ProxiFyreService
if ($null -ne $service -and $service.Status -ne 'Stopped') {
$processId = Get-ServiceProcessId $service.Name
if ($processId -gt 0) {
taskkill.exe /PID $processId /F | Out-Null
Start-Sleep -Milliseconds 700
}
}
if (Test-Path -LiteralPath $exePath) {
Push-Location (Split-Path -Parent $exePath)
try {
& $exePath uninstall | Out-Null
} finally {
Pop-Location
}
}
$service = Find-ProxiFyreService
if ($null -ne $service) {
sc.exe delete $service.Name | Out-Null
}
Get-Process -Name 'ProxiFyre' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $installDir) {
Remove-Item -LiteralPath $installDir -Recurse -Force
}
"#,
);
script
}
fn ensure_safe_proxifyre_install_dir(path: &Path) -> Result<(), CommandError> {
let name = path
.file_name()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase())
.unwrap_or_default();
if path.parent().is_some() && name.contains("proxifyre") {
return Ok(());
}
Err(CommandError::new(
ProxiFyrePackageAction::Uninstall.error_code(),
format!(
"Отказываюсь рекурсивно удалять папку ProxiFyre с небезопасным путем: {}",
path.display()
),
))
}
fn apply_to_detected_proxyfier(
request: HelperApplyRequest<'_>,
detected: &DetectedProxyfier,
@@ -1280,6 +1964,55 @@ fn background_task_error(error: impl std::fmt::Display) -> CommandError {
)
}
fn package_failure_details(result_path: &Path, output: &Output) -> String {
let mut parts = Vec::new();
if let Ok(contents) = fs::read_to_string(result_path) {
let details = compact_error_text(&contents);
if !details.is_empty() && !details.eq_ignore_ascii_case("ok") {
parts.push(details);
}
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = compact_error_text(&stdout);
if !stdout.is_empty() {
parts.push(format!("stdout: {stdout}"));
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = compact_error_text(&stderr);
if !stderr.is_empty() {
parts.push(format!("stderr: {stderr}"));
}
if parts.is_empty() {
parts.push(
"Лог elevated-скрипта не создан. Обычно это значит, что окно UAC было отменено или Windows не дала запустить elevated PowerShell."
.to_string(),
);
}
parts.join(" ")
}
fn compact_error_text(value: &str) -> String {
let text = value
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ");
let max_chars = 1400;
if text.chars().count() <= max_chars {
return text;
}
let truncated = text.chars().take(max_chars).collect::<String>();
format!("{truncated}...")
}
fn parse_service_command_output(stdout: &[u8]) -> Option<ServiceCommandOutput> {
let stdout = String::from_utf8_lossy(stdout);
let payload = stdout

View File

@@ -34,12 +34,15 @@ fn main() {
commands::get_targets,
commands::save_target,
commands::get_components,
commands::get_proxifyre_setup_status,
commands::resolve_profile_preview,
commands::apply_profiles,
commands::get_logs,
commands::open_config_location,
commands::start_proxifyre_service,
commands::stop_proxifyre_service
commands::stop_proxifyre_service,
commands::install_proxifyre,
commands::uninstall_proxifyre
])
.run(tauri::generate_context!())
.expect("не удалось запустить клиент VPN Proxy для Windows");

View File

@@ -6,7 +6,7 @@
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:5173",
"devUrl": "http://127.0.0.1:5173",
"frontendDist": "../dist"
},
"app": {

View File

@@ -32,6 +32,8 @@ use proxifyre::ProxiFyreAdapter;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
#[cfg(windows)]
use std::process::Command as ProcessCommand;
use std::time::{SystemTime, UNIX_EPOCH};
use storage::JsonStorage;
@@ -119,6 +121,40 @@ fn resolve_preview_returns_structured_apps_without_filesystem_scan() {
.any(|warning| warning.contains("Сканирование папок отложено")));
}
#[test]
#[cfg(windows)]
fn proxifyre_install_script_parses_as_powershell() {
let root = test_root("proxifyre-install-script");
fs::create_dir_all(&root).expect("test root should be created");
let script = commands::wrap_elevated_package_script(
&commands::install_proxifyre_script(&root.join("proxifyre-app-config.json")),
&root.join("install.log"),
);
let script_path = root.join("install.ps1");
let mut script_bytes = vec![0xEF, 0xBB, 0xBF];
script_bytes.extend_from_slice(script.as_bytes());
fs::write(&script_path, script_bytes).expect("script should be written");
let escaped_path = script_path.display().to_string().replace('\'', "''");
let parser = format!(
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
);
let output = ProcessCommand::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
.output()
.expect("powershell parser should run");
assert!(
output.status.success(),
"install script should parse\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
cleanup(&root);
}
#[test]
fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
let root = test_root("apply");

View File

@@ -33,6 +33,20 @@ export interface SavedStateResponse {
generatedConfigPath: string;
}
export interface ProxiFyreSetupItem {
id: string;
name: string;
installed: boolean;
version?: string;
details: string;
}
export interface ProxiFyreSetupStatus {
ready: boolean;
missingCount: number;
items: ProxiFyreSetupItem[];
}
export interface HelperApplyResult {
success: boolean;
changed: boolean;
@@ -80,6 +94,10 @@ export function getComponents(): Promise<ComponentStatus[]> {
return invoke<ComponentStatus[]>('get_components');
}
export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status');
}
export function applyProfiles(): Promise<ApplyProfilesResponse> {
return invoke<ApplyProfilesResponse>('apply_profiles');
}
@@ -95,3 +113,11 @@ export function startProxiFyreService(): Promise<ComponentStatus> {
export function stopProxiFyreService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('stop_proxifyre_service');
}
export function installProxiFyre(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('install_proxifyre');
}
export function uninstallProxiFyre(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('uninstall_proxifyre');
}

View File

@@ -1,20 +1,25 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { Cpu, FileCode2, FolderOpen } from 'lucide-react';
import { Cpu, FileCode2, FolderOpen, MoreHorizontal } from 'lucide-react';
import {
applyProfiles,
getComponents,
getProxiFyreSetupStatus,
getSavedState,
installProxiFyre,
openConfigLocation,
saveProfile,
saveTarget,
startProxiFyreService,
stopProxiFyreService,
uninstallProxiFyre,
type ApplyProfilesResponse,
type ProxiFyreSetupStatus,
} from '../api/tauriCommands';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, Target } from '../domain/types';
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
type ProxiFyreAction = 'start' | 'stop' | 'install' | 'uninstall';
type ServiceVisualState = 'active' | 'settling' | null;
interface DraftItem {
@@ -60,6 +65,8 @@ export function App() {
const [processInput, setProcessInput] = useState('');
const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null);
const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
const [setupStatus, setSetupStatus] = useState<ProxiFyreSetupStatus | null>(null);
const [isSetupOpen, setIsSetupOpen] = useState(false);
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [activeLogId, setActiveLogId] = useState<string | null>(null);
@@ -68,7 +75,8 @@ export function App() {
const [isDetectingComponents, setIsDetectingComponents] = useState(true);
const [isApplying, setIsApplying] = useState(false);
const [isOpeningConfig, setIsOpeningConfig] = useState(false);
const [serviceAction, setServiceAction] = useState<'start' | 'stop' | null>(null);
const [serviceAction, setServiceAction] = useState<ProxiFyreAction | null>(null);
const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false);
const [serviceVisualState, setServiceVisualState] = useState<ServiceVisualState>(null);
const serviceVisualTimerRef = useRef<number | null>(null);
@@ -127,8 +135,12 @@ export function App() {
async function refreshComponents() {
setIsDetectingComponents(true);
try {
const detectedComponents = await getComponents();
const [detectedComponents, detectedSetupStatus] = await Promise.all([
getComponents(),
getProxiFyreSetupStatus(),
]);
setComponents(detectedComponents);
setSetupStatus(detectedSetupStatus);
} catch (error) {
showNotice({
kind: 'error',
@@ -254,13 +266,15 @@ export function App() {
);
const result = await applyProfiles();
const [saved, detectedComponents] = await Promise.all([
const [saved, detectedComponents, detectedSetupStatus] = await Promise.all([
getSavedState(),
getComponents(),
getProxiFyreSetupStatus(),
]);
applySavedState(saved.profiles, saved.targets, result.generatedConfigPath);
setComponents(detectedComponents);
setSetupStatus(detectedSetupStatus);
showNotice(noticeFromApply(result));
} catch (error) {
showNotice({
@@ -296,6 +310,7 @@ export function App() {
async function setProxiFyreServiceRunning(shouldRun: boolean) {
const action = shouldRun ? 'start' : 'stop';
setServiceAction(action);
setIsServiceMenuOpen(false);
startServiceVisual();
try {
await nextFrame();
@@ -321,6 +336,65 @@ export function App() {
}
}
async function installProxiFyrePackage() {
setServiceAction('install');
setIsServiceMenuOpen(false);
startServiceVisual();
try {
await nextFrame();
const component = await installProxiFyre();
const detectedSetupStatus = await getProxiFyreSetupStatus();
setComponents((current) => upsertComponent(current, component));
setSetupStatus(detectedSetupStatus);
showNotice({
kind: 'success',
title: 'ProxiFyre установлен',
text: proxyfierDetails(component, false),
});
} catch (error) {
showNotice({
kind: 'error',
title: 'ProxiFyre не установлен',
text: errorMessage(error),
});
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
async function uninstallProxiFyrePackage() {
const confirmed = window.confirm(
'Удалить ProxiFyre с компьютера? Будет удалена служба и папка установки ProxiFyre.',
);
if (!confirmed) return;
setServiceAction('uninstall');
setIsServiceMenuOpen(false);
startServiceVisual();
try {
await nextFrame();
const component = await uninstallProxiFyre();
const detectedSetupStatus = await getProxiFyreSetupStatus();
setComponents((current) => upsertComponent(current, component));
setSetupStatus(detectedSetupStatus);
showNotice({
kind: 'success',
title: 'ProxiFyre удален',
text: 'Служба и папка установки ProxiFyre удалены.',
});
} catch (error) {
showNotice({
kind: 'error',
title: 'ProxiFyre не удален',
text: errorMessage(error),
});
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
function startServiceVisual() {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
@@ -382,25 +456,89 @@ export function App() {
<div className="finder-text">
<strong>{proxyfierTitle(proxyfier, isDetectingComponents)}</strong>
<span>{proxyfierDetails(proxyfier, isDetectingComponents)}</span>
<button
type="button"
className="setup-toggle"
onClick={() => setIsSetupOpen((current) => !current)}
disabled={isDetectingComponents && !setupStatus}
aria-expanded={isSetupOpen}
>
{proxyfier?.installed ? 'Состав ProxiFyre' : 'Что будет установлено'}
{setupStatus ? (
<span>{setupStatus.ready ? 'все есть' : `не хватает: ${setupStatus.missingCount}`}</span>
) : null}
</button>
</div>
<div className="service-actions" aria-label="Управление службой ProxiFyre">
<button
type="button"
className="service-button"
onClick={() => setProxiFyreServiceRunning(true)}
disabled={isDetectingComponents || Boolean(serviceAction) || !proxyfier?.installed || Boolean(proxyfier?.running)}
>
{serviceAction === 'start' ? '...' : 'Запустить'}
</button>
<button
type="button"
className="service-button stop"
onClick={() => setProxiFyreServiceRunning(false)}
disabled={isDetectingComponents || Boolean(serviceAction) || !proxyfier?.installed || !proxyfier?.running}
>
{serviceAction === 'stop' ? '...' : 'Остановить'}
</button>
{proxyfier?.installed ? (
<>
<button
type="button"
className={`service-button ${proxyfier.running ? 'stop' : ''}`.trim()}
onClick={() => setProxiFyreServiceRunning(!proxyfier.running)}
disabled={isDetectingComponents || Boolean(serviceAction)}
>
{serviceAction === 'start' || serviceAction === 'stop'
? '...'
: proxyfier.running
? 'Остановить'
: 'Запустить'}
</button>
<div className="service-menu">
<button
type="button"
className="service-menu-button"
onClick={() => setIsServiceMenuOpen((current) => !current)}
disabled={isDetectingComponents || Boolean(serviceAction)}
aria-label="Дополнительные действия ProxiFyre"
aria-expanded={isServiceMenuOpen}
title="Еще"
>
<MoreHorizontal size={20} strokeWidth={2} />
</button>
{isServiceMenuOpen ? (
<div className="service-menu-popover">
<button type="button" onClick={() => void uninstallProxiFyrePackage()}>
{serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre'}
</button>
</div>
) : null}
</div>
</>
) : (
<button
type="button"
className="service-button install"
onClick={() => void installProxiFyrePackage()}
disabled={isDetectingComponents || Boolean(serviceAction)}
>
{serviceAction === 'install' ? '...' : 'Установить'}
</button>
)}
</div>
{isSetupOpen ? (
<div className="setup-details">
{setupStatus ? (
setupStatus.items.map((item) => (
<div className={`setup-item ${item.installed ? 'installed' : 'missing'}`} key={item.id}>
<span className="setup-state-dot" aria-hidden="true" />
<div>
<strong>{item.name}</strong>
<span>{setupItemDetails(item.installed, item.version, item.details)}</span>
</div>
</div>
))
) : (
<div className="setup-item">
<span className="setup-state-dot" aria-hidden="true" />
<div>
<strong>Проверяю состав</strong>
<span>Ищу установленные зависимости ProxiFyre.</span>
</div>
</div>
)}
</div>
) : null}
</div>
<label className="simple-field">
@@ -699,6 +837,12 @@ function itemIcon(type: DraftItemType) {
return <FileCode2 size={18} strokeWidth={1.9} />;
}
function setupItemDetails(installed: boolean, version: string | undefined, details: string) {
if (!installed) return `Нужно установить. ${details}`;
if (version) return `${version}. ${details}`;
return details;
}
function profileInputFromProfile(profile: Profile, enabled: boolean) {
return {
id: profile.id,

View File

@@ -102,7 +102,8 @@ button:disabled {
.process-add-line button,
.app-row button,
.open-config-button,
.service-button {
.service-button,
.service-menu-button {
min-height: 36px;
border: 1px solid #343b49;
border-radius: 4px;
@@ -117,7 +118,8 @@ button:disabled {
.process-add-line button:hover,
.app-row button:hover,
.open-config-button:hover,
.service-button:hover {
.service-button:hover,
.service-menu-button:hover {
background: #2d3543;
}
@@ -128,7 +130,7 @@ button:disabled {
grid-template-columns: auto minmax(0, 1fr) auto;
justify-content: stretch;
min-height: 56px;
overflow: hidden;
overflow: visible;
border: 1px solid #2b3342;
border-radius: 4px;
background: #151923;
@@ -249,7 +251,83 @@ button:disabled {
animation: spin 0.75s linear infinite;
}
.setup-toggle {
display: inline-flex;
gap: 7px;
align-items: center;
width: fit-content;
min-height: 0;
border: 0;
background: transparent;
color: #93c5fd;
padding: 4px 0 0;
text-align: left;
cursor: pointer;
}
.setup-toggle:hover {
color: #bfdbfe;
}
.setup-toggle span {
display: inline-block;
border: 1px solid #343b49;
border-radius: 4px;
background: #1b202b;
color: #cbd5e1;
padding: 1px 6px;
font-size: 11px;
font-weight: 700;
}
.setup-details {
display: grid;
grid-column: 2 / -1;
gap: 6px;
border-top: 1px solid #2b3342;
margin-top: 2px;
padding-top: 10px;
}
.setup-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 9px;
align-items: start;
min-height: 34px;
border: 1px solid #263040;
border-radius: 4px;
background: #111720;
padding: 8px 9px;
}
.setup-state-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: #f59e0b;
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.12);
margin-top: 5px;
}
.setup-item.installed .setup-state-dot {
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.12);
}
.setup-item strong,
.setup-item span {
display: block;
overflow-wrap: anywhere;
}
.setup-item span {
color: #9aa8bd;
font-size: 13px;
}
.service-actions {
position: relative;
display: flex;
gap: 6px;
align-items: center;
@@ -264,6 +342,62 @@ button:disabled {
color: #fecaca;
}
.service-button.install {
border-color: #166534;
background: #14532d;
color: #dcfce7;
font-weight: 700;
}
.service-button.install:hover {
background: #166534;
}
.service-menu {
position: relative;
display: flex;
}
.service-menu-button {
display: grid;
place-items: center;
width: 42px;
padding: 0;
}
.service-menu-button svg {
display: block;
}
.service-menu-popover {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 6;
min-width: 172px;
border: 1px solid #343b49;
border-radius: 4px;
background: #171c26;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.38);
padding: 5px;
}
.service-menu-popover button {
width: 100%;
min-height: 34px;
border: 0;
border-radius: 3px;
background: transparent;
color: #fecaca;
text-align: left;
padding: 7px 9px;
cursor: pointer;
}
.service-menu-popover button:hover {
background: rgba(127, 29, 29, 0.42);
}
.simple-field {
display: grid;
gap: 7px;
@@ -750,6 +884,10 @@ button:disabled {
grid-column: 1 / -1;
}
.setup-details {
grid-column: 1 / -1;
}
.service-button {
flex: 1;
}

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const host = process.env.TAURI_DEV_HOST;
const host = process.env.TAURI_DEV_HOST || '127.0.0.1';
export default defineConfig({
plugins: [react()],
@@ -15,4 +15,3 @@ export default defineConfig({
},
},
});