Refactor application structure and simplify implementation

This commit is contained in:
2026-07-22 00:08:09 +03:00
parent dbba3806cc
commit 90b2eb507c
74 changed files with 11362 additions and 6814 deletions
+13
View File
@@ -26,6 +26,15 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
run: npm ci run: npm ci
- name: Check frontend formatting
run: npm run format:check
- name: Run frontend lints
run: npm run lint
- name: Check frontend types
run: npm run typecheck
- name: Run frontend tests - name: Run frontend tests
run: npm test -- --run run: npm test -- --run
@@ -58,3 +67,7 @@ jobs:
- name: Plan sing-box installer - name: Plan sing-box installer
shell: pwsh shell: pwsh
run: .\scripts\install-singbox.ps1 -PlanOnly run: .\scripts\install-singbox.ps1 -PlanOnly
- name: Plan Windows smoke evidence capture
shell: pwsh
run: .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly
+38
View File
@@ -0,0 +1,38 @@
# Участие в разработке ProxyWarden
ProxyWarden остается локальной Windows-утилитой. Изменения не должны превращать проект в VPN-провайдер, proxy server, SaaS или облачный control plane. Перед работой прочитайте `AGENTS.md` и релевантный skill из `.agent/skills`.
## Локальная проверка
```powershell
npm ci
npm run format:check
npm run lint
npm run typecheck
npm test -- --run
npm run build
Push-Location src-tauri
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets
Pop-Location
npm run tauri -- info
& .\scripts\install-control-app.ps1 -PlanOnly
& .\scripts\install-proxyfier.ps1 -PlanOnly
& .\scripts\install-singbox.ps1 -PlanOnly
& .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly
```
Windows service, UAC, installer и реальный routing нельзя считать проверенными только по unit-тестам. Для таких изменений укажите выполненный ручной сценарий или явно оставьте этот пробел в отчете.
## Изменения
- Держите `src/api/tauriCommands.ts` единственным TypeScript facade над Tauri `invoke`.
- Не показывайте subscription URL, credentials, proxy password или `X-HWID` в логах и UI.
- Не добавляйте скрытые install/start/stop/uninstall действия в apply.
- Добавляйте минимальный тест для новой ветвящейся логики.
- Не коммитьте runtime-файлы из `C:\ProgramData\ProxyWarden` и generated output.
В pull request кратко опишите поведение, затронутые файлы, выполненные проверки и оставшиеся Windows/manual риски.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ProxyWarden contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8
View File
@@ -143,6 +143,8 @@ C:\ProgramData\ProxyWarden\generated\sing-box-config.json
Subscription URL считается секретом. UI и diagnostics должны показывать только редактированную/сокращенную версию ссылки. Subscription URL считается секретом. UI и diagnostics должны показывать только редактированную/сокращенную версию ссылки.
При загрузке подписки ProxyWarden отправляет провайдеру стандартные идентификационные заголовки приложения и `X-HWID` - случайный постоянный UUID этой установки. Это не серийный номер оборудования, но провайдер может использовать его для связывания запросов одной установки. Проверка маршрута делает HTTPS-запросы через выбранный proxy к Cloudflare и ipify, чтобы подтвердить выход и определить внешний IP.
## Типовые сценарии ## Типовые сценарии
### Внешний SOCKS5 ### Внешний SOCKS5
@@ -224,6 +226,10 @@ Browser-preview годится для проверки интерфейса, н
Frontend/UI: Frontend/UI:
```powershell ```powershell
npm run format:check
npm run lint
npm run typecheck
npm test -- --run
npm run build npm run build
``` ```
@@ -253,6 +259,8 @@ Installer boundaries:
## Ограничения текущей версии ## Ограничения текущей версии
- Основной поддержанный маршрут - SOCKS5. - Основной поддержанный маршрут - SOCKS5.
- Link-подписки разбирают VLESS, VMess, Trojan и Shadowsocks; sing-box JSON также принимает поддержанные proxy outbounds. Неизвестные форматы отклоняются явно.
- Для VLESS outbound без собственного `packet_encoding` генератор добавляет `xudp`; значение, заданное провайдером подписки, не перезаписывается.
- ProxiFyre является текущим backend-слоем для per-app routing. - ProxiFyre является текущим backend-слоем для per-app routing.
- Local sing-box остается опциональным и не требуется для внешнего SOCKS5. - Local sing-box остается опциональным и не требуется для внешнего SOCKS5.
- Elevated install/start/stop/uninstall операции считаются реализованными, но требуют дополнительной проверки на реальной Windows-машине с UAC/admin confirmation. - Elevated install/start/stop/uninstall операции считаются реализованными, но требуют дополнительной проверки на реальной Windows-машине с UAC/admin confirmation.
+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist/**', 'src-tauri/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
globals: {
document: 'readonly',
HTMLElement: 'readonly',
HTMLDivElement: 'readonly',
requestAnimationFrame: 'readonly',
setTimeout: 'readonly',
window: 'readonly',
},
},
},
);
+1155
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -6,7 +6,11 @@
"description": "Standalone Windows desktop proxy management app for ProxyWarden.", "description": "Standalone Windows desktop proxy management app for ProxyWarden.",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "npm run typecheck && vite build",
"typecheck": "tsc --noEmit",
"lint": "eslint src",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"tauri": "tauri" "tauri": "tauri"
@@ -20,12 +24,16 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "^2.0.0", "@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"eslint": "^10.7.0",
"prettier": "^3.9.5",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^3.2.4", "typescript-eslint": "^8.63.0",
"vite": "^7.0.0" "vite": "^7.0.0",
"vitest": "^3.2.4"
} }
} }
+182
View File
@@ -0,0 +1,182 @@
param(
[ValidateSet("PlanOnly", "Capture")]
[string]$Mode = "PlanOnly",
[string]$DataRoot = "C:\ProgramData\ProxyWarden",
[string]$ProxiFyreRoot = "C:\Tools\ProxiFyre",
[string]$SingBoxRoot = "C:\Program Files\ProxyWarden\sing-box",
[string]$ForeignServiceName = "",
[string]$OutputPath = ""
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function New-Result {
param(
[bool]$Success,
[string]$Action,
[bool]$Changed,
[string]$Message,
[hashtable]$Details
)
[ordered]@{
success = $Success
action = $Action
changed = $Changed
message = $Message
details = $Details
} | ConvertTo-Json -Depth 8
}
function Get-ServiceEvidence {
param([string[]]$Names)
$result = @()
foreach ($name in $Names | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) {
$escaped = $name.Replace("'", "''")
$service = Get-CimInstance Win32_Service -Filter "Name='$escaped'" -ErrorAction SilentlyContinue
if ($null -eq $service) {
$result += [ordered]@{ name = $name; found = $false }
continue
}
$result += [ordered]@{
name = $service.Name
found = $true
state = $service.State
startMode = $service.StartMode
pathName = $service.PathName
processId = [int]$service.ProcessId
}
}
return $result
}
function Test-PathUnderRoot {
param([string]$Path, [string]$Root)
if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Root)) { return $false }
$fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\')
$fullRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\')
return $fullPath.Equals($fullRoot, [StringComparison]::OrdinalIgnoreCase) -or
$fullPath.StartsWith("$fullRoot\", [StringComparison]::OrdinalIgnoreCase)
}
function Get-ServiceExecutablePath {
param([string]$PathName)
if ([string]::IsNullOrWhiteSpace($PathName)) { return "" }
$trimmed = $PathName.Trim()
if ($trimmed.StartsWith('"')) {
$closingQuote = $trimmed.IndexOf('"', 1)
if ($closingQuote -gt 1) { return $trimmed.Substring(1, $closingQuote - 1) }
}
return ($trimmed -split '\s+', 2)[0]
}
function Get-FileEvidence {
param([string]$Root)
if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return @() }
return @(
Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue |
Select-Object @{N="path";E={$_.FullName}}, @{N="length";E={$_.Length}}, @{N="lastWriteTimeUtc";E={$_.LastWriteTimeUtc.ToString("o")}}
)
}
function Get-SecretFindingCategories {
param([string]$Root)
if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return @() }
$patterns = [ordered]@{
urlUserInfo = '://[^/\s"'']+@'
credentialQuery = '(?i)[?&](token|key|auth|password|passwd|secret)=[^&\s"'']+'
socksCredentials = '(?i)socks5://[^/\s:@]+:[^/\s@]+@'
hwidHeader = '(?i)x-hwid[^\r\n]*[0-9a-f]{8}-[0-9a-f-]{27,}'
}
$findings = @()
$files = Get-ChildItem -LiteralPath $Root -Recurse -File -Include *.json,*.log,*.txt -ErrorAction SilentlyContinue
foreach ($file in $files) {
$content = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue
if ($null -eq $content) { continue }
foreach ($entry in $patterns.GetEnumerator()) {
if ($content -match $entry.Value) {
$findings += [ordered]@{ path = $file.FullName; category = $entry.Key }
}
}
}
return $findings
}
try {
$quotedServiceFixture = '"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe" -service'
$quotedExecutable = Get-ServiceExecutablePath -PathName $quotedServiceFixture
if (-not (Test-PathUnderRoot -Path $quotedExecutable -Root "C:\Program Files\ProxyWarden\sing-box")) {
throw "Quoted service PathName ownership self-test failed."
}
$plan = [ordered]@{
mode = $Mode
serviceNames = @("ProxiFyreService", "ProxyWardenSingBox")
foreignServiceName = $ForeignServiceName
roots = [ordered]@{
data = [IO.Path]::GetFullPath($DataRoot)
proxifyre = [IO.Path]::GetFullPath($ProxiFyreRoot)
singbox = [IO.Path]::GetFullPath($SingBoxRoot)
}
checks = @("service-state-and-path", "managed-root-membership", "file-metadata", "secret-category-scan")
}
if ($Mode -eq "PlanOnly") {
New-Result -Success $true -Action "audit-windows-smoke.plan" -Changed $false -Message "Windows smoke evidence plan is ready." -Details $plan
exit 0
}
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$OutputPath = Join-Path $PWD ("audit-windows-smoke-{0}.json" -f (Get-Date -Format "yyyyMMdd-HHmmss"))
}
$outputFullPath = [IO.Path]::GetFullPath($OutputPath)
$outputDirectory = Split-Path -Parent $outputFullPath
if ([string]::IsNullOrWhiteSpace($outputDirectory)) { throw "OutputPath must include a writable directory." }
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
$serviceNames = @("ProxiFyreService", "ProxyWardenSingBox", $ForeignServiceName)
$services = @(Get-ServiceEvidence -Names $serviceNames)
$ownership = @(
$services | Where-Object found | ForEach-Object {
$expectedRoot = switch ($_.name) {
"ProxiFyreService" { $ProxiFyreRoot }
"ProxyWardenSingBox" { $SingBoxRoot }
default { "" }
}
[ordered]@{
name = $_.name
expectedManagedRoot = if ($expectedRoot) { [IO.Path]::GetFullPath($expectedRoot) } else { $null }
pathUnderExpectedRoot = if ($expectedRoot) { Test-PathUnderRoot -Path (Get-ServiceExecutablePath -PathName $_.pathName) -Root $expectedRoot } else { $false }
}
}
)
$report = [ordered]@{
capturedAt = (Get-Date).ToUniversalTime().ToString("o")
computerName = $env:COMPUTERNAME
os = (Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, OSArchitecture)
services = $services
ownership = $ownership
files = @(Get-FileEvidence -Root $DataRoot)
secretFindingCategories = @(Get-SecretFindingCategories -Root $DataRoot)
}
$report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $outputFullPath -Encoding UTF8
New-Result -Success $true -Action "audit-windows-smoke.capture" -Changed $true -Message "Read-only Windows smoke evidence captured." -Details @{
outputPath = $outputFullPath
serviceCount = @($services | Where-Object found).Count
fileCount = @($report.files).Count
secretFindingCount = @($report.secretFindingCategories).Count
}
} catch {
New-Result -Success $false -Action "audit-windows-smoke.$($Mode.ToLowerInvariant())" -Changed $false -Message $_.Exception.Message -Details @{}
exit 1
}
+1
View File
@@ -2324,6 +2324,7 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"thiserror 2.0.18",
"url", "url",
"uuid", "uuid",
"winreg", "winreg",
+1
View File
@@ -22,6 +22,7 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
percent-encoding = "2" percent-encoding = "2"
url = "2" url = "2"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
thiserror = "2"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.55" winreg = "0.55"
+4 -1
View File
@@ -205,7 +205,10 @@ fn app_names_for_profile(profile: &Profile) -> Vec<String> {
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value, ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value,
}; };
if !names.iter().any(|existing| existing == app_name) { if !names
.iter()
.any(|existing: &String| existing.eq_ignore_ascii_case(app_name))
{
names.push(app_name.to_string()); names.push(app_name.to_string());
} }
} }
+68 -33
View File
@@ -1,12 +1,8 @@
use crate::models::{LocalSingBoxConfig, SubscriptionCache}; use crate::models::{LocalSingBoxConfig, SubscriptionCache, SubscriptionServer};
use crate::process::command_no_window; use crate::process::command_no_window;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::{ use std::{env, fs, fs::OpenOptions, io::Write, path::Path};
env, fs,
path::Path,
time::{SystemTime, UNIX_EPOCH},
};
pub const SINGBOX_ADAPTER_ID: &str = "singbox"; pub const SINGBOX_ADAPTER_ID: &str = "singbox";
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json"; pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
@@ -43,23 +39,36 @@ impl SingBoxAdapter {
checker: &C, checker: &C,
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError> ) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
where where
C: SingBoxConfigChecker, C: SingBoxConfigChecker + ?Sized,
{ {
let selected_server_tag = request let selected_server = request
.config .config
.selected_server_tag .selected_server_id
.as_deref() .as_deref()
.map(str::trim) .and_then(|id| {
.filter(|value| !value.is_empty()) request
.subscription_cache
.servers
.iter()
.find(|server| server.id == id)
})
.or_else(|| {
let tag = request.config.selected_server_tag.as_deref()?;
request
.subscription_cache
.servers
.iter()
.find(|server| server.tag == tag)
})
.ok_or_else(|| { .ok_or_else(|| {
SingBoxConfigError::new( SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedServer, SingBoxConfigErrorKind::MissingSelectedServer,
"Сервер Local sing-box не выбран", "Сервер Local sing-box не выбран или отсутствует в текущей подписке",
) )
})?; })?;
let vpn_outbound = selected_outbound( let vpn_outbound = selected_outbound(
&request.subscription_cache.config, &request.subscription_cache.config,
selected_server_tag, selected_server,
&self.vpn_outbound_tag, &self.vpn_outbound_tag,
)?; )?;
let generated_config = json!({ let generated_config = json!({
@@ -105,7 +114,7 @@ impl SingBoxAdapter {
adapter_id: SINGBOX_ADAPTER_ID.to_string(), adapter_id: SINGBOX_ADAPTER_ID.to_string(),
output_file_name: SINGBOX_OUTPUT_FILE.to_string(), output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
contents, contents,
selected_server_tag: selected_server_tag.to_string(), selected_server_tag: selected_server.tag.clone(),
listen: request.config.listen_host.clone(), listen: request.config.listen_host.clone(),
listen_port: request.config.listen_port, listen_port: request.config.listen_port,
check, check,
@@ -200,20 +209,37 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
config_json: &str, config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> { ) -> Result<SingBoxCheckResult, SingBoxConfigError> {
let config_path = env::temp_dir().join(format!( let config_path = env::temp_dir().join(format!(
"proxywarden-sing-box-{}-{}.json", "proxywarden-sing-box-{}.json",
std::process::id(), uuid::Uuid::new_v4().hyphenated()
now_millis()
)); ));
fs::write(&config_path, config_json).map_err(|error| { {
let mut config_file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&config_path)
.map_err(|error| {
SingBoxConfigError::new( SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!(
"Не удалось создать временный конфиг sing-box '{}': {error}",
config_path.display()
),
)
})?;
let write_result = config_file.write_all(config_json.as_bytes());
drop(config_file);
if let Err(error) = write_result {
let _ = fs::remove_file(&config_path);
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed, SingBoxConfigErrorKind::CheckFailed,
format!( format!(
"Не удалось записать временный конфиг sing-box '{}': {error}", "Не удалось записать временный конфиг sing-box '{}': {error}",
config_path.display() config_path.display()
), ),
) ));
})?; }
}
let output = command_no_window(binary_path) let output = command_no_window(binary_path)
.arg("check") .arg("check")
@@ -257,7 +283,7 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
fn selected_outbound( fn selected_outbound(
subscription_config: &Value, subscription_config: &Value,
selected_server_tag: &str, selected_server: &SubscriptionServer,
vpn_outbound_tag: &str, vpn_outbound_tag: &str,
) -> Result<Value, SingBoxConfigError> { ) -> Result<Value, SingBoxConfigError> {
let outbounds = subscription_config let outbounds = subscription_config
@@ -272,15 +298,27 @@ fn selected_outbound(
let outbound = outbounds let outbound = outbounds
.iter() .iter()
.find(|outbound| { .find(|outbound| {
outbound let tag_matches = outbound
.get("tag") .get("tag")
.and_then(Value::as_str) .and_then(Value::as_str)
.is_some_and(|tag| tag.trim() == selected_server_tag) .is_some_and(|tag| tag.trim() == selected_server.tag);
let server_matches = outbound
.get("server")
.and_then(Value::as_str)
.is_some_and(|server| server.eq_ignore_ascii_case(&selected_server.server));
let port_matches = outbound
.get("server_port")
.and_then(Value::as_u64)
.is_some_and(|port| port == u64::from(selected_server.server_port));
tag_matches && server_matches && port_matches
}) })
.ok_or_else(|| { .ok_or_else(|| {
SingBoxConfigError::new( SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedOutbound, SingBoxConfigErrorKind::MissingSelectedOutbound,
format!("Outbound не найден: {selected_server_tag}"), format!(
"Outbound не найден: {} ({}:{})",
selected_server.tag, selected_server.server, selected_server.server_port
),
) )
})?; })?;
let outbound_type = outbound let outbound_type = outbound
@@ -292,7 +330,8 @@ fn selected_outbound(
return Err(SingBoxConfigError::new( return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedSelectedOutbound, SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
format!( format!(
"Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'" "Outbound '{}' имеет неподдерживаемый тип '{outbound_type}'",
selected_server.tag
), ),
)); ));
} }
@@ -301,7 +340,10 @@ fn selected_outbound(
let object = outbound.as_object_mut().ok_or_else(|| { let object = outbound.as_object_mut().ok_or_else(|| {
SingBoxConfigError::new( SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedSelectedOutbound, SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"), format!(
"Outbound '{}' должен быть JSON-объектом",
selected_server.tag
),
) )
})?; })?;
object.insert( object.insert(
@@ -318,13 +360,6 @@ fn selected_outbound(
Ok(outbound) Ok(outbound)
} }
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default()
}
fn command_message(stdout: &str, stderr: &str) -> String { fn command_message(stdout: &str, stderr: &str) -> String {
let stdout = stdout.trim(); let stdout = stdout.trim();
let stderr = stderr.trim(); let stderr = stderr.trim();
+89
View File
@@ -0,0 +1,89 @@
//! Administrator-state detection and explicit UAC restart boundary.
use crate::command_dto::{AdminStatusResponse, CommandError};
use crate::powershell::{
escape_single as escape_powershell_single, is_elevated as is_running_elevated,
output_message as powershell_output_message, run_command as run_powershell_command,
};
use std::env;
pub fn admin_status() -> AdminStatusResponse {
let is_windows = cfg!(windows);
let is_elevated = is_running_elevated();
let message = if !is_windows {
"Проверка прав администратора нужна только в Windows.".to_string()
} else if is_elevated {
"ProxyWarden уже запущен от имени администратора.".to_string()
} else {
"Для установки компонентов и управления службами можно перезапустить ProxyWarden от имени администратора один раз.".to_string()
};
AdminStatusResponse {
is_windows,
is_elevated,
can_restart_elevated: is_windows && !is_elevated,
message,
}
}
pub(crate) fn launch_app_as_admin() -> Result<(), CommandError> {
if !cfg!(windows) {
return Err(CommandError::new(
"admin_restart_unsupported",
"Перезапуск от имени администратора доступен только в Windows.",
));
}
if is_running_elevated() {
return Ok(());
}
let exe_path = env::current_exe().map_err(|error| {
CommandError::new(
"admin_restart_failed",
format!("Не удалось определить путь текущего приложения: {error}"),
)
})?;
let working_dir = env::current_dir().ok();
let working_dir_arg = working_dir
.as_ref()
.map(|path| {
format!(
" -WorkingDirectory '{}'",
escape_powershell_single(&path.display().to_string())
)
})
.unwrap_or_default();
let script = format!(
r#"
$ErrorActionPreference = 'Stop'
try {{
Start-Process -FilePath '{}' -Verb RunAs{}
exit 0
}} catch {{
Write-Error ($_ | Out-String)
exit 1
}}
"#,
escape_powershell_single(&exe_path.display().to_string()),
working_dir_arg
);
let output = run_powershell_command(&script).map_err(|error| {
CommandError::new(
"admin_restart_failed",
format!("Не удалось запросить права администратора: {error}"),
)
})?;
if output.status.success() {
return Ok(());
}
Err(CommandError::new(
"admin_restart_failed",
powershell_output_message(
&output,
"Перезапуск от имени администратора отменен или не был запущен.",
),
))
}
+594
View File
@@ -0,0 +1,594 @@
//! Transactional configuration apply use case.
//!
//! The module validates and generates all artifacts before source writes,
//! performs no service lifecycle actions, and attempts rollback when a later
//! write or runtime apply fails.
use crate::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
use crate::adapters::singbox::{
SingBoxAdapter, SingBoxConfigChecker, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
};
use crate::clock::Clock;
use crate::component_detection::{
proxyfier_component_from_detection, singbox_component_from_detection, DetectedProxyfier,
DetectedSingBox,
};
use crate::models::{
ActivityEntry, ActivityLevel, ComponentId, LocalSingBoxConfig, Profile, ProfileInput,
ProxyProtocol, Target, TargetInput, TargetKind,
};
use crate::proxy_apply::{HelperApplyRequest, ProxyApplyHelper};
use crate::safe_fs;
use crate::storage::JsonStorage;
use crate::validation::{normalize_profile, normalize_target, ValidationError};
use serde::{Deserialize, Serialize};
use std::{fs, path::Path};
use thiserror::Error;
const LOCAL_SINGBOX_TARGET_ID: &str = "local-singbox";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ApplyRouteMode {
External,
LocalSingbox,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyConfigurationInput {
pub route_mode: ApplyRouteMode,
pub profile: ProfileInput,
pub external_target: Option<TargetInput>,
#[serde(default = "default_true")]
pub disable_other_profiles: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyPhase {
pub id: String,
pub status: ApplyPhaseStatus,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApplyPhaseStatus {
Succeeded,
Failed,
RolledBack,
Skipped,
Warning,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyConfigurationResult {
pub success: bool,
pub changed: bool,
pub partial_state: bool,
pub message: String,
pub error_code: Option<String>,
pub generated_config_path: String,
pub singbox_generated_config_path: Option<String>,
pub restart_required: Vec<ComponentId>,
pub phases: Vec<ApplyPhase>,
}
#[derive(Debug, Error)]
pub enum ApplyFlowError {
#[error("Проверьте поля конфигурации")]
Validation { details: Vec<ValidationError> },
#[error("{message}")]
Failure { code: String, message: String },
}
impl ApplyFlowError {
pub fn code(&self) -> &str {
match self {
Self::Validation { .. } => "validation_failed",
Self::Failure { code, .. } => code,
}
}
pub fn details(self) -> Vec<ValidationError> {
match self {
Self::Validation { details } => details,
Self::Failure { .. } => Vec::new(),
}
}
fn failure(code: impl Into<String>, message: impl Into<String>) -> Self {
Self::Failure {
code: code.into(),
message: message.into(),
}
}
fn validation(details: Vec<ValidationError>) -> Self {
Self::Validation { details }
}
}
pub struct ApplyServices<'a> {
pub proxy_adapter: &'a dyn ProxyRouterAdapter,
pub singbox_adapter: &'a SingBoxAdapter,
pub checker: &'a dyn SingBoxConfigChecker,
pub helper: &'a dyn ProxyApplyHelper,
pub clock: &'a dyn Clock,
pub detected_proxyfier: Option<DetectedProxyfier>,
pub detected_singbox: Option<DetectedSingBox>,
}
/// Applies one complete routing draft without starting, stopping, installing,
/// uninstalling, or restarting Windows services.
pub fn apply_configuration(
storage: &JsonStorage,
input: ApplyConfigurationInput,
services: ApplyServices<'_>,
) -> Result<ApplyConfigurationResult, ApplyFlowError> {
let mut phases = Vec::new();
let old_profiles = storage
.read_profiles()
.map_err(|error| storage_error("profiles_read_failed", error))?;
let old_targets = storage
.read_targets()
.map_err(|error| storage_error("targets_read_failed", error))?;
let PreparedApply {
profiles,
targets,
proxy_config,
singbox_config,
} = prepare_apply(storage, input, &services)?;
phases.push(phase(
"preflight",
ApplyPhaseStatus::Succeeded,
"Входные данные и оба generated config проверены до записи.",
));
let source_changed = profiles != old_profiles || targets != old_targets;
let proxy_path = storage
.paths()
.generated_dir
.join(&proxy_config.output_file_name);
let singbox_path = singbox_config
.as_ref()
.map(|_| storage.paths().generated_dir.join(SINGBOX_OUTPUT_FILE));
let old_proxy_contents = fs::read(&proxy_path).ok();
let old_singbox_contents = singbox_path.as_ref().and_then(|path| fs::read(path).ok());
let rollback_state = RollbackState {
storage,
old_profiles: &old_profiles,
old_targets: &old_targets,
proxy_path: &proxy_path,
old_proxy_contents: old_proxy_contents.as_deref(),
singbox_path: singbox_path.as_deref(),
old_singbox_contents: old_singbox_contents.as_deref(),
};
if let Err(error) = storage.write_targets(&targets) {
let rollback = rollback_source(storage, &old_profiles, &old_targets);
phases.push(phase(
"source-state",
ApplyPhaseStatus::Failed,
"Не удалось сохранить targets.",
));
phases.push(rollback_phase(&rollback));
return Ok(failed_result(
"targets_write_failed",
format!("Не удалось сохранить цели: {error}"),
rollback.is_err(),
&proxy_path,
singbox_path.as_deref(),
phases,
));
}
if let Err(error) = storage.write_profiles(&profiles) {
let rollback = rollback_source(storage, &old_profiles, &old_targets);
phases.push(phase(
"source-state",
ApplyPhaseStatus::Failed,
"Не удалось сохранить profiles.",
));
phases.push(rollback_phase(&rollback));
return Ok(failed_result(
"profiles_write_failed",
format!("Не удалось сохранить профили: {error}"),
rollback.is_err(),
&proxy_path,
singbox_path.as_deref(),
phases,
));
}
phases.push(phase(
"source-state",
ApplyPhaseStatus::Succeeded,
"Profiles и targets сохранены.",
));
if let (Some(generated), Some(path)) = (singbox_config.as_ref(), singbox_path.as_ref()) {
if let Err(error) = safe_fs::write_with_backup(path, generated.contents.as_bytes()) {
return Ok(rollback_after_failure(
&rollback_state,
"singbox_config_write_failed",
format!("Не удалось записать generated sing-box config: {error}"),
"singbox-config",
phases,
));
}
phases.push(phase(
"singbox-config",
ApplyPhaseStatus::Succeeded,
"Generated sing-box config записан; служба не перезапускалась.",
));
} else {
phases.push(phase(
"singbox-config",
ApplyPhaseStatus::Skipped,
"External SOCKS5 не использует Local sing-box.",
));
}
if let Err(error) = safe_fs::write_with_backup(&proxy_path, proxy_config.contents.as_bytes()) {
return Ok(rollback_after_failure(
&rollback_state,
"proxifyre_config_write_failed",
format!("Не удалось записать generated ProxiFyre config: {error}"),
"proxifyre-config",
phases,
));
}
phases.push(phase(
"proxifyre-config",
ApplyPhaseStatus::Succeeded,
"Generated ProxiFyre config записан.",
));
let helper_result = match services.helper.apply_proxy_config(HelperApplyRequest {
adapter_id: &proxy_config.adapter_id,
config_path: &proxy_path,
config_contents: &proxy_config.contents,
}) {
Ok(result) if result.success => result,
Ok(result) => {
return Ok(rollback_after_failure(
&rollback_state,
"proxifyre_apply_failed",
result.message,
"runtime-apply",
phases,
));
}
Err(error) => {
return Ok(rollback_after_failure(
&rollback_state,
&error.code,
error.message,
"runtime-apply",
phases,
));
}
};
phases.push(phase(
"runtime-apply",
ApplyPhaseStatus::Succeeded,
"ProxiFyre config применён без управления службой.",
));
phases.push(phase(
"service-control",
ApplyPhaseStatus::Skipped,
"Apply не запускает, не останавливает и не перезапускает службы.",
));
let mut restart_required = Vec::new();
if services.detected_proxyfier.is_some() {
restart_required.push(ComponentId::Proxyfier);
}
if singbox_config.is_some() && services.detected_singbox.is_some() {
restart_required.push(ComponentId::Singbox);
}
let message = if restart_required.is_empty() {
helper_result.message.clone()
} else {
"Конфигурация применена. Для загрузки новых файлов явно перезапустите отмеченные службы."
.to_string()
};
let activity = ActivityEntry {
id: "configuration-applied".to_string(),
at: services.clock.now(),
level: ActivityLevel::Success,
title: "Маршрут применён".to_string(),
message: format!(
"Профилей: {}, приложений: {}. Управление службами не выполнялось.",
proxy_config.enabled_profiles, proxy_config.routed_apps
),
};
if let Err(error) = storage.append_activity(activity) {
phases.push(phase(
"activity",
ApplyPhaseStatus::Warning,
format!("Маршрут применён, но запись activity не удалась: {error}"),
));
} else {
phases.push(phase(
"activity",
ApplyPhaseStatus::Succeeded,
"Activity обновлена.",
));
}
Ok(ApplyConfigurationResult {
success: true,
changed: source_changed || helper_result.changed,
partial_state: false,
message,
error_code: None,
generated_config_path: proxy_path.display().to_string(),
singbox_generated_config_path: singbox_path.map(|path| path.display().to_string()),
restart_required,
phases,
})
}
struct PreparedApply {
profiles: Vec<Profile>,
targets: Vec<Target>,
proxy_config: crate::adapters::proxy_router::ProxyRouterGeneratedConfig,
singbox_config: Option<crate::adapters::singbox::SingBoxGeneratedConfig>,
}
fn prepare_apply(
storage: &JsonStorage,
input: ApplyConfigurationInput,
services: &ApplyServices<'_>,
) -> Result<PreparedApply, ApplyFlowError> {
if services.detected_proxyfier.is_none() {
return Err(ApplyFlowError::failure(
"proxifyre_not_found",
"ProxiFyre не найден. Установите компонент отдельным явным действием перед apply.",
));
}
let mut profile_input = input.profile;
let mut targets = storage
.read_targets()
.map_err(|error| storage_error("targets_read_failed", error))?;
let singbox_config = match input.route_mode {
ApplyRouteMode::External => {
let target_input = input.external_target.ok_or_else(|| {
ApplyFlowError::failure(
"external_target_missing",
"Для external маршрута требуется SOCKS5 target.",
)
})?;
let target = normalize_target(target_input).map_err(ApplyFlowError::validation)?;
profile_input.target_id = target.id.clone();
upsert_target(&mut targets, target);
None
}
ApplyRouteMode::LocalSingbox => {
let config = storage
.read_local_singbox_config()
.map_err(|error| storage_error("singbox_config_read_failed", error))?;
let cache = storage
.read_singbox_subscription_cache()
.map_err(|error| storage_error("singbox_cache_read_failed", error))?
.ok_or_else(|| {
ApplyFlowError::failure(
"singbox_subscription_cache_missing",
"Сначала загрузите подписку Local sing-box.",
)
})?;
profile_input.target_id = LOCAL_SINGBOX_TARGET_ID.to_string();
upsert_target(&mut targets, local_singbox_target(&config));
Some(
services
.singbox_adapter
.generate_config(
SingBoxGenerationRequest::new(
&config,
&cache,
services
.detected_singbox
.as_ref()
.map(|detected| detected.executable_path.as_path()),
),
services.checker,
)
.map_err(|error| {
ApplyFlowError::failure("singbox_preflight_failed", error.message)
})?,
)
}
};
let profile = normalize_profile(profile_input).map_err(ApplyFlowError::validation)?;
let mut profiles = storage
.read_profiles()
.map_err(|error| storage_error("profiles_read_failed", error))?;
if input.disable_other_profiles {
for existing in &mut profiles {
if existing.id != profile.id {
existing.enabled = false;
}
}
}
upsert_profile(&mut profiles, profile);
let components = vec![
proxyfier_component_from_detection(services.detected_proxyfier.as_ref()),
singbox_component_from_detection(services.detected_singbox.as_ref()),
];
let proxy_config = services
.proxy_adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.map_err(|error| ApplyFlowError::failure("proxifyre_preflight_failed", error.message))?;
Ok(PreparedApply {
profiles,
targets,
proxy_config,
singbox_config,
})
}
fn local_singbox_target(config: &LocalSingBoxConfig) -> Target {
Target {
id: LOCAL_SINGBOX_TARGET_ID.to_string(),
name: "Локальный sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: config.listen_host.clone(),
port: config.listen_port,
requires_component: Some(ComponentId::Singbox),
}
}
fn upsert_profile(profiles: &mut Vec<Profile>, profile: Profile) {
match profiles
.iter()
.position(|existing| existing.id == profile.id)
{
Some(index) => profiles[index] = profile,
None => profiles.push(profile),
}
}
fn upsert_target(targets: &mut Vec<Target>, target: Target) {
match targets.iter().position(|existing| existing.id == target.id) {
Some(index) => targets[index] = target,
None => targets.push(target),
}
}
struct RollbackState<'a> {
storage: &'a JsonStorage,
old_profiles: &'a [Profile],
old_targets: &'a [Target],
proxy_path: &'a Path,
old_proxy_contents: Option<&'a [u8]>,
singbox_path: Option<&'a Path>,
old_singbox_contents: Option<&'a [u8]>,
}
fn rollback_after_failure(
state: &RollbackState<'_>,
code: &str,
message: String,
failed_phase: &str,
mut phases: Vec<ApplyPhase>,
) -> ApplyConfigurationResult {
phases.push(phase(failed_phase, ApplyPhaseStatus::Failed, &message));
let source_rollback = rollback_source(state.storage, state.old_profiles, state.old_targets);
let proxy_rollback = restore_generated(state.proxy_path, state.old_proxy_contents);
let singbox_rollback = state
.singbox_path
.map(|path| restore_generated(path, state.old_singbox_contents))
.unwrap_or(Ok(()));
let rollback_ok = source_rollback.is_ok() && proxy_rollback.is_ok() && singbox_rollback.is_ok();
phases.push(if rollback_ok {
phase(
"rollback",
ApplyPhaseStatus::RolledBack,
"Source state и generated artifacts восстановлены.",
)
} else {
phase(
"rollback",
ApplyPhaseStatus::Failed,
"Rollback завершился не полностью; проверьте файлы config/generated.",
)
});
failed_result(
code,
message,
!rollback_ok,
state.proxy_path,
state.singbox_path,
phases,
)
}
fn rollback_source(
storage: &JsonStorage,
profiles: &[Profile],
targets: &[Target],
) -> Result<(), String> {
let targets_result = storage
.write_targets(targets)
.map_err(|error| error.to_string());
let profiles_result = storage
.write_profiles(profiles)
.map_err(|error| error.to_string());
targets_result.and(profiles_result)
}
fn restore_generated(path: &Path, previous: Option<&[u8]>) -> Result<(), String> {
match previous {
Some(contents) => {
safe_fs::write_with_backup(path, contents).map_err(|error| error.to_string())
}
None => match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.to_string()),
},
}
}
fn rollback_phase(result: &Result<(), String>) -> ApplyPhase {
match result {
Ok(()) => phase(
"rollback",
ApplyPhaseStatus::RolledBack,
"Source state восстановлен.",
),
Err(error) => phase(
"rollback",
ApplyPhaseStatus::Failed,
format!("Не удалось полностью восстановить source state: {error}"),
),
}
}
fn failed_result(
code: &str,
message: String,
partial_state: bool,
proxy_path: &Path,
singbox_path: Option<&Path>,
phases: Vec<ApplyPhase>,
) -> ApplyConfigurationResult {
ApplyConfigurationResult {
success: false,
changed: false,
partial_state,
message,
error_code: Some(code.to_string()),
generated_config_path: proxy_path.display().to_string(),
singbox_generated_config_path: singbox_path.map(|path| path.display().to_string()),
restart_required: Vec::new(),
phases,
}
}
fn phase(
id: impl Into<String>,
status: ApplyPhaseStatus,
message: impl Into<String>,
) -> ApplyPhase {
ApplyPhase {
id: id.into(),
status,
message: message.into(),
}
}
fn storage_error(code: &str, error: std::io::Error) -> ApplyFlowError {
ApplyFlowError::failure(code, format!("Ошибка storage: {error}"))
}
fn default_true() -> bool {
true
}
+19
View File
@@ -0,0 +1,19 @@
//! Small injectable time boundary for deterministic activity records.
use std::time::{SystemTime, UNIX_EPOCH};
pub trait Clock {
fn now(&self) -> String;
}
pub struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
format!("unix:{seconds}")
}
}
+542
View File
@@ -0,0 +1,542 @@
//! Serialized Tauri command boundary types.
//!
//! System/domain truth stays in `models`; these DTOs only define the stable
//! camelCase contract exposed to the React webview.
use crate::adapters::singbox::SingBoxCheckResult;
use crate::models::{
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
};
use crate::singbox_service::SingBoxSetupStatus;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminStatusResponse {
pub is_windows: bool,
pub is_elevated: bool,
pub can_restart_elevated: bool,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ValidationIssue {
pub field: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandError {
pub code: String,
pub message: String,
#[serde(default)]
pub details: Vec<ValidationIssue>,
}
impl CommandError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
details: Vec::new(),
}
}
pub fn with_details(
code: impl Into<String>,
message: impl Into<String>,
details: Vec<ValidationIssue>,
) -> Self {
Self {
code: code.into(),
message: message.into(),
details,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StatusResponse {
pub route_line: String,
pub active_profile_count: usize,
pub routed_app_count: usize,
pub active_target: Option<TargetDto>,
pub components: Vec<ComponentStatusDto>,
pub recent_activity: Vec<ActivityEntryDto>,
pub generated_config_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SavedStateResponse {
pub profiles: Vec<ProfileDto>,
pub targets: Vec<TargetDto>,
pub generated_config_path: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartupSnapshotResponse {
pub admin_status: AdminStatusResponse,
pub saved_state: SavedStateResponse,
pub components: Vec<ComponentStatusDto>,
pub proxifyre_setup_status: ProxiFyreSetupStatusDto,
pub singbox_status: LocalSingBoxStatusResponse,
pub singbox_setup_status: SingBoxSetupStatusDto,
}
#[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 ProxiFyreSetupProgressDto {
pub operation: String,
pub status: String,
pub active_step: Option<String>,
pub percent: u8,
pub message: String,
pub updated_at: Option<String>,
}
pub type SingBoxSetupStatusDto = SingBoxSetupStatus;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSingBoxStatusResponse {
pub config: LocalSingBoxConfigDto,
pub cache: Option<SubscriptionCacheDto>,
pub component: ComponentStatusDto,
pub generated_config_path: String,
pub lan_listen_host: Option<String>,
#[cfg(debug_assertions)]
pub subscription_identity: SubscriptionRequestIdentityDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSingBoxConfigDto {
pub subscription_display_url: Option<String>,
pub has_subscription: bool,
pub selected_server_tag: Option<String>,
pub selected_server_id: Option<String>,
pub listen_host: String,
pub listen_port: u16,
pub service_name: String,
pub install_root: String,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionCacheDto {
pub servers: Vec<SubscriptionServerDto>,
pub user_info: serde_json::Map<String, serde_json::Value>,
pub fetched_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionServerDto {
pub id: String,
pub tag: String,
#[serde(rename = "type")]
pub server_type: String,
pub server: String,
pub server_port: u16,
}
#[cfg(debug_assertions)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionRequestIdentityDto {
pub headers: Vec<SubscriptionRequestHeaderDto>,
}
#[cfg(debug_assertions)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionRequestHeaderDto {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveSingBoxSubscriptionInputDto {
pub subscription_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectSingBoxServerInputDto {
#[serde(default)]
pub id: Option<String>,
pub tag: String,
#[serde(default)]
pub server: Option<String>,
#[serde(default)]
pub server_port: Option<u16>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PingSingBoxServerInputDto {
#[serde(default)]
pub id: Option<String>,
pub tag: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PingProxyTargetInputDto {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PingServerResponse {
pub id: String,
pub tag: String,
pub server: String,
pub server_port: u16,
pub ok: bool,
pub latency: Option<u128>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyProbeResponse {
pub id: String,
pub name: String,
pub url: String,
pub ok: bool,
pub status: Option<u16>,
pub latency: Option<u128>,
pub ip: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyTargetCheckResponse {
pub tag: String,
pub server: String,
pub server_port: u16,
pub ok: bool,
pub latency: Option<u128>,
pub error: Option<String>,
pub probes: Vec<ProxyProbeResponse>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GenerateSingBoxConfigResponse {
pub success: bool,
pub message: String,
pub adapter_id: String,
pub generated_config_path: String,
pub selected_server_tag: String,
pub listen_host: String,
pub listen_port: u16,
pub check: Option<SingBoxCheckResult>,
pub activity: ActivityEntryDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInputDto {
pub id: Option<String>,
pub name: String,
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub target_id: Option<String>,
#[serde(default)]
pub protocols: Option<Vec<String>>,
#[serde(default)]
pub items: Option<Vec<ProfileItemInputDto>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileItemInputDto {
#[serde(rename = "type")]
pub item_type: String,
pub value: String,
#[serde(default)]
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetInputDto {
pub id: Option<String>,
pub name: String,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub protocol: Option<String>,
pub host: String,
pub port: u32,
#[serde(default)]
pub requires_component: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileDto {
pub id: String,
pub name: String,
pub enabled: bool,
pub target_id: String,
pub protocols: Vec<Protocol>,
pub items: Vec<ProfileItemDto>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileItemDto {
#[serde(rename = "type")]
pub item_type: ProfileItemType,
pub value: String,
pub recursive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetDto {
pub id: String,
pub name: String,
pub kind: TargetKind,
pub protocol: ProxyProtocol,
pub host: String,
pub port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub requires_component: Option<ComponentId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentStatusDto {
pub id: ComponentId,
pub name: String,
pub state: ComponentState,
pub installed: bool,
pub running: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_status: Option<String>,
pub problems: Vec<String>,
pub actions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ActivityEntryDto {
pub id: String,
pub at: String,
pub level: ActivityLevel,
pub title: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveProfilePreviewResponse {
pub profile_id: String,
pub apps: Vec<ResolvedAppDto>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedAppDto {
pub source_type: ProfileItemType,
pub source_value: String,
pub app_name: String,
pub notes: Vec<String>,
}
impl From<ProfileInputDto> for ProfileInput {
fn from(input: ProfileInputDto) -> Self {
Self {
id: input.id,
name: input.name,
enabled: input.enabled.unwrap_or(true),
target_id: input
.target_id
.unwrap_or_else(|| "local-singbox".to_string()),
protocols: input
.protocols
.unwrap_or_else(|| vec!["TCP".to_string(), "UDP".to_string()]),
items: input
.items
.unwrap_or_default()
.into_iter()
.map(ProfileItemInput::from)
.collect(),
}
}
}
impl From<ProfileItemInputDto> for ProfileItemInput {
fn from(input: ProfileItemInputDto) -> Self {
Self {
item_type: input.item_type,
value: input.value,
recursive: input.recursive,
}
}
}
impl From<TargetInputDto> for TargetInput {
fn from(input: TargetInputDto) -> Self {
Self {
id: input.id,
name: input.name,
kind: input.kind.unwrap_or_else(|| "external".to_string()),
protocol: input.protocol.unwrap_or_else(|| "socks5".to_string()),
host: input.host,
port: input.port,
requires_component: input.requires_component,
}
}
}
impl From<&Profile> for ProfileDto {
fn from(profile: &Profile) -> Self {
Self {
id: profile.id.clone(),
name: profile.name.clone(),
enabled: profile.enabled,
target_id: profile.target_id.clone(),
protocols: profile.protocols.clone(),
items: profile.items.iter().map(ProfileItemDto::from).collect(),
}
}
}
impl From<&ProfileItem> for ProfileItemDto {
fn from(item: &ProfileItem) -> Self {
Self {
item_type: item.item_type.clone(),
value: item.value.clone(),
recursive: item.recursive,
}
}
}
impl From<&Target> for TargetDto {
fn from(target: &Target) -> Self {
Self {
id: target.id.clone(),
name: target.name.clone(),
kind: target.kind.clone(),
protocol: target.protocol.clone(),
host: target.host.clone(),
port: target.port,
requires_component: target.requires_component.clone(),
}
}
}
impl From<&ComponentStatus> for ComponentStatusDto {
fn from(component: &ComponentStatus) -> Self {
Self {
id: component.id.clone(),
name: component.name.clone(),
state: component.state.clone(),
installed: component.installed,
running: component.running,
version: component.version.clone(),
path: component.path.clone(),
service_name: component.service_name.clone(),
service_status: component.service_status.clone(),
problems: component.problems.clone(),
actions: component.actions.clone(),
}
}
}
impl From<&ActivityEntry> for ActivityEntryDto {
fn from(entry: &ActivityEntry) -> Self {
Self {
id: entry.id.clone(),
at: entry.at.clone(),
level: entry.level.clone(),
title: entry.title.clone(),
message: entry.message.clone(),
}
}
}
impl From<&LocalSingBoxConfig> for LocalSingBoxConfigDto {
fn from(config: &LocalSingBoxConfig) -> Self {
Self {
subscription_display_url: config.subscription_display_url(),
has_subscription: config
.subscription_url
.as_deref()
.is_some_and(|value| !value.trim().is_empty()),
selected_server_tag: config.selected_server_tag.clone(),
selected_server_id: config.selected_server_id.clone(),
listen_host: config.listen_host.clone(),
listen_port: config.listen_port,
service_name: config.service_name.clone(),
install_root: config.install_root.clone(),
updated_at: config.updated_at.clone(),
}
}
}
impl From<&SubscriptionCache> for SubscriptionCacheDto {
fn from(cache: &SubscriptionCache) -> Self {
Self {
servers: cache
.servers
.iter()
.map(SubscriptionServerDto::from)
.collect(),
user_info: cache.user_info.clone(),
fetched_at: cache.fetched_at.clone(),
}
}
}
impl From<&SubscriptionServer> for SubscriptionServerDto {
fn from(server: &SubscriptionServer) -> Self {
Self {
id: server.id.clone(),
tag: server.tag.clone(),
server_type: server.server_type.clone(),
server: server.server.clone(),
server_port: server.server_port,
}
}
}
+78 -4666
View File
File diff suppressed because it is too large Load Diff
+54 -23
View File
@@ -30,10 +30,12 @@ pub struct DetectedProxyfier {
pub service_status: Option<String>, pub service_status: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DetectedService { pub struct DetectedService {
pub name: String, pub name: String,
pub status: String, pub status: String,
pub path_name: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -63,6 +65,15 @@ pub trait ProxyfierDetectionHost {
fn service_status(&self, service_name: &str) -> Option<String>; fn service_status(&self, service_name: &str) -> Option<String>;
fn service_info(&self, service_name: &str) -> Option<DetectedService> {
self.service_status(service_name)
.map(|status| DetectedService {
name: service_name.to_string(),
status,
path_name: None,
})
}
fn service_running(&self, service_name: &str) -> bool { fn service_running(&self, service_name: &str) -> bool {
self.service_status(service_name) self.service_status(service_name)
.is_some_and(|status| service_status_is_running(&status)) .is_some_and(|status| service_status_is_running(&status))
@@ -102,6 +113,15 @@ impl ProxyfierDetectionHost for SystemProxyfierDetectionHost {
powershell_text(&script).map(|status| status.to_ascii_lowercase()) powershell_text(&script).map(|status| status.to_ascii_lowercase())
} }
fn service_info(&self, service_name: &str) -> Option<DetectedService> {
let script = format!(
"$s = Get-CimInstance Win32_Service -Filter \"Name='{}'\" -ErrorAction SilentlyContinue; if ($s) {{ [ordered]@{{ name = $s.Name; status = $s.State; pathName = $s.PathName }} | ConvertTo-Json -Compress }}",
escape_powershell_single(service_name)
);
let json = powershell_text(&script)?;
serde_json::from_str(&json).ok()
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> { fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
read_registry_install_entries() read_registry_install_entries()
} }
@@ -148,16 +168,9 @@ pub fn default_singbox_install_dir() -> PathBuf {
pub fn detect_proxyfier_install_with_host( pub fn detect_proxyfier_install_with_host(
host: &impl ProxyfierDetectionHost, host: &impl ProxyfierDetectionHost,
) -> Option<DetectedProxyfier> { ) -> Option<DetectedProxyfier> {
let 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) proxyfier_candidates(host)
.into_iter() .into_iter()
.filter_map(|candidate| { .filter_map(|candidate| candidate.into_detected(host))
candidate.into_detected(host, proxifyre_running, detected_service.as_ref())
})
.next() .next()
} }
@@ -332,23 +345,24 @@ struct ProxyfierCandidate {
} }
impl ProxyfierCandidate { impl ProxyfierCandidate {
fn into_detected( fn into_detected(self, host: &impl ProxyfierDetectionHost) -> Option<DetectedProxyfier> {
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 executable_path = self.install_dir.join(executable_name(&self.engine));
let config_path = config_path(&self.engine, &self.install_dir); let config_path = config_path(&self.engine, &self.install_dir);
if !host.path_exists(&executable_path) { if !host.path_exists(&executable_path) {
return None; return None;
} }
let detected_service = detect_proxifyre_service(host, &executable_path);
let proxifyre_running = detected_service
.as_ref()
.is_some_and(|service| service_status_is_running(&service.status));
Some(DetectedProxyfier { Some(DetectedProxyfier {
service_name: detected_service service_name: detected_service
.map(|service| service.name.clone()) .as_ref()
.or_else(|| service_name(&self.engine).map(str::to_string)), .map(|service| service.name.clone()),
service_status: detected_service.map(|service| service.status.clone()), service_status: detected_service
.as_ref()
.map(|service| service.status.clone()),
engine: self.engine, engine: self.engine,
name: self.name, name: self.name,
install_dir: self.install_dir, install_dir: self.install_dir,
@@ -539,19 +553,36 @@ fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> {
} }
} }
fn detect_proxifyre_service(host: &impl ProxyfierDetectionHost) -> Option<DetectedService> { fn detect_proxifyre_service(
host: &impl ProxyfierDetectionHost,
executable_path: &Path,
) -> Option<DetectedService> {
for name in ["ProxiFyreService", "ProxiFyre"] { for name in ["ProxiFyreService", "ProxiFyre"] {
if let Some(status) = host.service_status(name) { if let Some(mut service) = host.service_info(name) {
return Some(DetectedService { let matches_executable = service.path_name.as_deref().is_some_and(|path_name| {
name: name.to_string(), service_path_matches_executable(path_name, executable_path)
status: normalize_service_status(&status),
}); });
if matches_executable {
service.status = normalize_service_status(&service.status);
return Some(service);
}
} }
} }
None None
} }
pub fn service_path_matches_executable(path_name: &str, executable_path: &Path) -> bool {
let path_name = path_name.trim();
let candidate = if let Some(rest) = path_name.strip_prefix('"') {
rest.split_once('"').map(|(path, _)| path)
} else {
path_name.split_whitespace().next()
};
candidate.is_some_and(|candidate| same_path(Path::new(candidate), executable_path))
}
fn normalize_service_status(status: &str) -> String { fn normalize_service_status(status: &str) -> String {
status.trim().to_ascii_lowercase() status.trim().to_ascii_lowercase()
} }
+156
View File
@@ -0,0 +1,156 @@
//! Live component status resolution and read-only route/profile presentation.
use crate::command_dto::{CommandError, ResolvedAppDto};
use crate::component_detection::{
detect_proxyfier_install, detect_singbox_install, proxyfier_component_from_detection,
singbox_component_from_detection, DetectedProxyfier, DetectedSingBox,
};
use crate::models::{
ComponentId, ComponentState, ComponentStatus, ProfileItem, ProfileItemType, Target,
};
use crate::storage::JsonStorage;
pub(crate) fn components_or_defaults(
storage: &JsonStorage,
) -> Result<Vec<ComponentStatus>, CommandError> {
components_or_defaults_with_detection(
storage,
detect_proxyfier_install(),
detect_singbox_install(),
)
}
pub(crate) fn components_or_defaults_with_detection(
storage: &JsonStorage,
detected_proxyfier: Option<DetectedProxyfier>,
detected_singbox: Option<DetectedSingBox>,
) -> Result<Vec<ComponentStatus>, CommandError> {
let components = storage.read_components().map_err(storage_error)?;
Ok(resolve_component_statuses(
components,
detected_proxyfier,
detected_singbox,
))
}
pub fn resolve_component_statuses(
stored_components: Vec<ComponentStatus>,
detected_proxyfier: Option<DetectedProxyfier>,
detected_singbox: Option<DetectedSingBox>,
) -> Vec<ComponentStatus> {
let mut components = default_components();
for component in stored_components {
upsert_component(&mut components, component);
}
upsert_component(
&mut components,
proxyfier_component_from_detection(detected_proxyfier.as_ref()),
);
upsert_component(
&mut components,
singbox_component_from_detection(detected_singbox.as_ref()),
);
components
}
fn default_components() -> Vec<ComponentStatus> {
vec![
ComponentStatus {
id: ComponentId::ControlApp,
name: "Приложение управления".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: None,
path: None,
service_name: None,
service_status: None,
problems: Vec::new(),
actions: vec![
"Открыть журнал".to_string(),
"Скопировать диагностику".to_string(),
],
},
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Missing,
installed: false,
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()],
},
ComponentStatus {
id: ComponentId::Singbox,
name: "Локальный sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
service_name: Some(crate::models::DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()),
service_status: None,
problems: Vec::new(),
actions: vec!["Установить локальный sing-box".to_string()],
},
]
}
fn upsert_component(components: &mut Vec<ComponentStatus>, component: ComponentStatus) {
match components
.iter()
.position(|existing| existing.id == component.id)
{
Some(index) => components[index] = component,
None => components.push(component),
}
}
pub(crate) fn route_line(active_target: Option<&Target>) -> String {
match active_target {
Some(target) if target.id == "local-singbox" => {
format!(
"Выбранные приложения -> ProxiFyre -> локальный sing-box {}:{} -> VPN",
target.host, target.port
)
}
Some(target) => format!(
"Выбранные приложения -> ProxiFyre -> внешний прокси {}:{}",
target.host, target.port
),
None => "Выбранные приложения -> ProxiFyre -> внешний прокси".to_string(),
}
}
pub(crate) fn resolved_app(item: &ProfileItem, warnings: &mut Vec<String>) -> ResolvedAppDto {
let mut notes = Vec::new();
match item.item_type {
ProfileItemType::Process => notes.push("Имя процесса используется напрямую".to_string()),
ProfileItemType::Folder => {
let note = "Сканирование папок отложено; ProxiFyre получает путь к папке";
notes.push(note.to_string());
warnings.push(note.to_string());
}
ProfileItemType::Exe => {
notes.push("Путь к EXE сохраняется для сопоставления в ProxiFyre".to_string())
}
}
ResolvedAppDto {
source_type: item.item_type.clone(),
source_value: item.value.clone(),
app_name: item.value.clone(),
notes,
}
}
fn storage_error(error: std::io::Error) -> CommandError {
CommandError::new("storage_error", error.to_string())
}
+430
View File
@@ -0,0 +1,430 @@
//! Persisted profiles/targets, startup snapshot, ProxiFyre bootstrap import, and preview use cases.
use crate::adapters::proxifyre::{ProxiFyreConfig, ProxiFyreProxy};
use crate::admin::admin_status;
use crate::command_dto::*;
use crate::component_detection::{
default_proxifyre_install_dir, default_singbox_install_dir, detect_proxyfier_install,
detect_singbox_install,
};
use crate::component_status::{
components_or_defaults, resolve_component_statuses, resolved_app, route_line,
};
use crate::models::{
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
};
use crate::proxifyre_runtime::build_proxifyre_setup_status_with_detection;
use crate::singbox_service::build_singbox_setup_status_with_install_root;
use crate::singbox_subscription::read_singbox_status_with_detection;
use crate::storage::JsonStorage;
use crate::validation::{normalize_profile, normalize_target, ValidationError};
use std::fs;
use std::path::Path;
const MAIN_PROFILE_ID: &str = "main-profile";
const MAIN_TARGET_ID: &str = "main-proxy";
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)?;
let components = components_or_defaults(storage)?;
let activity = storage.read_activity().map_err(storage_error)?;
let active_profile_count = profiles.iter().filter(|profile| profile.enabled).count();
let routed_app_count = profiles
.iter()
.filter(|profile| profile.enabled)
.map(|profile| profile.items.len())
.sum();
let active_target = profiles
.iter()
.find(|profile| profile.enabled)
.and_then(|profile| targets.iter().find(|target| target.id == profile.target_id));
let route_line = route_line(active_target);
Ok(StatusResponse {
route_line,
active_profile_count,
routed_app_count,
active_target: active_target.map(TargetDto::from),
components: components.iter().map(ComponentStatusDto::from).collect(),
recent_activity: activity
.iter()
.take(10)
.map(ActivityEntryDto::from)
.collect(),
generated_config_path: storage
.paths()
.generated_dir
.join("proxifyre-app-config.json")
.display()
.to_string(),
})
}
pub fn read_profiles(storage: &JsonStorage) -> Result<Vec<ProfileDto>, CommandError> {
storage
.read_profiles()
.map_err(storage_error)
.map(|profiles| profiles.iter().map(ProfileDto::from).collect())
}
pub fn save_profile_to_storage(
storage: &JsonStorage,
input: ProfileInputDto,
) -> Result<ProfileDto, CommandError> {
let profile = normalize_profile(input.into()).map_err(validation_error)?;
let mut profiles = storage.read_profiles().map_err(storage_error)?;
match profiles
.iter()
.position(|existing| existing.id == profile.id)
{
Some(index) => profiles[index] = profile.clone(),
None => profiles.push(profile.clone()),
}
storage.write_profiles(&profiles).map_err(storage_error)?;
Ok(ProfileDto::from(&profile))
}
pub fn read_targets(storage: &JsonStorage) -> Result<Vec<TargetDto>, CommandError> {
storage
.read_targets()
.map_err(storage_error)
.map(|targets| targets.iter().map(TargetDto::from).collect())
}
pub fn save_target_to_storage(
storage: &JsonStorage,
input: TargetInputDto,
) -> Result<TargetDto, CommandError> {
let target = normalize_target(input.into()).map_err(validation_error)?;
let mut targets = storage.read_targets().map_err(storage_error)?;
match targets.iter().position(|existing| existing.id == target.id) {
Some(index) => targets[index] = target.clone(),
None => targets.push(target.clone()),
}
storage.write_targets(&targets).map_err(storage_error)?;
Ok(TargetDto::from(&target))
}
pub fn read_components(storage: &JsonStorage) -> Result<Vec<ComponentStatusDto>, CommandError> {
components_or_defaults(storage).map(|components| {
components
.iter()
.map(ComponentStatusDto::from)
.collect::<Vec<_>>()
})
}
pub fn read_startup_snapshot(
storage: &JsonStorage,
) -> Result<StartupSnapshotResponse, CommandError> {
let detected_proxyfier = detect_proxyfier_install();
let detected_singbox = detect_singbox_install();
let saved_state = read_saved_state_with_proxifyre_config(
storage,
detected_proxyfier
.as_ref()
.and_then(|detected| detected.config_path.as_deref()),
)?;
let stored_components = storage.read_components().map_err(storage_error)?;
let components = resolve_component_statuses(
stored_components,
detected_proxyfier.clone(),
detected_singbox.clone(),
)
.iter()
.map(ComponentStatusDto::from)
.collect();
let proxifyre_setup_status = build_proxifyre_setup_status_with_detection(
detected_proxyfier.as_ref(),
&default_proxifyre_install_dir(),
);
let singbox_status = read_singbox_status_with_detection(storage, detected_singbox.as_ref())?;
let singbox_setup_status = build_singbox_setup_status_with_install_root(
detected_singbox.as_ref(),
&default_singbox_install_dir(),
);
Ok(StartupSnapshotResponse {
admin_status: admin_status(),
saved_state,
components,
proxifyre_setup_status,
singbox_status,
singbox_setup_status,
})
}
pub fn read_activity(storage: &JsonStorage) -> Result<Vec<ActivityEntryDto>, CommandError> {
storage
.read_activity()
.map_err(storage_error)
.map(|entries| entries.iter().map(ActivityEntryDto::from).collect())
}
pub fn read_saved_state(storage: &JsonStorage) -> Result<SavedStateResponse, CommandError> {
let detected_config_path = detect_proxyfier_install().and_then(|detected| detected.config_path);
read_saved_state_with_proxifyre_config(storage, detected_config_path.as_deref())
}
pub fn read_saved_state_with_proxifyre_config(
storage: &JsonStorage,
proxifyre_config_path: Option<&Path>,
) -> Result<SavedStateResponse, CommandError> {
let mut profiles = storage.read_profiles().map_err(storage_error)?;
let mut targets = storage.read_targets().map_err(storage_error)?;
if should_bootstrap_profiles(&profiles) {
if let Some(imported) =
proxifyre_config_path.and_then(import_saved_state_from_proxifyre_config)
{
profiles = imported.profiles;
upsert_targets(&mut targets, imported.targets);
storage.write_targets(&targets).map_err(storage_error)?;
storage.write_profiles(&profiles).map_err(storage_error)?;
}
}
Ok(SavedStateResponse {
profiles: profiles.iter().map(ProfileDto::from).collect(),
targets: targets.iter().map(TargetDto::from).collect(),
generated_config_path: storage
.paths()
.generated_dir
.join("proxifyre-app-config.json")
.display()
.to_string(),
})
}
struct ImportedSavedState {
profiles: Vec<Profile>,
targets: Vec<Target>,
}
fn should_bootstrap_profiles(profiles: &[Profile]) -> bool {
!profiles
.iter()
.any(|profile| profile.enabled && !profile.items.is_empty())
}
fn import_saved_state_from_proxifyre_config(path: &Path) -> Option<ImportedSavedState> {
let contents = fs::read_to_string(path).ok()?;
let config: ProxiFyreConfig = serde_json::from_str(&contents).ok()?;
let proxy_entries = config
.proxies
.iter()
.filter_map(import_proxy_entry)
.collect::<Vec<_>>();
if proxy_entries.is_empty() {
return None;
}
let single_entry = proxy_entries.len() == 1;
let mut profiles = Vec::with_capacity(proxy_entries.len());
let mut targets = Vec::with_capacity(proxy_entries.len());
for (index, entry) in proxy_entries.into_iter().enumerate() {
let ordinal = index + 1;
let target_id = if single_entry {
MAIN_TARGET_ID.to_string()
} else {
format!("proxifyre-import-target-{ordinal}")
};
let profile_id = if single_entry {
MAIN_PROFILE_ID.to_string()
} else {
format!("proxifyre-import-profile-{ordinal}")
};
let profile_name = if single_entry {
"Приложения через прокси".to_string()
} else {
format!("Импорт ProxiFyre {ordinal}")
};
targets.push(Target {
id: target_id.clone(),
name: if single_entry {
"Основной прокси".to_string()
} else {
format!("Прокси ProxiFyre {ordinal}")
},
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: entry.host,
port: entry.port,
requires_component: None,
});
profiles.push(Profile {
id: profile_id,
name: profile_name,
enabled: true,
target_id,
protocols: entry.protocols,
items: entry.items,
});
}
Some(ImportedSavedState { profiles, targets })
}
struct ImportedProxyEntry {
items: Vec<ProfileItem>,
protocols: Vec<Protocol>,
host: String,
port: u16,
}
fn import_proxy_entry(proxy: &ProxiFyreProxy) -> Option<ImportedProxyEntry> {
let items = proxy
.app_names
.iter()
.filter_map(|name| imported_profile_item(name))
.collect::<Vec<_>>();
if items.is_empty() {
return None;
}
let (host, port) = parse_socks5_endpoint(&proxy.socks5_proxy_endpoint)?;
Some(ImportedProxyEntry {
items,
protocols: imported_protocols(&proxy.supported_protocols),
host,
port,
})
}
fn imported_profile_item(raw_value: &str) -> Option<ProfileItem> {
let value = raw_value.trim().trim_matches('"');
if value.is_empty() {
return None;
}
let looks_like_path = value.contains('\\') || value.contains('/');
let item_type = if looks_like_path && value.to_ascii_lowercase().ends_with(".exe") {
ProfileItemType::Exe
} else if looks_like_path {
ProfileItemType::Folder
} else {
ProfileItemType::Process
};
let value = match item_type {
ProfileItemType::Process => {
let base = value.rsplit(['\\', '/']).next().unwrap_or(value);
if base.to_ascii_lowercase().ends_with(".exe") {
base[..base.len() - 4].to_string()
} else {
base.to_string()
}
}
ProfileItemType::Folder | ProfileItemType::Exe => value.to_string(),
};
if value.is_empty() {
return None;
}
Some(ProfileItem {
recursive: matches!(item_type, ProfileItemType::Folder),
item_type,
value,
})
}
fn imported_protocols(values: &[String]) -> Vec<Protocol> {
let mut protocols = Vec::new();
for value in values {
let protocol = match value.trim().to_ascii_uppercase().as_str() {
"TCP" => Protocol::Tcp,
"UDP" => Protocol::Udp,
_ => continue,
};
if !protocols.contains(&protocol) {
protocols.push(protocol);
}
}
if protocols.is_empty() {
vec![Protocol::Tcp, Protocol::Udp]
} else {
protocols
}
}
fn parse_socks5_endpoint(endpoint: &str) -> Option<(String, u16)> {
let endpoint = endpoint.trim();
let endpoint = if endpoint
.get(.."socks5://".len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("socks5://"))
{
&endpoint["socks5://".len()..]
} else {
endpoint
};
if endpoint.is_empty() {
return None;
}
if let Some(rest) = endpoint.strip_prefix('[') {
let (host, rest) = rest.split_once(']')?;
let port = rest.strip_prefix(':')?.parse::<u16>().ok()?;
let host = host.trim();
return (!host.is_empty()).then(|| (host.to_string(), port));
}
let (host, port) = endpoint.rsplit_once(':')?;
let host = host.trim();
let port = port.trim().parse::<u16>().ok()?;
(!host.is_empty()).then(|| (host.to_string(), port))
}
fn upsert_targets(targets: &mut Vec<Target>, imported_targets: Vec<Target>) {
for target in imported_targets {
match targets.iter().position(|existing| existing.id == target.id) {
Some(index) => targets[index] = target,
None => targets.push(target),
}
}
}
pub fn resolve_preview(
input: ProfileInputDto,
) -> Result<ResolveProfilePreviewResponse, CommandError> {
let profile = normalize_profile(input.into()).map_err(validation_error)?;
let mut warnings = Vec::new();
let apps = profile
.items
.iter()
.map(|item| resolved_app(item, &mut warnings))
.collect();
Ok(ResolveProfilePreviewResponse {
profile_id: profile.id,
apps,
warnings,
})
}
fn storage_error(error: std::io::Error) -> CommandError {
CommandError::new("storage_error", error.to_string())
}
fn validation_error(errors: Vec<ValidationError>) -> CommandError {
CommandError::with_details(
"validation_error",
"Проверка введенных данных не прошла",
errors
.into_iter()
.map(|error| ValidationIssue {
field: error.field,
message: error.message,
})
.collect(),
)
}
+16 -10
View File
@@ -1,12 +1,27 @@
pub mod activity; pub mod activity;
pub mod admin;
pub mod apply_flow;
pub mod clock;
pub mod command_dto;
pub mod commands; pub mod commands;
pub mod component_detection; pub mod component_detection;
pub mod component_status;
pub mod configuration_use_case;
pub mod elevated_scripts; pub mod elevated_scripts;
pub mod helper; pub mod helper;
pub mod models; pub mod models;
mod powershell;
pub mod process; pub mod process;
pub mod proxifyre_ownership;
pub mod proxifyre_runtime;
pub mod proxifyre_scripts;
pub mod proxy_apply;
pub mod proxy_probe;
pub mod safe_fs; pub mod safe_fs;
pub mod singbox_config;
pub mod singbox_runtime;
pub mod singbox_service; pub mod singbox_service;
pub mod singbox_subscription;
pub mod storage; pub mod storage;
pub mod subscription; pub mod subscription;
pub mod validation; pub mod validation;
@@ -22,21 +37,14 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.manage(commands::CommandState::default()) .manage(commands::CommandState::default())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::get_status,
commands::get_admin_status,
commands::restart_as_admin, commands::restart_as_admin,
commands::get_startup_snapshot, commands::get_startup_snapshot,
commands::get_profiles,
commands::get_saved_state, commands::get_saved_state,
commands::save_profile,
commands::get_targets,
commands::save_target,
commands::get_components, commands::get_components,
commands::get_proxifyre_setup_status, commands::get_proxifyre_setup_status,
commands::get_proxifyre_setup_progress, commands::get_proxifyre_setup_progress,
commands::get_singbox_status, commands::get_singbox_status,
commands::get_singbox_setup_status, commands::get_singbox_setup_status,
commands::resolve_profile_preview,
commands::save_singbox_subscription, commands::save_singbox_subscription,
commands::fetch_singbox_subscription, commands::fetch_singbox_subscription,
commands::forget_singbox_subscription, commands::forget_singbox_subscription,
@@ -45,9 +53,7 @@ pub fn run() {
commands::ping_all_singbox_servers, commands::ping_all_singbox_servers,
commands::ping_proxy_target, commands::ping_proxy_target,
commands::generate_singbox_config, commands::generate_singbox_config,
commands::apply_profiles, commands::apply_configuration,
commands::get_logs,
commands::open_config_location,
commands::start_proxifyre_service, commands::start_proxifyre_service,
commands::stop_proxifyre_service, commands::stop_proxifyre_service,
commands::install_proxifyre, commands::install_proxifyre,
+37
View File
@@ -57,6 +57,7 @@ pub enum ComponentState {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileItemInput { pub struct ProfileItemInput {
#[serde(rename = "type")] #[serde(rename = "type")]
pub item_type: String, pub item_type: String,
@@ -66,6 +67,7 @@ pub struct ProfileItemInput {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInput { pub struct ProfileInput {
pub id: Option<String>, pub id: Option<String>,
pub name: String, pub name: String,
@@ -98,6 +100,7 @@ pub struct Profile {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetInput { pub struct TargetInput {
pub id: Option<String>, pub id: Option<String>,
pub name: String, pub name: String,
@@ -149,6 +152,8 @@ pub struct LocalSingBoxConfig {
pub device_hwid: Option<String>, pub device_hwid: Option<String>,
#[serde(default)] #[serde(default)]
pub selected_server_tag: Option<String>, pub selected_server_tag: Option<String>,
#[serde(default)]
pub selected_server_id: Option<String>,
#[serde(default = "default_local_singbox_listen_host")] #[serde(default = "default_local_singbox_listen_host")]
pub listen_host: String, pub listen_host: String,
#[serde(default = "default_local_singbox_listen_port")] #[serde(default = "default_local_singbox_listen_port")]
@@ -181,6 +186,7 @@ impl Default for LocalSingBoxConfig {
subscription_url: None, subscription_url: None,
device_hwid: None, device_hwid: None,
selected_server_tag: None, selected_server_tag: None,
selected_server_id: None,
listen_host: default_local_singbox_listen_host(), listen_host: default_local_singbox_listen_host(),
listen_port: default_local_singbox_listen_port(), listen_port: default_local_singbox_listen_port(),
service_name: default_local_singbox_service_name(), service_name: default_local_singbox_service_name(),
@@ -204,6 +210,7 @@ impl SubscriptionCache {
pub fn normalize_percent_encoded_tags(&mut self) { pub fn normalize_percent_encoded_tags(&mut self) {
for server in &mut self.servers { for server in &mut self.servers {
server.tag = decode_percent_encoded_utf8(&server.tag); server.tag = decode_percent_encoded_utf8(&server.tag);
server.ensure_id();
} }
let Some(outbounds) = self let Some(outbounds) = self
@@ -232,6 +239,8 @@ impl SubscriptionCache {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscriptionServer { pub struct SubscriptionServer {
#[serde(default)]
pub id: String,
pub tag: String, pub tag: String,
#[serde(rename = "type")] #[serde(rename = "type")]
pub server_type: String, pub server_type: String,
@@ -239,6 +248,34 @@ pub struct SubscriptionServer {
pub server_port: u16, pub server_port: u16,
} }
impl SubscriptionServer {
pub fn ensure_id(&mut self) {
if self.id.trim().is_empty() {
self.id = subscription_server_id(
&self.server_type,
&self.tag,
&self.server,
self.server_port,
);
}
}
}
pub fn subscription_server_id(
server_type: &str,
tag: &str,
server: &str,
server_port: u16,
) -> String {
format!(
"{}|{}|{}|{}",
server_type.trim().to_ascii_lowercase(),
tag.trim(),
server.trim().to_ascii_lowercase(),
server_port
)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActivityEntry { pub struct ActivityEntry {
pub id: String, pub id: String,
+120
View File
@@ -0,0 +1,120 @@
//! Shared PowerShell execution boundary for fixed ProxyWarden scripts.
//!
//! Callers remain responsible for generating static script templates and for
//! validating every path or service identifier before invoking this module.
use crate::process::command_no_window;
use std::{fs, path::Path, process::Output};
pub(crate) fn write_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 run_command(script: &str) -> std::io::Result<Output> {
command_no_window("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
])
.output()
}
pub(crate) fn run_file(script_path: &Path) -> std::io::Result<Output> {
command_no_window("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(script_path)
.output()
}
pub(crate) fn is_elevated() -> bool {
if !cfg!(windows) {
return false;
}
let script = r#"([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#;
let Ok(output) = run_command(script) else {
return false;
};
output.status.success()
&& String::from_utf8_lossy(&output.stdout)
.trim()
.eq_ignore_ascii_case("true")
}
pub(crate) fn output_message(output: &Output, fallback: &str) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
return stderr;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stdout.is_empty() {
return stdout;
}
fallback.to_string()
}
pub(crate) 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 = compact_error_text(&String::from_utf8_lossy(&output.stdout));
if !stdout.is_empty() {
parts.push(format!("stdout: {stdout}"));
}
let stderr = compact_error_text(&String::from_utf8_lossy(&output.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(" ");
const MAX_CHARS: usize = 1400;
if text.chars().count() <= MAX_CHARS {
return text;
}
format!("{}...", text.chars().take(MAX_CHARS).collect::<String>())
}
pub(crate) fn escape_single(value: &str) -> String {
value.replace('\'', "''")
}
+104
View File
@@ -0,0 +1,104 @@
//! Ownership proof for destructive ProxiFyre uninstall operations.
use serde::Deserialize;
use std::{fs, path::Path};
pub const PROXIFYRE_MARKER_FILE: &str = "proxywarden-component.json";
pub const PROXIFYRE_MANAGED_SERVICE_NAME: &str = "ProxiFyreService";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManagedProxiFyreOwnership {
pub service_name: String,
pub remove_packet_filter: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProxiFyreInstallMarker {
manager: String,
component: String,
service_name: String,
install_root: String,
#[serde(default)]
packet_filter_installed_by_proxy_warden: bool,
}
pub fn verify_managed_proxifyre_install(
install_dir: &Path,
executable_path: &Path,
expected_install_dir: &Path,
) -> Result<ManagedProxiFyreOwnership, String> {
let install_dir = canonical_path(install_dir, "папку ProxiFyre")?;
let expected_install_dir = canonical_path(expected_install_dir, "ожидаемую папку ProxiFyre")?;
if install_dir != expected_install_dir {
return Err(format!(
"папка {} не является управляемой папкой {}",
install_dir.display(),
expected_install_dir.display()
));
}
let has_expected_shape = install_dir
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("ProxiFyre"))
&& install_dir
.parent()
.and_then(Path::file_name)
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("components"));
if !has_expected_shape {
return Err("управляемая папка должна оканчиваться на components\\ProxiFyre".to_string());
}
let executable_path = canonical_path(executable_path, "ProxiFyre.exe")?;
if executable_path.parent() != Some(install_dir.as_path())
|| !executable_path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("ProxiFyre.exe"))
{
return Err("обнаруженный ProxiFyre.exe находится вне управляемой папки".to_string());
}
let marker_path = install_dir.join(PROXIFYRE_MARKER_FILE);
let marker_text = fs::read_to_string(&marker_path).map_err(|error| {
format!(
"не удалось прочитать marker установки {}: {error}",
marker_path.display()
)
})?;
let marker: ProxiFyreInstallMarker = serde_json::from_str(&marker_text).map_err(|error| {
format!(
"marker установки {} содержит некорректный JSON: {error}",
marker_path.display()
)
})?;
if !marker.manager.eq_ignore_ascii_case("ProxyWarden")
|| !marker.component.eq_ignore_ascii_case("proxifyre")
{
return Err("marker установки не подтверждает владение ProxyWarden/ProxiFyre".to_string());
}
if !marker
.service_name
.eq_ignore_ascii_case(PROXIFYRE_MANAGED_SERVICE_NAME)
{
return Err("marker установки содержит неподдерживаемое имя службы".to_string());
}
let marker_root = canonical_path(Path::new(&marker.install_root), "installRoot из marker")?;
if marker_root != install_dir {
return Err("installRoot из marker не совпадает с управляемой папкой".to_string());
}
Ok(ManagedProxiFyreOwnership {
service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_string(),
remove_packet_filter: marker.packet_filter_installed_by_proxy_warden,
})
}
fn canonical_path(path: &Path, label: &str) -> Result<std::path::PathBuf, String> {
fs::canonicalize(path)
.map_err(|error| format!("не удалось проверить {label} '{}': {error}", path.display()))
}
File diff suppressed because it is too large Load Diff
+647
View File
@@ -0,0 +1,647 @@
//! Static-template PowerShell generation for explicit ProxiFyre package actions.
use crate::component_detection::{default_proxifyre_install_dir, DetectedProxyfier};
use crate::powershell::escape_single as escape_powershell_single;
use crate::proxifyre_ownership::ManagedProxiFyreOwnership;
use std::path::Path;
const PROXIFYRE_RELEASE_API_URL: &str =
"https://api.github.com/repos/wiresock/proxifyre/releases/latest";
const NDISAPI_RELEASE_API_URL: &str =
"https://api.github.com/repos/wiresock/ndisapi/releases/latest";
const PROXIFYRE_PINNED_RELEASE_TAG: &str = "v2.2.1";
const NDISAPI_PINNED_RELEASE_TAG: &str = "v3.6.2";
const NDISAPI_PINNED_INSTALLER_VERSION: &str = "3.6.2.1";
const VC_REDIST_X64_URL: &str = "https://aka.ms/vc14/vc_redist.x64.exe";
const VC_REDIST_X86_URL: &str = "https://aka.ms/vc14/vc_redist.x86.exe";
pub fn install_proxifyre_script(generated_config_path: &Path) -> String {
install_proxifyre_script_with_bundle(generated_config_path, None)
}
pub fn install_proxifyre_script_with_bundle(
generated_config_path: &Path,
bundled_asset_dir: Option<&Path>,
) -> String {
install_proxifyre_script_for_target(
generated_config_path,
bundled_asset_dir,
&default_proxifyre_install_dir(),
)
}
pub fn install_proxifyre_script_for_target(
generated_config_path: &Path,
bundled_asset_dir: Option<&Path>,
target_dir: &Path,
) -> String {
let mut script = String::new();
script.push_str(&format!(
"$targetDir = '{}'\n",
escape_powershell_single(&target_dir.display().to_string())
));
script.push_str(&format!(
"$generatedConfigPath = '{}'\n",
escape_powershell_single(&generated_config_path.display().to_string())
));
script.push_str(&format!(
"$bundledAssetDir = '{}'\n",
escape_powershell_single(
&bundled_asset_dir
.map(|path| path.display().to_string())
.unwrap_or_default()
)
));
script.push_str("$script:bundledAssetDir = [string]$bundledAssetDir\n");
script.push_str(&format!(
"$proxifyreReleaseApi = '{}'\n",
escape_powershell_single(PROXIFYRE_RELEASE_API_URL)
));
script.push_str(&format!(
"$ndisapiReleaseApi = '{}'\n",
escape_powershell_single(NDISAPI_RELEASE_API_URL)
));
script.push_str(&format!(
"$proxifyrePinnedReleaseTag = '{}'\n",
escape_powershell_single(PROXIFYRE_PINNED_RELEASE_TAG)
));
script.push_str(&format!(
"$ndisapiPinnedReleaseTag = '{}'\n",
escape_powershell_single(NDISAPI_PINNED_RELEASE_TAG)
));
script.push_str(&format!(
"$ndisapiPinnedInstallerVersion = '{}'\n",
escape_powershell_single(NDISAPI_PINNED_INSTALLER_VERSION)
));
script.push_str(&format!(
"$vcRedistX64Url = '{}'\n",
escape_powershell_single(VC_REDIST_X64_URL)
));
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 Get-SafeUriForLog([string]$uri) {
try {
$parsed = [Uri]$uri
$port = if ($parsed.IsDefaultPort) { '' } else { ":$($parsed.Port)" }
return "$($parsed.Scheme)://$($parsed.Host)$port$($parsed.AbsolutePath)"
} catch {
return '<invalid-url>'
}
}
function Invoke-ReleaseApi([string]$uri, [string]$label) {
$safeUri = Get-SafeUriForLog $uri
$headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/vnd.github+json' }
$lastError = $null
foreach ($attempt in 1..3) {
try {
return Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 60 -MaximumRedirection 10
} catch {
$lastError = $_.Exception.Message
if ($attempt -lt 3) {
Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2))
}
}
}
throw "Не удалось получить metadata для $label ($safeUri): $lastError"
}
function New-ReleaseAsset([string]$name, [string]$url) {
[PSCustomObject]@{
name = $name
browser_download_url = $url
digest = $null
}
}
function Resolve-ReleaseAsset([string]$apiUri, [string]$pattern, [string]$label, $fallbackAsset, [int]$fallbackPercent) {
try {
$release = Invoke-ReleaseApi $apiUri $label
return Select-Asset $release.assets $pattern $label
} catch {
$fallbackUri = Get-SafeUriForLog $fallbackAsset.browser_download_url
Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'running' $fallbackPercent "GitHub API недоступен для $label. Пробую прямую ссылку: $fallbackUri"
return $fallbackAsset
}
}
function Get-PinnedProxiFyreAsset([string]$arch) {
$archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' }
$name = "ProxiFyre-$proxifyrePinnedReleaseTag-$archLabel-signed.zip"
$url = "https://github.com/wiresock/proxifyre/releases/download/$proxifyrePinnedReleaseTag/$name"
return New-ReleaseAsset $name $url
}
function Get-PinnedWindowsPacketFilterAsset([string]$arch) {
$archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' }
$name = "Windows.Packet.Filter.$ndisapiPinnedInstallerVersion.$archLabel.msi"
$url = "https://github.com/wiresock/ndisapi/releases/download/$ndisapiPinnedReleaseTag/$name"
return New-ReleaseAsset $name $url
}
function Complete-Download([string]$partialPath, [string]$path, [string]$label) {
if (-not (Test-Path -LiteralPath $partialPath)) {
throw "${label}: файл не был создан."
}
$item = Get-Item -LiteralPath $partialPath
if ($item.Length -le 0) {
throw "${label}: скачанный файл пустой."
}
Move-Item -LiteralPath $partialPath -Destination $path -Force
}
function Invoke-WebClientDownload([string]$uri, [string]$partialPath) {
$client = New-Object System.Net.WebClient
try {
$client.Headers.Add('User-Agent', 'proxywarden')
$client.Headers.Add('Accept', 'application/octet-stream,*/*')
$client.DownloadFile($uri, $partialPath)
} finally {
$client.Dispose()
}
}
function Invoke-CurlDownload([string]$uri, [string]$partialPath) {
$curl = Get-Command 'curl.exe' -ErrorAction SilentlyContinue
if ($null -eq $curl) {
throw 'curl.exe не найден.'
}
$curlOutput = & $curl.Source --silent --show-error --fail --location --retry 2 --retry-delay 2 --connect-timeout 30 --max-time 180 --user-agent 'proxywarden' --output $partialPath --url $uri 2>&1
if ($LASTEXITCODE -ne 0) {
$curlMessage = ($curlOutput | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($curlMessage)) {
throw "curl.exe завершился с кодом $LASTEXITCODE."
}
throw "curl.exe завершился с кодом ${LASTEXITCODE}: $curlMessage"
}
}
function Invoke-Download([string]$uri, [string]$path, [string]$label) {
$safeUri = Get-SafeUriForLog $uri
$partialPath = "$path.part"
$headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/octet-stream,*/*' }
$webRequestError = $null
$webClientError = $null
$curlError = $null
foreach ($attempt in 1..3) {
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
try {
Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $partialPath -Headers $headers -TimeoutSec 180 -MaximumRedirection 10
Complete-Download $partialPath $path $label
return
} catch {
$webRequestError = $_.Exception.Message
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
if ($attempt -lt 3) {
Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2))
}
}
}
try {
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
Invoke-WebClientDownload $uri $partialPath
Complete-Download $partialPath $path $label
return
} catch {
$webClientError = $_.Exception.Message
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
}
try {
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
Invoke-CurlDownload $uri $partialPath
Complete-Download $partialPath $path $label
return
} catch {
$curlError = $_.Exception.Message
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
}
$errors = @()
if (-not [string]::IsNullOrWhiteSpace($webRequestError)) { $errors += "Invoke-WebRequest: $webRequestError" }
if (-not [string]::IsNullOrWhiteSpace($webClientError)) { $errors += "WebClient: $webClientError" }
if (-not [string]::IsNullOrWhiteSpace($curlError)) { $errors += "curl.exe: $curlError" }
$details = if ($errors.Count -gt 0) { $errors -join ' | ' } else { 'неизвестная ошибка' }
throw "Не удалось скачать $label ($safeUri): $details"
}
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 ' '
}
function Get-BundledAssetDir {
$dir = [string]$script:bundledAssetDir
if ([string]::IsNullOrWhiteSpace($dir)) { return $null }
if (-not (Test-Path -LiteralPath $dir -PathType Container)) { return $null }
return $dir
}
function Get-BundledAssetManifest {
$assetDir = Get-BundledAssetDir
if ($null -eq $assetDir) { return $null }
$manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json')
if (-not (Test-Path -LiteralPath $manifestPath)) { return $null }
try {
return Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
} catch {
throw "Не удалось прочитать manifest встроенных пакетов ProxiFyre: $($_.Exception.Message)"
}
}
$script:bundledAssetManifest = Get-BundledAssetManifest
function Get-BundledAssetHash([string]$name) {
if ($null -eq $script:bundledAssetManifest -or $null -eq $script:bundledAssetManifest.files) {
return $null
}
$entry = $script:bundledAssetManifest.files |
Where-Object { $_.name -eq $name } |
Select-Object -First 1
if ($null -eq $entry) { return $null }
return [string]$entry.sha256
}
function Verify-BundledAssetHash([string]$path, [string]$label) {
$name = [IO.Path]::GetFileName($path)
$expected = Get-BundledAssetHash $name
if ([string]::IsNullOrWhiteSpace($expected)) {
throw "Во встроенном manifest нет SHA256 для $label ($name)."
}
$actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected.ToLowerInvariant()) {
throw "SHA256 не совпал для встроенного $label ($name). Ожидалось $expected, получилось $actual."
}
}
function Get-BundledAsset([string]$pattern, [string]$label) {
$assetDir = Get-BundledAssetDir
if ($null -eq $assetDir) { return $null }
$asset = Get-ChildItem -LiteralPath $assetDir -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match $pattern } |
Select-Object -First 1
if ($null -eq $asset) { return $null }
Verify-BundledAssetHash $asset.FullName $label
return $asset.FullName
}
function Copy-BundledAsset([string]$sourcePath, [string]$targetPath, [string]$label) {
Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Force
$item = Get-Item -LiteralPath $targetPath
if ($item.Length -le 0) {
throw "${label}: встроенный файл пустой."
}
}
$arch = Get-NativeArchitecture
$workDir = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-proxifyre-install'
$extractDir = Join-Path $workDir 'proxifyre'
Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $workDir, $extractDir, $targetDir | Out-Null
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 8 'Проверяю сетевой драйвер Windows Packet Filter.'
$packetFilterAlreadyInstalled = Test-WindowsPacketFilter
if (-not $packetFilterAlreadyInstalled) {
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 14 'Готовлю Windows Packet Filter.'
$ndisPattern = if ($arch -eq 'ARM64') { 'ARM64\.msi$' } elseif ($arch -eq 'x86') { 'x86\.msi$' } else { 'x64\.msi$' }
$bundledNdisPath = Get-BundledAsset $ndisPattern 'Windows Packet Filter'
if ($null -ne $bundledNdisPath) {
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Использую встроенный Windows Packet Filter.'
$ndisPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledNdisPath))
Copy-BundledAsset $bundledNdisPath $ndisPath 'Windows Packet Filter'
} else {
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Скачиваю Windows Packet Filter.'
$ndisAsset = Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter' (Get-PinnedWindowsPacketFilterAsset $arch) 16
$ndisPath = Join-Path $workDir $ndisAsset.name
Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'
Verify-AssetHash $ndisPath $ndisAsset
}
$ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log'
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 26 'Устанавливаю Windows Packet Filter.'
$ndisProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $ndisPath, '/qn', '/norestart', '/L*v', $ndisLogPath) -Wait -PassThru -WindowStyle Hidden
if ($ndisProcess.ExitCode -ne 0 -and $ndisProcess.ExitCode -ne 3010 -and -not (Test-WindowsPacketFilter)) {
$ndisLogTail = Get-LogTail $ndisLogPath
throw "Windows Packet Filter завершился с кодом $($ndisProcess.ExitCode). MSI log: $ndisLogPath $ndisLogTail"
}
}
Write-ProxyWardenProgress 'install' 'packet-filter' 'succeeded' 36 'Сетевой драйвер готов.'
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 40 'Проверяю Microsoft Visual C++ Runtime.'
if (-not (Test-VcRuntime $arch)) {
$vcBundledPattern = if ($arch -eq 'x86') { '^vc_redist\.x86\.exe$' } else { '^vc_redist\.x64\.exe$' }
$vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url }
$bundledVcPath = Get-BundledAsset $vcBundledPattern 'Microsoft Visual C++ Runtime'
$vcRedistPath = Join-Path $workDir 'vc_redist.exe'
if ($null -ne $bundledVcPath) {
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Использую встроенный Microsoft Visual C++ Runtime.'
Copy-BundledAsset $bundledVcPath $vcRedistPath 'Microsoft Visual C++ Runtime'
} else {
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Скачиваю Microsoft Visual C++ Runtime.'
Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'
}
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 54 'Устанавливаю Microsoft Visual C++ Runtime.'
$vcProcess = Start-Process -FilePath $vcRedistPath -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru -WindowStyle Hidden
if ($vcProcess.ExitCode -ne 0 -and $vcProcess.ExitCode -ne 3010 -and $vcProcess.ExitCode -ne 1638 -and -not (Test-VcRuntime $arch)) {
throw "Visual C++ Runtime завершился с кодом $($vcProcess.ExitCode)."
}
}
Write-ProxyWardenProgress 'install' 'vc-runtime' 'succeeded' 62 'Среда запуска готова.'
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 66 'Готовлю ProxiFyre.'
$proxifyrePattern = if ($arch -eq 'ARM64') { 'ARM64-signed\.zip$' } elseif ($arch -eq 'x86') { 'x86-signed\.zip$' } else { 'x64-signed\.zip$' }
$bundledProxiFyrePath = Get-BundledAsset $proxifyrePattern 'ProxiFyre'
if ($null -ne $bundledProxiFyrePath) {
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Использую встроенный ProxiFyre.'
$proxifyreZipPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledProxiFyrePath))
Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath 'ProxiFyre'
} else {
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Скачиваю ProxiFyre.'
$proxifyreAsset = Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre' (Get-PinnedProxiFyreAsset $arch) 68
$proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name
Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'
Verify-AssetHash $proxifyreZipPath $proxifyreAsset
}
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 76 'Распаковываю ProxiFyre.'
Expand-Archive -LiteralPath $proxifyreZipPath -DestinationPath $extractDir -Force
$proxifyreExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'ProxiFyre.exe' | Select-Object -First 1
if ($null -eq $proxifyreExe) { throw 'В архиве ProxiFyre не найден ProxiFyre.exe.' }
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 82 'Копирую ProxiFyre в папку установки.'
Copy-Item -Path (Join-Path $proxifyreExe.Directory.FullName '*') -Destination $targetDir -Recurse -Force
$configTarget = Join-Path $targetDir 'app-config.json'
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
}
$markerPath = Join-Path $targetDir 'proxywarden-component.json'
[ordered]@{
manager = 'ProxyWarden'
component = 'proxifyre'
serviceName = 'ProxiFyreService'
installedAt = (Get-Date).ToString('o')
installRoot = $targetDir
packetFilterInstalledByProxyWarden = (-not $packetFilterAlreadyInstalled)
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 90 'Устанавливаю и запускаю службу ProxiFyre.'
Push-Location $targetDir
try {
& .\ProxiFyre.exe stop | Out-Null
& .\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
}
Write-ProxyWardenProgress 'install' 'proxifyre' 'succeeded' 100 'ProxiFyre и сетевой драйвер готовы.'
"#,
);
script
}
pub fn uninstall_proxifyre_script(
detected: Option<&DetectedProxyfier>,
ownership: &ManagedProxiFyreOwnership,
) -> String {
let mut script = String::new();
let install_dir = detected
.map(|detected| detected.install_dir.display().to_string())
.unwrap_or_default();
let executable_path = detected
.map(|detected| detected.executable_path.display().to_string())
.unwrap_or_default();
script.push_str(&format!(
"$installDir = '{}'\n",
escape_powershell_single(&install_dir)
));
script.push_str(&format!(
"$exePath = '{}'\n",
escape_powershell_single(&executable_path)
));
script.push_str(&format!(
"$serviceName = '{}'\n",
escape_powershell_single(&ownership.service_name)
));
script.push_str(&format!(
"$removePacketFilter = ${}\n",
if ownership.remove_packet_filter {
"true"
} else {
"false"
}
));
script.push_str(
r#"
function Get-InstalledProgram([string]$pattern) {
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -match $pattern } |
Select-Object -First 1 DisplayName, DisplayVersion, PSChildName, UninstallString, QuietUninstallString
}
function Test-WindowsPacketFilter {
return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI')
}
function Get-LogTail([string]$path) {
if (-not (Test-Path -LiteralPath $path)) { return '' }
return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' '
}
function Resolve-MsiProductCode($program, [string]$label) {
if ($null -eq $program) { return $null }
if ($program.PSChildName -match '^\{[0-9A-Fa-f-]{36}\}$') {
return $program.PSChildName
}
foreach ($candidate in @($program.QuietUninstallString, $program.UninstallString)) {
if ($candidate -match '\{[0-9A-Fa-f-]{36}\}') {
return $Matches[0]
}
}
throw "Не удалось найти MSI product code для $label. Отказываюсь запускать произвольный UninstallString."
}
function Uninstall-MsiProgram($program, [string]$label, [string]$logPath) {
$productCode = Resolve-MsiProductCode $program $label
if ([string]::IsNullOrWhiteSpace($productCode)) { return }
$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/x', $productCode, '/qn', '/norestart', '/L*v', $logPath) -Wait -PassThru -WindowStyle Hidden
if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010 -and $process.ExitCode -ne 1605) {
$logTail = Get-LogTail $logPath
throw "$label uninstall завершился с кодом $($process.ExitCode). MSI log: $logPath $logTail"
}
}
function Get-ServiceBinaryPath([string]$pathName) {
if ([string]::IsNullOrWhiteSpace($pathName)) { return $null }
$pathName = $pathName.Trim()
if ($pathName.StartsWith('"')) {
$closingQuote = $pathName.IndexOf('"', 1)
if ($closingQuote -lt 2) { return $null }
return $pathName.Substring(1, $closingQuote - 1)
}
return ($pathName -split '\s+', 2)[0]
}
function Find-ManagedProxiFyreService {
$escapedName = $serviceName.Replace("'", "''")
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
if ($null -eq $record) { return $null }
$binaryPath = Get-ServiceBinaryPath $record.PathName
if (-not [string]::Equals($binaryPath, $exePath, [StringComparison]::OrdinalIgnoreCase)) { return $null }
return Get-Service -Name $serviceName -ErrorAction SilentlyContinue
}
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
}
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 10 'Останавливаю службу ProxiFyre.'
$service = Find-ManagedProxiFyreService
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-ManagedProxiFyreService
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
}
}
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 34 'Удаляю службу и файлы ProxiFyre.'
if (-not [string]::IsNullOrWhiteSpace($exePath) -and (Test-Path -LiteralPath $exePath)) {
Push-Location (Split-Path -Parent $exePath)
try {
& $exePath uninstall | Out-Null
} finally {
Pop-Location
}
}
$service = Find-ManagedProxiFyreService
if ($null -ne $service) {
sc.exe delete $service.Name | Out-Null
}
if (-not [string]::IsNullOrWhiteSpace($installDir) -and (Test-Path -LiteralPath $installDir)) {
Remove-Item -LiteralPath $installDir -Recurse -Force
}
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'succeeded' 58 'ProxiFyre удален.'
if ($removePacketFilter) {
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 68 'Проверяю Windows Packet Filter.'
$packetFilter = Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI'
if ($null -ne $packetFilter) {
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 78 'Удаляю Windows Packet Filter.'
$driverLogPath = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-windows-packet-filter-uninstall.log'
Uninstall-MsiProgram $packetFilter 'Windows Packet Filter' $driverLogPath
}
if (Test-WindowsPacketFilter) {
throw 'Windows Packet Filter все еще найден после удаления. Возможно, Windows требует перезагрузку.'
}
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'succeeded' 100 'ProxiFyre и принадлежащий ProxyWarden Windows Packet Filter удалены.'
} else {
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'skipped' 100 'Windows Packet Filter оставлен: marker не подтверждает владение ProxyWarden.'
}
"#,
);
script
}
+258
View File
@@ -0,0 +1,258 @@
//! ProxiFyre config apply helper boundary and testable legacy apply fixture.
//!
//! The current webview path uses `apply_flow`; the lower-level fixture remains
//! for adapter/storage integration tests and shares the same detected writer.
use crate::adapters::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
use crate::clock::Clock;
use crate::command_dto::{ActivityEntryDto, CommandError};
use crate::component_detection::{
detect_proxyfier_install, detect_proxyfier_install_with_host, detect_singbox_install,
DetectedProxyfier, DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost,
};
use crate::component_status::components_or_defaults_with_detection;
use crate::models::{ActivityEntry, ActivityLevel};
use crate::safe_fs;
use crate::storage::JsonStorage;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyProfilesResponse {
pub success: bool,
pub changed: bool,
pub message: String,
pub adapter_id: String,
pub generated_config_path: String,
pub enabled_profiles: usize,
pub routed_apps: usize,
pub helper: HelperApplyResult,
pub activity: ActivityEntryDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperApplyResult {
pub success: bool,
pub changed: bool,
pub action: String,
pub message: String,
}
pub struct HelperApplyRequest<'a> {
pub adapter_id: &'a str,
pub config_path: &'a Path,
pub config_contents: &'a str,
}
pub trait ProxyApplyHelper {
fn apply_proxy_config(
&self,
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError>;
}
pub struct DetectedProxyApplyHelper<H = SystemProxyfierDetectionHost> {
host: H,
}
impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
pub fn system() -> Self {
SystemProxyfierDetectionHost.into()
}
}
impl<H> From<H> for DetectedProxyApplyHelper<H> {
fn from(host: H) -> Self {
Self { host }
}
}
impl<H> ProxyApplyHelper for DetectedProxyApplyHelper<H>
where
H: ProxyfierDetectionHost,
{
fn apply_proxy_config(
&self,
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
let Some(detected) = detect_proxyfier_install_with_host(&self.host) else {
return staged_apply_result(request);
};
apply_to_detected_proxyfier(request, &detected)
}
}
pub fn apply_profiles_with_services(
storage: &JsonStorage,
adapter: &impl ProxyRouterAdapter,
helper: &impl ProxyApplyHelper,
clock: &impl Clock,
) -> Result<ApplyProfilesResponse, CommandError> {
apply_profiles_with_services_and_detection(
storage,
adapter,
helper,
clock,
detect_proxyfier_install(),
detect_singbox_install(),
)
}
pub fn apply_profiles_with_services_and_detection(
storage: &JsonStorage,
adapter: &impl ProxyRouterAdapter,
helper: &impl ProxyApplyHelper,
clock: &impl Clock,
detected_proxyfier: Option<DetectedProxyfier>,
detected_singbox: Option<DetectedSingBox>,
) -> Result<ApplyProfilesResponse, CommandError> {
let profiles = storage.read_profiles().map_err(storage_error)?;
let targets = storage.read_targets().map_err(storage_error)?;
let components =
components_or_defaults_with_detection(storage, detected_proxyfier, detected_singbox)?;
let generated =
match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) {
Ok(generated) => generated,
Err(error) => {
let command_error = adapter_error(error);
let activity = activity_for_apply_error(clock, &command_error);
storage.append_activity(activity).map_err(storage_error)?;
return Err(command_error);
}
};
let generated_path = storage
.paths()
.generated_dir
.join(generated.output_file_name.as_str());
write_generated_config(&generated_path, &generated.contents)?;
let helper_result = helper.apply_proxy_config(HelperApplyRequest {
adapter_id: generated.adapter_id.as_str(),
config_path: &generated_path,
config_contents: generated.contents.as_str(),
})?;
let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result);
storage
.append_activity(activity.clone())
.map_err(storage_error)?;
Ok(ApplyProfilesResponse {
success: helper_result.success,
changed: helper_result.changed,
message: helper_result.message.clone(),
adapter_id: generated.adapter_id,
generated_config_path: generated_path.display().to_string(),
enabled_profiles: generated.enabled_profiles,
routed_apps: generated.routed_apps,
helper: helper_result,
activity: ActivityEntryDto::from(&activity),
})
}
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
}
fn apply_to_detected_proxyfier(
request: HelperApplyRequest<'_>,
detected: &DetectedProxyfier,
) -> Result<HelperApplyResult, CommandError> {
let Some(config_path) = &detected.config_path else {
return staged_apply_result(request);
};
safe_fs::write_with_backup(config_path, request.config_contents.as_bytes()).map_err(
|error| {
CommandError::new(
"proxyfier_apply_failed",
format!(
"Не удалось безопасно записать конфиг ProxiFyre '{}': {error}",
config_path.display()
),
)
},
)?;
Ok(HelperApplyResult {
success: true,
changed: true,
action: "proxifyre.apply-detected-config".to_string(),
message: format!(
"Сгенерированный конфиг записан в найденную установку ProxiFyre: {}",
config_path.display()
),
})
}
fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result<HelperApplyResult, CommandError> {
Ok(HelperApplyResult {
success: true,
changed: true,
action: format!("{}.stage-generated-config", request.adapter_id),
message: format!(
"Сгенерированный конфиг подготовлен в {}; совместимая установка ProxiFyre не найдена",
request.config_path.display()
),
})
}
fn activity_for_apply(
clock: &impl Clock,
generated: &ProxyRouterGeneratedConfig,
generated_path: &Path,
helper_result: &HelperApplyResult,
) -> ActivityEntry {
let level = if helper_result.success {
ActivityLevel::Success
} else {
ActivityLevel::Error
};
ActivityEntry {
id: format!("apply-{}", generated.adapter_id),
at: clock.now(),
level,
title: "Конфиг ProxiFyre создан".to_string(),
message: format!(
"Профилей: {}, приложений: {}, конфиг: {}",
generated.enabled_profiles,
generated.routed_apps,
generated_path.display()
),
}
}
fn activity_for_apply_error(clock: &impl Clock, error: &CommandError) -> ActivityEntry {
ActivityEntry {
id: format!("apply-error-{}", error.code),
at: clock.now(),
level: ActivityLevel::Error,
title: "Применение ProxiFyre заблокировано".to_string(),
message: error.message.clone(),
}
}
fn storage_error(error: std::io::Error) -> CommandError {
CommandError::new("storage_error", error.to_string())
}
fn adapter_error(error: ProxyRouterError) -> CommandError {
let code = match error.kind {
ProxyRouterErrorKind::EmptyProfileItems => "empty_profile_items",
ProxyRouterErrorKind::MissingTarget => "missing_target",
ProxyRouterErrorKind::MissingRequiredComponent => "missing_required_component",
ProxyRouterErrorKind::RequiredComponentNotRunning => "required_component_not_running",
ProxyRouterErrorKind::UnsupportedTargetProtocol => "unsupported_target_protocol",
ProxyRouterErrorKind::Serialization => "serialization_error",
};
CommandError::new(code, error.message)
}
+316
View File
@@ -0,0 +1,316 @@
//! TCP and outbound HTTP checks used to verify a configured SOCKS5 route.
//!
//! All functions are blocking. Tauri handlers must call them through
//! `spawn_blocking`; probe URLs are static and never come from webview input.
use crate::command_dto::{
CommandError, PingProxyTargetInputDto, PingServerResponse, ProxyProbeResponse,
ProxyTargetCheckResponse,
};
use std::net::{IpAddr, TcpStream, ToSocketAddrs};
use std::time::{Duration, Instant};
const PROXY_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
const PROXY_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
const PROXY_CHECK_USER_AGENT: &str = "proxywarden route-check";
const DEFAULT_PROXY_PROBES: &[ProxyProbeEndpoint] = &[
ProxyProbeEndpoint {
id: "cloudflare-trace",
name: "Cloudflare Trace",
url: "https://www.cloudflare.com/cdn-cgi/trace",
ip_source: ProbeIpSource::CloudflareTrace,
},
ProxyProbeEndpoint {
id: "cloudflare-speed",
name: "Cloudflare Speed",
url: "https://speed.cloudflare.com/meta",
ip_source: ProbeIpSource::JsonField("clientIp"),
},
ProxyProbeEndpoint {
id: "ipify",
name: "ipify",
url: "https://api.ipify.org?format=json",
ip_source: ProbeIpSource::JsonField("ip"),
},
];
#[derive(Debug, Clone, Copy)]
pub struct ProxyProbeEndpoint {
id: &'static str,
name: &'static str,
url: &'static str,
ip_source: ProbeIpSource,
}
#[derive(Debug, Clone, Copy)]
enum ProbeIpSource {
CloudflareTrace,
JsonField(&'static str),
}
pub fn ping_proxy_target_endpoint(
input: PingProxyTargetInputDto,
) -> Result<ProxyTargetCheckResponse, CommandError> {
ping_proxy_target_endpoint_with_probes(input, DEFAULT_PROXY_PROBES)
}
pub fn ping_proxy_target_endpoint_with_probes(
input: PingProxyTargetInputDto,
probes: &[ProxyProbeEndpoint],
) -> Result<ProxyTargetCheckResponse, CommandError> {
let host = input.host.trim();
if host.is_empty() {
return Err(CommandError::new(
"proxy_target_host_missing",
"Хост внешнего прокси не указан.",
));
}
let tcp = ping_endpoint("route-proxy", "route-proxy", host, input.port);
if !tcp.ok {
return Ok(ProxyTargetCheckResponse {
tag: "route-proxy".to_string(),
server: host.to_string(),
server_port: input.port,
ok: false,
latency: tcp.latency,
error: tcp.error,
probes: Vec::new(),
});
}
let probe_results = run_proxy_probes(host, input.port, probes);
let has_probe_success = probe_results.iter().any(|probe| probe.ok);
let ok = probe_results.is_empty() || has_probe_success;
let error = (!ok).then(|| {
"SOCKS5 порт доступен, но тестовые HTTP endpoints не ответили через прокси.".to_string()
});
Ok(ProxyTargetCheckResponse {
tag: "route-proxy".to_string(),
server: host.to_string(),
server_port: input.port,
ok,
latency: tcp.latency,
error,
probes: probe_results,
})
}
pub fn ping_endpoint(id: &str, tag: &str, server: &str, server_port: u16) -> PingServerResponse {
let started = Instant::now();
let addresses = match (server, server_port).to_socket_addrs() {
Ok(addresses) => addresses.collect::<Vec<_>>(),
Err(error) => {
return PingServerResponse {
id: id.to_string(),
tag: tag.to_string(),
server: server.to_string(),
server_port,
ok: false,
latency: None,
error: Some(format!("DNS/адрес недоступен: {error}")),
};
}
};
if addresses.is_empty() {
return PingServerResponse {
id: id.to_string(),
tag: tag.to_string(),
server: server.to_string(),
server_port,
ok: false,
latency: None,
error: Some("DNS не вернул адреса".to_string()),
};
}
let timeout = Duration::from_secs(2);
let mut last_error = None;
for address in addresses {
match TcpStream::connect_timeout(&address, timeout) {
Ok(_) => {
return PingServerResponse {
id: id.to_string(),
tag: tag.to_string(),
server: server.to_string(),
server_port,
ok: true,
latency: Some(started.elapsed().as_millis()),
error: None,
};
}
Err(error) => last_error = Some(error.to_string()),
}
}
PingServerResponse {
id: id.to_string(),
tag: tag.to_string(),
server: server.to_string(),
server_port,
ok: false,
latency: None,
error: last_error,
}
}
fn run_proxy_probes(
proxy_host: &str,
proxy_port: u16,
probes: &[ProxyProbeEndpoint],
) -> Vec<ProxyProbeResponse> {
if probes.is_empty() {
return Vec::new();
}
let proxy_url = socks5h_proxy_url(proxy_host, proxy_port);
let client = match reqwest::Proxy::all(&proxy_url).and_then(|proxy| {
reqwest::blocking::Client::builder()
.timeout(PROXY_CHECK_TIMEOUT)
.connect_timeout(PROXY_CHECK_CONNECT_TIMEOUT)
.proxy(proxy)
.build()
}) {
Ok(client) => client,
Err(error) => {
return probes
.iter()
.map(|probe| {
failed_probe(
*probe,
format!("Не удалось подготовить SOCKS5 проверку: {error}"),
)
})
.collect();
}
};
let handles = probes
.iter()
.copied()
.map(|probe| {
let client = client.clone();
std::thread::spawn(move || run_proxy_probe(&client, probe))
})
.collect::<Vec<_>>();
handles
.into_iter()
.zip(probes.iter().copied())
.map(|(handle, probe)| {
handle
.join()
.unwrap_or_else(|_| failed_probe(probe, "Проверка была прервана.".to_string()))
})
.collect()
}
fn run_proxy_probe(
client: &reqwest::blocking::Client,
probe: ProxyProbeEndpoint,
) -> ProxyProbeResponse {
let started = Instant::now();
let response = match client
.get(probe.url)
.header(reqwest::header::USER_AGENT, PROXY_CHECK_USER_AGENT)
.send()
{
Ok(response) => response,
Err(error) => return failed_probe(probe, format!("HTTP через SOCKS5 не прошел: {error}")),
};
let status = response.status();
let status_code = status.as_u16();
let body = match response.text() {
Ok(body) => body,
Err(error) => {
return failed_probe_with_status(
probe,
status_code,
format!("Ответ не прочитан: {error}"),
);
}
};
let latency = started.elapsed().as_millis();
if !status.is_success() {
return ProxyProbeResponse {
id: probe.id.to_string(),
name: probe.name.to_string(),
url: probe.url.to_string(),
ok: false,
status: Some(status_code),
latency: Some(latency),
ip: None,
error: Some(format!("HTTP {status_code}")),
};
}
ProxyProbeResponse {
id: probe.id.to_string(),
name: probe.name.to_string(),
url: probe.url.to_string(),
ok: true,
status: Some(status_code),
latency: Some(latency),
ip: extract_probe_ip(probe, &body),
error: None,
}
}
fn failed_probe(probe: ProxyProbeEndpoint, error: String) -> ProxyProbeResponse {
failed_probe_with_status(probe, 0, error)
}
fn failed_probe_with_status(
probe: ProxyProbeEndpoint,
status: u16,
error: String,
) -> ProxyProbeResponse {
ProxyProbeResponse {
id: probe.id.to_string(),
name: probe.name.to_string(),
url: probe.url.to_string(),
ok: false,
status: (status > 0).then_some(status),
latency: None,
ip: None,
error: Some(error),
}
}
fn socks5h_proxy_url(host: &str, port: u16) -> String {
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
if host.contains(':') {
format!("socks5h://[{host}]:{port}")
} else {
format!("socks5h://{host}:{port}")
}
}
fn extract_probe_ip(probe: ProxyProbeEndpoint, body: &str) -> Option<String> {
match probe.ip_source {
ProbeIpSource::CloudflareTrace => body
.lines()
.find_map(|line| line.strip_prefix("ip=").and_then(normalize_ip)),
ProbeIpSource::JsonField(field) => serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|value| {
value
.get(field)
.and_then(|field| field.as_str())
.and_then(normalize_ip)
}),
}
}
fn normalize_ip(value: &str) -> Option<String> {
let candidate = value.trim().trim_matches('"');
candidate
.parse::<IpAddr>()
.is_ok()
.then(|| candidate.to_string())
}
+125
View File
@@ -0,0 +1,125 @@
//! Local sing-box config generation and derived local-target persistence.
use crate::adapters::singbox::{
SingBoxAdapter, SingBoxConfigChecker, SingBoxConfigError, SingBoxConfigErrorKind,
SingBoxGeneratedConfig, SingBoxGenerationRequest,
};
use crate::clock::Clock;
use crate::command_dto::{ActivityEntryDto, CommandError, GenerateSingBoxConfigResponse};
use crate::models::{
ActivityEntry, ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, Target,
TargetKind,
};
use crate::safe_fs;
use crate::singbox_subscription::read_required_singbox_cache;
use crate::storage::JsonStorage;
use std::path::Path;
pub fn generate_singbox_config_with_services<C>(
storage: &JsonStorage,
adapter: &SingBoxAdapter,
checker: &C,
clock: &impl Clock,
binary_path: Option<&Path>,
) -> Result<GenerateSingBoxConfigResponse, CommandError>
where
C: SingBoxConfigChecker,
{
let config = storage.read_local_singbox_config().map_err(storage_error)?;
let cache = read_required_singbox_cache(storage)?;
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&config, &cache, binary_path),
checker,
)
.map_err(singbox_adapter_error)?;
let generated_path = storage
.paths()
.generated_dir
.join(generated.output_file_name.as_str());
write_generated_config(&generated_path, &generated.contents)?;
ensure_local_singbox_target(storage, &config)?;
let activity = activity_for_singbox_generate(clock, &generated, &generated_path);
storage
.append_activity(activity.clone())
.map_err(storage_error)?;
Ok(GenerateSingBoxConfigResponse {
success: true,
message: "Конфиг Local sing-box создан".to_string(),
adapter_id: generated.adapter_id,
generated_config_path: generated_path.display().to_string(),
selected_server_tag: generated.selected_server_tag,
listen_host: generated.listen,
listen_port: generated.listen_port,
check: generated.check,
activity: ActivityEntryDto::from(&activity),
})
}
fn ensure_local_singbox_target(
storage: &JsonStorage,
config: &LocalSingBoxConfig,
) -> Result<(), CommandError> {
let mut targets = storage.read_targets().map_err(storage_error)?;
let target = Target {
id: "local-singbox".to_string(),
name: "Локальный sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: config.listen_host.clone(),
port: config.listen_port,
requires_component: Some(ComponentId::Singbox),
};
match targets.iter().position(|existing| existing.id == target.id) {
Some(index) => targets[index] = target,
None => targets.push(target),
}
storage.write_targets(&targets).map_err(storage_error)
}
fn activity_for_singbox_generate(
clock: &impl Clock,
generated: &SingBoxGeneratedConfig,
generated_path: &Path,
) -> ActivityEntry {
ActivityEntry {
id: "singbox-config-generated".to_string(),
at: clock.now(),
level: ActivityLevel::Success,
title: "Конфиг Local sing-box создан".to_string(),
message: format!(
"Сервер: {}, listen: {}:{}, конфиг: {}",
generated.selected_server_tag,
generated.listen,
generated.listen_port,
generated_path.display()
),
}
}
fn singbox_adapter_error(error: SingBoxConfigError) -> CommandError {
let code = match error.kind {
SingBoxConfigErrorKind::MissingSelectedServer => "singbox_server_not_selected",
SingBoxConfigErrorKind::MissingSelectedOutbound => "singbox_selected_server_missing",
SingBoxConfigErrorKind::UnsupportedSelectedOutbound => {
"singbox_selected_server_unsupported"
}
SingBoxConfigErrorKind::Serialization => "serialization_error",
SingBoxConfigErrorKind::CheckFailed => "singbox_check_failed",
};
CommandError::new(code, error.message)
}
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
}
fn storage_error(error: std::io::Error) -> CommandError {
CommandError::new("storage_error", error.to_string())
}
+532
View File
@@ -0,0 +1,532 @@
//! Explicit Local sing-box service and package lifecycle orchestration.
//!
//! These operations may request UAC elevation. Apply configuration never calls
//! this module; install/start/stop/uninstall remain separate user actions.
use crate::command_dto::{CommandError, ComponentStatusDto};
use crate::component_detection::{detect_singbox_install, singbox_component_from_detection};
use crate::elevated_scripts;
use crate::powershell::{
escape_single as escape_powershell_single, is_elevated as is_running_elevated,
package_failure_details, run_command as run_powershell_command,
run_file as run_powershell_file, write_script as write_powershell_script,
};
use crate::process::command_no_window;
use crate::singbox_service::{
ensure_safe_singbox_install_dir,
parse_service_command_output as parse_singbox_service_command_output, service_control_script,
ServiceCommandOutput as SingBoxServiceCommandOutput, SingBoxServiceAction,
};
use crate::storage::{default_config_root, JsonStorage};
use std::fs;
use std::path::{Path, PathBuf};
pub(crate) fn control_singbox_service(
action: SingBoxServiceAction,
config_source: Option<&Path>,
) -> Result<ComponentStatusDto, CommandError> {
let Some(detected) = detect_singbox_install() else {
return Err(CommandError::new(
"singbox_not_found",
"Local sing-box не найден на компьютере.",
));
};
let config_target = config_source.map(|_| detected.install_dir.join("config.json"));
let script = service_control_script(
action,
&detected.service_name,
config_source,
config_target.as_deref(),
);
let output = command_no_window("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script.as_str(),
])
.output()
.map_err(|error| {
CommandError::new(
singbox_service_error_code(action),
format!(
"Не удалось {} службу Local sing-box: {error}",
action.label()
),
)
})?;
let result = parse_singbox_service_command_output(&output.stdout).ok_or_else(|| {
CommandError::new(
singbox_service_error_code(action),
singbox_service_script_failed_message(action, output.status.code()),
)
})?;
if result.success {
let refreshed = detect_singbox_install();
let component = singbox_component_from_detection(refreshed.as_ref());
return Ok(ComponentStatusDto::from(&component));
}
if matches!(
result.code.as_str(),
"start_failed" | "stop_failed" | "config_sync_failed"
) {
run_elevated_singbox_service_command(
action,
&detected.service_name,
config_source,
config_target.as_deref(),
&result,
)?;
let refreshed = detect_singbox_install();
let component = singbox_component_from_detection(refreshed.as_ref());
return Ok(ComponentStatusDto::from(&component));
}
Err(CommandError::new(
singbox_service_error_code(action),
singbox_service_command_failed_message(action, &result),
))
}
fn run_elevated_singbox_service_command(
action: SingBoxServiceAction,
service_name: &str,
config_source: Option<&Path>,
config_target: Option<&Path>,
direct_result: &SingBoxServiceCommandOutput,
) -> Result<(), CommandError> {
let script_path =
write_elevated_singbox_service_script(action, service_name, config_source, config_target)?;
let launch_script = format!(
"$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode",
escape_powershell_single(&script_path.display().to_string())
);
let output = if is_running_elevated() {
run_powershell_file(&script_path)
} else {
run_powershell_command(&launch_script)
};
let _ = fs::remove_file(&script_path);
match output {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(CommandError::new(
singbox_service_error_code(action),
elevated_singbox_service_failed_message(action, direct_result, output.status.code()),
)),
Err(error) => Err(CommandError::new(
singbox_service_error_code(action),
format!(
"Не удалось запросить права администратора, чтобы {} службу Local sing-box: {error}",
action.label()
),
)),
}
}
fn write_elevated_singbox_service_script(
action: SingBoxServiceAction,
service_name: &str,
config_source: Option<&Path>,
config_target: Option<&Path>,
) -> Result<PathBuf, CommandError> {
let script_path = elevated_scripts::temp_script_path("proxywarden-singbox-service");
let script =
elevated_singbox_service_script(action, service_name, config_source, config_target);
write_powershell_script(&script_path, &script).map_err(|error| {
CommandError::new(
singbox_service_error_code(action),
format!(
"Не удалось подготовить временный скрипт для управления Local sing-box '{}': {error}",
script_path.display()
),
)
})?;
Ok(script_path)
}
fn elevated_singbox_service_script(
action: SingBoxServiceAction,
service_name: &str,
config_source: Option<&Path>,
config_target: Option<&Path>,
) -> String {
let action_name = action.action_name();
let escaped_service_name = escape_powershell_single(service_name);
let escaped_config_source = config_source
.map(|path| escape_powershell_single(&path.display().to_string()))
.unwrap_or_default();
let escaped_config_target = config_target
.map(|path| escape_powershell_single(&path.display().to_string()))
.unwrap_or_default();
format!(
r#"
$ErrorActionPreference = 'SilentlyContinue'
$serviceName = '{escaped_service_name}'
$action = '{action_name}'
$configSource = '{escaped_config_source}'
$configTarget = '{escaped_config_target}'
if ($action -eq 'start') {{
if (-not [string]::IsNullOrWhiteSpace($configSource)) {{
if (-not (Test-Path -LiteralPath $configSource)) {{ exit 5 }}
if (-not [string]::IsNullOrWhiteSpace($configTarget)) {{
try {{
Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop
}} catch {{
exit 6
}}
}}
}}
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($null -eq $service) {{ exit 2 }}
if ($service.Status -eq 'Running') {{ exit 0 }}
Start-Service -Name $serviceName -ErrorAction SilentlyContinue
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($null -ne $service) {{
try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}}
if ($service.Status -eq 'Running') {{ exit 0 }}
}}
exit 3
}}
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($null -eq $service) {{ exit 2 }}
if ($service.Status -eq 'Stopped') {{ exit 0 }}
Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($null -ne $service) {{
try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15)) }} catch {{}}
if ($service.Status -eq 'Stopped') {{ exit 0 }}
}}
exit 4
"#
)
}
pub(crate) fn install_singbox_component(
storage: &JsonStorage,
install_dir: &Path,
) -> Result<ComponentStatusDto, CommandError> {
let generated_config_path = storage.paths().generated_dir.join("sing-box-config.json");
run_elevated_singbox_package_script(
SingBoxPackageAction::Install,
include_str!("../../scripts/install-singbox.ps1"),
vec![
"-InstallRoot".to_string(),
install_dir.display().to_string(),
"-ConfigSource".to_string(),
generated_config_path.display().to_string(),
],
&storage.paths().state_dir,
)?;
let refreshed = detect_singbox_install();
let Some(detected) = refreshed.as_ref() else {
return Err(CommandError::new(
SingBoxPackageAction::Install.error_code(),
"Установка Local sing-box завершилась, но приложение не найдено после проверки.",
));
};
Ok(ComponentStatusDto::from(&singbox_component_from_detection(
Some(detected),
)))
}
pub(crate) fn uninstall_singbox_component() -> Result<ComponentStatusDto, CommandError> {
let Some(detected) = detect_singbox_install() else {
let component = singbox_component_from_detection(None);
return Ok(ComponentStatusDto::from(&component));
};
ensure_safe_singbox_install_dir(&detected.install_dir).map_err(|message| {
CommandError::new(SingBoxPackageAction::Uninstall.error_code(), message)
})?;
let artifact_dir = default_config_root().join("state");
run_elevated_singbox_package_script(
SingBoxPackageAction::Uninstall,
include_str!("../../scripts/install-singbox.ps1"),
vec![
"-InstallRoot".to_string(),
detected.install_dir.display().to_string(),
"-ServiceName".to_string(),
detected.service_name,
"-Uninstall".to_string(),
],
&artifact_dir,
)?;
let refreshed = detect_singbox_install();
if refreshed.is_some() {
return Err(CommandError::new(
SingBoxPackageAction::Uninstall.error_code(),
"Удаление Local sing-box завершилось, но приложение все еще найдено на компьютере.",
));
}
let component = singbox_component_from_detection(None);
Ok(ComponentStatusDto::from(&component))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SingBoxPackageAction {
Install,
Uninstall,
}
impl SingBoxPackageAction {
fn error_code(self) -> &'static str {
match self {
SingBoxPackageAction::Install => "singbox_install_failed",
SingBoxPackageAction::Uninstall => "singbox_uninstall_failed",
}
}
fn label(self) -> &'static str {
match self {
SingBoxPackageAction::Install => "установить",
SingBoxPackageAction::Uninstall => "удалить",
}
}
fn file_label(self) -> &'static str {
match self {
SingBoxPackageAction::Install => "install",
SingBoxPackageAction::Uninstall => "uninstall",
}
}
}
fn run_elevated_singbox_package_script(
action: SingBoxPackageAction,
installer_body: &str,
installer_args: Vec<String>,
artifact_dir: &Path,
) -> Result<(), CommandError> {
fs::create_dir_all(artifact_dir).map_err(|error| {
CommandError::new(
action.error_code(),
format!(
"Не удалось создать папку для временных файлов Local sing-box '{}': {error}",
artifact_dir.display()
),
)
})?;
let prefix = format!("proxywarden-singbox-{}", action.file_label());
let installer_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1");
let runner_path =
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.runner"), "ps1");
let result_path =
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log");
write_powershell_script(&installer_path, installer_body).map_err(|error| {
CommandError::new(
action.error_code(),
format!(
"Не удалось подготовить установщик Local sing-box '{}': {error}",
installer_path.display()
),
)
})?;
write_powershell_script(
&runner_path,
&singbox_installer_runner_script(&installer_path, &result_path, &installer_args),
)
.map_err(|error| {
CommandError::new(
action.error_code(),
format!(
"Не удалось подготовить runner Local sing-box '{}': {error}",
runner_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(&runner_path.display().to_string())
);
let output = if is_running_elevated() {
run_powershell_file(&runner_path)
} else {
run_powershell_command(&launch_script)
};
let _ = fs::remove_file(&installer_path);
let _ = fs::remove_file(&runner_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!(
"Не удалось {} Local sing-box. Код elevated-команды: {}. {details}",
action.label(),
output.status.code().unwrap_or(-1),
),
))
}
Err(error) => Err(CommandError::new(
action.error_code(),
format!(
"Не удалось запросить права администратора, чтобы {} Local sing-box: {error}",
action.label()
),
)),
}
}
pub fn singbox_installer_runner_script(
installer_path: &Path,
result_path: &Path,
installer_args: &[String],
) -> String {
let args = installer_args
.iter()
.map(|arg| format!("'{}'", escape_powershell_single(arg)))
.collect::<Vec<_>>()
.join(", ");
format!(
r#"
$ErrorActionPreference = 'Stop'
$installerPath = '{}'
$resultPath = '{}'
$stdoutPath = "$resultPath.stdout.log"
$stderrPath = "$resultPath.stderr.log"
$installerArgs = @({args})
try {{
$output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs 2>&1
$exitCode = $LASTEXITCODE
Set-Content -LiteralPath $stdoutPath -Value ($output | Out-String) -Encoding UTF8
if ($exitCode -ne 0) {{
$stdout = if (Test-Path -LiteralPath $stdoutPath) {{ Get-Content -LiteralPath $stdoutPath -Raw }} else {{ '' }}
$stderr = if (Test-Path -LiteralPath $stderrPath) {{ Get-Content -LiteralPath $stderrPath -Raw }} else {{ '' }}
throw "install-singbox.ps1 завершился с кодом $exitCode. stdout: $stdout stderr: $stderr"
}}
Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8
exit 0
}} catch {{
Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8
exit 1
}} finally {{
Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
}}
"#,
escape_powershell_single(&installer_path.display().to_string()),
escape_powershell_single(&result_path.display().to_string())
)
}
fn singbox_service_error_code(action: SingBoxServiceAction) -> &'static str {
match action {
SingBoxServiceAction::Start => "singbox_service_start_failed",
SingBoxServiceAction::Stop => "singbox_service_stop_failed",
}
}
fn singbox_service_script_failed_message(
action: SingBoxServiceAction,
exit_code: Option<i32>,
) -> String {
let exit_code = exit_code
.map(|code| format!(" Код выхода PowerShell: {code}."))
.unwrap_or_default();
format!(
"Не удалось {} службу Local sing-box: команда управления службой не вернула корректный результат.{exit_code}",
action.label()
)
}
fn singbox_service_command_failed_message(
action: SingBoxServiceAction,
result: &SingBoxServiceCommandOutput,
) -> String {
let service_name = result
.service_name
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("ProxyWardenSingBox");
let status = result
.status
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("неизвестен");
let pid = result
.process_id
.filter(|value| *value > 0)
.map(|value| format!(", PID: {value}"))
.unwrap_or_default();
match result.code.as_str() {
"service_not_found" => "Служба Local sing-box не найдена.".to_string(),
"config_source_missing" => {
"Сгенерированный конфиг Local sing-box не найден перед запуском службы.".to_string()
}
"config_sync_failed" => {
"Не удалось обновить config.json службы Local sing-box перед запуском. Попробуй запустить приложение от имени администратора.".to_string()
}
"start_failed" => format!(
"Не удалось запустить службу {service_name}. Текущий статус: {status}{pid}. Попробуй запустить приложение от имени администратора."
),
"stop_failed" => format!(
"Не удалось остановить службу {service_name}. Текущий статус: {status}{pid}. Запусти приложение от имени администратора или останови службу вручную в services.msc."
),
_ => format!(
"Не удалось {} службу {service_name}. Текущий статус: {status}{pid}.",
action.label()
),
}
}
fn elevated_singbox_service_failed_message(
action: SingBoxServiceAction,
direct_result: &SingBoxServiceCommandOutput,
exit_code: Option<i32>,
) -> String {
let exit_code = exit_code
.map(|code| format!(" Код выхода elevated PowerShell: {code}."))
.unwrap_or_default();
format!(
"{} Попытка с правами администратора тоже не сработала.{exit_code}",
singbox_service_command_failed_message(action, direct_result)
)
}
+377
View File
@@ -0,0 +1,377 @@
//! Local sing-box subscription persistence, selection, status, and ping use cases.
use crate::clock::Clock;
use crate::command_dto::*;
use crate::component_detection::{
detect_singbox_install, singbox_component_from_detection, DetectedSingBox,
};
use crate::models::{
ActivityEntry, ActivityLevel, LocalSingBoxConfig, SubscriptionCache, SubscriptionServer,
};
use crate::proxy_probe::ping_endpoint;
use crate::storage::JsonStorage;
use crate::subscription;
use std::net::{IpAddr, UdpSocket};
pub trait SubscriptionFetcher {
fn fetch_subscription(
&self,
url: &str,
identity: &subscription::SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, subscription::SubscriptionError>;
}
pub struct SystemSubscriptionFetcher;
impl SubscriptionFetcher for SystemSubscriptionFetcher {
fn fetch_subscription(
&self,
url: &str,
identity: &subscription::SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
subscription::fetch_subscription_with_identity(url, identity)
}
}
#[cfg(debug_assertions)]
fn subscription_request_identity_for_display() -> SubscriptionRequestIdentityDto {
let identity = subscription::SubscriptionFetchIdentity::default();
let headers = identity
.request_headers_without_device_hwid()
.into_iter()
.map(|(name, value)| SubscriptionRequestHeaderDto {
name: name.to_string(),
value,
})
.collect();
SubscriptionRequestIdentityDto { headers }
}
pub fn read_singbox_status(
storage: &JsonStorage,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let detected = detect_singbox_install();
read_singbox_status_with_detection(storage, detected.as_ref())
}
pub(crate) fn read_singbox_status_with_detection(
storage: &JsonStorage,
detected: Option<&DetectedSingBox>,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let config = storage.read_local_singbox_config().map_err(storage_error)?;
let cache = storage
.read_singbox_subscription_cache()
.map_err(storage_error)?;
let component = singbox_component_from_detection(detected);
Ok(LocalSingBoxStatusResponse {
config: LocalSingBoxConfigDto::from(&config),
cache: cache.as_ref().map(SubscriptionCacheDto::from),
component: ComponentStatusDto::from(&component),
generated_config_path: storage
.paths()
.generated_dir
.join("sing-box-config.json")
.display()
.to_string(),
lan_listen_host: local_lan_ipv4(),
#[cfg(debug_assertions)]
subscription_identity: subscription_request_identity_for_display(),
})
}
pub fn save_singbox_subscription_to_storage(
storage: &JsonStorage,
input: SaveSingBoxSubscriptionInputDto,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let subscription_url = input.subscription_url.trim().to_string();
validate_subscription_url(&subscription_url)?;
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
config.subscription_url = Some(subscription_url);
ensure_device_hwid(&mut config);
config.updated_at = Some(clock.now());
storage
.write_local_singbox_config(&config)
.map_err(storage_error)?;
read_singbox_status(storage)
}
pub fn fetch_singbox_subscription_with_fetcher(
storage: &JsonStorage,
fetcher: &impl SubscriptionFetcher,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
let subscription_url = config
.subscription_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| {
CommandError::new(
"singbox_subscription_missing",
"Ссылка на подписку Local sing-box не сохранена.",
)
})?;
let device_hwid_created = ensure_device_hwid(&mut config);
if device_hwid_created {
config.updated_at = Some(clock.now());
storage
.write_local_singbox_config(&config)
.map_err(storage_error)?;
}
let identity =
subscription::SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref());
let cache = fetcher
.fetch_subscription(&subscription_url, &identity)
.map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?;
let selected_server = config
.selected_server_id
.as_deref()
.and_then(|id| cache.servers.iter().find(|server| server.id == id))
.or_else(|| {
let tag = config.selected_server_tag.as_deref()?;
cache.servers.iter().find(|server| server.tag == tag)
})
.or_else(|| cache.servers.first());
config.selected_server_id = selected_server.map(|server| server.id.clone());
config.selected_server_tag = selected_server.map(|server| server.tag.clone());
config.updated_at = Some(clock.now());
storage
.write_singbox_subscription_cache(&cache)
.map_err(storage_error)?;
storage
.write_local_singbox_config(&config)
.map_err(storage_error)?;
storage
.append_activity(ActivityEntry {
id: "singbox-subscription-fetched".to_string(),
at: clock.now(),
level: ActivityLevel::Success,
title: "Подписка Local sing-box обновлена".to_string(),
message: format!("Серверов найдено: {}", cache.servers.len()),
})
.map_err(storage_error)?;
read_singbox_status(storage)
}
pub fn forget_singbox_subscription_in_storage(
storage: &JsonStorage,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
config.subscription_url = None;
config.selected_server_tag = None;
config.selected_server_id = None;
config.updated_at = Some(clock.now());
storage
.write_local_singbox_config(&config)
.map_err(storage_error)?;
storage
.remove_singbox_subscription_cache()
.map_err(storage_error)?;
read_singbox_status(storage)
}
pub fn select_singbox_server_in_storage(
storage: &JsonStorage,
input: SelectSingBoxServerInputDto,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let requested_tag = input.tag.trim().to_string();
let requested_id = input
.id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty());
if requested_tag.is_empty() {
return Err(CommandError::new(
"singbox_server_tag_missing",
"Сервер Local sing-box не выбран.",
));
}
let cache = storage
.read_singbox_subscription_cache()
.map_err(storage_error)?
.ok_or_else(|| {
CommandError::new(
"singbox_subscription_cache_missing",
"Сначала нужно загрузить подписку Local sing-box.",
)
})?;
let Some(server) = find_subscription_server(
&cache,
requested_id,
&requested_tag,
input.server.as_deref(),
input.server_port,
) else {
return Err(CommandError::new(
"singbox_server_not_found",
format!("Сервер Local sing-box '{requested_tag}' не найден в текущей подписке."),
));
};
let selected_tag = server.tag.clone();
let selected_id = server.id.clone();
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
config.selected_server_tag = Some(selected_tag);
config.selected_server_id = Some(selected_id);
config.updated_at = Some(clock.now());
storage
.write_local_singbox_config(&config)
.map_err(storage_error)?;
read_singbox_status(storage)
}
pub fn ping_singbox_server_in_storage(
storage: &JsonStorage,
input: PingSingBoxServerInputDto,
) -> Result<PingServerResponse, CommandError> {
let tag = input.tag.trim();
let id = input
.id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty());
let cache = read_required_singbox_cache(storage)?;
let server = find_subscription_server(&cache, id, tag, None, None).ok_or_else(|| {
CommandError::new(
"singbox_server_not_found",
format!("Сервер Local sing-box '{tag}' не найден в текущей подписке."),
)
})?;
Ok(ping_subscription_server(server))
}
pub fn ping_all_singbox_servers_in_storage(
storage: &JsonStorage,
) -> Result<Vec<PingServerResponse>, CommandError> {
let cache = read_required_singbox_cache(storage)?;
Ok(cache.servers.iter().map(ping_subscription_server).collect())
}
pub(crate) fn read_required_singbox_cache(
storage: &JsonStorage,
) -> Result<SubscriptionCache, CommandError> {
storage
.read_singbox_subscription_cache()
.map_err(storage_error)?
.ok_or_else(|| {
CommandError::new(
"singbox_subscription_cache_missing",
"Сначала нужно загрузить подписку Local sing-box.",
)
})
}
fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError> {
if subscription_url.is_empty() {
return Err(CommandError::new(
"singbox_subscription_url_missing",
"Ссылка на подписку Local sing-box не указана.",
));
}
let parsed = url::Url::parse(subscription_url).map_err(|_| {
CommandError::new(
"singbox_subscription_url_invalid",
"Ссылка на подписку Local sing-box должна быть корректным URL.",
)
})?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(CommandError::new(
"singbox_subscription_url_invalid",
"Ссылка на подписку Local sing-box должна начинаться с http:// или https://.",
));
}
Ok(())
}
fn ensure_device_hwid(config: &mut LocalSingBoxConfig) -> bool {
if config
.device_hwid
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
return false;
}
config.device_hwid = Some(uuid::Uuid::new_v4().hyphenated().to_string().to_uppercase());
true
}
fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse {
ping_endpoint(&server.id, &server.tag, &server.server, server.server_port)
}
fn local_lan_ipv4() -> Option<String> {
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
let IpAddr::V4(address) = socket.local_addr().ok()?.ip() else {
return None;
};
if address.is_loopback() || address.is_link_local() || address.is_unspecified() {
return None;
}
Some(address.to_string())
}
fn find_subscription_server<'a>(
cache: &'a SubscriptionCache,
requested_id: Option<&str>,
requested_tag: &str,
requested_server: Option<&str>,
requested_port: Option<u16>,
) -> Option<&'a SubscriptionServer> {
requested_id
.and_then(|id| cache.servers.iter().find(|server| server.id == id))
.or_else(|| {
cache
.servers
.iter()
.find(|server| server.tag == requested_tag)
})
.or_else(|| {
let requested = comparable_server_tag(requested_tag);
cache
.servers
.iter()
.find(|server| comparable_server_tag(&server.tag) == requested)
})
.or_else(|| {
let server_name = requested_server?.trim();
let server_port = requested_port?;
cache.servers.iter().find(|server| {
server.server.eq_ignore_ascii_case(server_name) && server.server_port == server_port
})
})
}
fn comparable_server_tag(value: &str) -> String {
value
.chars()
.filter(|ch| !matches!(ch, '\u{fe0e}' | '\u{fe0f}' | '\u{200d}'))
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn storage_error(error: std::io::Error) -> CommandError {
CommandError::new("storage_error", error.to_string())
}
+267 -29
View File
@@ -1,8 +1,7 @@
use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer}; use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer};
use base64::{engine::general_purpose, Engine}; use base64::{engine::general_purpose, Engine};
use reqwest::redirect;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use std::net::{IpAddr, Ipv6Addr}; use std::net::{IpAddr, Ipv6Addr, SocketAddr, ToSocketAddrs};
use std::time::Duration; use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use url::Url; use url::Url;
@@ -11,6 +10,7 @@ const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsock
const DEFAULT_APP_NAME: &str = "ProxyWarden"; const DEFAULT_APP_NAME: &str = "ProxyWarden";
const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
const SUBSCRIPTION_MAX_REDIRECTS: usize = 5;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionError { pub struct SubscriptionError {
@@ -155,29 +155,16 @@ pub fn fetch_subscription_with_identity_and_policy(
) -> Result<SubscriptionCache, SubscriptionError> { ) -> Result<SubscriptionCache, SubscriptionError> {
let parsed_url = let parsed_url =
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?; Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
validate_subscription_fetch_url(&parsed_url, policy)?; let mut current_url = parsed_url;
let redirect_policy = redirect::Policy::custom(move |attempt| { for redirect_count in 0..=SUBSCRIPTION_MAX_REDIRECTS {
if validate_subscription_fetch_url(attempt.url(), policy).is_ok() { validate_subscription_fetch_url(&current_url, policy)?;
attempt.follow() let client = subscription_client_for_url(&current_url, policy)?;
} else { let mut request = client.get(current_url.clone());
attempt.stop()
}
});
let client = reqwest::blocking::Client::builder()
.connect_timeout(SUBSCRIPTION_CONNECT_TIMEOUT)
.timeout(SUBSCRIPTION_REQUEST_TIMEOUT)
.redirect(redirect_policy)
.build()
.map_err(|error| {
SubscriptionError::new(format!("Subscription client setup failed: {error}"))
})?;
let mut request = client.get(parsed_url);
for (name, value) in identity.request_headers_without_device_hwid() { for (name, value) in identity.request_headers_without_device_hwid() {
request = request.header(name, value); request = request.header(name, value);
} }
if let Some(device_hwid) = identity if let Some(device_hwid) = identity
.device_hwid .device_hwid
.as_deref() .as_deref()
@@ -187,11 +174,28 @@ pub fn fetch_subscription_with_identity_and_policy(
request = request.header("x-hwid", device_hwid); request = request.header("x-hwid", device_hwid);
} }
let response = request let response = request.send().map_err(|error| {
.send() SubscriptionError::new(format!("Subscription request failed: {error}"))
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?; })?;
let status = response.status(); let status = response.status();
if status.is_redirection() {
if redirect_count == SUBSCRIPTION_MAX_REDIRECTS {
return Err(SubscriptionError::new(
"Subscription request exceeded redirect limit",
));
}
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
SubscriptionError::new("Subscription redirect has no valid Location header")
})?;
current_url = current_url
.join(location)
.map_err(|_| SubscriptionError::new("Subscription redirect URL is invalid"))?;
continue;
}
if !status.is_success() { if !status.is_success() {
return Err(SubscriptionError::new(format!( return Err(SubscriptionError::new(format!(
"Subscription request failed: HTTP {}", "Subscription request failed: HTTP {}",
@@ -210,14 +214,70 @@ pub fn fetch_subscription_with_identity_and_policy(
})?; })?;
let parsed = parse_subscription_body(&body)?; let parsed = parse_subscription_body(&body)?;
Ok(SubscriptionCache { return Ok(SubscriptionCache {
config: parsed.config, config: parsed.config,
servers: parsed.servers, servers: parsed.servers,
user_info, user_info,
fetched_at: now_timestamp(), fetched_at: now_timestamp(),
});
}
Err(SubscriptionError::new(
"Subscription request could not complete",
))
}
fn subscription_client_for_url(
parsed_url: &Url,
policy: SubscriptionFetchPolicy,
) -> Result<reqwest::blocking::Client, SubscriptionError> {
let mut builder = reqwest::blocking::Client::builder()
.connect_timeout(SUBSCRIPTION_CONNECT_TIMEOUT)
.timeout(SUBSCRIPTION_REQUEST_TIMEOUT)
.redirect(reqwest::redirect::Policy::none());
if !policy.allow_unsafe_local_urls {
let host = parsed_url
.host_str()
.ok_or_else(|| SubscriptionError::new("Subscription URL has no host"))?;
if host.parse::<IpAddr>().is_err() {
let port = parsed_url
.port_or_known_default()
.ok_or_else(|| SubscriptionError::new("Subscription URL has no resolvable port"))?;
let addresses = (host, port)
.to_socket_addrs()
.map_err(|error| {
SubscriptionError::new(format!(
"Subscription host DNS resolution failed: {error}"
))
})?
.collect::<Vec<_>>();
validate_resolved_subscription_addresses(&addresses)?;
builder = builder.resolve_to_addrs(host, &addresses);
}
}
builder.build().map_err(|error| {
SubscriptionError::new(format!("Subscription client setup failed: {error}"))
}) })
} }
pub fn validate_resolved_subscription_addresses(
addresses: &[SocketAddr],
) -> Result<(), SubscriptionError> {
if addresses.is_empty() {
return Err(SubscriptionError::new(
"Subscription host DNS resolution returned no addresses",
));
}
if addresses.iter().any(|address| is_unsafe_ip(address.ip())) {
return Err(SubscriptionError::new(
"Subscription host resolves to a local, private, link-local, multicast, or metadata address",
));
}
Ok(())
}
fn validate_subscription_fetch_url( fn validate_subscription_fetch_url(
parsed_url: &Url, parsed_url: &Url,
policy: SubscriptionFetchPolicy, policy: SubscriptionFetchPolicy,
@@ -286,23 +346,171 @@ fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
let links = decoded let links = decoded
.lines() .lines()
.map(str::trim) .map(str::trim)
.filter(|line| line.starts_with("vless://")) .filter(|line| {
["vless://", "trojan://", "ss://", "vmess://"]
.iter()
.any(|scheme| line.starts_with(scheme))
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if links.is_empty() { if links.is_empty() {
return Err(SubscriptionError::new( return Err(SubscriptionError::new(
"Subscription does not contain JSON config or VLESS links", "Subscription does not contain JSON config or supported VLESS, VMess, Trojan, or Shadowsocks links",
)); ));
} }
let outbounds = links let outbounds = links
.into_iter() .into_iter()
.map(parse_vless_url) .map(|link| {
if link.starts_with("vless://") {
parse_vless_url(link)
} else if link.starts_with("trojan://") {
parse_trojan_url(link)
} else if link.starts_with("ss://") {
parse_shadowsocks_url(link)
} else {
parse_vmess_url(link)
}
})
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
Ok(json!({ "outbounds": outbounds })) Ok(json!({ "outbounds": outbounds }))
} }
fn parse_trojan_url(raw_url: &str) -> Result<Value, SubscriptionError> {
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid Trojan URL"))?;
let password = parsed.username().trim().to_string();
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
let server_port = parsed.port_or_known_default().unwrap_or(443);
if password.is_empty() || server.is_empty() {
return Err(SubscriptionError::new(
"Trojan URL misses password, host or port",
));
}
let tag = parsed
.fragment()
.map(decode_percent_encoded_utf8)
.unwrap_or_else(|| "trojan-out".to_string());
let server_name = query_value(&parsed, "sni").unwrap_or_else(|| server.clone());
Ok(json!({
"type": "trojan",
"tag": tag,
"server": server,
"server_port": server_port,
"password": password,
"tls": {
"enabled": true,
"server_name": server_name
}
}))
}
fn parse_shadowsocks_url(raw_url: &str) -> Result<Value, SubscriptionError> {
let parsed =
Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid Shadowsocks URL"))?;
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
let server_port = parsed.port().unwrap_or(8388);
let credentials = match parsed.password() {
Some(password) => format!("{}:{password}", parsed.username()),
None => decode_base64_text(parsed.username()).ok_or_else(|| {
SubscriptionError::new("Shadowsocks credentials are not valid base64")
})?,
};
let (method, password) = credentials
.split_once(':')
.ok_or_else(|| SubscriptionError::new("Shadowsocks URL misses method or password"))?;
if method.trim().is_empty() || password.is_empty() || server.is_empty() {
return Err(SubscriptionError::new(
"Shadowsocks URL misses method, password, host or port",
));
}
let tag = parsed
.fragment()
.map(decode_percent_encoded_utf8)
.unwrap_or_else(|| "shadowsocks-out".to_string());
Ok(json!({
"type": "shadowsocks",
"tag": tag,
"server": server,
"server_port": server_port,
"method": method,
"password": password
}))
}
fn parse_vmess_url(raw_url: &str) -> Result<Value, SubscriptionError> {
let payload = raw_url
.strip_prefix("vmess://")
.and_then(|value| value.split('#').next())
.ok_or_else(|| SubscriptionError::new("Invalid VMess URL"))?;
let decoded = decode_base64_text(payload)
.ok_or_else(|| SubscriptionError::new("VMess payload is not valid base64"))?;
let source: Value = serde_json::from_str(&decoded)
.map_err(|_| SubscriptionError::new("VMess payload is not valid JSON"))?;
let server = source
.get("add")
.and_then(Value::as_str)
.unwrap_or_default();
let server_port = source
.get("port")
.and_then(|value| value.as_u64().or_else(|| value.as_str()?.parse().ok()))
.and_then(|value| u16::try_from(value).ok())
.unwrap_or(443);
let uuid = source.get("id").and_then(Value::as_str).unwrap_or_default();
if server.is_empty() || uuid.is_empty() {
return Err(SubscriptionError::new(
"VMess payload misses host, port or uuid",
));
}
let tag = source
.get("ps")
.and_then(Value::as_str)
.map(decode_percent_encoded_utf8)
.unwrap_or_else(|| "vmess-out".to_string());
let security = source
.get("scy")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("auto");
let mut outbound = json!({
"type": "vmess",
"tag": tag,
"server": server,
"server_port": server_port,
"uuid": uuid,
"security": security
});
if source.get("tls").and_then(Value::as_str) == Some("tls") {
let server_name = source
.get("sni")
.or_else(|| source.get("host"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or(server);
outbound["tls"] = json!({ "enabled": true, "server_name": server_name });
}
if source.get("net").and_then(Value::as_str) == Some("ws") {
let path = source
.get("path")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("/");
let host = source
.get("host")
.and_then(Value::as_str)
.filter(|value| !value.is_empty());
outbound["transport"] = json!({
"type": "ws",
"path": path,
"headers": host.map(|host| json!({ "Host": host })).unwrap_or_else(|| json!({}))
});
}
Ok(outbound)
}
fn parse_vless_url(raw_url: &str) -> Result<Value, SubscriptionError> { fn parse_vless_url(raw_url: &str) -> Result<Value, SubscriptionError> {
if !raw_url.starts_with("vless://") { if !raw_url.starts_with("vless://") {
return Err(SubscriptionError::new("VLESS URL must start with vless://")); return Err(SubscriptionError::new("VLESS URL must start with vless://"));
@@ -399,6 +607,7 @@ fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
.unwrap_or_else(|| format!("{server_type}-{server}")); .unwrap_or_else(|| format!("{server_type}-{server}"));
Some(SubscriptionServer { Some(SubscriptionServer {
id: outbound_server_id(outbound),
tag, tag,
server_type, server_type,
server, server,
@@ -406,6 +615,14 @@ fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
}) })
} }
fn outbound_server_id(outbound: &Value) -> String {
let bytes = serde_json::to_vec(outbound).unwrap_or_default();
let hash = bytes.iter().fold(0xcbf29ce484222325_u64, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
});
format!("pw-{hash:016x}")
}
fn maybe_decode_base64(content: &str) -> String { fn maybe_decode_base64(content: &str) -> String {
let compact = content.split_whitespace().collect::<String>(); let compact = content.split_whitespace().collect::<String>();
if compact.is_empty() if compact.is_empty()
@@ -419,7 +636,11 @@ fn maybe_decode_base64(content: &str) -> String {
for engine in [general_purpose::STANDARD, general_purpose::URL_SAFE] { for engine in [general_purpose::STANDARD, general_purpose::URL_SAFE] {
if let Ok(decoded) = engine.decode(compact.as_bytes()) { if let Ok(decoded) = engine.decode(compact.as_bytes()) {
if let Ok(decoded) = String::from_utf8(decoded) { if let Ok(decoded) = String::from_utf8(decoded) {
if decoded.contains("vless://") || decoded.contains('{') { if ["vless://", "vmess://", "trojan://", "ss://"]
.iter()
.any(|scheme| decoded.contains(scheme))
|| decoded.contains('{')
{
return decoded; return decoded;
} }
} }
@@ -429,6 +650,23 @@ fn maybe_decode_base64(content: &str) -> String {
content.to_string() content.to_string()
} }
fn decode_base64_text(value: &str) -> Option<String> {
let value = value.trim();
for engine in [
general_purpose::STANDARD,
general_purpose::STANDARD_NO_PAD,
general_purpose::URL_SAFE,
general_purpose::URL_SAFE_NO_PAD,
] {
if let Ok(decoded) = engine.decode(value.as_bytes()) {
if let Ok(decoded) = String::from_utf8(decoded) {
return Some(decoded);
}
}
}
None
}
fn query_value(url: &Url, key: &str) -> Option<String> { fn query_value(url: &Url, key: &str) -> Option<String> {
url.query_pairs() url.query_pairs()
.find(|(name, _)| name == key) .find(|(name, _)| name == key)
+67 -3
View File
@@ -25,11 +25,18 @@ fn clean(value: &str) -> String {
fn slug(value: &str, fallback: &str) -> String { fn slug(value: &str, fallback: &str) -> String {
let mut output = String::new(); let mut output = String::new();
let mut previous_dash = false; let mut previous_dash = false;
let mut has_non_ascii = false;
for ch in value.trim().to_lowercase().chars() { for ch in value.trim().to_lowercase().chars() {
if ch.is_ascii_alphanumeric() { if ch.is_ascii_alphanumeric() {
output.push(ch); output.push(ch);
previous_dash = false; previous_dash = false;
} else if ch.is_alphanumeric() {
has_non_ascii = true;
if !previous_dash {
output.push('-');
previous_dash = true;
}
} else if !previous_dash { } else if !previous_dash {
output.push('-'); output.push('-');
previous_dash = true; previous_dash = true;
@@ -37,13 +44,56 @@ fn slug(value: &str, fallback: &str) -> String {
} }
let output = output.trim_matches('-').to_string(); let output = output.trim_matches('-').to_string();
if output.is_empty() { let base = if output.is_empty() { fallback } else { &output };
fallback.to_string() if has_non_ascii {
format!("{base}-{:016x}", stable_hash(value.trim().as_bytes()))
} else { } else {
output base.to_string()
} }
} }
fn stable_hash(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf29ce484222325, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
})
}
fn valid_proxy_host(value: &str) -> bool {
!value.is_empty()
&& !value.contains("://")
&& !value.chars().any(|ch| {
ch.is_whitespace() || ch.is_control() || matches!(ch, '/' | '\\' | '@' | '?' | '#')
})
&& url::Host::parse(value).is_ok()
}
fn valid_windows_item_path(value: &str, item_type: &ProfileItemType) -> bool {
if value
.chars()
.any(|ch| ch.is_control() || matches!(ch, '"' | '<' | '>' | '|' | '?' | '*'))
{
return false;
}
let bytes = value.as_bytes();
let absolute_drive = bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/');
let unc = value.starts_with(r"\\");
let environment_root = value.starts_with('%')
&& value[1..].find('%').is_some_and(|index| {
value
.as_bytes()
.get(index + 2)
.is_some_and(|ch| matches!(ch, b'\\' | b'/'))
});
let path_shape_valid = absolute_drive || unc || environment_root;
path_shape_valid
&& (!matches!(item_type, ProfileItemType::Exe)
|| value.to_ascii_lowercase().ends_with(".exe"))
}
fn process_name(value: &str) -> String { fn process_name(value: &str) -> String {
let base = value.trim().rsplit(['\\', '/']).next().unwrap_or("").trim(); let base = value.trim().rsplit(['\\', '/']).next().unwrap_or("").trim();
base.strip_suffix(".exe") base.strip_suffix(".exe")
@@ -149,6 +199,15 @@ pub fn normalize_profile(input: ProfileInput) -> ValidationResult<Profile> {
errors.push(error("items.value", "Укажите значение элемента профиля")); errors.push(error("items.value", "Укажите значение элемента профиля"));
continue; continue;
} }
if matches!(item_type, ProfileItemType::Folder | ProfileItemType::Exe)
&& !valid_windows_item_path(&value, &item_type)
{
errors.push(error(
"items.value",
"Укажите абсолютный Windows-путь; для exe путь должен оканчиваться на .exe",
));
continue;
}
let recursive = let recursive =
matches!(item_type, ProfileItemType::Folder) && raw_item.recursive.unwrap_or(true); matches!(item_type, ProfileItemType::Folder) && raw_item.recursive.unwrap_or(true);
@@ -183,6 +242,11 @@ pub fn normalize_target(input: TargetInput) -> ValidationResult<Target> {
} }
if host.is_empty() { if host.is_empty() {
errors.push(error("host", "Укажите хост цели")); errors.push(error("host", "Укажите хост цели"));
} else if !valid_proxy_host(&host) {
errors.push(error(
"host",
"Укажите только IP-адрес или имя хоста без схемы, пути и учетных данных",
));
} }
if input.port == 0 || input.port > u16::MAX as u32 { if input.port == 0 || input.port > u16::MAX as u32 {
errors.push(error("port", "Порт цели должен быть от 1 до 65535")); errors.push(error("port", "Порт цели должен быть от 1 до 65535"));
+423
View File
@@ -0,0 +1,423 @@
use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter;
use proxywarden_lib::adapters::singbox::{
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
};
use proxywarden_lib::apply_flow::{
apply_configuration, ApplyConfigurationInput, ApplyPhaseStatus, ApplyRouteMode, ApplyServices,
};
use proxywarden_lib::commands::{
Clock, CommandError, HelperApplyRequest, HelperApplyResult, ProxyApplyHelper,
};
use proxywarden_lib::component_detection::{DetectedProxyfier, ProxyfierEngine};
use proxywarden_lib::models::{
LocalSingBoxConfig, Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType,
Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, TargetInput,
TargetKind,
};
use proxywarden_lib::storage::JsonStorage;
use std::{cell::Cell, fs, path::Path};
#[test]
fn external_apply_commits_one_source_state_without_service_control() {
let fixture = ApplyFixture::new("external-success");
fixture.seed_old_state();
let helper = RecordingHelper::success();
let result =
run_apply(&fixture.storage, external_input(), &helper).expect("preflight should succeed");
assert!(result.success);
assert!(!result.partial_state);
assert_eq!(helper.calls.get(), 1);
assert!(result.phases.iter().any(|phase| {
phase.id == "service-control" && phase.status == ApplyPhaseStatus::Skipped
}));
let profiles = fixture.storage.read_profiles().expect("read profiles");
let targets = fixture.storage.read_targets().expect("read targets");
assert!(profiles
.iter()
.any(|profile| profile.id == "main-profile" && profile.enabled));
assert!(profiles
.iter()
.any(|profile| profile.id == "legacy" && !profile.enabled));
assert!(targets.iter().any(|target| {
target.id == "main-proxy" && target.host == "proxy.example.test" && target.port == 1080
}));
assert!(Path::new(&result.generated_config_path).exists());
}
#[test]
fn preflight_failure_does_not_write_source_or_call_helper() {
let fixture = ApplyFixture::new("preflight-failure");
fixture.seed_old_state();
let before_profiles = fixture.storage.read_profiles().expect("profiles before");
let before_targets = fixture.storage.read_targets().expect("targets before");
let helper = RecordingHelper::success();
let mut input = external_input();
input.external_target.as_mut().expect("target").host =
"socks5://unsafe.example.test".to_string();
let error = run_apply(&fixture.storage, input, &helper)
.expect_err("invalid target should fail before writes");
assert_eq!(error.code(), "validation_failed");
assert_eq!(helper.calls.get(), 0);
assert_eq!(
fixture.storage.read_profiles().expect("profiles after"),
before_profiles
);
assert_eq!(
fixture.storage.read_targets().expect("targets after"),
before_targets
);
}
#[test]
fn backend_blocks_apply_when_proxifyre_is_not_detected() {
let fixture = ApplyFixture::new("missing-proxifyre");
fixture.seed_old_state();
let helper = RecordingHelper::success();
let proxy_adapter = ProxiFyreAdapter::default();
let singbox_adapter = SingBoxAdapter::default();
let error = apply_configuration(
&fixture.storage,
external_input(),
ApplyServices {
proxy_adapter: &proxy_adapter,
singbox_adapter: &singbox_adapter,
checker: &NoopChecker,
helper: &helper,
clock: &FixedClock,
detected_proxyfier: None,
detected_singbox: None,
},
)
.expect_err("backend must not trust frontend readiness");
assert_eq!(error.code(), "proxifyre_not_found");
assert_eq!(helper.calls.get(), 0);
}
#[test]
fn apply_command_contract_uses_camel_case_nested_dtos() {
let input: ApplyConfigurationInput = serde_json::from_value(serde_json::json!({
"routeMode": "external",
"profile": {
"id": "main-profile",
"name": "Main",
"enabled": true,
"targetId": "main-proxy",
"protocols": ["TCP"],
"items": [{ "type": "process", "value": "Discord.exe" }]
},
"externalTarget": {
"id": "main-proxy",
"name": "Proxy",
"kind": "external",
"protocol": "socks5",
"host": "proxy.example.test",
"port": 1080
},
"disableOtherProfiles": true
}))
.expect("typed Tauri input should deserialize");
assert_eq!(input.profile.target_id, "main-proxy");
assert_eq!(input.profile.items[0].item_type, "process");
assert_eq!(
input.external_target.expect("target").host,
"proxy.example.test"
);
}
#[test]
fn helper_failure_rolls_back_source_and_generated_artifact() {
let fixture = ApplyFixture::new("helper-rollback");
fixture.seed_old_state();
let before_profiles = fixture.storage.read_profiles().expect("profiles before");
let before_targets = fixture.storage.read_targets().expect("targets before");
let generated_path = fixture
.storage
.paths()
.generated_dir
.join("proxifyre-app-config.json");
fs::create_dir_all(generated_path.parent().expect("generated parent"))
.expect("create generated dir");
fs::write(&generated_path, b"old-generated").expect("seed generated config");
let helper = RecordingHelper::failure();
let result = run_apply(&fixture.storage, external_input(), &helper)
.expect("runtime failure should return phase result");
assert!(!result.success);
assert!(!result.partial_state);
assert_eq!(result.error_code.as_deref(), Some("fixture_apply_failed"));
assert!(result
.phases
.iter()
.any(|phase| phase.status == ApplyPhaseStatus::RolledBack));
assert_eq!(
fixture.storage.read_profiles().expect("profiles after"),
before_profiles
);
assert_eq!(
fixture.storage.read_targets().expect("targets after"),
before_targets
);
assert_eq!(
fs::read(&generated_path).expect("generated after"),
b"old-generated"
);
}
#[test]
fn local_apply_with_missing_running_service_stops_at_preflight() {
let fixture = ApplyFixture::new("local-service-preflight");
fixture.seed_old_state();
fixture
.storage
.write_local_singbox_config(&LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/list".to_string()),
selected_server_id: Some("fixture-server".to_string()),
selected_server_tag: Some("fixture".to_string()),
..LocalSingBoxConfig::default()
})
.expect("write local config");
fixture
.storage
.write_singbox_subscription_cache(&SubscriptionCache {
config: serde_json::json!({
"outbounds": [{
"type": "vless",
"tag": "fixture",
"server": "edge.example.test",
"server_port": 443,
"uuid": "11111111-1111-1111-1111-111111111111"
}]
}),
servers: vec![SubscriptionServer {
id: "fixture-server".to_string(),
tag: "fixture".to_string(),
server_type: "vless".to_string(),
server: "edge.example.test".to_string(),
server_port: 443,
}],
user_info: serde_json::Map::new(),
fetched_at: "fixture".to_string(),
})
.expect("write cache");
let helper = RecordingHelper::success();
let before_profiles = fixture.storage.read_profiles().expect("profiles before");
let error = run_apply(
&fixture.storage,
ApplyConfigurationInput {
route_mode: ApplyRouteMode::LocalSingbox,
profile: profile_input(),
external_target: None,
disable_other_profiles: true,
},
&helper,
)
.expect_err("stopped/missing Local sing-box must block preflight");
assert_eq!(error.code(), "proxifyre_preflight_failed");
assert_eq!(helper.calls.get(), 0);
assert_eq!(
fixture.storage.read_profiles().expect("profiles after"),
before_profiles
);
}
fn external_input() -> ApplyConfigurationInput {
ApplyConfigurationInput {
route_mode: ApplyRouteMode::External,
profile: profile_input(),
external_target: Some(TargetInput {
id: Some("main-proxy".to_string()),
name: "Основной прокси".to_string(),
kind: "external".to_string(),
protocol: "socks5".to_string(),
host: "proxy.example.test".to_string(),
port: 1080,
requires_component: None,
}),
disable_other_profiles: true,
}
}
fn profile_input() -> ProfileInput {
ProfileInput {
id: Some("main-profile".to_string()),
name: "Приложения через прокси".to_string(),
enabled: true,
target_id: String::new(),
protocols: vec!["TCP".to_string(), "UDP".to_string()],
items: vec![ProfileItemInput {
item_type: "process".to_string(),
value: "Discord.exe".to_string(),
recursive: None,
}],
}
}
fn run_apply(
storage: &JsonStorage,
input: ApplyConfigurationInput,
helper: &dyn ProxyApplyHelper,
) -> Result<
proxywarden_lib::apply_flow::ApplyConfigurationResult,
proxywarden_lib::apply_flow::ApplyFlowError,
> {
let proxy_adapter = ProxiFyreAdapter::default();
let singbox_adapter = SingBoxAdapter::default();
apply_configuration(
storage,
input,
ApplyServices {
proxy_adapter: &proxy_adapter,
singbox_adapter: &singbox_adapter,
checker: &NoopChecker,
helper,
clock: &FixedClock,
detected_proxyfier: Some(test_proxyfier()),
detected_singbox: None,
},
)
}
fn test_proxyfier() -> DetectedProxyfier {
DetectedProxyfier {
engine: ProxyfierEngine::ProxiFyre,
name: "ProxiFyre".to_string(),
install_dir: r"C:\Program Files\ProxyWarden\components\ProxiFyre".into(),
executable_path: r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe".into(),
config_path: Some(
r"C:\Program Files\ProxyWarden\components\ProxiFyre\app-config.json".into(),
),
running: true,
service_name: Some("ProxiFyreService".to_string()),
service_status: Some("running".to_string()),
}
}
struct RecordingHelper {
calls: Cell<usize>,
succeed: bool,
}
impl RecordingHelper {
fn success() -> Self {
Self {
calls: Cell::new(0),
succeed: true,
}
}
fn failure() -> Self {
Self {
calls: Cell::new(0),
succeed: false,
}
}
}
impl ProxyApplyHelper for RecordingHelper {
fn apply_proxy_config(
&self,
_request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
self.calls.set(self.calls.get() + 1);
if self.succeed {
Ok(HelperApplyResult {
success: true,
changed: true,
action: "apply".to_string(),
message: "fixture applied".to_string(),
})
} else {
Err(CommandError {
code: "fixture_apply_failed".to_string(),
message: "fixture helper failed".to_string(),
details: Vec::new(),
})
}
}
}
struct NoopChecker;
impl SingBoxConfigChecker for NoopChecker {
fn check_config(
&self,
_binary_path: &Path,
_config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
Ok(SingBoxCheckResult {
checked: true,
success: true,
message: "fixture valid".to_string(),
})
}
}
struct FixedClock;
impl Clock for FixedClock {
fn now(&self) -> String {
"2026-07-11T00:00:00Z".to_string()
}
}
struct ApplyFixture {
root: std::path::PathBuf,
storage: JsonStorage,
}
impl ApplyFixture {
fn new(label: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"proxywarden-apply-flow-{label}-{}",
uuid::Uuid::new_v4().hyphenated()
));
Self {
storage: JsonStorage::new(root.clone()),
root,
}
}
fn seed_old_state(&self) {
self.storage
.write_profiles(&[Profile {
id: "legacy".to_string(),
name: "Legacy".to_string(),
enabled: true,
target_id: "legacy-target".to_string(),
protocols: vec![Protocol::Tcp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "legacy".to_string(),
recursive: false,
}],
}])
.expect("seed profiles");
self.storage
.write_targets(&[Target {
id: "legacy-target".to_string(),
name: "Legacy".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "legacy.example.test".to_string(),
port: 1080,
requires_component: None,
}])
.expect("seed targets");
}
}
impl Drop for ApplyFixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
+52 -2
View File
@@ -13,6 +13,7 @@ use proxywarden_lib::models::{
self, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, self, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType,
Protocol, ProxyProtocol, Target, TargetKind, Protocol, ProxyProtocol, Target, TargetKind,
}; };
use proxywarden_lib::proxifyre_ownership::ManagedProxiFyreOwnership;
use proxywarden_lib::storage::JsonStorage; use proxywarden_lib::storage::JsonStorage;
use std::collections::HashSet; use std::collections::HashSet;
use std::fs; use std::fs;
@@ -358,7 +359,7 @@ fn proxifyre_uninstall_script_parses_as_powershell() {
}; };
let script = commands::wrap_elevated_package_script( let script = commands::wrap_elevated_package_script(
&commands::uninstall_proxifyre_script(Some(&detected)), &commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(true)),
&root.join("uninstall.log"), &root.join("uninstall.log"),
); );
let script_path = root.join("uninstall.ps1"); let script_path = root.join("uninstall.ps1");
@@ -397,8 +398,13 @@ fn proxifyre_uninstall_script_removes_packet_filter_after_proxifyre() {
service_name: Some("ProxiFyreService".to_string()), service_name: Some("ProxiFyreService".to_string()),
service_status: Some("running".to_string()), service_status: Some("running".to_string()),
}; };
let script = commands::uninstall_proxifyre_script(Some(&detected)); let script = commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(true));
assert!(script.contains("function Find-ManagedProxiFyreService"));
assert!(script.contains("Get-CimInstance Win32_Service"));
assert!(script.contains("[StringComparison]::OrdinalIgnoreCase"));
assert!(!script.contains("function Find-ProxiFyreService"));
assert!(!script.contains("Where-Object { $_.Name -match 'ProxiFyre|Proxifyre'"));
assert!(script.contains("function Resolve-MsiProductCode($program, [string]$label)")); assert!(script.contains("function Resolve-MsiProductCode($program, [string]$label)"));
assert!(script.contains("Отказываюсь запускать произвольный UninstallString")); assert!(script.contains("Отказываюсь запускать произвольный UninstallString"));
assert!(script.contains("Start-Process -FilePath 'msiexec.exe'")); assert!(script.contains("Start-Process -FilePath 'msiexec.exe'"));
@@ -413,6 +419,29 @@ fn proxifyre_uninstall_script_removes_packet_filter_after_proxifyre() {
assert!(proxifyre_step < packet_filter_step); assert!(proxifyre_step < packet_filter_step);
} }
#[test]
fn proxifyre_uninstall_script_leaves_shared_packet_filter_installed() {
let detected = DetectedProxyfier {
engine: ProxyfierEngine::ProxiFyre,
name: "ProxiFyre".to_string(),
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"),
executable_path: PathBuf::from(
r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe",
),
config_path: None,
running: false,
service_name: Some("ProxiFyreService".to_string()),
service_status: Some("stopped".to_string()),
};
let script = commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(false));
assert!(script.contains("$removePacketFilter = $false"));
assert!(script.contains("if ($removePacketFilter)"));
assert!(script.contains("Windows Packet Filter оставлен"));
assert!(!script.contains("Get-Process -Name 'ProxiFyre'"));
}
#[test] #[test]
fn singbox_runner_preserves_installer_args_with_spaces() { fn singbox_runner_preserves_installer_args_with_spaces() {
let script = commands::singbox_installer_runner_script( let script = commands::singbox_installer_runner_script(
@@ -540,6 +569,20 @@ fn component_status_merges_detected_existing_proxifyre() {
assert!(proxyfier.problems.is_empty()); assert!(proxyfier.problems.is_empty());
} }
#[test]
fn component_status_does_not_keep_stale_installed_state_when_detection_is_missing() {
let components = resolve_component_statuses(vec![proxyfier_running()], None, None);
let proxyfier = components
.iter()
.find(|component| component.id == ComponentId::Proxyfier)
.expect("proxyfier component");
assert_eq!(proxyfier.state, ComponentState::Missing);
assert!(!proxyfier.installed);
assert!(!proxyfier.running);
assert_eq!(proxyfier.path, None);
}
#[test] #[test]
fn detected_proxy_apply_helper_writes_proxifyre_app_config() { fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
let root = test_root("detected-proxifyre"); let root = test_root("detected-proxifyre");
@@ -782,3 +825,10 @@ fn singbox_missing() -> ComponentStatus {
actions: vec!["Установить локальный sing-box".to_string()], actions: vec!["Установить локальный sing-box".to_string()],
} }
} }
fn managed_ownership(remove_packet_filter: bool) -> ManagedProxiFyreOwnership {
ManagedProxiFyreOwnership {
service_name: "ProxiFyreService".to_string(),
remove_packet_filter,
}
}
+65 -3
View File
@@ -15,7 +15,10 @@ fn detects_existing_proxifyre_from_registry_install_location() {
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre") .with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre") .with_path(r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe") .with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
.with_service("ProxiFyreService"); .with_service_path(
"ProxiFyreService",
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#,
);
let detected = detect_proxyfier_install_with_host(&host) let detected = detect_proxyfier_install_with_host(&host)
.expect("existing ProxiFyre install should be detected"); .expect("existing ProxiFyre install should be detected");
@@ -82,7 +85,10 @@ fn reports_stopped_proxifyre_service_when_executable_exists() {
let host = MockHost::new() let host = MockHost::new()
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre") .with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe") .with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
.with_stopped_service("ProxiFyreService"); .with_stopped_service_path(
"ProxiFyreService",
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#,
);
let detected = let detected =
detect_proxyfier_install_with_host(&host).expect("proxifyre executable should be detected"); detect_proxyfier_install_with_host(&host).expect("proxifyre executable should be detected");
@@ -105,6 +111,37 @@ fn missing_proxyfier_returns_install_action_status() {
assert_eq!(component.actions, vec!["Установить ProxiFyre"]); assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
} }
#[test]
fn ignores_known_service_name_when_path_points_to_foreign_binary() {
let host = MockHost::new()
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
.with_service_path(
"ProxiFyreService",
r#""C:\Foreign\ProxiFyre.exe" --service"#,
);
let detected =
detect_proxyfier_install_with_host(&host).expect("executable should still be detected");
assert!(!detected.running);
assert_eq!(detected.service_status, None);
}
#[test]
fn ignores_known_service_name_without_path_metadata() {
let host = MockHost::new()
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
.with_service("ProxiFyreService");
let detected =
detect_proxyfier_install_with_host(&host).expect("executable should still be detected");
assert!(!detected.running);
assert_eq!(detected.service_status, None);
}
#[test] #[test]
fn detects_running_local_singbox_from_default_install_root_and_service() { fn detects_running_local_singbox_from_default_install_root_and_service() {
let host = MockHost::new() let host = MockHost::new()
@@ -176,6 +213,7 @@ struct MockHost {
paths: HashSet<String>, paths: HashSet<String>,
processes: HashSet<String>, processes: HashSet<String>,
services: HashMap<String, String>, services: HashMap<String, String>,
service_paths: HashMap<String, String>,
registry: Vec<RegistryInstallEntry>, registry: Vec<RegistryInstallEntry>,
} }
@@ -205,9 +243,19 @@ impl MockHost {
self self
} }
fn with_stopped_service(mut self, service: &str) -> Self { fn with_service_path(mut self, service: &str, path_name: &str) -> Self {
self.services
.insert(service.to_ascii_lowercase(), "running".to_string());
self.service_paths
.insert(service.to_ascii_lowercase(), path_name.to_string());
self
}
fn with_stopped_service_path(mut self, service: &str, path_name: &str) -> Self {
self.services self.services
.insert(service.to_ascii_lowercase(), "stopped".to_string()); .insert(service.to_ascii_lowercase(), "stopped".to_string());
self.service_paths
.insert(service.to_ascii_lowercase(), path_name.to_string());
self self
} }
@@ -241,6 +289,20 @@ impl ProxyfierDetectionHost for MockHost {
.cloned() .cloned()
} }
fn service_info(
&self,
service_name: &str,
) -> Option<proxywarden_lib::component_detection::DetectedService> {
let key = service_name.to_ascii_lowercase();
self.services.get(&key).map(|status| {
proxywarden_lib::component_detection::DetectedService {
name: service_name.to_string(),
status: status.clone(),
path_name: self.service_paths.get(&key).cloned(),
}
})
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> { fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
self.registry.clone() self.registry.clone()
} }
+88
View File
@@ -121,3 +121,91 @@ fn rejects_malformed_target_fields() {
assert!(error.iter().any(|item| item.field == "protocol")); assert!(error.iter().any(|item| item.field == "protocol"));
assert!(error.iter().any(|item| item.field == "requires_component")); assert!(error.iter().any(|item| item.field == "requires_component"));
} }
#[test]
fn unicode_names_receive_distinct_stable_ids() {
let profile = normalize_profile(ProfileInput {
id: None,
name: "Игры".to_string(),
enabled: true,
target_id: "main-proxy".to_string(),
protocols: vec!["TCP".to_string()],
items: vec![ProfileItemInput {
item_type: "process".to_string(),
value: "game.exe".to_string(),
recursive: None,
}],
})
.expect("unicode profile should normalize");
let other = normalize_profile(ProfileInput {
id: None,
name: "Работа".to_string(),
enabled: true,
target_id: "main-proxy".to_string(),
protocols: vec!["TCP".to_string()],
items: vec![ProfileItemInput {
item_type: "process".to_string(),
value: "work.exe".to_string(),
recursive: None,
}],
})
.expect("second unicode profile should normalize");
assert!(profile.id.starts_with("profile-"));
assert!(other.id.starts_with("profile-"));
assert_ne!(profile.id, other.id);
}
#[test]
fn rejects_host_with_scheme_credentials_or_path() {
for host in [
"socks5://proxy.example.test",
"user@proxy.example.test",
"proxy.example.test/path",
] {
let error = normalize_target(TargetInput {
id: None,
name: "Invalid host".to_string(),
kind: "external".to_string(),
protocol: "socks5".to_string(),
host: host.to_string(),
port: 1080,
requires_component: None,
})
.expect_err("host must not contain URL syntax");
assert!(error.iter().any(|item| item.field == "host"));
}
}
#[test]
fn rejects_relative_or_non_executable_profile_paths() {
let error = normalize_profile(ProfileInput {
id: None,
name: "Invalid paths".to_string(),
enabled: true,
target_id: "main-proxy".to_string(),
protocols: vec!["TCP".to_string()],
items: vec![
ProfileItemInput {
item_type: "folder".to_string(),
value: r"relative\folder".to_string(),
recursive: None,
},
ProfileItemInput {
item_type: "exe".to_string(),
value: r"C:\Games\game.txt".to_string(),
recursive: None,
},
],
})
.expect_err("unsafe path shapes should fail validation");
assert_eq!(
error
.iter()
.filter(|item| item.field == "items.value")
.count(),
2
);
}
@@ -83,6 +83,35 @@ fn includes_folder_paths_when_generating_proxifyre_config() {
); );
} }
#[test]
fn deduplicates_windows_app_names_case_insensitively() {
let adapter = ProxiFyreAdapter::default();
let mut profile = discord_profile("home-gateway");
profile.items.extend([
ProfileItem {
item_type: ProfileItemType::Process,
value: "discord".to_string(),
recursive: false,
},
ProfileItem {
item_type: ProfileItemType::Exe,
value: "DISCORD".to_string(),
recursive: false,
},
]);
let profiles = vec![profile];
let targets = vec![external_socks5_target()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &[]))
.expect("Windows app names should generate");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(config.proxies[0].app_names, vec!["Discord"]);
assert_eq!(generated.routed_apps, 1);
}
#[test] #[test]
fn blocks_local_singbox_target_when_required_component_is_missing() { fn blocks_local_singbox_target_when_required_component_is_missing() {
let adapter = ProxiFyreAdapter::default(); let adapter = ProxiFyreAdapter::default();
@@ -0,0 +1,145 @@
use proxywarden_lib::proxifyre_ownership::verify_managed_proxifyre_install;
use serde_json::json;
use std::{fs, path::PathBuf};
#[test]
fn accepts_matching_managed_install_and_returns_packet_filter_ownership() {
let fixture = ManagedInstallFixture::new("owned");
fixture.write_marker(true, &fixture.install_dir);
let ownership = verify_managed_proxifyre_install(
&fixture.install_dir,
&fixture.executable_path,
&fixture.install_dir,
)
.expect("matching marker should prove ownership");
assert_eq!(ownership.service_name, "ProxiFyreService");
assert!(ownership.remove_packet_filter);
}
#[test]
fn rejects_install_outside_expected_managed_directory() {
let fixture = ManagedInstallFixture::new("unexpected-root");
fixture.write_marker(true, &fixture.install_dir);
let other_root = fixture
.root
.join("other")
.join("components")
.join("ProxiFyre");
fs::create_dir_all(&other_root).expect("other root should be created");
let error = verify_managed_proxifyre_install(
&fixture.install_dir,
&fixture.executable_path,
&other_root,
)
.expect_err("a detected portable install must not be recursively removed");
assert!(error.contains("не является управляемой папкой"));
}
#[test]
fn rejects_marker_with_mismatched_install_root() {
let fixture = ManagedInstallFixture::new("mismatched-marker");
fixture.write_marker(false, &fixture.root);
let error = verify_managed_proxifyre_install(
&fixture.install_dir,
&fixture.executable_path,
&fixture.install_dir,
)
.expect_err("marker installRoot must match the managed directory");
assert!(error.contains("installRoot из marker"));
}
#[test]
fn rejects_marker_with_foreign_service_name() {
let fixture = ManagedInstallFixture::new("foreign-service");
fixture.write_custom_marker(json!({
"manager": "ProxyWarden",
"component": "proxifyre",
"serviceName": "ForeignProxyService",
"installRoot": fixture.install_dir,
"packetFilterInstalledByProxyWarden": true
}));
let error = verify_managed_proxifyre_install(
&fixture.install_dir,
&fixture.executable_path,
&fixture.install_dir,
)
.expect_err("foreign service name must not be trusted");
assert!(error.contains("неподдерживаемое имя службы"));
}
#[test]
fn missing_packet_filter_flag_defaults_to_not_owned() {
let fixture = ManagedInstallFixture::new("shared-driver");
fixture.write_custom_marker(json!({
"manager": "ProxyWarden",
"component": "proxifyre",
"serviceName": "ProxiFyreService",
"installRoot": fixture.install_dir
}));
let ownership = verify_managed_proxifyre_install(
&fixture.install_dir,
&fixture.executable_path,
&fixture.install_dir,
)
.expect("valid marker without ownership flag should remain safe");
assert!(!ownership.remove_packet_filter);
}
struct ManagedInstallFixture {
root: PathBuf,
install_dir: PathBuf,
executable_path: PathBuf,
}
impl ManagedInstallFixture {
fn new(label: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"proxywarden-ownership-{label}-{}",
uuid::Uuid::new_v4().hyphenated()
));
let install_dir = root.join("components").join("ProxiFyre");
let executable_path = install_dir.join("ProxiFyre.exe");
fs::create_dir_all(&install_dir).expect("managed install directory should be created");
fs::write(&executable_path, b"fixture").expect("fixture executable should be written");
Self {
root,
install_dir,
executable_path,
}
}
fn write_marker(&self, packet_filter_owned: bool, install_root: &std::path::Path) {
self.write_custom_marker(json!({
"manager": "ProxyWarden",
"component": "proxifyre",
"serviceName": "ProxiFyreService",
"installRoot": install_root,
"packetFilterInstalledByProxyWarden": packet_filter_owned
}));
}
fn write_custom_marker(&self, marker: serde_json::Value) {
fs::write(
self.install_dir.join("proxywarden-component.json"),
serde_json::to_vec_pretty(&marker).expect("marker should serialize"),
)
.expect("marker should be written");
}
}
impl Drop for ManagedInstallFixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
+64 -3
View File
@@ -91,6 +91,7 @@ fn blocks_config_when_server_is_not_selected() {
let adapter = SingBoxAdapter::default(); let adapter = SingBoxAdapter::default();
let mut config = local_singbox_config("nl-1"); let mut config = local_singbox_config("nl-1");
config.selected_server_tag = None; config.selected_server_tag = None;
config.selected_server_id = None;
let cache = subscription_cache(); let cache = subscription_cache();
let checker = RecordingChecker::ok("should not run"); let checker = RecordingChecker::ok("should not run");
@@ -108,8 +109,9 @@ fn blocks_config_when_server_is_not_selected() {
#[test] #[test]
fn blocks_config_when_selected_outbound_is_missing() { fn blocks_config_when_selected_outbound_is_missing() {
let adapter = SingBoxAdapter::default(); let adapter = SingBoxAdapter::default();
let config = local_singbox_config("missing-server"); let config = local_singbox_config("nl-1");
let cache = subscription_cache(); let mut cache = subscription_cache();
cache.config = serde_json::json!({ "outbounds": [] });
let checker = RecordingChecker::ok("should not run"); let checker = RecordingChecker::ok("should not run");
let error = adapter let error = adapter
@@ -120,10 +122,67 @@ fn blocks_config_when_selected_outbound_is_missing() {
.expect_err("missing outbound should block config"); .expect_err("missing outbound should block config");
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound); assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound);
assert!(error.message.contains("missing-server")); assert!(error.message.contains("nl-1"));
assert!(checker.calls.borrow().is_empty()); assert!(checker.calls.borrow().is_empty());
} }
#[test]
fn duplicate_tags_generate_the_outbound_selected_by_stable_id() {
let adapter = SingBoxAdapter::default();
let mut config = local_singbox_config("shared-name");
config.selected_server_id = Some("vless|shared-name|second.example.test|8443".to_string());
let cache = SubscriptionCache {
config: serde_json::json!({
"outbounds": [
{
"type": "vless",
"tag": "shared-name",
"server": "first.example.test",
"server_port": 443,
"uuid": "11111111-1111-1111-1111-111111111111"
},
{
"type": "vless",
"tag": "shared-name",
"server": "second.example.test",
"server_port": 8443,
"uuid": "22222222-2222-2222-2222-222222222222"
}
]
}),
servers: vec![
SubscriptionServer {
id: "vless|shared-name|first.example.test|443".to_string(),
tag: "shared-name".to_string(),
server_type: "vless".to_string(),
server: "first.example.test".to_string(),
server_port: 443,
},
SubscriptionServer {
id: "vless|shared-name|second.example.test|8443".to_string(),
tag: "shared-name".to_string(),
server_type: "vless".to_string(),
server: "second.example.test".to_string(),
server_port: 8443,
},
],
user_info: serde_json::Map::new(),
fetched_at: "2026-07-11T00:00:00Z".to_string(),
};
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&config, &cache, None),
&RecordingChecker::ok("not used"),
)
.expect("stable id should resolve the second duplicate tag");
let value: serde_json::Value =
serde_json::from_str(&generated.contents).expect("generated config should parse");
assert_eq!(value["outbounds"][0]["server"], "second.example.test");
assert_eq!(value["outbounds"][0]["server_port"], 8443);
}
#[test] #[test]
fn propagates_failed_singbox_check_as_structured_error() { fn propagates_failed_singbox_check_as_structured_error() {
let adapter = SingBoxAdapter::default(); let adapter = SingBoxAdapter::default();
@@ -208,6 +267,7 @@ fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/list".to_string()), subscription_url: Some("https://sub.example.test/list".to_string()),
device_hwid: None, device_hwid: None,
selected_server_tag: Some(selected_server_tag.to_string()), selected_server_tag: Some(selected_server_tag.to_string()),
selected_server_id: Some(format!("vless|{selected_server_tag}|nl.example.test|443")),
listen_host: "127.0.0.1".to_string(), listen_host: "127.0.0.1".to_string(),
listen_port: 1080, listen_port: 1080,
service_name: "ProxyWardenSingBox".to_string(), service_name: "ProxyWardenSingBox".to_string(),
@@ -234,6 +294,7 @@ fn subscription_cache() -> SubscriptionCache {
] ]
}), }),
servers: vec![SubscriptionServer { servers: vec![SubscriptionServer {
id: "vless|nl-1|nl.example.test|443".to_string(),
tag: "nl-1".to_string(), tag: "nl-1".to_string(),
server_type: "vless".to_string(), server_type: "vless".to_string(),
server: "nl.example.test".to_string(), server: "nl.example.test".to_string(),
+45
View File
@@ -208,6 +208,7 @@ fn selects_server_from_cached_subscription() {
let status = select_singbox_server_in_storage( let status = select_singbox_server_in_storage(
&storage, &storage,
SelectSingBoxServerInputDto { SelectSingBoxServerInputDto {
id: Some("trojan|de-1|de.example.test|443".to_string()),
tag: "de-1".to_string(), tag: "de-1".to_string(),
server: None, server: None,
server_port: None, server_port: None,
@@ -220,7 +221,47 @@ fn selects_server_from_cached_subscription() {
.expect("read local sing-box config"); .expect("read local sing-box config");
assert_eq!(status.config.selected_server_tag, Some("de-1".to_string())); assert_eq!(status.config.selected_server_tag, Some("de-1".to_string()));
assert_eq!(
status.config.selected_server_id,
Some("trojan|de-1|de.example.test|443".to_string())
);
assert_eq!(config.selected_server_tag, Some("de-1".to_string())); assert_eq!(config.selected_server_tag, Some("de-1".to_string()));
assert_eq!(
config.selected_server_id,
Some("trojan|de-1|de.example.test|443".to_string())
);
cleanup(&root);
}
#[test]
fn selects_duplicate_tag_by_stable_server_id() {
let root = test_root("select-duplicate-tag");
let storage = JsonStorage::new(root.clone());
let mut cache = sample_cache();
cache.servers[1].tag = "nl-1".to_string();
cache.servers[1].id = "trojan|nl-1|de.example.test|443".to_string();
storage
.write_singbox_subscription_cache(&cache)
.expect("write cache");
let status = select_singbox_server_in_storage(
&storage,
SelectSingBoxServerInputDto {
id: Some("trojan|nl-1|de.example.test|443".to_string()),
tag: "nl-1".to_string(),
server: Some("de.example.test".to_string()),
server_port: Some(443),
},
&FixedClock,
)
.expect("stable id should select the second duplicate tag");
assert_eq!(
status.config.selected_server_id,
Some("trojan|nl-1|de.example.test|443".to_string())
);
assert_eq!(status.config.selected_server_tag, Some("nl-1".to_string()));
cleanup(&root); cleanup(&root);
} }
@@ -236,6 +277,7 @@ fn selects_server_by_endpoint_when_display_tag_is_sanitized() {
let status = select_singbox_server_in_storage( let status = select_singbox_server_in_storage(
&storage, &storage,
SelectSingBoxServerInputDto { SelectSingBoxServerInputDto {
id: None,
tag: "Умный".to_string(), tag: "Умный".to_string(),
server: Some("media.example.test".to_string()), server: Some("media.example.test".to_string()),
server_port: Some(443), server_port: Some(443),
@@ -465,12 +507,14 @@ fn sample_cache() -> SubscriptionCache {
}), }),
servers: vec![ servers: vec![
SubscriptionServer { SubscriptionServer {
id: "vless|nl-1|nl.example.test|443".to_string(),
tag: "nl-1".to_string(), tag: "nl-1".to_string(),
server_type: "vless".to_string(), server_type: "vless".to_string(),
server: "nl.example.test".to_string(), server: "nl.example.test".to_string(),
server_port: 443, server_port: 443,
}, },
SubscriptionServer { SubscriptionServer {
id: "trojan|de-1|de.example.test|443".to_string(),
tag: "de-1".to_string(), tag: "de-1".to_string(),
server_type: "trojan".to_string(), server_type: "trojan".to_string(),
server: "de.example.test".to_string(), server: "de.example.test".to_string(),
@@ -496,6 +540,7 @@ fn sample_cache_with_flag_tag() -> SubscriptionCache {
] ]
}), }),
servers: vec![SubscriptionServer { servers: vec![SubscriptionServer {
id: "vless|Умный 🇳🇱->🇷🇺|media.example.test|443".to_string(),
tag: "Умный 🇳🇱->🇷🇺".to_string(), tag: "Умный 🇳🇱->🇷🇺".to_string(),
server_type: "vless".to_string(), server_type: "vless".to_string(),
server: "media.example.test".to_string(), server: "media.example.test".to_string(),
+3
View File
@@ -54,6 +54,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()), subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
device_hwid: Some("hwid-abcdef1234".to_string()), device_hwid: Some("hwid-abcdef1234".to_string()),
selected_server_tag: Some("nl-1".to_string()), selected_server_tag: Some("nl-1".to_string()),
selected_server_id: Some("vless|nl-1|nl.example.test|443".to_string()),
listen_host: "127.0.0.1".to_string(), listen_host: "127.0.0.1".to_string(),
listen_port: 1080, listen_port: 1080,
service_name: "ProxyWardenSingBox".to_string(), service_name: "ProxyWardenSingBox".to_string(),
@@ -133,6 +134,7 @@ fn reads_percent_encoded_singbox_tags_as_utf8() {
] ]
}), }),
servers: vec![SubscriptionServer { servers: vec![SubscriptionServer {
id: String::new(),
tag: encoded_tag.to_string(), tag: encoded_tag.to_string(),
server_type: "vless".to_string(), server_type: "vless".to_string(),
server: "nl.example.test".to_string(), server: "nl.example.test".to_string(),
@@ -390,6 +392,7 @@ fn sample_subscription_cache() -> SubscriptionCache {
] ]
}), }),
servers: vec![SubscriptionServer { servers: vec![SubscriptionServer {
id: "vless|nl-1|nl.example.test|443".to_string(),
tag: "nl-1".to_string(), tag: "nl-1".to_string(),
server_type: "vless".to_string(), server_type: "vless".to_string(),
server: "nl.example.test".to_string(), server: "nl.example.test".to_string(),
+84 -3
View File
@@ -1,11 +1,11 @@
use base64::{engine::general_purpose, Engine}; use base64::{engine::general_purpose, Engine};
use proxywarden_lib::models::redact_subscription_url; use proxywarden_lib::models::redact_subscription_url;
use proxywarden_lib::subscription::{ use proxywarden_lib::subscription::{
self, parse_subscription_body, parse_user_info, SubscriptionFetchIdentity, self, parse_subscription_body, parse_user_info, validate_resolved_subscription_addresses,
SubscriptionFetchPolicy, SubscriptionFetchIdentity, SubscriptionFetchPolicy,
}; };
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::TcpListener; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
use std::time::Duration; use std::time::Duration;
#[test] #[test]
@@ -27,6 +27,29 @@ fn parses_singbox_json_config_servers() {
assert_eq!(parsed.servers[1].server_port, 8443); assert_eq!(parsed.servers[1].server_port, 8443);
} }
#[test]
fn server_ids_are_opaque_and_distinguish_credentials_on_same_endpoint() {
let parsed = parse_subscription_body(
r#"{
"outbounds": [
{ "type": "vless", "tag": "same", "server": "edge.example.test", "server_port": 443, "uuid": "11111111-1111-1111-1111-111111111111" },
{ "type": "vless", "tag": "same", "server": "edge.example.test", "server_port": 443, "uuid": "22222222-2222-2222-2222-222222222222" }
]
}"#,
)
.expect("duplicate endpoint subscription should parse");
assert_ne!(parsed.servers[0].id, parsed.servers[1].id);
assert!(parsed
.servers
.iter()
.all(|server| server.id.starts_with("pw-")));
assert!(parsed
.servers
.iter()
.all(|server| !server.id.contains("11111111")));
}
#[test] #[test]
fn parses_base64_vless_link_list() { fn parses_base64_vless_link_list() {
let link = sample_vless_link("nl-1"); let link = sample_vless_link("nl-1");
@@ -42,6 +65,45 @@ fn parses_base64_vless_link_list() {
assert_eq!(outbound["packet_encoding"], "xudp"); assert_eq!(outbound["packet_encoding"], "xudp");
} }
#[test]
fn parses_trojan_shadowsocks_and_vmess_link_formats() {
let vmess_payload = serde_json::json!({
"v": "2",
"ps": "VMess NL",
"add": "vmess.example.test",
"port": "443",
"id": "33333333-3333-3333-3333-333333333333",
"scy": "auto",
"net": "ws",
"host": "cdn.example.test",
"path": "/ws",
"tls": "tls",
"sni": "vmess.example.test"
});
let vmess_link = format!(
"vmess://{}",
general_purpose::STANDARD_NO_PAD.encode(vmess_payload.to_string())
);
let body = format!(
"trojan://secret@trojan.example.test:443?sni=edge.example.test#Trojan%20DE\nss://aes-256-gcm:password@ss.example.test:8388#SS%20US\n{vmess_link}"
);
let parsed = parse_subscription_body(&body).expect("supported link formats should parse");
assert_eq!(parsed.servers.len(), 3);
assert_eq!(parsed.servers[0].server_type, "trojan");
assert_eq!(parsed.servers[0].tag, "Trojan DE");
assert_eq!(
parsed.config["outbounds"][0]["tls"]["server_name"],
"edge.example.test"
);
assert_eq!(parsed.servers[1].server_type, "shadowsocks");
assert_eq!(parsed.config["outbounds"][1]["method"], "aes-256-gcm");
assert_eq!(parsed.servers[2].server_type, "vmess");
assert_eq!(parsed.config["outbounds"][2]["transport"]["type"], "ws");
assert_eq!(parsed.config["outbounds"][2]["tls"]["enabled"], true);
}
#[test] #[test]
fn decodes_percent_encoded_vless_fragment_tag() { fn decodes_percent_encoded_vless_fragment_tag() {
let link = sample_vless_link( let link = sample_vless_link(
@@ -111,6 +173,25 @@ fn rejects_unsafe_local_subscription_urls_before_network() {
} }
} }
#[test]
fn rejects_dns_results_containing_private_or_metadata_addresses() {
for ip in [
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
] {
let error = validate_resolved_subscription_addresses(&[SocketAddr::new(ip, 443)])
.expect_err("unsafe resolved address should be blocked");
assert!(error.message.contains("resolves to"));
}
validate_resolved_subscription_addresses(&[SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
443,
)])
.expect("public resolved address should be accepted");
}
#[test] #[test]
fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() { fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener"); let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener");
+76 -92
View File
@@ -1,4 +1,4 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from "@tauri-apps/api/core";
import type { import type {
ActivityEntry, ActivityEntry,
ComponentStatus, ComponentStatus,
@@ -9,7 +9,7 @@ import type {
SubscriptionServer, SubscriptionServer,
Target, Target,
TargetInput, TargetInput,
} from '../domain/types'; } from "../domain/types";
export interface CommandError { export interface CommandError {
code: string; code: string;
@@ -20,16 +20,6 @@ export interface CommandError {
}>; }>;
} }
export interface StatusResponse {
routeLine: string;
activeProfileCount: number;
routedAppCount: number;
activeTarget?: Target;
components: ComponentStatus[];
recentActivity: ActivityEntry[];
generatedConfigPath: string;
}
export interface AdminStatusResponse { export interface AdminStatusResponse {
isWindows: boolean; isWindows: boolean;
isElevated: boolean; isElevated: boolean;
@@ -67,8 +57,8 @@ export interface ProxiFyreSetupStatus {
} }
export interface ProxiFyreSetupProgress { export interface ProxiFyreSetupProgress {
operation: 'idle' | 'install' | 'uninstall' | string; operation: "idle" | "install" | "uninstall" | string;
status: 'idle' | 'running' | 'succeeded' | 'failed' | string; status: "idle" | "running" | "succeeded" | "failed" | string;
activeStep?: string; activeStep?: string;
percent: number; percent: number;
message: string; message: string;
@@ -102,6 +92,7 @@ export interface SubscriptionRequestHeader {
} }
export interface PingServerResponse { export interface PingServerResponse {
id: string;
tag: string; tag: string;
server: string; server: string;
serverPort: number; serverPort: number;
@@ -110,6 +101,34 @@ export interface PingServerResponse {
error?: string; error?: string;
} }
export type ApplyPhaseStatus =
"succeeded" | "failed" | "rolledback" | "skipped" | "warning";
export interface ApplyPhase {
id: string;
status: ApplyPhaseStatus;
message: string;
}
export interface ApplyConfigurationInput {
routeMode: "external" | "local-singbox";
profile: ProfileInput;
externalTarget?: TargetInput;
disableOtherProfiles?: boolean;
}
export interface ApplyConfigurationResult {
success: boolean;
changed: boolean;
partialState: boolean;
message: string;
errorCode?: string;
generatedConfigPath: string;
singboxGeneratedConfigPath?: string;
restartRequired: Array<"control-app" | "proxyfier" | "singbox">;
phases: ApplyPhase[];
}
export interface ProxyProbeResponse { export interface ProxyProbeResponse {
id: string; id: string;
name: string; name: string;
@@ -147,98 +166,60 @@ export interface GenerateSingBoxConfigResponse {
activity: ActivityEntry; activity: ActivityEntry;
} }
export interface HelperApplyResult {
success: boolean;
changed: boolean;
action: string;
message: string;
}
export interface ApplyProfilesResponse {
success: boolean;
changed: boolean;
message: string;
adapterId: string;
generatedConfigPath: string;
enabledProfiles: number;
routedApps: number;
helper: HelperApplyResult;
activity: ActivityEntry;
}
export function getStatus(): Promise<StatusResponse> {
return invoke<StatusResponse>('get_status');
}
export function getAdminStatus(): Promise<AdminStatusResponse> {
return invoke<AdminStatusResponse>('get_admin_status');
}
export function restartAsAdmin(): Promise<void> { export function restartAsAdmin(): Promise<void> {
return invoke<void>('restart_as_admin'); return invoke<void>("restart_as_admin");
} }
export function getStartupSnapshot(): Promise<StartupSnapshotResponse> { export function getStartupSnapshot(): Promise<StartupSnapshotResponse> {
return invoke<StartupSnapshotResponse>('get_startup_snapshot'); return invoke<StartupSnapshotResponse>("get_startup_snapshot");
} }
export function getSavedState(): Promise<SavedStateResponse> { export function getSavedState(): Promise<SavedStateResponse> {
return invoke<SavedStateResponse>('get_saved_state'); return invoke<SavedStateResponse>("get_saved_state");
}
export function getProfiles(): Promise<Profile[]> {
return invoke<Profile[]>('get_profiles');
}
export function saveProfile(input: ProfileInput): Promise<Profile> {
return invoke<Profile>('save_profile', { input });
}
export function getTargets(): Promise<Target[]> {
return invoke<Target[]>('get_targets');
}
export function saveTarget(input: TargetInput): Promise<Target> {
return invoke<Target>('save_target', { input });
} }
export function getComponents(): Promise<ComponentStatus[]> { export function getComponents(): Promise<ComponentStatus[]> {
return invoke<ComponentStatus[]>('get_components'); return invoke<ComponentStatus[]>("get_components");
} }
export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> { export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status'); return invoke<ProxiFyreSetupStatus>("get_proxifyre_setup_status");
} }
export function getProxiFyreSetupProgress(): Promise<ProxiFyreSetupProgress> { export function getProxiFyreSetupProgress(): Promise<ProxiFyreSetupProgress> {
return invoke<ProxiFyreSetupProgress>('get_proxifyre_setup_progress'); return invoke<ProxiFyreSetupProgress>("get_proxifyre_setup_progress");
} }
export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> { export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('get_singbox_status'); return invoke<LocalSingBoxStatusResponse>("get_singbox_status");
} }
export function getSingBoxSetupStatus(): Promise<SingBoxSetupStatus> { export function getSingBoxSetupStatus(): Promise<SingBoxSetupStatus> {
return invoke<SingBoxSetupStatus>('get_singbox_setup_status'); return invoke<SingBoxSetupStatus>("get_singbox_setup_status");
} }
export function saveSingBoxSubscription(subscriptionUrl: string): Promise<LocalSingBoxStatusResponse> { export function saveSingBoxSubscription(
return invoke<LocalSingBoxStatusResponse>('save_singbox_subscription', { subscriptionUrl: string,
): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>("save_singbox_subscription", {
input: { subscriptionUrl }, input: { subscriptionUrl },
}); });
} }
export function fetchSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> { export function fetchSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('fetch_singbox_subscription'); return invoke<LocalSingBoxStatusResponse>("fetch_singbox_subscription");
} }
export function forgetSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> { export function forgetSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('forget_singbox_subscription'); return invoke<LocalSingBoxStatusResponse>("forget_singbox_subscription");
} }
export function selectSingBoxServer(server: SubscriptionServer): Promise<LocalSingBoxStatusResponse> { export function selectSingBoxServer(
return invoke<LocalSingBoxStatusResponse>('select_singbox_server', { server: SubscriptionServer,
): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>("select_singbox_server", {
input: { input: {
id: server.id,
tag: server.tag, tag: server.tag,
server: server.server, server: server.server,
serverPort: server.serverPort, serverPort: server.serverPort,
@@ -246,62 +227,65 @@ export function selectSingBoxServer(server: SubscriptionServer): Promise<LocalSi
}); });
} }
export function pingSingBoxServer(tag: string): Promise<PingServerResponse> { export function pingSingBoxServer(
return invoke<PingServerResponse>('ping_singbox_server', { server: SubscriptionServer,
input: { tag }, ): Promise<PingServerResponse> {
return invoke<PingServerResponse>("ping_singbox_server", {
input: { id: server.id, tag: server.tag },
}); });
} }
export function pingAllSingBoxServers(): Promise<PingServerResponse[]> { export function pingAllSingBoxServers(): Promise<PingServerResponse[]> {
return invoke<PingServerResponse[]>('ping_all_singbox_servers'); return invoke<PingServerResponse[]>("ping_all_singbox_servers");
} }
export function pingProxyTarget(host: string, port: number): Promise<ProxyTargetCheckResponse> { export function pingProxyTarget(
return invoke<ProxyTargetCheckResponse>('ping_proxy_target', { host: string,
port: number,
): Promise<ProxyTargetCheckResponse> {
return invoke<ProxyTargetCheckResponse>("ping_proxy_target", {
input: { host, port }, input: { host, port },
}); });
} }
export function generateSingBoxConfig(): Promise<GenerateSingBoxConfigResponse> { export function generateSingBoxConfig(): Promise<GenerateSingBoxConfigResponse> {
return invoke<GenerateSingBoxConfigResponse>('generate_singbox_config'); return invoke<GenerateSingBoxConfigResponse>("generate_singbox_config");
} }
export function applyProfiles(): Promise<ApplyProfilesResponse> { export function applyConfiguration(
return invoke<ApplyProfilesResponse>('apply_profiles'); input: ApplyConfigurationInput,
} ): Promise<ApplyConfigurationResult> {
return invoke<ApplyConfigurationResult>("apply_configuration", { input });
export function openConfigLocation(): Promise<string> {
return invoke<string>('open_config_location');
} }
export function startProxiFyreService(): Promise<ComponentStatus> { export function startProxiFyreService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('start_proxifyre_service'); return invoke<ComponentStatus>("start_proxifyre_service");
} }
export function stopProxiFyreService(): Promise<ComponentStatus> { export function stopProxiFyreService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('stop_proxifyre_service'); return invoke<ComponentStatus>("stop_proxifyre_service");
} }
export function installProxiFyre(): Promise<ComponentStatus> { export function installProxiFyre(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('install_proxifyre'); return invoke<ComponentStatus>("install_proxifyre");
} }
export function uninstallProxiFyre(): Promise<ComponentStatus> { export function uninstallProxiFyre(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('uninstall_proxifyre'); return invoke<ComponentStatus>("uninstall_proxifyre");
} }
export function startSingBoxService(): Promise<ComponentStatus> { export function startSingBoxService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('start_singbox_service'); return invoke<ComponentStatus>("start_singbox_service");
} }
export function stopSingBoxService(): Promise<ComponentStatus> { export function stopSingBoxService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('stop_singbox_service'); return invoke<ComponentStatus>("stop_singbox_service");
} }
export function installSingBox(): Promise<ComponentStatus> { export function installSingBox(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('install_singbox'); return invoke<ComponentStatus>("install_singbox");
} }
export function uninstallSingBox(): Promise<ComponentStatus> { export function uninstallSingBox(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('uninstall_singbox'); return invoke<ComponentStatus>("uninstall_singbox");
} }
+619 -1558
View File
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import type {
ApplyConfigurationResult,
PingServerResponse,
} from "../api/tauriCommands";
import type { ComponentStatus } from "../domain/types";
import {
noticeFromConfigurationApply,
pingSummary,
routeChainSegments,
} from "./viewModel";
const runningProxiFyre: ComponentStatus = {
id: "proxyfier",
name: "ProxiFyre",
state: "running",
installed: true,
running: true,
path: "C:\\Tools\\ProxiFyre\\ProxiFyre.exe",
problems: [],
actions: [],
};
describe("App view helpers", () => {
it("describes the external SOCKS5 route without Local sing-box", () => {
const segments = routeChainSegments({
routeMode: "external",
proxyInput: "proxy.example.test:1080",
proxyfier: runningProxiFyre,
singbox: undefined,
singBoxStatus: null,
selectedServer: null,
appCount: 2,
isDetectingComponents: false,
});
expect(segments.map((segment) => segment.id)).toEqual([
"apps",
"proxifyre",
"endpoint",
"exit",
]);
expect(segments[2]).toMatchObject({
value: "proxy.example.test:1080",
tone: "ok",
});
expect(segments[3].details).toContain(
"Local sing-box не нужен для этого маршрута.",
);
});
it("summarizes the fastest successful ping", () => {
const results = [
{ tag: "slow", ok: true, latency: 90 },
{ tag: "failed", ok: false },
{ tag: "fast", ok: true, latency: 20 },
] as PingServerResponse[];
expect(pingSummary(results)).toBe("Ответили 2/3; быстрее fast: 20 ms.");
});
it("distinguishes rolled-back and partial apply failures", () => {
const base: ApplyConfigurationResult = {
success: false,
changed: false,
partialState: false,
message: "Helper failed; previous files restored.",
generatedConfigPath:
"C:\\ProgramData\\ProxyWarden\\generated\\proxifyre-app-config.json",
restartRequired: [],
phases: [],
};
expect(noticeFromConfigurationApply(base).title).toBe("Изменения отменены");
expect(
noticeFromConfigurationApply({ ...base, partialState: true }).title,
).toBe("Проверь состояние файлов");
});
});
+70 -36
View File
@@ -1,25 +1,47 @@
import type { ProxiFyreSetupProgress, ProxiFyreSetupStatus } from '../../api/tauriCommands'; import type {
ProxiFyreSetupProgress,
ProxiFyreSetupStatus,
} from "../../api/tauriCommands";
interface ProxiFyreSetupStripProps { interface ProxiFyreSetupStripProps {
setupStatus: ProxiFyreSetupStatus | null; setupStatus: ProxiFyreSetupStatus | null;
progress: ProxiFyreSetupProgress | null; progress: ProxiFyreSetupProgress | null;
} }
const SETUP_PLACEHOLDERS: ProxiFyreSetupStatus['items'] = [ const SETUP_PLACEHOLDERS: ProxiFyreSetupStatus["items"] = [
{ id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' }, {
{ id: 'packet-filter', name: 'Сетевой драйвер', installed: false, details: 'Проверяю' }, id: "vc-runtime",
{ id: 'proxifyre', name: 'Клиент ProxiFyre', installed: false, details: 'Проверяю' }, name: "Среда запуска",
installed: false,
details: "Проверяю",
},
{
id: "packet-filter",
name: "Сетевой драйвер",
installed: false,
details: "Проверяю",
},
{
id: "proxifyre",
name: "Клиент ProxiFyre",
installed: false,
details: "Проверяю",
},
]; ];
export function ProxiFyreSetupStrip({ setupStatus, progress }: ProxiFyreSetupStripProps) { export function ProxiFyreSetupStrip({
setupStatus,
progress,
}: ProxiFyreSetupStripProps) {
const stripItems = setupStatus?.items ?? SETUP_PLACEHOLDERS; const stripItems = setupStatus?.items ?? SETUP_PLACEHOLDERS;
const visibleProgress = isVisibleProgress(progress) ? progress : null; const visibleProgress = isVisibleProgress(progress) ? progress : null;
const progressTone = visibleProgress?.status === 'failed' ? 'failed' : 'running'; const progressTone =
visibleProgress?.status === "failed" ? "failed" : "running";
const percent = clampPercent(visibleProgress?.percent ?? 0); const percent = clampPercent(visibleProgress?.percent ?? 0);
return ( return (
<div <div
className={`setup-strip ${setupStatus?.ready ? 'ready' : 'attention'} ${visibleProgress ? 'with-progress' : ''}`} className={`setup-strip ${setupStatus?.ready ? "ready" : "attention"} ${visibleProgress ? "with-progress" : ""}`}
aria-label="Состав ProxiFyre" aria-label="Состав ProxiFyre"
> >
<span className="setup-strip-title">Состав</span> <span className="setup-strip-title">Состав</span>
@@ -45,9 +67,14 @@ export function ProxiFyreSetupStrip({ setupStatus, progress }: ProxiFyreSetupStr
aria-valuenow={percent} aria-valuenow={percent}
aria-label={visibleProgress.message} aria-label={visibleProgress.message}
> >
<span className="setup-progress-fill" style={{ width: `${percent}%` }} /> <span
className="setup-progress-fill"
style={{ width: `${percent}%` }}
/>
</div> </div>
<span className="setup-progress-message">{visibleProgress.message}</span> <span className="setup-progress-message">
{visibleProgress.message}
</span>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -55,52 +82,59 @@ export function ProxiFyreSetupStrip({ setupStatus, progress }: ProxiFyreSetupStr
} }
function setupItemClass( function setupItemClass(
item: ProxiFyreSetupStatus['items'][number], item: ProxiFyreSetupStatus["items"][number],
progress: ProxiFyreSetupProgress | null, progress: ProxiFyreSetupProgress | null,
) { ) {
if (progress?.activeStep === item.id) { if (progress?.activeStep === item.id) {
if (progress.status === 'failed') return 'failed'; if (progress.status === "failed") return "failed";
return 'active'; return "active";
} }
if (item.installed) return 'installed'; if (item.installed) return "installed";
return 'missing'; return "missing";
} }
function setupItemUserName(id: string, fallbackName: string) { function setupItemUserName(id: string, fallbackName: string) {
if (id === 'vc-runtime') return 'Среда запуска'; if (id === "vc-runtime") return "Среда запуска";
if (id === 'packet-filter') return 'Сетевой драйвер'; if (id === "packet-filter") return "Сетевой драйвер";
if (id === 'proxifyre') return 'Клиент ProxiFyre'; if (id === "proxifyre") return "Клиент ProxiFyre";
return fallbackName; return fallbackName;
} }
function setupItemShortStatus( function setupItemShortStatus(
item: ProxiFyreSetupStatus['items'][number], item: ProxiFyreSetupStatus["items"][number],
progress: ProxiFyreSetupProgress | null, progress: ProxiFyreSetupProgress | null,
) { ) {
if (progress?.activeStep === item.id) { if (progress?.activeStep === item.id) {
if (progress.status === 'failed') return 'ошибка'; if (progress.status === "failed") return "ошибка";
if (progress.status === 'succeeded') return progress.operation === 'uninstall' ? 'удалено' : 'готово'; if (progress.status === "succeeded")
return 'в процессе'; return progress.operation === "uninstall" ? "удалено" : "готово";
return "в процессе";
} }
if (item.details === 'Проверяю') return 'проверяю'; if (item.details === "Проверяю") return "проверяю";
if (!item.installed) return progress?.operation === 'uninstall' && progress.status === 'succeeded' if (!item.installed)
? 'удалено' return progress?.operation === "uninstall" &&
: 'нужно установить'; progress.status === "succeeded"
if (item.id === 'proxifyre') return proxifyreSetupServiceSummary(item.version); ? "удалено"
return 'готово'; : "нужно установить";
if (item.id === "proxifyre")
return proxifyreSetupServiceSummary(item.version);
return "готово";
} }
function proxifyreSetupServiceSummary(version: string | undefined) { function proxifyreSetupServiceSummary(version: string | undefined) {
const normalized = version?.trim().toLowerCase() ?? ''; const normalized = version?.trim().toLowerCase() ?? "";
if (normalized.includes('не установлена')) return 'служба не установлена'; if (normalized.includes("не установлена")) return "служба не установлена";
if (normalized.includes('остановлена') || normalized.includes('не запущена')) return 'служба остановлена'; if (normalized.includes("остановлена") || normalized.includes("не запущена"))
if (normalized.includes('запущена')) return 'служба запущена'; return "служба остановлена";
return 'готово'; if (normalized.includes("запущена")) return "служба запущена";
return "готово";
} }
function isVisibleProgress(progress: ProxiFyreSetupProgress | null): progress is ProxiFyreSetupProgress { function isVisibleProgress(
if (!progress || progress.status === 'idle') return false; progress: ProxiFyreSetupProgress | null,
return progress.status === 'running' || progress.status === 'failed'; ): progress is ProxiFyreSetupProgress {
if (!progress || progress.status === "idle") return false;
return progress.status === "running" || progress.status === "failed";
} }
function clampPercent(value: number) { function clampPercent(value: number) {
@@ -0,0 +1,52 @@
import { Power } from "lucide-react";
import { BusyRing } from "../../ui";
import type { StatusTone } from "../viewModel";
interface SummaryStatusControlProps {
installed: boolean;
running: boolean;
working: boolean;
checking: boolean;
tone: StatusTone;
onToggle: (running: boolean) => void;
}
export function SummaryStatusControl({
installed,
running,
working,
checking,
tone,
onToggle,
}: SummaryStatusControlProps) {
const stateLabel =
working || tone === "checking"
? "Проверяю"
: tone === "ok"
? "Работает"
: "Не работает";
const buttonAriaLabel = !installed
? "ProxiFyre не установлен"
: running
? "Отключить ProxyWarden"
: "Включить ProxyWarden";
return (
<div className={`summary-status-control ${tone} ${running ? "on" : "off"}`}>
<button
type="button"
className="summary-toggle-button"
onClick={() => onToggle(!running)}
disabled={!installed || checking || working}
aria-label={buttonAriaLabel}
aria-pressed={installed ? running : undefined}
>
{tone === "checking" || working ? <BusyRing /> : null}
<span className="summary-toggle-face" aria-hidden="true">
<Power size={88} strokeWidth={1.45} />
</span>
</button>
<strong className={`summary-state-label ${tone}`}>{stateLabel}</strong>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useMemo, useState } from "react";
import type { LogEntry, Notice } from "../viewModel";
const LOG_VISIBLE_MS = 6500;
const LOG_LIMIT = 40;
export function useNoticeLog() {
const [entries, setEntries] = useState<LogEntry[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const activeEntry = useMemo(
() => entries.find((entry) => entry.id === activeId) ?? null,
[activeId, entries],
);
useEffect(() => {
if (!activeId) return undefined;
const timer = window.setTimeout(() => {
setActiveId((current) => (current === activeId ? null : current));
}, LOG_VISIBLE_MS);
return () => window.clearTimeout(timer);
}, [activeId]);
function showNotice(notice: Notice) {
const entry: LogEntry = {
...notice,
id: `log-${Date.now()}-${Math.random().toString(36).slice(2)}`,
at: Date.now(),
};
setEntries((current) => [entry, ...current].slice(0, LOG_LIMIT));
setActiveId(entry.id);
}
return {
entries,
activeEntry,
open,
showNotice,
toggle: () => setOpen((current) => !current),
};
}
+29 -27
View File
@@ -1,43 +1,45 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import { parseProxy } from './parseProxy'; import { parseProxy } from "./parseProxy";
describe('parseProxy', () => { describe("parseProxy", () => {
it('parses host and port without explicit protocol', () => { it("parses host and port without explicit protocol", () => {
expect(parseProxy('proxy.example.test:1080')).toEqual({ expect(parseProxy("proxy.example.test:1080")).toEqual({
protocol: 'socks5', protocol: "socks5",
host: 'proxy.example.test', host: "proxy.example.test",
port: 1080, port: 1080,
}); });
}); });
it('parses socks5 URLs', () => { it("parses socks5 URLs", () => {
expect(parseProxy('socks5://127.0.0.1:1080')).toEqual({ expect(parseProxy("socks5://127.0.0.1:1080")).toEqual({
protocol: 'socks5', protocol: "socks5",
host: '127.0.0.1', host: "127.0.0.1",
port: 1080, port: 1080,
}); });
}); });
it('parses bracketed IPv6 hosts', () => { it("parses bracketed IPv6 hosts", () => {
expect(parseProxy('socks5://[::1]:1080')).toEqual({ expect(parseProxy("socks5://[::1]:1080")).toEqual({
protocol: 'socks5', protocol: "socks5",
host: '::1', host: "::1",
port: 1080, port: 1080,
}); });
}); });
it('rejects unsupported schemes', () => { it("rejects unsupported schemes", () => {
expect(() => parseProxy('http://proxy.example.test:8080')).toThrow('SOCKS5'); expect(() => parseProxy("http://proxy.example.test:8080")).toThrow(
}); "SOCKS5",
it('rejects missing or invalid ports', () => {
expect(() => parseProxy('proxy.example.test')).toThrow('хост и порт');
expect(() => parseProxy('proxy.example.test:70000')).toThrow('Формат');
});
it('rejects userinfo credentials', () => {
expect(() => parseProxy('socks5://user:password@proxy.example.test:1080')).toThrow(
'логином и паролем',
); );
}); });
it("rejects missing or invalid ports", () => {
expect(() => parseProxy("proxy.example.test")).toThrow("хост и порт");
expect(() => parseProxy("proxy.example.test:70000")).toThrow("Формат");
});
it("rejects userinfo credentials", () => {
expect(() =>
parseProxy("socks5://user:password@proxy.example.test:1080"),
).toThrow("логином и паролем");
});
}); });
+13 -11
View File
@@ -1,34 +1,36 @@
export interface ParsedProxy { export interface ParsedProxy {
protocol: 'socks5'; protocol: "socks5";
host: string; host: string;
port: number; port: number;
} }
export function parseProxy(rawValue: string): ParsedProxy { export function parseProxy(rawValue: string): ParsedProxy {
const value = rawValue.trim(); const value = rawValue.trim();
if (!value) throw new Error('Введи адрес прокси.'); if (!value) throw new Error("Введи адрес прокси.");
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`; const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
? value
: `socks5://${value}`;
let parsed: URL; let parsed: URL;
try { try {
parsed = new URL(withProtocol); parsed = new URL(withProtocol);
} catch { } catch {
throw new Error('Формат: socks5://host:port или host:port.'); throw new Error("Формат: socks5://host:port или host:port.");
} }
const protocol = parsed.protocol.replace(':', '').toLowerCase(); const protocol = parsed.protocol.replace(":", "").toLowerCase();
if (protocol !== 'socks5') { if (protocol !== "socks5") {
throw new Error('Сейчас поддерживается только SOCKS5.'); throw new Error("Сейчас поддерживается только SOCKS5.");
} }
if (parsed.username || parsed.password) { if (parsed.username || parsed.password) {
throw new Error('Прокси с логином и паролем пока не поддерживаются.'); throw new Error("Прокси с логином и паролем пока не поддерживаются.");
} }
const host = parsed.hostname.replace(/^\[|\]$/g, ''); const host = parsed.hostname.replace(/^\[|\]$/g, "");
const port = Number(parsed.port); const port = Number(parsed.port);
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) { if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('Укажи хост и порт прокси.'); throw new Error("Укажи хост и порт прокси.");
} }
return { protocol: 'socks5', host, port }; return { protocol: "socks5", host, port };
} }
+26
View File
@@ -0,0 +1,26 @@
import type { ProfileItemType } from "../../domain/types";
export type DraftItemType = Extract<
ProfileItemType,
"process" | "folder" | "exe"
>;
export function normalizeItemValue(value: string, type: DraftItemType) {
const clean = value.trim().replace(/^"|"$/g, "");
if (!clean) return "";
if (type === "folder" || type === "exe") return clean;
return (
clean
.split(/[\\/]/)
.pop()
?.replace(/\.exe$/i, "")
.trim() ?? ""
);
}
export function itemTypeLabel(type: DraftItemType) {
if (type === "process") return "процесс";
if (type === "folder") return "папка";
return "EXE-файл";
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
configChangeRows,
configSnapshotFromUi,
sameConfigSnapshot,
} from "./snapshots";
describe("configuration snapshots", () => {
it("normalizes proxy and Windows app values", () => {
const snapshot = configSnapshotFromUi(
"external",
" SOCKS5://Proxy.Example.Test:1080 ",
[
{ type: "process", value: "C:\\Apps\\Discord.exe" },
{ type: "folder", value: " C:\\Games " },
],
);
expect(snapshot.proxy).toBe("socks5://proxy.example.test:1080");
expect(snapshot.items).toEqual([
{ type: "folder", value: "c:\\games" },
{ type: "process", value: "discord" },
]);
});
it("detects a server change by stable id even when tags match", () => {
const applied = configSnapshotFromUi(
"local-singbox",
"",
[],
"server-a",
"Same tag",
);
const current = configSnapshotFromUi(
"local-singbox",
"",
[],
"server-b",
"Same tag",
);
expect(sameConfigSnapshot(applied, current)).toBe(false);
expect(configChangeRows(applied, current).map((row) => row.id)).toContain(
"vpn-server",
);
});
it("reports added and removed app items independent of input order", () => {
const applied = configSnapshotFromUi("external", "proxy.test:1080", [
{ type: "process", value: "Discord.exe" },
]);
const current = configSnapshotFromUi("external", "proxy.test:1080", [
{ type: "process", value: "Telegram.exe" },
]);
expect(configChangeRows(applied, current).map((row) => row.tone)).toEqual([
"added",
"removed",
]);
});
});
+201
View File
@@ -0,0 +1,201 @@
import { parseProxy } from "./parseProxy";
import {
itemTypeLabel,
normalizeItemValue,
type DraftItemType,
} from "./profileItems";
export type RouteMode = "external" | "local-singbox";
export interface ConfigSnapshotItem {
type: DraftItemType;
value: string;
}
export interface ConfigSnapshot {
routeMode: RouteMode;
proxy: string;
selectedServerId: string;
selectedServerTag: string;
items: ConfigSnapshotItem[];
}
export interface PendingChangeRow {
id: string;
label: string;
before?: string;
after: string;
tone?: "added" | "removed" | "changed";
}
export function configSnapshotFromUi(
routeMode: RouteMode,
proxyInput: string,
items: Array<{ type: DraftItemType; value: string }>,
selectedServerId?: string,
selectedServerTag?: string,
): ConfigSnapshot {
return {
routeMode,
proxy: routeMode === "external" ? normalizeProxySnapshot(proxyInput) : "",
selectedServerId:
routeMode === "local-singbox" ? (selectedServerId?.trim() ?? "") : "",
selectedServerTag:
routeMode === "local-singbox" ? (selectedServerTag?.trim() ?? "") : "",
items: normalizeSnapshotItems(items),
};
}
export function configChangeRows(
applied: ConfigSnapshot,
current: ConfigSnapshot,
): PendingChangeRow[] {
if (sameConfigSnapshot(applied, current)) return [];
const rows: PendingChangeRow[] = [];
if (applied.routeMode !== current.routeMode) {
rows.push({
id: "route-mode",
label: "Маршрут",
before: routeModeLabel(applied.routeMode),
after: routeModeLabel(current.routeMode),
});
}
if (
applied.proxy !== current.proxy &&
(applied.routeMode === "external" || current.routeMode === "external")
) {
rows.push({
id: "external-proxy",
label: "SOCKS5",
before: snapshotProxyChangeText(applied),
after: snapshotProxyChangeText(current),
});
}
if (
applied.selectedServerId !== current.selectedServerId &&
(applied.routeMode === "local-singbox" ||
current.routeMode === "local-singbox")
) {
rows.push({
id: "vpn-server",
label: "VPN сервер",
before: snapshotServerChangeText(applied),
after: snapshotServerChangeText(current),
});
}
rows.push(...snapshotItemChangeRows(applied.items, current.items));
return rows;
}
export function sameConfigSnapshot(
left: ConfigSnapshot,
right: ConfigSnapshot,
) {
return (
left.routeMode === right.routeMode &&
left.proxy === right.proxy &&
left.selectedServerId === right.selectedServerId &&
left.selectedServerTag === right.selectedServerTag &&
left.items.length === right.items.length &&
left.items.every((item, index) => {
const other = right.items[index];
return item.type === other.type && item.value === other.value;
})
);
}
export function routeModeLabel(routeMode: RouteMode) {
return routeMode === "local-singbox" ? "Локальный прокси" : "Внешний прокси";
}
export function displaySnapshotProxy(proxy: string) {
return proxy.replace(/^socks5:\/\//, "") || "не указан";
}
export function displayServerTag(tag: string) {
const withoutFlags = tag
.replace(/[\u{1f1e6}-\u{1f1ff}]/gu, "")
.replace(/\s*->\s*/g, " -> ")
.replace(/\s*->\s*$/g, "")
.replace(/^\s*->\s*/g, "")
.replace(/\s{2,}/g, " ")
.trim();
return withoutFlags || tag;
}
function normalizeProxySnapshot(value: string) {
try {
const parsed = parseProxy(value);
return `${parsed.protocol}://${parsed.host.trim().toLowerCase()}:${parsed.port}`;
} catch {
return value.trim().toLowerCase();
}
}
function normalizeSnapshotItems(
items: Array<{ type: DraftItemType; value: string }>,
): ConfigSnapshotItem[] {
return items
.map((item) => ({
type: item.type,
value: normalizeItemValue(item.value, item.type).toLowerCase(),
}))
.filter((item) => item.value)
.sort((left, right) =>
`${left.type}:${left.value}`.localeCompare(
`${right.type}:${right.value}`,
),
);
}
function snapshotProxyChangeText(snapshot: ConfigSnapshot) {
return snapshot.routeMode === "external"
? displaySnapshotProxy(snapshot.proxy)
: "не используется";
}
function snapshotServerChangeText(snapshot: ConfigSnapshot) {
if (snapshot.routeMode !== "local-singbox") return "не используется";
return snapshot.selectedServerTag
? displayServerTag(snapshot.selectedServerTag)
: "сервер не выбран";
}
function snapshotItemChangeRows(
appliedItems: ConfigSnapshotItem[],
currentItems: ConfigSnapshotItem[],
): PendingChangeRow[] {
const appliedKeys = new Set(appliedItems.map(snapshotItemKey));
const currentKeys = new Set(currentItems.map(snapshotItemKey));
const added = currentItems.filter(
(item) => !appliedKeys.has(snapshotItemKey(item)),
);
const removed = appliedItems.filter(
(item) => !currentKeys.has(snapshotItemKey(item)),
);
return [
...added.map((item) => ({
id: `app-add-${snapshotItemKey(item)}`,
label: "Добавлено",
after: `+ ${formatSnapshotItem(item)}`,
tone: "added" as const,
})),
...removed.map((item) => ({
id: `app-remove-${snapshotItemKey(item)}`,
label: "Удалено",
after: `- ${formatSnapshotItem(item)}`,
tone: "removed" as const,
})),
];
}
function snapshotItemKey(item: ConfigSnapshotItem) {
return `${item.type}:${item.value}`;
}
function formatSnapshotItem(item: ConfigSnapshotItem) {
return `${itemTypeLabel(item.type)} ${item.value}`;
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { getApplyReadiness, type ApplyReadinessInput } from "./readiness";
const base: ApplyReadinessInput = {
routeMode: "external",
appCount: 1,
proxiFyreInstalled: true,
singBoxInstalled: false,
singBoxRunning: false,
selectedServerTag: undefined,
externalProxyValue: "proxy.example.test:1080",
externalProxyError: null,
busy: false,
};
describe("getApplyReadiness", () => {
it("keeps external SOCKS5 independent from Local sing-box", () => {
expect(getApplyReadiness(base)).toEqual({ ready: true });
});
it("requires an explicitly running Local sing-box service", () => {
const readiness = getApplyReadiness({
...base,
routeMode: "local-singbox",
singBoxInstalled: true,
singBoxRunning: false,
selectedServerTag: "nl-1",
externalProxyValue: "",
});
expect(readiness.ready).toBe(false);
expect(readiness.title).toBe("Local sing-box остановлен");
});
it("allows Local sing-box only after explicit start and server selection", () => {
expect(
getApplyReadiness({
...base,
routeMode: "local-singbox",
singBoxInstalled: true,
singBoxRunning: true,
selectedServerTag: "nl-1",
externalProxyValue: "",
}),
).toEqual({ ready: true });
});
});
+25 -17
View File
@@ -1,10 +1,11 @@
export type RouteMode = 'external' | 'local-singbox'; export type RouteMode = "external" | "local-singbox";
export interface ApplyReadinessInput { export interface ApplyReadinessInput {
routeMode: RouteMode; routeMode: RouteMode;
appCount: number; appCount: number;
proxiFyreInstalled: boolean; proxiFyreInstalled: boolean;
singBoxInstalled: boolean; singBoxInstalled: boolean;
singBoxRunning: boolean;
selectedServerTag?: string; selectedServerTag?: string;
externalProxyValue: string; externalProxyValue: string;
externalProxyError?: string | null; externalProxyError?: string | null;
@@ -21,63 +22,70 @@ export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness {
if (input.busy) { if (input.busy) {
return { return {
ready: false, ready: false,
title: 'Операция уже выполняется', title: "Операция уже выполняется",
text: 'Дождись завершения текущего действия перед повторным применением.', text: "Дождись завершения текущего действия перед повторным применением.",
}; };
} }
if (!input.proxiFyreInstalled) { if (!input.proxiFyreInstalled) {
return { return {
ready: false, ready: false,
title: 'ProxiFyre не установлен', title: "ProxiFyre не установлен",
text: 'Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.', text: "Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.",
}; };
} }
if (input.appCount < 1) { if (input.appCount < 1) {
return { return {
ready: false, ready: false,
title: 'Нет приложений', title: "Нет приложений",
text: 'Добавь хотя бы один процесс, EXE-файл или папку.', text: "Добавь хотя бы один процесс, EXE-файл или папку.",
}; };
} }
if (input.routeMode === 'external') { if (input.routeMode === "external") {
if (!input.externalProxyValue.trim()) { if (!input.externalProxyValue.trim()) {
return { return {
ready: false, ready: false,
title: 'Прокси не указан', title: "Прокси не указан",
text: 'Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.', text: "Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.",
}; };
} }
if (input.externalProxyError) { if (input.externalProxyError) {
return { return {
ready: false, ready: false,
title: 'Проверь формат прокси', title: "Проверь формат прокси",
text: input.externalProxyError, text: input.externalProxyError,
}; };
} }
} }
if (input.routeMode === 'local-singbox') { if (input.routeMode === "local-singbox") {
if (!input.singBoxInstalled) { if (!input.singBoxInstalled) {
return { return {
ready: false, ready: false,
title: 'Local sing-box не установлен', title: "Local sing-box не установлен",
text: 'Установи Local sing-box, чтобы применить локальный маршрут.', text: "Установи Local sing-box, чтобы применить локальный маршрут.",
};
}
if (!input.singBoxRunning) {
return {
ready: false,
title: "Local sing-box остановлен",
text: "Явно запусти службу Local sing-box перед применением маршрута.",
}; };
} }
if (!input.selectedServerTag) { if (!input.selectedServerTag) {
return { return {
ready: false, ready: false,
title: 'Сервер не выбран', title: "Сервер не выбран",
text: 'Выбери сервер Local sing-box перед применением маршрута.', text: "Выбери сервер Local sing-box перед применением маршрута.",
}; };
} }
} }
return { ready: true }; return { ready: true };
} }
+1057 -9
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 873 KiB

+10 -7
View File
@@ -1,10 +1,11 @@
export type Protocol = 'TCP' | 'UDP'; export type Protocol = "TCP" | "UDP";
export type ProfileItemType = 'process' | 'folder' | 'exe'; export type ProfileItemType = "process" | "folder" | "exe";
export type TargetKind = 'local' | 'external'; export type TargetKind = "local" | "external";
export type ProxyProtocol = 'socks5' | 'http'; export type ProxyProtocol = "socks5" | "http";
export type ComponentId = 'control-app' | 'proxyfier' | 'singbox'; export type ComponentId = "control-app" | "proxyfier" | "singbox";
export type ComponentState = 'installed' | 'missing' | 'stopped' | 'running' | 'error'; export type ComponentState =
export type ActivityLevel = 'info' | 'warning' | 'error' | 'success'; "installed" | "missing" | "stopped" | "running" | "error";
export type ActivityLevel = "info" | "warning" | "error" | "success";
export interface ProfileItemInput { export interface ProfileItemInput {
type: ProfileItemType | string; type: ProfileItemType | string;
@@ -74,6 +75,7 @@ export interface LocalSingBoxConfig {
subscriptionDisplayUrl?: string; subscriptionDisplayUrl?: string;
hasSubscription: boolean; hasSubscription: boolean;
selectedServerTag?: string; selectedServerTag?: string;
selectedServerId?: string;
listenHost: string; listenHost: string;
listenPort: number; listenPort: number;
serviceName: string; serviceName: string;
@@ -82,6 +84,7 @@ export interface LocalSingBoxConfig {
} }
export interface SubscriptionServer { export interface SubscriptionServer {
id: string;
tag: string; tag: string;
type: string; type: string;
server: string; server: string;
+6 -7
View File
@@ -1,12 +1,11 @@
import React from 'react'; import React from "react";
import { createRoot } from 'react-dom/client'; import { createRoot } from "react-dom/client";
import '@fontsource-variable/jetbrains-mono'; import "@fontsource-variable/jetbrains-mono";
import { App } from './app/App'; import { App } from "./app/App";
import './styles/app.css'; import "./styles/app.css";
createRoot(document.getElementById('root') as HTMLElement).render( createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>,
); );
+136 -25
View File
@@ -1,3 +1,4 @@
/* Foundations: tokens, document defaults, and scrollbars. */
:root { :root {
--app-footer-height: 54px; --app-footer-height: 54px;
--app-change-dock-height: 0px; --app-change-dock-height: 0px;
@@ -5,7 +6,9 @@
--change-row-count: 1; --change-row-count: 1;
--app-header-row-height: 0px; --app-header-row-height: 0px;
--app-tab-height: 46px; --app-tab-height: 46px;
--app-header-height: calc(var(--app-header-row-height) + var(--app-tab-height)); --app-header-height: calc(
var(--app-header-row-height) + var(--app-tab-height)
);
--motion-fast: 120ms; --motion-fast: 120ms;
--motion-standard: 180ms; --motion-standard: 180ms;
--motion-panel: 220ms; --motion-panel: 220ms;
@@ -24,8 +27,8 @@
--border-strong: #343b49; --border-strong: #343b49;
--focus-ring: #3b82f6; --focus-ring: #3b82f6;
font-family: font-family:
"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular,
"Liberation Mono", monospace; Consolas, "Liberation Mono", monospace;
color: #e5e7eb; color: #e5e7eb;
background: #101216; background: #101216;
font-synthesis: none; font-synthesis: none;
@@ -93,6 +96,7 @@ button:disabled {
opacity: 0.56; opacity: 0.56;
} }
/* UI primitives shared by buttons, menus, popovers, tabs, and service rows. */
.ui-button, .ui-button,
.ui-icon-button { .ui-icon-button {
appearance: none; appearance: none;
@@ -357,14 +361,26 @@ button:disabled {
.ui-busy-ring-segment.bottom { .ui-busy-ring-segment.bottom {
width: var(--busy-ring-long); width: var(--busy-ring-long);
height: var(--busy-ring-thickness); height: var(--busy-ring-thickness);
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); background: linear-gradient(
90deg,
transparent,
#60a5fa 24%,
#bbf7d0 54%,
transparent
);
} }
.ui-busy-ring-segment.right, .ui-busy-ring-segment.right,
.ui-busy-ring-segment.left { .ui-busy-ring-segment.left {
width: var(--busy-ring-thickness); width: var(--busy-ring-thickness);
height: var(--busy-ring-short); height: var(--busy-ring-short);
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); background: linear-gradient(
180deg,
transparent,
#60a5fa 24%,
#bbf7d0 54%,
transparent
);
} }
.ui-busy-ring-segment.top { .ui-busy-ring-segment.top {
@@ -639,14 +655,26 @@ button:disabled {
.ui-service-border-glow-segment.bottom { .ui-service-border-glow-segment.bottom {
width: var(--busy-ring-long); width: var(--busy-ring-long);
height: var(--busy-ring-thickness); height: var(--busy-ring-thickness);
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); background: linear-gradient(
90deg,
transparent,
#60a5fa 24%,
#bbf7d0 54%,
transparent
);
} }
.ui-service-border-glow-segment.right, .ui-service-border-glow-segment.right,
.ui-service-border-glow-segment.left { .ui-service-border-glow-segment.left {
width: var(--busy-ring-thickness); width: var(--busy-ring-thickness);
height: var(--busy-ring-short); height: var(--busy-ring-short);
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); background: linear-gradient(
180deg,
transparent,
#60a5fa 24%,
#bbf7d0 54%,
transparent
);
} }
.ui-service-border-glow-segment.top { .ui-service-border-glow-segment.top {
@@ -758,16 +786,25 @@ button:disabled {
} }
} }
/* Application shell, tab panels, and shared workspace layout. */
.simple-shell { .simple-shell {
display: block; display: block;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
background: #101216; background: #101216;
padding: var(--app-header-height) 0 calc(var(--app-footer-height) + var(--app-change-dock-height) + var(--app-admin-prompt-height)); padding: var(--app-header-height) 0
calc(
var(--app-footer-height) + var(--app-change-dock-height) +
var(--app-admin-prompt-height)
);
} }
.simple-shell.has-change-dock { .simple-shell.has-change-dock {
--app-change-dock-height: clamp(60px, calc(20px + (var(--change-row-count) * 26px)), 220px); --app-change-dock-height: clamp(
60px,
calc(20px + (var(--change-row-count) * 26px)),
220px
);
} }
.simple-shell.has-admin-prompt { .simple-shell.has-admin-prompt {
@@ -779,7 +816,10 @@ button:disabled {
grid-template-rows: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr);
align-content: stretch; align-content: stretch;
min-height: 0; min-height: 0;
height: calc(100vh - var(--app-header-height) - var(--app-footer-height) - var(--app-change-dock-height) - var(--app-admin-prompt-height)); height: calc(
100vh - var(--app-header-height) - var(--app-footer-height) -
var(--app-change-dock-height) - var(--app-admin-prompt-height)
);
width: 100%; width: 100%;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
@@ -1076,6 +1116,7 @@ button:disabled {
text-align: center; text-align: center;
} }
/* Read-only summary panel and power state. */
.summary-main { .summary-main {
align-self: stretch; align-self: stretch;
display: grid; display: grid;
@@ -1116,9 +1157,14 @@ button:disabled {
width: clamp(180px, 30vw, 268px); width: clamp(180px, 30vw, 268px);
aspect-ratio: 1; aspect-ratio: 1;
overflow: hidden; overflow: hidden;
border: 0; border: 1px solid #3f4b5e;
border-radius: 50%; border-radius: 50%;
background: transparent; background: radial-gradient(
circle at 48% 38%,
#263244 0%,
#151c28 62%,
#0c1119 100%
);
box-shadow: box-shadow:
0 22px 54px rgba(0, 0, 0, 0.48), 0 22px 54px rgba(0, 0, 0, 0.48),
0 0 38px rgba(59, 130, 246, 0.1); 0 0 38px rgba(59, 130, 246, 0.1);
@@ -1154,17 +1200,42 @@ button:disabled {
z-index: 1; z-index: 1;
} }
.summary-toggle-button img { .summary-toggle-face {
display: block; display: grid;
width: 100%; place-items: center;
height: 100%; width: 78%;
aspect-ratio: 1;
border-radius: 50%; border-radius: 50%;
object-fit: cover; border: 1px solid #4b596e;
color: #98a4b8;
background: radial-gradient(circle at 48% 38%, #283446 0%, #151c27 72%);
box-shadow: inset 0 0 28px rgba(0, 0, 0, 0.46);
pointer-events: none; pointer-events: none;
transform: scale(1.18);
user-select: none; user-select: none;
} }
.summary-status-control.on .summary-toggle-button {
border-color: #19b985;
background: radial-gradient(
circle at 48% 38%,
#193b36 0%,
#12251f 62%,
#0b1513 100%
);
box-shadow:
0 22px 54px rgba(0, 0, 0, 0.48),
0 0 42px rgba(25, 185, 133, 0.24);
}
.summary-status-control.on .summary-toggle-face {
border-color: #2fd5a0;
color: #5ce6b8;
background: radial-gradient(circle at 48% 38%, #205044 0%, #143029 72%);
box-shadow:
inset 0 0 28px rgba(0, 0, 0, 0.34),
0 0 30px rgba(47, 213, 160, 0.18);
}
.summary-state-label { .summary-state-label {
color: #e5e7eb; color: #e5e7eb;
font-size: clamp(22px, 3.4vw, 34px); font-size: clamp(22px, 3.4vw, 34px);
@@ -1173,6 +1244,14 @@ button:disabled {
text-align: center; text-align: center;
} }
.network-disclosure {
grid-column: 1 / -1;
margin: 0;
color: #8d99ae;
font-size: 11px;
line-height: 1.45;
}
.summary-state-label.ok { .summary-state-label.ok {
color: #bbf7d0; color: #bbf7d0;
} }
@@ -1422,14 +1501,26 @@ button.summary-card:hover {
.finder-border-glow-segment.bottom { .finder-border-glow-segment.bottom {
width: var(--busy-ring-long); width: var(--busy-ring-long);
height: var(--busy-ring-thickness); height: var(--busy-ring-thickness);
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); background: linear-gradient(
90deg,
transparent,
#60a5fa 24%,
#bbf7d0 54%,
transparent
);
} }
.finder-border-glow-segment.right, .finder-border-glow-segment.right,
.finder-border-glow-segment.left { .finder-border-glow-segment.left {
width: var(--busy-ring-thickness); width: var(--busy-ring-thickness);
height: var(--busy-ring-short); height: var(--busy-ring-short);
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); background: linear-gradient(
180deg,
transparent,
#60a5fa 24%,
#bbf7d0 54%,
transparent
);
} }
.finder-border-glow-segment.top { .finder-border-glow-segment.top {
@@ -1708,9 +1799,12 @@ button.summary-card:hover {
padding: 10px; padding: 10px;
} }
/* Proxy endpoint inputs, subscription workspace, and route checks. */
.connection-check { .connection-check {
display: grid; display: grid;
grid-template-columns: minmax(190px, 1fr) minmax(220px, auto) minmax(132px, auto) auto; grid-template-columns:
minmax(190px, 1fr) minmax(220px, auto) minmax(132px, auto)
auto;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
@@ -2081,6 +2175,7 @@ button.summary-card:hover {
font-weight: 800; font-weight: 800;
} }
/* Route-chain visualization and packet states. */
.route-chain { .route-chain {
position: relative; position: relative;
display: grid; display: grid;
@@ -2300,6 +2395,7 @@ button.summary-card:hover {
justify-content: flex-start; justify-content: flex-start;
} }
/* Pending configuration diff and apply controls. */
.changes-dock { .changes-dock {
position: fixed; position: fixed;
right: 0; right: 0;
@@ -2730,6 +2826,7 @@ button.summary-card:hover {
border: 0; border: 0;
} }
/* Activity log dock and administrator prompt. */
.log-dock { .log-dock {
position: fixed; position: fixed;
right: 0; right: 0;
@@ -2766,7 +2863,9 @@ button.summary-card:hover {
gap: 10px; gap: 10px;
align-items: baseline; align-items: baseline;
padding: 0 8px; padding: 0 8px;
transition: opacity 180ms ease, transform 180ms ease; transition:
opacity 180ms ease,
transform 180ms ease;
} }
.log-current.hidden { .log-current.hidden {
@@ -2880,6 +2979,7 @@ button.summary-card:hover {
color: #b6c2d4; color: #b6c2d4;
} }
/* Motion and responsive overrides. */
@keyframes finder-border-top { @keyframes finder-border-top {
0% { 0% {
left: -116px; left: -116px;
@@ -3014,11 +3114,19 @@ button.summary-card:hover {
@media (max-width: 680px) { @media (max-width: 680px) {
.simple-shell { .simple-shell {
padding: var(--app-header-height) 0 calc(var(--app-footer-height) + var(--app-change-dock-height) + var(--app-admin-prompt-height)); padding: var(--app-header-height) 0
calc(
var(--app-footer-height) + var(--app-change-dock-height) +
var(--app-admin-prompt-height)
);
} }
.simple-shell.has-change-dock { .simple-shell.has-change-dock {
--app-change-dock-height: clamp(104px, calc(82px + (var(--change-row-count) * 25px)), 260px); --app-change-dock-height: clamp(
104px,
calc(82px + (var(--change-row-count) * 25px)),
260px
);
} }
.simple-shell.has-admin-prompt { .simple-shell.has-admin-prompt {
@@ -3026,7 +3134,10 @@ button.summary-card:hover {
} }
.simple-panel { .simple-panel {
height: calc(100vh - var(--app-header-height) - var(--app-footer-height) - var(--app-change-dock-height) - var(--app-admin-prompt-height)); height: calc(
100vh - var(--app-header-height) - var(--app-footer-height) -
var(--app-change-dock-height) - var(--app-admin-prompt-height)
);
padding: 12px 12px 16px; padding: 12px 12px 16px;
} }
+33 -26
View File
@@ -1,4 +1,4 @@
import { MoreHorizontal } from 'lucide-react'; import { MoreHorizontal } from "lucide-react";
import { import {
useEffect, useEffect,
useId, useId,
@@ -6,9 +6,9 @@ import {
useRef, useRef,
useState, useState,
type CSSProperties, type CSSProperties,
} from 'react'; } from "react";
import { createPortal } from 'react-dom'; import { createPortal } from "react-dom";
import { IconButton } from './IconButton'; import { IconButton } from "./IconButton";
export interface ActionMenuItem { export interface ActionMenuItem {
label: string; label: string;
@@ -29,7 +29,7 @@ interface ActionMenuPosition {
top: number; top: number;
left: number; left: number;
width: number; width: number;
placement: 'top' | 'bottom'; placement: "top" | "bottom";
} }
const MENU_WIDTH = 190; const MENU_WIDTH = 190;
@@ -50,7 +50,7 @@ export function ActionMenu({
top: 0, top: 0,
left: 0, left: 0,
width: MENU_WIDTH, width: MENU_WIDTH,
placement: 'bottom', placement: "bottom",
}); });
useEffect(() => { useEffect(() => {
@@ -65,22 +65,28 @@ export function ActionMenu({
if (!trigger) return; if (!trigger) return;
const rect = trigger.getBoundingClientRect(); const rect = trigger.getBoundingClientRect();
const width = Math.min(MENU_WIDTH, Math.max(180, window.innerWidth - VIEWPORT_MARGIN * 2)); const width = Math.min(
MENU_WIDTH,
Math.max(180, window.innerWidth - VIEWPORT_MARGIN * 2),
);
const popoverHeight = popoverRef.current?.offsetHeight ?? 0; const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
const left = Math.max( const left = Math.max(
VIEWPORT_MARGIN, VIEWPORT_MARGIN,
Math.min(rect.right - width, window.innerWidth - width - VIEWPORT_MARGIN), Math.min(
rect.right - width,
window.innerWidth - width - VIEWPORT_MARGIN,
),
); );
let top = rect.bottom + MENU_OFFSET; let top = rect.bottom + MENU_OFFSET;
let placement: ActionMenuPosition['placement'] = 'bottom'; let placement: ActionMenuPosition["placement"] = "bottom";
if ( if (
popoverHeight popoverHeight &&
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN &&
&& rect.top > popoverHeight + VIEWPORT_MARGIN + MENU_OFFSET rect.top > popoverHeight + VIEWPORT_MARGIN + MENU_OFFSET
) { ) {
top = rect.top - popoverHeight - MENU_OFFSET; top = rect.top - popoverHeight - MENU_OFFSET;
placement = 'top'; placement = "top";
} }
const maxTop = popoverHeight const maxTop = popoverHeight
@@ -97,13 +103,13 @@ export function ActionMenu({
updatePosition(); updatePosition();
const frame = window.requestAnimationFrame(updatePosition); const frame = window.requestAnimationFrame(updatePosition);
window.addEventListener('resize', updatePosition); window.addEventListener("resize", updatePosition);
window.addEventListener('scroll', updatePosition, true); window.addEventListener("scroll", updatePosition, true);
return () => { return () => {
window.cancelAnimationFrame(frame); window.cancelAnimationFrame(frame);
window.removeEventListener('resize', updatePosition); window.removeEventListener("resize", updatePosition);
window.removeEventListener('scroll', updatePosition, true); window.removeEventListener("scroll", updatePosition, true);
}; };
}, [open]); }, [open]);
@@ -118,16 +124,16 @@ export function ActionMenu({
}; };
const closeOnEscape = (event: KeyboardEvent) => { const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return; if (event.key !== "Escape") return;
onOpenChange(false); onOpenChange(false);
}; };
document.addEventListener('pointerdown', closeOnOutsidePointer); document.addEventListener("pointerdown", closeOnOutsidePointer);
document.addEventListener('keydown', closeOnEscape); document.addEventListener("keydown", closeOnEscape);
return () => { return () => {
document.removeEventListener('pointerdown', closeOnOutsidePointer); document.removeEventListener("pointerdown", closeOnOutsidePointer);
document.removeEventListener('keydown', closeOnEscape); document.removeEventListener("keydown", closeOnEscape);
}; };
}, [onOpenChange, open]); }, [onOpenChange, open]);
@@ -148,7 +154,8 @@ export function ActionMenu({
aria-expanded={open} aria-expanded={open}
aria-haspopup="menu" aria-haspopup="menu"
/> />
{open && typeof document !== 'undefined' ? createPortal( {open && typeof document !== "undefined"
? createPortal(
<div <div
className="ui-action-menu-popover" className="ui-action-menu-popover"
data-placement={position.placement} data-placement={position.placement}
@@ -161,7 +168,7 @@ export function ActionMenu({
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className={item.danger ? 'is-danger' : ''} className={item.danger ? "is-danger" : ""}
onClick={() => { onClick={() => {
onOpenChange(false); onOpenChange(false);
item.onClick(); item.onClick();
@@ -174,8 +181,8 @@ export function ActionMenu({
))} ))}
</div>, </div>,
document.body, document.body,
) : null} )
: null}
</div> </div>
); );
} }
+1 -1
View File
@@ -3,7 +3,7 @@ export interface BusyRingProps {
} }
export function BusyRing({ className }: BusyRingProps) { export function BusyRing({ className }: BusyRingProps) {
const classes = ['ui-busy-ring', className ?? ''].filter(Boolean).join(' '); const classes = ["ui-busy-ring", className ?? ""].filter(Boolean).join(" ");
return ( return (
<span className={classes} aria-hidden="true"> <span className={classes} aria-hidden="true">
+23 -14
View File
@@ -1,8 +1,8 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { ButtonHTMLAttributes, ReactNode } from "react";
import { BusyRing } from './BusyRing'; import { BusyRing } from "./BusyRing";
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger'; export type ButtonVariant = "primary" | "neutral" | "add" | "danger";
export type ButtonSize = 'sm' | 'md' | 'lg'; export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; variant?: ButtonVariant;
@@ -14,8 +14,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
} }
export function Button({ export function Button({
variant = 'neutral', variant = "neutral",
size = 'md', size = "md",
loading = false, loading = false,
loadingLabel, loadingLabel,
leftIcon, leftIcon,
@@ -26,12 +26,14 @@ export function Button({
...props ...props
}: ButtonProps) { }: ButtonProps) {
const classes = [ const classes = [
'ui-button', "ui-button",
`ui-button--${variant}`, `ui-button--${variant}`,
`ui-button--${size}`, `ui-button--${size}`,
loading ? 'is-loading' : '', loading ? "is-loading" : "",
className ?? '', className ?? "",
].filter(Boolean).join(' '); ]
.filter(Boolean)
.join(" ");
return ( return (
<button <button
@@ -42,11 +44,18 @@ export function Button({
> >
{loading ? <BusyRing /> : null} {loading ? <BusyRing /> : null}
{!loading && leftIcon ? ( {!loading && leftIcon ? (
<span className="ui-button-icon" aria-hidden="true">{leftIcon}</span> <span className="ui-button-icon" aria-hidden="true">
{leftIcon}
</span>
) : null}
<span className="ui-button-label">
{loading && loadingLabel ? loadingLabel : children}
</span>
{!loading && rightIcon ? (
<span className="ui-button-icon" aria-hidden="true">
{rightIcon}
</span>
) : null} ) : null}
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>
{!loading && rightIcon ? <span className="ui-button-icon" aria-hidden="true">{rightIcon}</span> : null}
</button> </button>
); );
} }
+45 -31
View File
@@ -8,12 +8,15 @@ import {
type CSSProperties, type CSSProperties,
type MouseEvent, type MouseEvent,
type ReactNode, type ReactNode,
} from 'react'; } from "react";
import { createPortal } from 'react-dom'; import { createPortal } from "react-dom";
export type DetailsPopoverAlign = 'start' | 'center' | 'end'; export type DetailsPopoverAlign = "start" | "center" | "end";
export interface DetailsPopoverProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> { export interface DetailsPopoverProps extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"title"
> {
details: string | string[]; details: string | string[];
children: ReactNode; children: ReactNode;
popoverLabel?: string; popoverLabel?: string;
@@ -26,7 +29,7 @@ interface DetailsPopoverPosition {
left: number; left: number;
width: number; width: number;
arrowLeft: number; arrowLeft: number;
placement: 'top' | 'bottom'; placement: "top" | "bottom";
} }
const VIEWPORT_MARGIN = 12; const VIEWPORT_MARGIN = 12;
@@ -35,8 +38,8 @@ export function DetailsPopover({
details, details,
children, children,
className, className,
popoverLabel = 'Детали', popoverLabel = "Детали",
align = 'start', align = "start",
maxWidth = 360, maxWidth = 360,
disabled, disabled,
onClick, onClick,
@@ -51,12 +54,14 @@ export function DetailsPopover({
left: 0, left: 0,
width: Math.min(maxWidth, 360), width: Math.min(maxWidth, 360),
arrowLeft: 24, arrowLeft: 24,
placement: 'bottom', placement: "bottom",
}); });
const detailLines = Array.isArray(details) const detailLines = Array.isArray(details)
? details.filter(Boolean) ? details.filter(Boolean)
: [details].filter(Boolean); : [details].filter(Boolean);
const classes = ['ui-details-popover-trigger', className ?? ''].filter(Boolean).join(' '); const classes = ["ui-details-popover-trigger", className ?? ""]
.filter(Boolean)
.join(" ");
useEffect(() => { useEffect(() => {
if (disabled && open) setOpen(false); if (disabled && open) setOpen(false);
@@ -70,26 +75,35 @@ export function DetailsPopover({
if (!trigger) return; if (!trigger) return;
const rect = trigger.getBoundingClientRect(); const rect = trigger.getBoundingClientRect();
const width = Math.min(maxWidth, Math.max(220, window.innerWidth - VIEWPORT_MARGIN * 2)); const width = Math.min(
maxWidth,
Math.max(220, window.innerWidth - VIEWPORT_MARGIN * 2),
);
let left = rect.left; let left = rect.left;
if (align === 'center') left = rect.left + rect.width / 2 - width / 2; if (align === "center") left = rect.left + rect.width / 2 - width / 2;
if (align === 'end') left = rect.right - width; if (align === "end") left = rect.right - width;
left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - width - VIEWPORT_MARGIN)); left = Math.max(
VIEWPORT_MARGIN,
Math.min(left, window.innerWidth - width - VIEWPORT_MARGIN),
);
const popoverHeight = popoverRef.current?.offsetHeight ?? 0; const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
let top = rect.bottom + 8; let top = rect.bottom + 8;
let placement: DetailsPopoverPosition['placement'] = 'bottom'; let placement: DetailsPopoverPosition["placement"] = "bottom";
if ( if (
popoverHeight popoverHeight &&
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN &&
&& rect.top > popoverHeight + VIEWPORT_MARGIN + 8 rect.top > popoverHeight + VIEWPORT_MARGIN + 8
) { ) {
top = rect.top - popoverHeight - 8; top = rect.top - popoverHeight - 8;
placement = 'top'; placement = "top";
} else if (popoverHeight) { } else if (popoverHeight) {
top = Math.min(top, window.innerHeight - popoverHeight - VIEWPORT_MARGIN); top = Math.min(
top,
window.innerHeight - popoverHeight - VIEWPORT_MARGIN,
);
} }
const arrowLeft = Math.max( const arrowLeft = Math.max(
@@ -108,13 +122,13 @@ export function DetailsPopover({
updatePosition(); updatePosition();
const frame = window.requestAnimationFrame(updatePosition); const frame = window.requestAnimationFrame(updatePosition);
window.addEventListener('resize', updatePosition); window.addEventListener("resize", updatePosition);
window.addEventListener('scroll', updatePosition, true); window.addEventListener("scroll", updatePosition, true);
return () => { return () => {
window.cancelAnimationFrame(frame); window.cancelAnimationFrame(frame);
window.removeEventListener('resize', updatePosition); window.removeEventListener("resize", updatePosition);
window.removeEventListener('scroll', updatePosition, true); window.removeEventListener("scroll", updatePosition, true);
}; };
}, [align, maxWidth, open]); }, [align, maxWidth, open]);
@@ -129,17 +143,17 @@ export function DetailsPopover({
}; };
const closeOnEscape = (event: KeyboardEvent) => { const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return; if (event.key !== "Escape") return;
setOpen(false); setOpen(false);
triggerRef.current?.focus(); triggerRef.current?.focus();
}; };
document.addEventListener('pointerdown', closeOnOutsidePointer); document.addEventListener("pointerdown", closeOnOutsidePointer);
document.addEventListener('keydown', closeOnEscape); document.addEventListener("keydown", closeOnEscape);
return () => { return () => {
document.removeEventListener('pointerdown', closeOnOutsidePointer); document.removeEventListener("pointerdown", closeOnOutsidePointer);
document.removeEventListener('keydown', closeOnEscape); document.removeEventListener("keydown", closeOnEscape);
}; };
}, [open]); }, [open]);
@@ -152,7 +166,7 @@ export function DetailsPopover({
top: position.top, top: position.top,
left: position.left, left: position.left,
width: position.width, width: position.width,
'--details-popover-arrow-left': `${position.arrowLeft}px`, "--details-popover-arrow-left": `${position.arrowLeft}px`,
} as CSSProperties; } as CSSProperties;
return ( return (
@@ -160,7 +174,7 @@ export function DetailsPopover({
<button <button
{...props} {...props}
ref={triggerRef} ref={triggerRef}
type={props.type ?? 'button'} type={props.type ?? "button"}
className={classes} className={classes}
aria-controls={open ? detailsId : undefined} aria-controls={open ? detailsId : undefined}
aria-expanded={open} aria-expanded={open}
@@ -170,7 +184,7 @@ export function DetailsPopover({
> >
{children} {children}
</button> </button>
{open && detailLines.length && typeof document !== 'undefined' {open && detailLines.length && typeof document !== "undefined"
? createPortal( ? createPortal(
<div <div
ref={popoverRef} ref={popoverRef}
+11 -5
View File
@@ -1,4 +1,4 @@
import type { InputHTMLAttributes, ReactNode } from 'react'; import type { InputHTMLAttributes, ReactNode } from "react";
export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> { export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string; label: string;
@@ -16,11 +16,11 @@ export function Field({
id, id,
...props ...props
}: FieldProps) { }: FieldProps) {
const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, '-')}`; const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, "-")}`;
const helpId = `${inputId}-help`; const helpId = `${inputId}-help`;
return ( return (
<label className={`ui-field ${className ?? ''}`.trim()} htmlFor={inputId}> <label className={`ui-field ${className ?? ""}`.trim()} htmlFor={inputId}>
<span className="ui-field-label">{label}</span> <span className="ui-field-label">{label}</span>
<div className="ui-field-row"> <div className="ui-field-row">
<input <input
@@ -31,8 +31,14 @@ export function Field({
/> />
{action} {action}
</div> </div>
{error || hint ? <span id={helpId} className={`ui-field-help ${error ? 'is-error' : ''}`.trim()}>{error ?? hint}</span> : null} {error || hint ? (
<span
id={helpId}
className={`ui-field-help ${error ? "is-error" : ""}`.trim()}
>
{error ?? hint}
</span>
) : null}
</label> </label>
); );
} }
+5 -3
View File
@@ -1,4 +1,4 @@
import type { HTMLAttributes, ReactNode } from 'react'; import type { HTMLAttributes, ReactNode } from "react";
export interface HoverDetailsProps extends HTMLAttributes<HTMLSpanElement> { export interface HoverDetailsProps extends HTMLAttributes<HTMLSpanElement> {
details: string | string[]; details: string | string[];
@@ -12,9 +12,11 @@ export function HoverDetails({
...props ...props
}: HoverDetailsProps) { }: HoverDetailsProps) {
const detailText = Array.isArray(details) const detailText = Array.isArray(details)
? details.filter(Boolean).join('\n') ? details.filter(Boolean).join("\n")
: details; : details;
const classes = ['ui-hover-details', className ?? ''].filter(Boolean).join(' '); const classes = ["ui-hover-details", className ?? ""]
.filter(Boolean)
.join(" ");
return ( return (
<span <span
+16 -12
View File
@@ -1,9 +1,12 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { ButtonHTMLAttributes, ReactNode } from "react";
import { BusyRing } from './BusyRing'; import { BusyRing } from "./BusyRing";
export type IconButtonVariant = 'neutral' | 'add' | 'danger'; export type IconButtonVariant = "neutral" | "add" | "danger";
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> { export interface IconButtonProps extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"title"
> {
label: string; label: string;
icon: ReactNode; icon: ReactNode;
variant?: IconButtonVariant; variant?: IconButtonVariant;
@@ -14,25 +17,27 @@ export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
export function IconButton({ export function IconButton({
label, label,
icon, icon,
variant = 'neutral', variant = "neutral",
loading = false, loading = false,
tooltip, tooltip,
className, className,
disabled, disabled,
...props ...props
}: IconButtonProps) { }: IconButtonProps) {
const tooltipText = tooltip === '' ? undefined : tooltip ?? label; const tooltipText = tooltip === "" ? undefined : (tooltip ?? label);
const classes = [ const classes = [
'ui-icon-button', "ui-icon-button",
`ui-icon-button--${variant}`, `ui-icon-button--${variant}`,
loading ? 'is-loading' : '', loading ? "is-loading" : "",
className ?? '', className ?? "",
].filter(Boolean).join(' '); ]
.filter(Boolean)
.join(" ");
return ( return (
<button <button
{...props} {...props}
type={props.type ?? 'button'} type={props.type ?? "button"}
className={classes} className={classes}
aria-label={label} aria-label={label}
data-tooltip={tooltipText} data-tooltip={tooltipText}
@@ -44,4 +49,3 @@ export function IconButton({
</button> </button>
); );
} }
+31 -12
View File
@@ -1,8 +1,8 @@
import { Button } from './Button'; import { Button } from "./Button";
export interface LogDockEntry { export interface LogDockEntry {
id: string; id: string;
kind: 'success' | 'error' | 'info'; kind: "success" | "error" | "info";
title: string; title: string;
text: string; text: string;
at: number; at: number;
@@ -18,7 +18,10 @@ export interface LogDockProps {
function isNativePreviewError(entry: LogDockEntry | null) { function isNativePreviewError(entry: LogDockEntry | null) {
if (!entry) return false; if (!entry) return false;
return entry.text.includes("reading 'invoke'") || entry.text.includes('undefined (reading'); return (
entry.text.includes("reading 'invoke'") ||
entry.text.includes("undefined (reading")
);
} }
function displayEntry(entry: LogDockEntry | null) { function displayEntry(entry: LogDockEntry | null) {
@@ -26,8 +29,8 @@ function displayEntry(entry: LogDockEntry | null) {
if (!isNativePreviewError(entry)) return entry; if (!isNativePreviewError(entry)) return entry;
return { return {
...entry, ...entry,
title: 'Desktop-команды недоступны', title: "Desktop-команды недоступны",
text: 'Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.', text: "Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.",
}; };
} }
@@ -41,8 +44,11 @@ export function LogDock({
const current = displayEntry(activeEntry); const current = displayEntry(activeEntry);
return ( return (
<footer className={`log-dock ${current?.kind ?? 'idle'}`} aria-live="polite"> <footer
<div className={`log-current ${current ? 'visible' : 'hidden'}`}> className={`log-dock ${current?.kind ?? "idle"}`}
aria-live="polite"
>
<div className={`log-current ${current ? "visible" : "hidden"}`}>
{current ? ( {current ? (
<> <>
<strong>{current.title}</strong> <strong>{current.title}</strong>
@@ -52,12 +58,20 @@ export function LogDock({
<span className="log-muted">Журнал событий</span> <span className="log-muted">Журнал событий</span>
)} )}
</div> </div>
<Button type="button" variant="neutral" size="sm" className="log-toggle" onClick={onToggle}> <Button
{open ? 'Скрыть' : 'Посмотреть'} <span className="log-count">{entries.length}</span> type="button"
variant="neutral"
size="sm"
className="log-toggle"
onClick={onToggle}
>
{open ? "Скрыть" : "Посмотреть"}{" "}
<span className="log-count">{entries.length}</span>
</Button> </Button>
{open ? ( {open ? (
<div className="log-history"> <div className="log-history">
{entries.length ? entries.map((entry) => { {entries.length ? (
entries.map((entry) => {
const friendly = displayEntry(entry); const friendly = displayEntry(entry);
return ( return (
<div className={`log-history-row ${entry.kind}`} key={entry.id}> <div className={`log-history-row ${entry.kind}`} key={entry.id}>
@@ -65,11 +79,16 @@ export function LogDock({
<div> <div>
<strong>{friendly?.title ?? entry.title}</strong> <strong>{friendly?.title ?? entry.title}</strong>
<span>{friendly?.text ?? entry.text}</span> <span>{friendly?.text ?? entry.text}</span>
{isNativePreviewError(entry) ? <span className="log-raw-detail">Детали: {entry.text}</span> : null} {isNativePreviewError(entry) ? (
<span className="log-raw-detail">
Детали: {entry.text}
</span>
) : null}
</div> </div>
</div> </div>
); );
}) : ( })
) : (
<div className="log-history-row"> <div className="log-history-row">
<time>--:--:--</time> <time>--:--:--</time>
<span>Событий пока нет.</span> <span>Событий пока нет.</span>
+16 -12
View File
@@ -1,8 +1,9 @@
import type { ReactNode } from 'react'; import type { ReactNode } from "react";
import { Button, type ButtonVariant } from './Button'; import { Button, type ButtonVariant } from "./Button";
import { ActionMenu, type ActionMenuItem } from './ActionMenu'; import { ActionMenu, type ActionMenuItem } from "./ActionMenu";
export type ServiceControlState = 'checking' | 'missing' | 'installed' | 'running' | 'stopped' | 'error'; export type ServiceControlState =
"checking" | "missing" | "installed" | "running" | "stopped" | "error";
export interface ServicePrimaryAction { export interface ServicePrimaryAction {
label: string; label: string;
@@ -25,7 +26,7 @@ export interface ServiceControlRowProps {
items: ActionMenuItem[]; items: ActionMenuItem[];
disabled?: boolean; disabled?: boolean;
}; };
visualState?: 'working' | 'settling' | null; visualState?: "working" | "settling" | null;
className?: string; className?: string;
inlineActions?: ReactNode; inlineActions?: ReactNode;
children?: ReactNode; children?: ReactNode;
@@ -43,11 +44,13 @@ export function ServiceControlRow({
children, children,
}: ServiceControlRowProps) { }: ServiceControlRowProps) {
const classes = [ const classes = [
'ui-service-row', "ui-service-row",
`ui-service-row--${state}`, `ui-service-row--${state}`,
visualState ? `ui-service-row--${visualState}` : '', visualState ? `ui-service-row--${visualState}` : "",
className ?? '', className ?? "",
].filter(Boolean).join(' '); ]
.filter(Boolean)
.join(" ");
return ( return (
<div className={classes}> <div className={classes}>
@@ -61,7 +64,9 @@ export function ServiceControlRow({
<div className="ui-service-text"> <div className="ui-service-text">
<div className="ui-service-title-line"> <div className="ui-service-title-line">
<strong>{title}</strong> <strong>{title}</strong>
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null} {inlineActions ? (
<div className="ui-service-inline-actions">{inlineActions}</div>
) : null}
</div> </div>
<span>{detail}</span> <span>{detail}</span>
</div> </div>
@@ -69,7 +74,7 @@ export function ServiceControlRow({
{primaryAction ? ( {primaryAction ? (
<Button <Button
type="button" type="button"
variant={primaryAction.variant ?? 'neutral'} variant={primaryAction.variant ?? "neutral"}
onClick={primaryAction.onClick} onClick={primaryAction.onClick}
disabled={primaryAction.disabled} disabled={primaryAction.disabled}
loading={primaryAction.loading} loading={primaryAction.loading}
@@ -92,4 +97,3 @@ export function ServiceControlRow({
</div> </div>
); );
} }
+5 -6
View File
@@ -1,21 +1,20 @@
import { BusyRing } from './BusyRing'; import { BusyRing } from "./BusyRing";
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted'; export type StatusPillTone = "ok" | "warning" | "error" | "checking" | "muted";
export interface StatusPillProps { export interface StatusPillProps {
tone?: StatusPillTone; tone?: StatusPillTone;
children: string; children: string;
} }
export function StatusPill({ tone = 'muted', children }: StatusPillProps) { export function StatusPill({ tone = "muted", children }: StatusPillProps) {
return ( return (
<span <span
className={`ui-status-pill ui-status-pill--${tone}`} className={`ui-status-pill ui-status-pill--${tone}`}
aria-busy={tone === 'checking' || undefined} aria-busy={tone === "checking" || undefined}
> >
{tone === 'checking' ? <BusyRing /> : null} {tone === "checking" ? <BusyRing /> : null}
{children} {children}
</span> </span>
); );
} }
+9 -8
View File
@@ -1,4 +1,4 @@
import { useRef, type KeyboardEvent } from 'react'; import { useRef, type KeyboardEvent } from "react";
export interface TabItem<T extends string> { export interface TabItem<T extends string> {
id: T; id: T;
@@ -30,24 +30,26 @@ export function Tabs<T extends string>({
} }
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, id: T) { function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, id: T) {
if (event.key === 'ArrowRight') { if (event.key === "ArrowRight") {
event.preventDefault(); event.preventDefault();
moveFocus(id, 1); moveFocus(id, 1);
} else if (event.key === 'ArrowLeft') { } else if (event.key === "ArrowLeft") {
event.preventDefault(); event.preventDefault();
moveFocus(id, -1); moveFocus(id, -1);
} else if (event.key === 'Home') { } else if (event.key === "Home") {
event.preventDefault(); event.preventDefault();
const first = items[0]; const first = items[0];
if (!first) return; if (!first) return;
onChange(first.id); onChange(first.id);
window.requestAnimationFrame(() => refs.current[0]?.focus()); window.requestAnimationFrame(() => refs.current[0]?.focus());
} else if (event.key === 'End') { } else if (event.key === "End") {
event.preventDefault(); event.preventDefault();
const last = items[items.length - 1]; const last = items[items.length - 1];
if (!last) return; if (!last) return;
onChange(last.id); onChange(last.id);
window.requestAnimationFrame(() => refs.current[items.length - 1]?.focus()); window.requestAnimationFrame(() =>
refs.current[items.length - 1]?.focus(),
);
} }
} }
@@ -63,7 +65,7 @@ export function Tabs<T extends string>({
aria-controls={`panel-${item.id}`} aria-controls={`panel-${item.id}`}
aria-selected={active} aria-selected={active}
tabIndex={active ? 0 : -1} tabIndex={active ? 0 : -1}
className={`ui-tab ${active ? 'is-active' : ''}`.trim()} className={`ui-tab ${active ? "is-active" : ""}`.trim()}
key={item.id} key={item.id}
ref={(node) => { ref={(node) => {
refs.current[index] = node; refs.current[index] = node;
@@ -78,4 +80,3 @@ export function Tabs<T extends string>({
</div> </div>
); );
} }
+28 -23
View File
@@ -1,23 +1,28 @@
export { ActionMenu } from './ActionMenu'; export { ActionMenu } from "./ActionMenu";
export type { ActionMenuItem } from './ActionMenu'; export type { ActionMenuItem } from "./ActionMenu";
export { BusyRing } from './BusyRing'; export { BusyRing } from "./BusyRing";
export type { BusyRingProps } from './BusyRing'; export type { BusyRingProps } from "./BusyRing";
export { Button } from './Button'; export { Button } from "./Button";
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button'; export type { ButtonProps, ButtonSize, ButtonVariant } from "./Button";
export { DetailsPopover } from './DetailsPopover'; export { DetailsPopover } from "./DetailsPopover";
export type { DetailsPopoverAlign, DetailsPopoverProps } from './DetailsPopover'; export type {
export { Field } from './Field'; DetailsPopoverAlign,
export type { FieldProps } from './Field'; DetailsPopoverProps,
export { HoverDetails } from './HoverDetails'; } from "./DetailsPopover";
export type { HoverDetailsProps } from './HoverDetails'; export { Field } from "./Field";
export { IconButton } from './IconButton'; export type { FieldProps } from "./Field";
export type { IconButtonProps, IconButtonVariant } from './IconButton'; export { HoverDetails } from "./HoverDetails";
export { LogDock } from './LogDock'; export type { HoverDetailsProps } from "./HoverDetails";
export type { LogDockEntry, LogDockProps } from './LogDock'; export { IconButton } from "./IconButton";
export { ServiceControlRow } from './ServiceControlRow'; export type { IconButtonProps, IconButtonVariant } from "./IconButton";
export type { ServiceControlRowProps, ServiceControlState } from './ServiceControlRow'; export { LogDock } from "./LogDock";
export { StatusPill } from './StatusPill'; export type { LogDockEntry, LogDockProps } from "./LogDock";
export type { StatusPillProps, StatusPillTone } from './StatusPill'; export { ServiceControlRow } from "./ServiceControlRow";
export { Tabs } from './Tabs'; export type {
export type { TabItem, TabsProps } from './Tabs'; ServiceControlRowProps,
ServiceControlState,
} from "./ServiceControlRow";
export { StatusPill } from "./StatusPill";
export type { StatusPillProps, StatusPillTone } from "./StatusPill";
export { Tabs } from "./Tabs";
export type { TabItem, TabsProps } from "./Tabs";