Add VPN proxy connection handling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user