Refactor application structure and simplify implementation
This commit is contained in:
@@ -26,6 +26,15 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
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
|
||||
run: npm test -- --run
|
||||
|
||||
@@ -58,3 +67,7 @@ jobs:
|
||||
- name: Plan sing-box installer
|
||||
shell: pwsh
|
||||
run: .\scripts\install-singbox.ps1 -PlanOnly
|
||||
|
||||
- name: Plan Windows smoke evidence capture
|
||||
shell: pwsh
|
||||
run: .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly
|
||||
|
||||
@@ -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 риски.
|
||||
@@ -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.
|
||||
@@ -143,6 +143,8 @@ C:\ProgramData\ProxyWarden\generated\sing-box-config.json
|
||||
|
||||
Subscription URL считается секретом. UI и diagnostics должны показывать только редактированную/сокращенную версию ссылки.
|
||||
|
||||
При загрузке подписки ProxyWarden отправляет провайдеру стандартные идентификационные заголовки приложения и `X-HWID` - случайный постоянный UUID этой установки. Это не серийный номер оборудования, но провайдер может использовать его для связывания запросов одной установки. Проверка маршрута делает HTTPS-запросы через выбранный proxy к Cloudflare и ipify, чтобы подтвердить выход и определить внешний IP.
|
||||
|
||||
## Типовые сценарии
|
||||
|
||||
### Внешний SOCKS5
|
||||
@@ -224,6 +226,10 @@ Browser-preview годится для проверки интерфейса, н
|
||||
Frontend/UI:
|
||||
|
||||
```powershell
|
||||
npm run format:check
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm test -- --run
|
||||
npm run build
|
||||
```
|
||||
|
||||
@@ -253,6 +259,8 @@ Installer boundaries:
|
||||
## Ограничения текущей версии
|
||||
|
||||
- Основной поддержанный маршрут - SOCKS5.
|
||||
- Link-подписки разбирают VLESS, VMess, Trojan и Shadowsocks; sing-box JSON также принимает поддержанные proxy outbounds. Неизвестные форматы отклоняются явно.
|
||||
- Для VLESS outbound без собственного `packet_encoding` генератор добавляет `xudp`; значение, заданное провайдером подписки, не перезаписывается.
|
||||
- ProxiFyre является текущим backend-слоем для per-app routing.
|
||||
- Local sing-box остается опциональным и не требуется для внешнего SOCKS5.
|
||||
- Elevated install/start/stop/uninstall операции считаются реализованными, но требуют дополнительной проверки на реальной Windows-машине с UAC/admin confirmation.
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+1155
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -6,7 +6,11 @@
|
||||
"description": "Standalone Windows desktop proxy management app for ProxyWarden.",
|
||||
"scripts": {
|
||||
"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",
|
||||
"test": "vitest",
|
||||
"tauri": "tauri"
|
||||
@@ -20,12 +24,16 @@
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^10.7.0",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.2.4",
|
||||
"vite": "^7.0.0"
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Generated
+1
@@ -2324,6 +2324,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"uuid",
|
||||
"winreg",
|
||||
|
||||
@@ -22,6 +22,7 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
|
||||
percent-encoding = "2"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
thiserror = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.55"
|
||||
|
||||
@@ -205,7 +205,10 @@ fn app_names_for_profile(profile: &Profile) -> Vec<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache, SubscriptionServer};
|
||||
use crate::process::command_no_window;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::Path,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use std::{env, fs, fs::OpenOptions, io::Write, path::Path};
|
||||
|
||||
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
|
||||
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
|
||||
@@ -43,23 +39,36 @@ impl SingBoxAdapter {
|
||||
checker: &C,
|
||||
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
|
||||
where
|
||||
C: SingBoxConfigChecker,
|
||||
C: SingBoxConfigChecker + ?Sized,
|
||||
{
|
||||
let selected_server_tag = request
|
||||
let selected_server = request
|
||||
.config
|
||||
.selected_server_tag
|
||||
.selected_server_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|id| {
|
||||
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(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedServer,
|
||||
"Сервер Local sing-box не выбран",
|
||||
"Сервер Local sing-box не выбран или отсутствует в текущей подписке",
|
||||
)
|
||||
})?;
|
||||
let vpn_outbound = selected_outbound(
|
||||
&request.subscription_cache.config,
|
||||
selected_server_tag,
|
||||
selected_server,
|
||||
&self.vpn_outbound_tag,
|
||||
)?;
|
||||
let generated_config = json!({
|
||||
@@ -105,7 +114,7 @@ impl SingBoxAdapter {
|
||||
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
|
||||
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
|
||||
contents,
|
||||
selected_server_tag: selected_server_tag.to_string(),
|
||||
selected_server_tag: selected_server.tag.clone(),
|
||||
listen: request.config.listen_host.clone(),
|
||||
listen_port: request.config.listen_port,
|
||||
check,
|
||||
@@ -200,20 +209,37 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
|
||||
config_json: &str,
|
||||
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||
let config_path = env::temp_dir().join(format!(
|
||||
"proxywarden-sing-box-{}-{}.json",
|
||||
std::process::id(),
|
||||
now_millis()
|
||||
"proxywarden-sing-box-{}.json",
|
||||
uuid::Uuid::new_v4().hyphenated()
|
||||
));
|
||||
|
||||
fs::write(&config_path, config_json).map_err(|error| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!(
|
||||
"Не удалось записать временный конфиг sing-box '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
{
|
||||
let mut config_file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&config_path)
|
||||
.map_err(|error| {
|
||||
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,
|
||||
format!(
|
||||
"Не удалось записать временный конфиг sing-box '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let output = command_no_window(binary_path)
|
||||
.arg("check")
|
||||
@@ -257,7 +283,7 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
|
||||
|
||||
fn selected_outbound(
|
||||
subscription_config: &Value,
|
||||
selected_server_tag: &str,
|
||||
selected_server: &SubscriptionServer,
|
||||
vpn_outbound_tag: &str,
|
||||
) -> Result<Value, SingBoxConfigError> {
|
||||
let outbounds = subscription_config
|
||||
@@ -272,15 +298,27 @@ fn selected_outbound(
|
||||
let outbound = outbounds
|
||||
.iter()
|
||||
.find(|outbound| {
|
||||
outbound
|
||||
let tag_matches = outbound
|
||||
.get("tag")
|
||||
.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(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||
format!("Outbound не найден: {selected_server_tag}"),
|
||||
format!(
|
||||
"Outbound не найден: {} ({}:{})",
|
||||
selected_server.tag, selected_server.server, selected_server.server_port
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let outbound_type = outbound
|
||||
@@ -292,7 +330,8 @@ fn selected_outbound(
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
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(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"),
|
||||
format!(
|
||||
"Outbound '{}' должен быть JSON-объектом",
|
||||
selected_server.tag
|
||||
),
|
||||
)
|
||||
})?;
|
||||
object.insert(
|
||||
@@ -318,13 +360,6 @@ fn selected_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 {
|
||||
let stdout = stdout.trim();
|
||||
let stderr = stderr.trim();
|
||||
|
||||
@@ -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,
|
||||
"Перезапуск от имени администратора отменен или не был запущен.",
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -30,10 +30,12 @@ pub struct DetectedProxyfier {
|
||||
pub service_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DetectedService {
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub path_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -63,6 +65,15 @@ pub trait ProxyfierDetectionHost {
|
||||
|
||||
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 {
|
||||
self.service_status(service_name)
|
||||
.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())
|
||||
}
|
||||
|
||||
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> {
|
||||
read_registry_install_entries()
|
||||
}
|
||||
@@ -148,16 +168,9 @@ pub fn default_singbox_install_dir() -> PathBuf {
|
||||
pub fn detect_proxyfier_install_with_host(
|
||||
host: &impl ProxyfierDetectionHost,
|
||||
) -> 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)
|
||||
.into_iter()
|
||||
.filter_map(|candidate| {
|
||||
candidate.into_detected(host, proxifyre_running, detected_service.as_ref())
|
||||
})
|
||||
.filter_map(|candidate| candidate.into_detected(host))
|
||||
.next()
|
||||
}
|
||||
|
||||
@@ -332,23 +345,24 @@ struct ProxyfierCandidate {
|
||||
}
|
||||
|
||||
impl ProxyfierCandidate {
|
||||
fn into_detected(
|
||||
self,
|
||||
host: &impl ProxyfierDetectionHost,
|
||||
proxifyre_running: bool,
|
||||
detected_service: Option<&DetectedService>,
|
||||
) -> Option<DetectedProxyfier> {
|
||||
fn into_detected(self, host: &impl ProxyfierDetectionHost) -> Option<DetectedProxyfier> {
|
||||
let executable_path = self.install_dir.join(executable_name(&self.engine));
|
||||
let config_path = config_path(&self.engine, &self.install_dir);
|
||||
if !host.path_exists(&executable_path) {
|
||||
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 {
|
||||
service_name: detected_service
|
||||
.map(|service| service.name.clone())
|
||||
.or_else(|| service_name(&self.engine).map(str::to_string)),
|
||||
service_status: detected_service.map(|service| service.status.clone()),
|
||||
.as_ref()
|
||||
.map(|service| service.name.clone()),
|
||||
service_status: detected_service
|
||||
.as_ref()
|
||||
.map(|service| service.status.clone()),
|
||||
engine: self.engine,
|
||||
name: self.name,
|
||||
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"] {
|
||||
if let Some(status) = host.service_status(name) {
|
||||
return Some(DetectedService {
|
||||
name: name.to_string(),
|
||||
status: normalize_service_status(&status),
|
||||
if let Some(mut service) = host.service_info(name) {
|
||||
let matches_executable = service.path_name.as_deref().is_some_and(|path_name| {
|
||||
service_path_matches_executable(path_name, executable_path)
|
||||
});
|
||||
if matches_executable {
|
||||
service.status = normalize_service_status(&service.status);
|
||||
return Some(service);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
status.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -1,12 +1,27 @@
|
||||
pub mod activity;
|
||||
pub mod admin;
|
||||
pub mod apply_flow;
|
||||
pub mod clock;
|
||||
pub mod command_dto;
|
||||
pub mod commands;
|
||||
pub mod component_detection;
|
||||
pub mod component_status;
|
||||
pub mod configuration_use_case;
|
||||
pub mod elevated_scripts;
|
||||
pub mod helper;
|
||||
pub mod models;
|
||||
mod powershell;
|
||||
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 singbox_config;
|
||||
pub mod singbox_runtime;
|
||||
pub mod singbox_service;
|
||||
pub mod singbox_subscription;
|
||||
pub mod storage;
|
||||
pub mod subscription;
|
||||
pub mod validation;
|
||||
@@ -22,21 +37,14 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(commands::CommandState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
commands::get_admin_status,
|
||||
commands::restart_as_admin,
|
||||
commands::get_startup_snapshot,
|
||||
commands::get_profiles,
|
||||
commands::get_saved_state,
|
||||
commands::save_profile,
|
||||
commands::get_targets,
|
||||
commands::save_target,
|
||||
commands::get_components,
|
||||
commands::get_proxifyre_setup_status,
|
||||
commands::get_proxifyre_setup_progress,
|
||||
commands::get_singbox_status,
|
||||
commands::get_singbox_setup_status,
|
||||
commands::resolve_profile_preview,
|
||||
commands::save_singbox_subscription,
|
||||
commands::fetch_singbox_subscription,
|
||||
commands::forget_singbox_subscription,
|
||||
@@ -45,9 +53,7 @@ pub fn run() {
|
||||
commands::ping_all_singbox_servers,
|
||||
commands::ping_proxy_target,
|
||||
commands::generate_singbox_config,
|
||||
commands::apply_profiles,
|
||||
commands::get_logs,
|
||||
commands::open_config_location,
|
||||
commands::apply_configuration,
|
||||
commands::start_proxifyre_service,
|
||||
commands::stop_proxifyre_service,
|
||||
commands::install_proxifyre,
|
||||
|
||||
@@ -57,6 +57,7 @@ pub enum ComponentState {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileItemInput {
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: String,
|
||||
@@ -66,6 +67,7 @@ pub struct ProfileItemInput {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileInput {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
@@ -98,6 +100,7 @@ pub struct Profile {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInput {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
@@ -149,6 +152,8 @@ pub struct LocalSingBoxConfig {
|
||||
pub device_hwid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_id: Option<String>,
|
||||
#[serde(default = "default_local_singbox_listen_host")]
|
||||
pub listen_host: String,
|
||||
#[serde(default = "default_local_singbox_listen_port")]
|
||||
@@ -181,6 +186,7 @@ impl Default for LocalSingBoxConfig {
|
||||
subscription_url: None,
|
||||
device_hwid: None,
|
||||
selected_server_tag: None,
|
||||
selected_server_id: None,
|
||||
listen_host: default_local_singbox_listen_host(),
|
||||
listen_port: default_local_singbox_listen_port(),
|
||||
service_name: default_local_singbox_service_name(),
|
||||
@@ -204,6 +210,7 @@ impl SubscriptionCache {
|
||||
pub fn normalize_percent_encoded_tags(&mut self) {
|
||||
for server in &mut self.servers {
|
||||
server.tag = decode_percent_encoded_utf8(&server.tag);
|
||||
server.ensure_id();
|
||||
}
|
||||
|
||||
let Some(outbounds) = self
|
||||
@@ -232,6 +239,8 @@ impl SubscriptionCache {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SubscriptionServer {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
pub tag: String,
|
||||
#[serde(rename = "type")]
|
||||
pub server_type: String,
|
||||
@@ -239,6 +248,34 @@ pub struct SubscriptionServer {
|
||||
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)]
|
||||
pub struct ActivityEntry {
|
||||
pub id: String,
|
||||
|
||||
@@ -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('\'', "''")
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
+297
-59
@@ -1,8 +1,7 @@
|
||||
use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer};
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use reqwest::redirect;
|
||||
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::{SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
@@ -11,6 +10,7 @@ const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsock
|
||||
const DEFAULT_APP_NAME: &str = "ProxyWarden";
|
||||
const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const SUBSCRIPTION_MAX_REDIRECTS: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionError {
|
||||
@@ -155,69 +155,129 @@ pub fn fetch_subscription_with_identity_and_policy(
|
||||
) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
let parsed_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| {
|
||||
if validate_subscription_fetch_url(attempt.url(), policy).is_ok() {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.stop()
|
||||
for redirect_count in 0..=SUBSCRIPTION_MAX_REDIRECTS {
|
||||
validate_subscription_fetch_url(¤t_url, policy)?;
|
||||
let client = subscription_client_for_url(¤t_url, policy)?;
|
||||
let mut request = client.get(current_url.clone());
|
||||
|
||||
for (name, value) in identity.request_headers_without_device_hwid() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
});
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
if let Some(device_hwid) = identity
|
||||
.device_hwid
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
request = request.header("x-hwid", device_hwid);
|
||||
}
|
||||
|
||||
let response = request.send().map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription request failed: {error}"))
|
||||
})?;
|
||||
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() {
|
||||
return Err(SubscriptionError::new(format!(
|
||||
"Subscription request failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
let user_info = parse_user_info(
|
||||
response
|
||||
.headers()
|
||||
.get("subscription-userinfo")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
);
|
||||
let body = response.text().map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription body read failed: {error}"))
|
||||
})?;
|
||||
let parsed = parse_subscription_body(&body)?;
|
||||
|
||||
return Ok(SubscriptionCache {
|
||||
config: parsed.config,
|
||||
servers: parsed.servers,
|
||||
user_info,
|
||||
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(redirect_policy)
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription client setup failed: {error}"))
|
||||
})?;
|
||||
let mut request = client.get(parsed_url);
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
|
||||
for (name, value) in identity.request_headers_without_device_hwid() {
|
||||
request = request.header(name, value);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(device_hwid) = identity
|
||||
.device_hwid
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
request = request.header("x-hwid", device_hwid);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(SubscriptionError::new(format!(
|
||||
"Subscription request failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
let user_info = parse_user_info(
|
||||
response
|
||||
.headers()
|
||||
.get("subscription-userinfo")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
);
|
||||
let body = response.text().map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription body read failed: {error}"))
|
||||
})?;
|
||||
let parsed = parse_subscription_body(&body)?;
|
||||
|
||||
Ok(SubscriptionCache {
|
||||
config: parsed.config,
|
||||
servers: parsed.servers,
|
||||
user_info,
|
||||
fetched_at: now_timestamp(),
|
||||
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(
|
||||
parsed_url: &Url,
|
||||
policy: SubscriptionFetchPolicy,
|
||||
@@ -286,23 +346,171 @@ fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
|
||||
let links = decoded
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with("vless://"))
|
||||
.filter(|line| {
|
||||
["vless://", "trojan://", "ss://", "vmess://"]
|
||||
.iter()
|
||||
.any(|scheme| line.starts_with(scheme))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if links.is_empty() {
|
||||
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
|
||||
.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<_>, _>>()?;
|
||||
|
||||
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> {
|
||||
if !raw_url.starts_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}"));
|
||||
|
||||
Some(SubscriptionServer {
|
||||
id: outbound_server_id(outbound),
|
||||
tag,
|
||||
server_type,
|
||||
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 {
|
||||
let compact = content.split_whitespace().collect::<String>();
|
||||
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] {
|
||||
if let Ok(decoded) = engine.decode(compact.as_bytes()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -429,6 +650,23 @@ fn maybe_decode_base64(content: &str) -> 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> {
|
||||
url.query_pairs()
|
||||
.find(|(name, _)| name == key)
|
||||
|
||||
@@ -25,11 +25,18 @@ fn clean(value: &str) -> String {
|
||||
fn slug(value: &str, fallback: &str) -> String {
|
||||
let mut output = String::new();
|
||||
let mut previous_dash = false;
|
||||
let mut has_non_ascii = false;
|
||||
|
||||
for ch in value.trim().to_lowercase().chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
output.push(ch);
|
||||
previous_dash = false;
|
||||
} else if ch.is_alphanumeric() {
|
||||
has_non_ascii = true;
|
||||
if !previous_dash {
|
||||
output.push('-');
|
||||
previous_dash = true;
|
||||
}
|
||||
} else if !previous_dash {
|
||||
output.push('-');
|
||||
previous_dash = true;
|
||||
@@ -37,13 +44,56 @@ fn slug(value: &str, fallback: &str) -> String {
|
||||
}
|
||||
|
||||
let output = output.trim_matches('-').to_string();
|
||||
if output.is_empty() {
|
||||
fallback.to_string()
|
||||
let base = if output.is_empty() { fallback } else { &output };
|
||||
if has_non_ascii {
|
||||
format!("{base}-{:016x}", stable_hash(value.trim().as_bytes()))
|
||||
} 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 {
|
||||
let base = value.trim().rsplit(['\\', '/']).next().unwrap_or("").trim();
|
||||
base.strip_suffix(".exe")
|
||||
@@ -149,6 +199,15 @@ pub fn normalize_profile(input: ProfileInput) -> ValidationResult<Profile> {
|
||||
errors.push(error("items.value", "Укажите значение элемента профиля"));
|
||||
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 =
|
||||
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() {
|
||||
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 {
|
||||
errors.push(error("port", "Порт цели должен быть от 1 до 65535"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use proxywarden_lib::models::{
|
||||
self, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType,
|
||||
Protocol, ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use proxywarden_lib::proxifyre_ownership::ManagedProxiFyreOwnership;
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
@@ -358,7 +359,7 @@ fn proxifyre_uninstall_script_parses_as_powershell() {
|
||||
};
|
||||
|
||||
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"),
|
||||
);
|
||||
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_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("Отказываюсь запускать произвольный UninstallString"));
|
||||
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);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn singbox_runner_preserves_installer_args_with_spaces() {
|
||||
let script = commands::singbox_installer_runner_script(
|
||||
@@ -540,6 +569,20 @@ fn component_status_merges_detected_existing_proxifyre() {
|
||||
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]
|
||||
fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
|
||||
let root = test_root("detected-proxifyre");
|
||||
@@ -782,3 +825,10 @@ fn singbox_missing() -> ComponentStatus {
|
||||
actions: vec!["Установить локальный sing-box".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_ownership(remove_packet_filter: bool) -> ManagedProxiFyreOwnership {
|
||||
ManagedProxiFyreOwnership {
|
||||
service_name: "ProxiFyreService".to_string(),
|
||||
remove_packet_filter,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ fn detects_existing_proxifyre_from_registry_install_location() {
|
||||
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre")
|
||||
.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)
|
||||
.expect("existing ProxiFyre install should be detected");
|
||||
@@ -82,7 +85,10 @@ fn reports_stopped_proxifyre_service_when_executable_exists() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.with_stopped_service("ProxiFyreService");
|
||||
.with_stopped_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#,
|
||||
);
|
||||
|
||||
let 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"]);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn detects_running_local_singbox_from_default_install_root_and_service() {
|
||||
let host = MockHost::new()
|
||||
@@ -176,6 +213,7 @@ struct MockHost {
|
||||
paths: HashSet<String>,
|
||||
processes: HashSet<String>,
|
||||
services: HashMap<String, String>,
|
||||
service_paths: HashMap<String, String>,
|
||||
registry: Vec<RegistryInstallEntry>,
|
||||
}
|
||||
|
||||
@@ -205,9 +243,19 @@ impl MockHost {
|
||||
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
|
||||
.insert(service.to_ascii_lowercase(), "stopped".to_string());
|
||||
self.service_paths
|
||||
.insert(service.to_ascii_lowercase(), path_name.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -241,6 +289,20 @@ impl ProxyfierDetectionHost for MockHost {
|
||||
.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> {
|
||||
self.registry.clone()
|
||||
}
|
||||
|
||||
@@ -121,3 +121,91 @@ fn rejects_malformed_target_fields() {
|
||||
assert!(error.iter().any(|item| item.field == "protocol"));
|
||||
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]
|
||||
fn blocks_local_singbox_target_when_required_component_is_missing() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ fn blocks_config_when_server_is_not_selected() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let mut config = local_singbox_config("nl-1");
|
||||
config.selected_server_tag = None;
|
||||
config.selected_server_id = None;
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
@@ -108,8 +109,9 @@ fn blocks_config_when_server_is_not_selected() {
|
||||
#[test]
|
||||
fn blocks_config_when_selected_outbound_is_missing() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let config = local_singbox_config("missing-server");
|
||||
let cache = subscription_cache();
|
||||
let config = local_singbox_config("nl-1");
|
||||
let mut cache = subscription_cache();
|
||||
cache.config = serde_json::json!({ "outbounds": [] });
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
let error = adapter
|
||||
@@ -120,10 +122,67 @@ fn blocks_config_when_selected_outbound_is_missing() {
|
||||
.expect_err("missing outbound should block config");
|
||||
|
||||
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound);
|
||||
assert!(error.message.contains("missing-server"));
|
||||
assert!(error.message.contains("nl-1"));
|
||||
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]
|
||||
fn propagates_failed_singbox_check_as_structured_error() {
|
||||
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()),
|
||||
device_hwid: None,
|
||||
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_port: 1080,
|
||||
service_name: "ProxyWardenSingBox".to_string(),
|
||||
@@ -234,6 +294,7 @@ fn subscription_cache() -> SubscriptionCache {
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: "vless|nl-1|nl.example.test|443".to_string(),
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
|
||||
@@ -208,6 +208,7 @@ fn selects_server_from_cached_subscription() {
|
||||
let status = select_singbox_server_in_storage(
|
||||
&storage,
|
||||
SelectSingBoxServerInputDto {
|
||||
id: Some("trojan|de-1|de.example.test|443".to_string()),
|
||||
tag: "de-1".to_string(),
|
||||
server: None,
|
||||
server_port: None,
|
||||
@@ -220,7 +221,47 @@ fn selects_server_from_cached_subscription() {
|
||||
.expect("read local sing-box config");
|
||||
|
||||
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_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);
|
||||
}
|
||||
@@ -236,6 +277,7 @@ fn selects_server_by_endpoint_when_display_tag_is_sanitized() {
|
||||
let status = select_singbox_server_in_storage(
|
||||
&storage,
|
||||
SelectSingBoxServerInputDto {
|
||||
id: None,
|
||||
tag: "Умный".to_string(),
|
||||
server: Some("media.example.test".to_string()),
|
||||
server_port: Some(443),
|
||||
@@ -465,12 +507,14 @@ fn sample_cache() -> SubscriptionCache {
|
||||
}),
|
||||
servers: vec![
|
||||
SubscriptionServer {
|
||||
id: "vless|nl-1|nl.example.test|443".to_string(),
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
server_port: 443,
|
||||
},
|
||||
SubscriptionServer {
|
||||
id: "trojan|de-1|de.example.test|443".to_string(),
|
||||
tag: "de-1".to_string(),
|
||||
server_type: "trojan".to_string(),
|
||||
server: "de.example.test".to_string(),
|
||||
@@ -496,6 +540,7 @@ fn sample_cache_with_flag_tag() -> SubscriptionCache {
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: "vless|Умный 🇳🇱->🇷🇺|media.example.test|443".to_string(),
|
||||
tag: "Умный 🇳🇱->🇷🇺".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "media.example.test".to_string(),
|
||||
|
||||
@@ -54,6 +54,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
|
||||
device_hwid: Some("hwid-abcdef1234".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_port: 1080,
|
||||
service_name: "ProxyWardenSingBox".to_string(),
|
||||
@@ -133,6 +134,7 @@ fn reads_percent_encoded_singbox_tags_as_utf8() {
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: String::new(),
|
||||
tag: encoded_tag.to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
@@ -390,6 +392,7 @@ fn sample_subscription_cache() -> SubscriptionCache {
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: "vless|nl-1|nl.example.test|443".to_string(),
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use proxywarden_lib::models::redact_subscription_url;
|
||||
use proxywarden_lib::subscription::{
|
||||
self, parse_subscription_body, parse_user_info, SubscriptionFetchIdentity,
|
||||
SubscriptionFetchPolicy,
|
||||
self, parse_subscription_body, parse_user_info, validate_resolved_subscription_addresses,
|
||||
SubscriptionFetchIdentity, SubscriptionFetchPolicy,
|
||||
};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
@@ -27,6 +27,29 @@ fn parses_singbox_json_config_servers() {
|
||||
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]
|
||||
fn parses_base64_vless_link_list() {
|
||||
let link = sample_vless_link("nl-1");
|
||||
@@ -42,6 +65,45 @@ fn parses_base64_vless_link_list() {
|
||||
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]
|
||||
fn decodes_percent_encoded_vless_fragment_tag() {
|
||||
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]
|
||||
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");
|
||||
|
||||
+76
-92
@@ -1,4 +1,4 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
ActivityEntry,
|
||||
ComponentStatus,
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
SubscriptionServer,
|
||||
Target,
|
||||
TargetInput,
|
||||
} from '../domain/types';
|
||||
} from "../domain/types";
|
||||
|
||||
export interface CommandError {
|
||||
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 {
|
||||
isWindows: boolean;
|
||||
isElevated: boolean;
|
||||
@@ -67,8 +57,8 @@ export interface ProxiFyreSetupStatus {
|
||||
}
|
||||
|
||||
export interface ProxiFyreSetupProgress {
|
||||
operation: 'idle' | 'install' | 'uninstall' | string;
|
||||
status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
|
||||
operation: "idle" | "install" | "uninstall" | string;
|
||||
status: "idle" | "running" | "succeeded" | "failed" | string;
|
||||
activeStep?: string;
|
||||
percent: number;
|
||||
message: string;
|
||||
@@ -102,6 +92,7 @@ export interface SubscriptionRequestHeader {
|
||||
}
|
||||
|
||||
export interface PingServerResponse {
|
||||
id: string;
|
||||
tag: string;
|
||||
server: string;
|
||||
serverPort: number;
|
||||
@@ -110,6 +101,34 @@ export interface PingServerResponse {
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -147,98 +166,60 @@ export interface GenerateSingBoxConfigResponse {
|
||||
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> {
|
||||
return invoke<void>('restart_as_admin');
|
||||
return invoke<void>("restart_as_admin");
|
||||
}
|
||||
|
||||
export function getStartupSnapshot(): Promise<StartupSnapshotResponse> {
|
||||
return invoke<StartupSnapshotResponse>('get_startup_snapshot');
|
||||
return invoke<StartupSnapshotResponse>("get_startup_snapshot");
|
||||
}
|
||||
|
||||
export function getSavedState(): Promise<SavedStateResponse> {
|
||||
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 });
|
||||
return invoke<SavedStateResponse>("get_saved_state");
|
||||
}
|
||||
|
||||
export function getComponents(): Promise<ComponentStatus[]> {
|
||||
return invoke<ComponentStatus[]>('get_components');
|
||||
return invoke<ComponentStatus[]>("get_components");
|
||||
}
|
||||
|
||||
export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
|
||||
return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status');
|
||||
return invoke<ProxiFyreSetupStatus>("get_proxifyre_setup_status");
|
||||
}
|
||||
|
||||
export function getProxiFyreSetupProgress(): Promise<ProxiFyreSetupProgress> {
|
||||
return invoke<ProxiFyreSetupProgress>('get_proxifyre_setup_progress');
|
||||
return invoke<ProxiFyreSetupProgress>("get_proxifyre_setup_progress");
|
||||
}
|
||||
|
||||
export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('get_singbox_status');
|
||||
return invoke<LocalSingBoxStatusResponse>("get_singbox_status");
|
||||
}
|
||||
|
||||
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> {
|
||||
return invoke<LocalSingBoxStatusResponse>('save_singbox_subscription', {
|
||||
export function saveSingBoxSubscription(
|
||||
subscriptionUrl: string,
|
||||
): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>("save_singbox_subscription", {
|
||||
input: { subscriptionUrl },
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('fetch_singbox_subscription');
|
||||
return invoke<LocalSingBoxStatusResponse>("fetch_singbox_subscription");
|
||||
}
|
||||
|
||||
export function forgetSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('forget_singbox_subscription');
|
||||
return invoke<LocalSingBoxStatusResponse>("forget_singbox_subscription");
|
||||
}
|
||||
|
||||
export function selectSingBoxServer(server: SubscriptionServer): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('select_singbox_server', {
|
||||
export function selectSingBoxServer(
|
||||
server: SubscriptionServer,
|
||||
): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>("select_singbox_server", {
|
||||
input: {
|
||||
id: server.id,
|
||||
tag: server.tag,
|
||||
server: server.server,
|
||||
serverPort: server.serverPort,
|
||||
@@ -246,62 +227,65 @@ export function selectSingBoxServer(server: SubscriptionServer): Promise<LocalSi
|
||||
});
|
||||
}
|
||||
|
||||
export function pingSingBoxServer(tag: string): Promise<PingServerResponse> {
|
||||
return invoke<PingServerResponse>('ping_singbox_server', {
|
||||
input: { tag },
|
||||
export function pingSingBoxServer(
|
||||
server: SubscriptionServer,
|
||||
): Promise<PingServerResponse> {
|
||||
return invoke<PingServerResponse>("ping_singbox_server", {
|
||||
input: { id: server.id, tag: server.tag },
|
||||
});
|
||||
}
|
||||
|
||||
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> {
|
||||
return invoke<ProxyTargetCheckResponse>('ping_proxy_target', {
|
||||
export function pingProxyTarget(
|
||||
host: string,
|
||||
port: number,
|
||||
): Promise<ProxyTargetCheckResponse> {
|
||||
return invoke<ProxyTargetCheckResponse>("ping_proxy_target", {
|
||||
input: { host, port },
|
||||
});
|
||||
}
|
||||
|
||||
export function generateSingBoxConfig(): Promise<GenerateSingBoxConfigResponse> {
|
||||
return invoke<GenerateSingBoxConfigResponse>('generate_singbox_config');
|
||||
return invoke<GenerateSingBoxConfigResponse>("generate_singbox_config");
|
||||
}
|
||||
|
||||
export function applyProfiles(): Promise<ApplyProfilesResponse> {
|
||||
return invoke<ApplyProfilesResponse>('apply_profiles');
|
||||
}
|
||||
|
||||
export function openConfigLocation(): Promise<string> {
|
||||
return invoke<string>('open_config_location');
|
||||
export function applyConfiguration(
|
||||
input: ApplyConfigurationInput,
|
||||
): Promise<ApplyConfigurationResult> {
|
||||
return invoke<ApplyConfigurationResult>("apply_configuration", { input });
|
||||
}
|
||||
|
||||
export function startProxiFyreService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('start_proxifyre_service');
|
||||
return invoke<ComponentStatus>("start_proxifyre_service");
|
||||
}
|
||||
|
||||
export function stopProxiFyreService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('stop_proxifyre_service');
|
||||
return invoke<ComponentStatus>("stop_proxifyre_service");
|
||||
}
|
||||
|
||||
export function installProxiFyre(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('install_proxifyre');
|
||||
return invoke<ComponentStatus>("install_proxifyre");
|
||||
}
|
||||
|
||||
export function uninstallProxiFyre(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('uninstall_proxifyre');
|
||||
return invoke<ComponentStatus>("uninstall_proxifyre");
|
||||
}
|
||||
|
||||
export function startSingBoxService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('start_singbox_service');
|
||||
return invoke<ComponentStatus>("start_singbox_service");
|
||||
}
|
||||
|
||||
export function stopSingBoxService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('stop_singbox_service');
|
||||
return invoke<ComponentStatus>("stop_singbox_service");
|
||||
}
|
||||
|
||||
export function installSingBox(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('install_singbox');
|
||||
return invoke<ComponentStatus>("install_singbox");
|
||||
}
|
||||
|
||||
export function uninstallSingBox(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('uninstall_singbox');
|
||||
return invoke<ComponentStatus>("uninstall_singbox");
|
||||
}
|
||||
|
||||
+644
-1583
File diff suppressed because it is too large
Load Diff
@@ -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("Проверь состояние файлов");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,47 @@
|
||||
import type { ProxiFyreSetupProgress, ProxiFyreSetupStatus } from '../../api/tauriCommands';
|
||||
import type {
|
||||
ProxiFyreSetupProgress,
|
||||
ProxiFyreSetupStatus,
|
||||
} from "../../api/tauriCommands";
|
||||
|
||||
interface ProxiFyreSetupStripProps {
|
||||
setupStatus: ProxiFyreSetupStatus | null;
|
||||
progress: ProxiFyreSetupProgress | null;
|
||||
}
|
||||
|
||||
const SETUP_PLACEHOLDERS: ProxiFyreSetupStatus['items'] = [
|
||||
{ id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' },
|
||||
{ id: 'packet-filter', name: 'Сетевой драйвер', installed: false, details: 'Проверяю' },
|
||||
{ id: 'proxifyre', name: 'Клиент ProxiFyre', installed: false, details: 'Проверяю' },
|
||||
const SETUP_PLACEHOLDERS: ProxiFyreSetupStatus["items"] = [
|
||||
{
|
||||
id: "vc-runtime",
|
||||
name: "Среда запуска",
|
||||
installed: false,
|
||||
details: "Проверяю",
|
||||
},
|
||||
{
|
||||
id: "packet-filter",
|
||||
name: "Сетевой драйвер",
|
||||
installed: false,
|
||||
details: "Проверяю",
|
||||
},
|
||||
{
|
||||
id: "proxifyre",
|
||||
name: "Клиент ProxiFyre",
|
||||
installed: false,
|
||||
details: "Проверяю",
|
||||
},
|
||||
];
|
||||
|
||||
export function ProxiFyreSetupStrip({ setupStatus, progress }: ProxiFyreSetupStripProps) {
|
||||
export function ProxiFyreSetupStrip({
|
||||
setupStatus,
|
||||
progress,
|
||||
}: ProxiFyreSetupStripProps) {
|
||||
const stripItems = setupStatus?.items ?? SETUP_PLACEHOLDERS;
|
||||
const visibleProgress = isVisibleProgress(progress) ? progress : null;
|
||||
const progressTone = visibleProgress?.status === 'failed' ? 'failed' : 'running';
|
||||
const progressTone =
|
||||
visibleProgress?.status === "failed" ? "failed" : "running";
|
||||
const percent = clampPercent(visibleProgress?.percent ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`setup-strip ${setupStatus?.ready ? 'ready' : 'attention'} ${visibleProgress ? 'with-progress' : ''}`}
|
||||
className={`setup-strip ${setupStatus?.ready ? "ready" : "attention"} ${visibleProgress ? "with-progress" : ""}`}
|
||||
aria-label="Состав ProxiFyre"
|
||||
>
|
||||
<span className="setup-strip-title">Состав</span>
|
||||
@@ -45,9 +67,14 @@ export function ProxiFyreSetupStrip({ setupStatus, progress }: ProxiFyreSetupStr
|
||||
aria-valuenow={percent}
|
||||
aria-label={visibleProgress.message}
|
||||
>
|
||||
<span className="setup-progress-fill" style={{ width: `${percent}%` }} />
|
||||
<span
|
||||
className="setup-progress-fill"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="setup-progress-message">{visibleProgress.message}</span>
|
||||
<span className="setup-progress-message">
|
||||
{visibleProgress.message}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -55,52 +82,59 @@ export function ProxiFyreSetupStrip({ setupStatus, progress }: ProxiFyreSetupStr
|
||||
}
|
||||
|
||||
function setupItemClass(
|
||||
item: ProxiFyreSetupStatus['items'][number],
|
||||
item: ProxiFyreSetupStatus["items"][number],
|
||||
progress: ProxiFyreSetupProgress | null,
|
||||
) {
|
||||
if (progress?.activeStep === item.id) {
|
||||
if (progress.status === 'failed') return 'failed';
|
||||
return 'active';
|
||||
if (progress.status === "failed") return "failed";
|
||||
return "active";
|
||||
}
|
||||
if (item.installed) return 'installed';
|
||||
return 'missing';
|
||||
if (item.installed) return "installed";
|
||||
return "missing";
|
||||
}
|
||||
|
||||
function setupItemUserName(id: string, fallbackName: string) {
|
||||
if (id === 'vc-runtime') return 'Среда запуска';
|
||||
if (id === 'packet-filter') return 'Сетевой драйвер';
|
||||
if (id === 'proxifyre') return 'Клиент ProxiFyre';
|
||||
if (id === "vc-runtime") return "Среда запуска";
|
||||
if (id === "packet-filter") return "Сетевой драйвер";
|
||||
if (id === "proxifyre") return "Клиент ProxiFyre";
|
||||
return fallbackName;
|
||||
}
|
||||
|
||||
function setupItemShortStatus(
|
||||
item: ProxiFyreSetupStatus['items'][number],
|
||||
item: ProxiFyreSetupStatus["items"][number],
|
||||
progress: ProxiFyreSetupProgress | null,
|
||||
) {
|
||||
if (progress?.activeStep === item.id) {
|
||||
if (progress.status === 'failed') return 'ошибка';
|
||||
if (progress.status === 'succeeded') return progress.operation === 'uninstall' ? 'удалено' : 'готово';
|
||||
return 'в процессе';
|
||||
if (progress.status === "failed") return "ошибка";
|
||||
if (progress.status === "succeeded")
|
||||
return progress.operation === "uninstall" ? "удалено" : "готово";
|
||||
return "в процессе";
|
||||
}
|
||||
if (item.details === 'Проверяю') return 'проверяю';
|
||||
if (!item.installed) return progress?.operation === 'uninstall' && progress.status === 'succeeded'
|
||||
? 'удалено'
|
||||
: 'нужно установить';
|
||||
if (item.id === 'proxifyre') return proxifyreSetupServiceSummary(item.version);
|
||||
return 'готово';
|
||||
if (item.details === "Проверяю") return "проверяю";
|
||||
if (!item.installed)
|
||||
return progress?.operation === "uninstall" &&
|
||||
progress.status === "succeeded"
|
||||
? "удалено"
|
||||
: "нужно установить";
|
||||
if (item.id === "proxifyre")
|
||||
return proxifyreSetupServiceSummary(item.version);
|
||||
return "готово";
|
||||
}
|
||||
|
||||
function proxifyreSetupServiceSummary(version: string | undefined) {
|
||||
const normalized = version?.trim().toLowerCase() ?? '';
|
||||
if (normalized.includes('не установлена')) return 'служба не установлена';
|
||||
if (normalized.includes('остановлена') || normalized.includes('не запущена')) return 'служба остановлена';
|
||||
if (normalized.includes('запущена')) return 'служба запущена';
|
||||
return 'готово';
|
||||
const normalized = version?.trim().toLowerCase() ?? "";
|
||||
if (normalized.includes("не установлена")) return "служба не установлена";
|
||||
if (normalized.includes("остановлена") || normalized.includes("не запущена"))
|
||||
return "служба остановлена";
|
||||
if (normalized.includes("запущена")) return "служба запущена";
|
||||
return "готово";
|
||||
}
|
||||
|
||||
function isVisibleProgress(progress: ProxiFyreSetupProgress | null): progress is ProxiFyreSetupProgress {
|
||||
if (!progress || progress.status === 'idle') return false;
|
||||
return progress.status === 'running' || progress.status === 'failed';
|
||||
function isVisibleProgress(
|
||||
progress: ProxiFyreSetupProgress | null,
|
||||
): progress is ProxiFyreSetupProgress {
|
||||
if (!progress || progress.status === "idle") return false;
|
||||
return progress.status === "running" || progress.status === "failed";
|
||||
}
|
||||
|
||||
function clampPercent(value: number) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -1,43 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseProxy } from './parseProxy';
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseProxy } from "./parseProxy";
|
||||
|
||||
describe('parseProxy', () => {
|
||||
it('parses host and port without explicit protocol', () => {
|
||||
expect(parseProxy('proxy.example.test:1080')).toEqual({
|
||||
protocol: 'socks5',
|
||||
host: 'proxy.example.test',
|
||||
describe("parseProxy", () => {
|
||||
it("parses host and port without explicit protocol", () => {
|
||||
expect(parseProxy("proxy.example.test:1080")).toEqual({
|
||||
protocol: "socks5",
|
||||
host: "proxy.example.test",
|
||||
port: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses socks5 URLs', () => {
|
||||
expect(parseProxy('socks5://127.0.0.1:1080')).toEqual({
|
||||
protocol: 'socks5',
|
||||
host: '127.0.0.1',
|
||||
it("parses socks5 URLs", () => {
|
||||
expect(parseProxy("socks5://127.0.0.1:1080")).toEqual({
|
||||
protocol: "socks5",
|
||||
host: "127.0.0.1",
|
||||
port: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses bracketed IPv6 hosts', () => {
|
||||
expect(parseProxy('socks5://[::1]:1080')).toEqual({
|
||||
protocol: 'socks5',
|
||||
host: '::1',
|
||||
it("parses bracketed IPv6 hosts", () => {
|
||||
expect(parseProxy("socks5://[::1]:1080")).toEqual({
|
||||
protocol: "socks5",
|
||||
host: "::1",
|
||||
port: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsupported schemes', () => {
|
||||
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 unsupported schemes", () => {
|
||||
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("логином и паролем");
|
||||
});
|
||||
});
|
||||
|
||||
+13
-11
@@ -1,34 +1,36 @@
|
||||
export interface ParsedProxy {
|
||||
protocol: 'socks5';
|
||||
protocol: "socks5";
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function parseProxy(rawValue: string): ParsedProxy {
|
||||
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;
|
||||
try {
|
||||
parsed = new URL(withProtocol);
|
||||
} catch {
|
||||
throw new Error('Формат: socks5://host:port или host:port.');
|
||||
throw new Error("Формат: socks5://host:port или host:port.");
|
||||
}
|
||||
|
||||
const protocol = parsed.protocol.replace(':', '').toLowerCase();
|
||||
if (protocol !== 'socks5') {
|
||||
throw new Error('Сейчас поддерживается только SOCKS5.');
|
||||
const protocol = parsed.protocol.replace(":", "").toLowerCase();
|
||||
if (protocol !== "socks5") {
|
||||
throw new Error("Сейчас поддерживается только SOCKS5.");
|
||||
}
|
||||
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);
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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-файл";
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -1,10 +1,11 @@
|
||||
export type RouteMode = 'external' | 'local-singbox';
|
||||
export type RouteMode = "external" | "local-singbox";
|
||||
|
||||
export interface ApplyReadinessInput {
|
||||
routeMode: RouteMode;
|
||||
appCount: number;
|
||||
proxiFyreInstalled: boolean;
|
||||
singBoxInstalled: boolean;
|
||||
singBoxRunning: boolean;
|
||||
selectedServerTag?: string;
|
||||
externalProxyValue: string;
|
||||
externalProxyError?: string | null;
|
||||
@@ -21,63 +22,70 @@ export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness {
|
||||
if (input.busy) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Операция уже выполняется',
|
||||
text: 'Дождись завершения текущего действия перед повторным применением.',
|
||||
title: "Операция уже выполняется",
|
||||
text: "Дождись завершения текущего действия перед повторным применением.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.proxiFyreInstalled) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'ProxiFyre не установлен',
|
||||
text: 'Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.',
|
||||
title: "ProxiFyre не установлен",
|
||||
text: "Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.appCount < 1) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Нет приложений',
|
||||
text: 'Добавь хотя бы один процесс, EXE-файл или папку.',
|
||||
title: "Нет приложений",
|
||||
text: "Добавь хотя бы один процесс, EXE-файл или папку.",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.routeMode === 'external') {
|
||||
if (input.routeMode === "external") {
|
||||
if (!input.externalProxyValue.trim()) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Прокси не указан',
|
||||
text: 'Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.',
|
||||
title: "Прокси не указан",
|
||||
text: "Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.externalProxyError) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Проверь формат прокси',
|
||||
title: "Проверь формат прокси",
|
||||
text: input.externalProxyError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (input.routeMode === 'local-singbox') {
|
||||
if (input.routeMode === "local-singbox") {
|
||||
if (!input.singBoxInstalled) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Local sing-box не установлен',
|
||||
text: 'Установи Local sing-box, чтобы применить локальный маршрут.',
|
||||
title: "Local sing-box не установлен",
|
||||
text: "Установи Local sing-box, чтобы применить локальный маршрут.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.singBoxRunning) {
|
||||
return {
|
||||
ready: false,
|
||||
title: "Local sing-box остановлен",
|
||||
text: "Явно запусти службу Local sing-box перед применением маршрута.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.selectedServerTag) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Сервер не выбран',
|
||||
text: 'Выбери сервер Local sing-box перед применением маршрута.',
|
||||
title: "Сервер не выбран",
|
||||
text: "Выбери сервер Local sing-box перед применением маршрута.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
|
||||
+1057
-9
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
@@ -1,10 +1,11 @@
|
||||
export type Protocol = 'TCP' | 'UDP';
|
||||
export type ProfileItemType = 'process' | 'folder' | 'exe';
|
||||
export type TargetKind = 'local' | 'external';
|
||||
export type ProxyProtocol = 'socks5' | 'http';
|
||||
export type ComponentId = 'control-app' | 'proxyfier' | 'singbox';
|
||||
export type ComponentState = 'installed' | 'missing' | 'stopped' | 'running' | 'error';
|
||||
export type ActivityLevel = 'info' | 'warning' | 'error' | 'success';
|
||||
export type Protocol = "TCP" | "UDP";
|
||||
export type ProfileItemType = "process" | "folder" | "exe";
|
||||
export type TargetKind = "local" | "external";
|
||||
export type ProxyProtocol = "socks5" | "http";
|
||||
export type ComponentId = "control-app" | "proxyfier" | "singbox";
|
||||
export type ComponentState =
|
||||
"installed" | "missing" | "stopped" | "running" | "error";
|
||||
export type ActivityLevel = "info" | "warning" | "error" | "success";
|
||||
|
||||
export interface ProfileItemInput {
|
||||
type: ProfileItemType | string;
|
||||
@@ -74,6 +75,7 @@ export interface LocalSingBoxConfig {
|
||||
subscriptionDisplayUrl?: string;
|
||||
hasSubscription: boolean;
|
||||
selectedServerTag?: string;
|
||||
selectedServerId?: string;
|
||||
listenHost: string;
|
||||
listenPort: number;
|
||||
serviceName: string;
|
||||
@@ -82,6 +84,7 @@ export interface LocalSingBoxConfig {
|
||||
}
|
||||
|
||||
export interface SubscriptionServer {
|
||||
id: string;
|
||||
tag: string;
|
||||
type: string;
|
||||
server: string;
|
||||
|
||||
+6
-7
@@ -1,12 +1,11 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@fontsource-variable/jetbrains-mono';
|
||||
import { App } from './app/App';
|
||||
import './styles/app.css';
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@fontsource-variable/jetbrains-mono";
|
||||
import { App } from "./app/App";
|
||||
import "./styles/app.css";
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
|
||||
+136
-25
@@ -1,3 +1,4 @@
|
||||
/* Foundations: tokens, document defaults, and scrollbars. */
|
||||
:root {
|
||||
--app-footer-height: 54px;
|
||||
--app-change-dock-height: 0px;
|
||||
@@ -5,7 +6,9 @@
|
||||
--change-row-count: 1;
|
||||
--app-header-row-height: 0px;
|
||||
--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-standard: 180ms;
|
||||
--motion-panel: 220ms;
|
||||
@@ -24,8 +27,8 @@
|
||||
--border-strong: #343b49;
|
||||
--focus-ring: #3b82f6;
|
||||
font-family:
|
||||
"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular,
|
||||
Consolas, "Liberation Mono", monospace;
|
||||
color: #e5e7eb;
|
||||
background: #101216;
|
||||
font-synthesis: none;
|
||||
@@ -93,6 +96,7 @@ button:disabled {
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
/* UI primitives shared by buttons, menus, popovers, tabs, and service rows. */
|
||||
.ui-button,
|
||||
.ui-icon-button {
|
||||
appearance: none;
|
||||
@@ -357,14 +361,26 @@ button:disabled {
|
||||
.ui-busy-ring-segment.bottom {
|
||||
width: var(--busy-ring-long);
|
||||
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.left {
|
||||
width: var(--busy-ring-thickness);
|
||||
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 {
|
||||
@@ -639,14 +655,26 @@ button:disabled {
|
||||
.ui-service-border-glow-segment.bottom {
|
||||
width: var(--busy-ring-long);
|
||||
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.left {
|
||||
width: var(--busy-ring-thickness);
|
||||
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 {
|
||||
@@ -758,16 +786,25 @@ button:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
/* Application shell, tab panels, and shared workspace layout. */
|
||||
.simple-shell {
|
||||
display: block;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
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 {
|
||||
--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 {
|
||||
@@ -779,7 +816,10 @@ button:disabled {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
align-content: stretch;
|
||||
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%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
@@ -1076,6 +1116,7 @@ button:disabled {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Read-only summary panel and power state. */
|
||||
.summary-main {
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
@@ -1116,9 +1157,14 @@ button:disabled {
|
||||
width: clamp(180px, 30vw, 268px);
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border: 1px solid #3f4b5e;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
background: radial-gradient(
|
||||
circle at 48% 38%,
|
||||
#263244 0%,
|
||||
#151c28 62%,
|
||||
#0c1119 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 22px 54px rgba(0, 0, 0, 0.48),
|
||||
0 0 38px rgba(59, 130, 246, 0.1);
|
||||
@@ -1154,17 +1200,42 @@ button:disabled {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.summary-toggle-button img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.summary-toggle-face {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 78%;
|
||||
aspect-ratio: 1;
|
||||
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;
|
||||
transform: scale(1.18);
|
||||
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 {
|
||||
color: #e5e7eb;
|
||||
font-size: clamp(22px, 3.4vw, 34px);
|
||||
@@ -1173,6 +1244,14 @@ button:disabled {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.network-disclosure {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: #8d99ae;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.summary-state-label.ok {
|
||||
color: #bbf7d0;
|
||||
}
|
||||
@@ -1422,14 +1501,26 @@ button.summary-card:hover {
|
||||
.finder-border-glow-segment.bottom {
|
||||
width: var(--busy-ring-long);
|
||||
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.left {
|
||||
width: var(--busy-ring-thickness);
|
||||
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 {
|
||||
@@ -1708,9 +1799,12 @@ button.summary-card:hover {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Proxy endpoint inputs, subscription workspace, and route checks. */
|
||||
.connection-check {
|
||||
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;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
@@ -2081,6 +2175,7 @@ button.summary-card:hover {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Route-chain visualization and packet states. */
|
||||
.route-chain {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -2300,6 +2395,7 @@ button.summary-card:hover {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Pending configuration diff and apply controls. */
|
||||
.changes-dock {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
@@ -2730,6 +2826,7 @@ button.summary-card:hover {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Activity log dock and administrator prompt. */
|
||||
.log-dock {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
@@ -2766,7 +2863,9 @@ button.summary-card:hover {
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
padding: 0 8px;
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.log-current.hidden {
|
||||
@@ -2880,6 +2979,7 @@ button.summary-card:hover {
|
||||
color: #b6c2d4;
|
||||
}
|
||||
|
||||
/* Motion and responsive overrides. */
|
||||
@keyframes finder-border-top {
|
||||
0% {
|
||||
left: -116px;
|
||||
@@ -3014,11 +3114,19 @@ button.summary-card:hover {
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.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 {
|
||||
--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 {
|
||||
@@ -3026,7 +3134,10 @@ button.summary-card:hover {
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
+56
-49
@@ -1,4 +1,4 @@
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconButton } from './IconButton';
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface ActionMenuItem {
|
||||
label: string;
|
||||
@@ -29,7 +29,7 @@ interface ActionMenuPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
placement: 'top' | 'bottom';
|
||||
placement: "top" | "bottom";
|
||||
}
|
||||
|
||||
const MENU_WIDTH = 190;
|
||||
@@ -50,7 +50,7 @@ export function ActionMenu({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: MENU_WIDTH,
|
||||
placement: 'bottom',
|
||||
placement: "bottom",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,22 +65,28 @@ export function ActionMenu({
|
||||
if (!trigger) return;
|
||||
|
||||
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 left = Math.max(
|
||||
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 placement: ActionMenuPosition['placement'] = 'bottom';
|
||||
let placement: ActionMenuPosition["placement"] = "bottom";
|
||||
|
||||
if (
|
||||
popoverHeight
|
||||
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
|
||||
&& rect.top > popoverHeight + VIEWPORT_MARGIN + MENU_OFFSET
|
||||
popoverHeight &&
|
||||
top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN &&
|
||||
rect.top > popoverHeight + VIEWPORT_MARGIN + MENU_OFFSET
|
||||
) {
|
||||
top = rect.top - popoverHeight - MENU_OFFSET;
|
||||
placement = 'top';
|
||||
placement = "top";
|
||||
}
|
||||
|
||||
const maxTop = popoverHeight
|
||||
@@ -97,13 +103,13 @@ export function ActionMenu({
|
||||
|
||||
updatePosition();
|
||||
const frame = window.requestAnimationFrame(updatePosition);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -118,16 +124,16 @@ export function ActionMenu({
|
||||
};
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (event.key !== "Escape") return;
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [onOpenChange, open]);
|
||||
|
||||
@@ -148,34 +154,35 @@ export function ActionMenu({
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
/>
|
||||
{open && typeof document !== 'undefined' ? createPortal(
|
||||
<div
|
||||
className="ui-action-menu-popover"
|
||||
data-placement={position.placement}
|
||||
id={menuId}
|
||||
ref={popoverRef}
|
||||
role="menu"
|
||||
style={popoverStyle}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item.danger ? 'is-danger' : ''}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
item.onClick();
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
key={item.label}
|
||||
{open && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
className="ui-action-menu-popover"
|
||||
data-placement={position.placement}
|
||||
id={menuId}
|
||||
ref={popoverRef}
|
||||
role="menu"
|
||||
style={popoverStyle}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
) : null}
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item.danger ? "is-danger" : ""}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
item.onClick();
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
key={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ export interface 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 (
|
||||
<span className={classes} aria-hidden="true">
|
||||
|
||||
+23
-14
@@ -1,8 +1,8 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { BusyRing } from './BusyRing';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { BusyRing } from "./BusyRing";
|
||||
|
||||
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
export type ButtonVariant = "primary" | "neutral" | "add" | "danger";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
@@ -14,8 +14,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'neutral',
|
||||
size = 'md',
|
||||
variant = "neutral",
|
||||
size = "md",
|
||||
loading = false,
|
||||
loadingLabel,
|
||||
leftIcon,
|
||||
@@ -26,12 +26,14 @@ export function Button({
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const classes = [
|
||||
'ui-button',
|
||||
"ui-button",
|
||||
`ui-button--${variant}`,
|
||||
`ui-button--${size}`,
|
||||
loading ? 'is-loading' : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
loading ? "is-loading" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -42,11 +44,18 @@ export function Button({
|
||||
>
|
||||
{loading ? <BusyRing /> : null}
|
||||
{!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}
|
||||
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>
|
||||
{!loading && rightIcon ? <span className="ui-button-icon" aria-hidden="true">{rightIcon}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+45
-31
@@ -8,12 +8,15 @@ import {
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
} from "react";
|
||||
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[];
|
||||
children: ReactNode;
|
||||
popoverLabel?: string;
|
||||
@@ -26,7 +29,7 @@ interface DetailsPopoverPosition {
|
||||
left: number;
|
||||
width: number;
|
||||
arrowLeft: number;
|
||||
placement: 'top' | 'bottom';
|
||||
placement: "top" | "bottom";
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
@@ -35,8 +38,8 @@ export function DetailsPopover({
|
||||
details,
|
||||
children,
|
||||
className,
|
||||
popoverLabel = 'Детали',
|
||||
align = 'start',
|
||||
popoverLabel = "Детали",
|
||||
align = "start",
|
||||
maxWidth = 360,
|
||||
disabled,
|
||||
onClick,
|
||||
@@ -51,12 +54,14 @@ export function DetailsPopover({
|
||||
left: 0,
|
||||
width: Math.min(maxWidth, 360),
|
||||
arrowLeft: 24,
|
||||
placement: 'bottom',
|
||||
placement: "bottom",
|
||||
});
|
||||
const detailLines = Array.isArray(details)
|
||||
? 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(() => {
|
||||
if (disabled && open) setOpen(false);
|
||||
@@ -70,26 +75,35 @@ export function DetailsPopover({
|
||||
if (!trigger) return;
|
||||
|
||||
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;
|
||||
if (align === 'center') left = rect.left + rect.width / 2 - width / 2;
|
||||
if (align === 'end') left = rect.right - width;
|
||||
if (align === "center") left = rect.left + rect.width / 2 - width / 2;
|
||||
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;
|
||||
let top = rect.bottom + 8;
|
||||
let placement: DetailsPopoverPosition['placement'] = 'bottom';
|
||||
let placement: DetailsPopoverPosition["placement"] = "bottom";
|
||||
|
||||
if (
|
||||
popoverHeight
|
||||
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
|
||||
&& rect.top > popoverHeight + VIEWPORT_MARGIN + 8
|
||||
popoverHeight &&
|
||||
top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN &&
|
||||
rect.top > popoverHeight + VIEWPORT_MARGIN + 8
|
||||
) {
|
||||
top = rect.top - popoverHeight - 8;
|
||||
placement = 'top';
|
||||
placement = "top";
|
||||
} 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(
|
||||
@@ -108,13 +122,13 @@ export function DetailsPopover({
|
||||
|
||||
updatePosition();
|
||||
const frame = window.requestAnimationFrame(updatePosition);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [align, maxWidth, open]);
|
||||
|
||||
@@ -129,17 +143,17 @@ export function DetailsPopover({
|
||||
};
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (event.key !== "Escape") return;
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -152,7 +166,7 @@ export function DetailsPopover({
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: position.width,
|
||||
'--details-popover-arrow-left': `${position.arrowLeft}px`,
|
||||
"--details-popover-arrow-left": `${position.arrowLeft}px`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
@@ -160,7 +174,7 @@ export function DetailsPopover({
|
||||
<button
|
||||
{...props}
|
||||
ref={triggerRef}
|
||||
type={props.type ?? 'button'}
|
||||
type={props.type ?? "button"}
|
||||
className={classes}
|
||||
aria-controls={open ? detailsId : undefined}
|
||||
aria-expanded={open}
|
||||
@@ -170,7 +184,7 @@ export function DetailsPopover({
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{open && detailLines.length && typeof document !== 'undefined'
|
||||
{open && detailLines.length && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
|
||||
+11
-5
@@ -1,4 +1,4 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
@@ -16,11 +16,11 @@ export function Field({
|
||||
id,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
const helpId = `${inputId}-help`;
|
||||
|
||||
return (
|
||||
<label className={`ui-field ${className ?? ''}`.trim()} htmlFor={inputId}>
|
||||
<label className={`ui-field ${className ?? ""}`.trim()} htmlFor={inputId}>
|
||||
<span className="ui-field-label">{label}</span>
|
||||
<div className="ui-field-row">
|
||||
<input
|
||||
@@ -31,8 +31,14 @@ export function Field({
|
||||
/>
|
||||
{action}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface HoverDetailsProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
details: string | string[];
|
||||
@@ -12,9 +12,11 @@ export function HoverDetails({
|
||||
...props
|
||||
}: HoverDetailsProps) {
|
||||
const detailText = Array.isArray(details)
|
||||
? details.filter(Boolean).join('\n')
|
||||
? details.filter(Boolean).join("\n")
|
||||
: details;
|
||||
const classes = ['ui-hover-details', className ?? ''].filter(Boolean).join(' ');
|
||||
const classes = ["ui-hover-details", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
+16
-12
@@ -1,9 +1,12 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { BusyRing } from './BusyRing';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
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;
|
||||
icon: ReactNode;
|
||||
variant?: IconButtonVariant;
|
||||
@@ -14,25 +17,27 @@ export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
|
||||
export function IconButton({
|
||||
label,
|
||||
icon,
|
||||
variant = 'neutral',
|
||||
variant = "neutral",
|
||||
loading = false,
|
||||
tooltip,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
const tooltipText = tooltip === '' ? undefined : tooltip ?? label;
|
||||
const tooltipText = tooltip === "" ? undefined : (tooltip ?? label);
|
||||
const classes = [
|
||||
'ui-icon-button',
|
||||
"ui-icon-button",
|
||||
`ui-icon-button--${variant}`,
|
||||
loading ? 'is-loading' : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
loading ? "is-loading" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type={props.type ?? 'button'}
|
||||
type={props.type ?? "button"}
|
||||
className={classes}
|
||||
aria-label={label}
|
||||
data-tooltip={tooltipText}
|
||||
@@ -44,4 +49,3 @@ export function IconButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+40
-21
@@ -1,8 +1,8 @@
|
||||
import { Button } from './Button';
|
||||
import { Button } from "./Button";
|
||||
|
||||
export interface LogDockEntry {
|
||||
id: string;
|
||||
kind: 'success' | 'error' | 'info';
|
||||
kind: "success" | "error" | "info";
|
||||
title: string;
|
||||
text: string;
|
||||
at: number;
|
||||
@@ -18,7 +18,10 @@ export interface LogDockProps {
|
||||
|
||||
function isNativePreviewError(entry: LogDockEntry | null) {
|
||||
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) {
|
||||
@@ -26,8 +29,8 @@ function displayEntry(entry: LogDockEntry | null) {
|
||||
if (!isNativePreviewError(entry)) return entry;
|
||||
return {
|
||||
...entry,
|
||||
title: 'Desktop-команды недоступны',
|
||||
text: 'Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.',
|
||||
title: "Desktop-команды недоступны",
|
||||
text: "Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,8 +44,11 @@ export function LogDock({
|
||||
const current = displayEntry(activeEntry);
|
||||
|
||||
return (
|
||||
<footer className={`log-dock ${current?.kind ?? 'idle'}`} aria-live="polite">
|
||||
<div className={`log-current ${current ? 'visible' : 'hidden'}`}>
|
||||
<footer
|
||||
className={`log-dock ${current?.kind ?? "idle"}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className={`log-current ${current ? "visible" : "hidden"}`}>
|
||||
{current ? (
|
||||
<>
|
||||
<strong>{current.title}</strong>
|
||||
@@ -52,24 +58,37 @@ export function LogDock({
|
||||
<span className="log-muted">Журнал событий</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" variant="neutral" size="sm" className="log-toggle" onClick={onToggle}>
|
||||
{open ? 'Скрыть' : 'Посмотреть'} <span className="log-count">{entries.length}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
size="sm"
|
||||
className="log-toggle"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{open ? "Скрыть" : "Посмотреть"}{" "}
|
||||
<span className="log-count">{entries.length}</span>
|
||||
</Button>
|
||||
{open ? (
|
||||
<div className="log-history">
|
||||
{entries.length ? entries.map((entry) => {
|
||||
const friendly = displayEntry(entry);
|
||||
return (
|
||||
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
|
||||
<time>{formatTime(entry.at)}</time>
|
||||
<div>
|
||||
<strong>{friendly?.title ?? entry.title}</strong>
|
||||
<span>{friendly?.text ?? entry.text}</span>
|
||||
{isNativePreviewError(entry) ? <span className="log-raw-detail">Детали: {entry.text}</span> : null}
|
||||
{entries.length ? (
|
||||
entries.map((entry) => {
|
||||
const friendly = displayEntry(entry);
|
||||
return (
|
||||
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
|
||||
<time>{formatTime(entry.at)}</time>
|
||||
<div>
|
||||
<strong>{friendly?.title ?? entry.title}</strong>
|
||||
<span>{friendly?.text ?? entry.text}</span>
|
||||
{isNativePreviewError(entry) ? (
|
||||
<span className="log-raw-detail">
|
||||
Детали: {entry.text}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="log-history-row">
|
||||
<time>--:--:--</time>
|
||||
<span>Событий пока нет.</span>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button, type ButtonVariant } from './Button';
|
||||
import { ActionMenu, type ActionMenuItem } from './ActionMenu';
|
||||
import type { ReactNode } from "react";
|
||||
import { Button, type ButtonVariant } from "./Button";
|
||||
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 {
|
||||
label: string;
|
||||
@@ -25,7 +26,7 @@ export interface ServiceControlRowProps {
|
||||
items: ActionMenuItem[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
visualState?: 'working' | 'settling' | null;
|
||||
visualState?: "working" | "settling" | null;
|
||||
className?: string;
|
||||
inlineActions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
@@ -43,11 +44,13 @@ export function ServiceControlRow({
|
||||
children,
|
||||
}: ServiceControlRowProps) {
|
||||
const classes = [
|
||||
'ui-service-row',
|
||||
"ui-service-row",
|
||||
`ui-service-row--${state}`,
|
||||
visualState ? `ui-service-row--${visualState}` : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
visualState ? `ui-service-row--${visualState}` : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
@@ -61,7 +64,9 @@ export function ServiceControlRow({
|
||||
<div className="ui-service-text">
|
||||
<div className="ui-service-title-line">
|
||||
<strong>{title}</strong>
|
||||
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
|
||||
{inlineActions ? (
|
||||
<div className="ui-service-inline-actions">{inlineActions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span>{detail}</span>
|
||||
</div>
|
||||
@@ -69,7 +74,7 @@ export function ServiceControlRow({
|
||||
{primaryAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={primaryAction.variant ?? 'neutral'}
|
||||
variant={primaryAction.variant ?? "neutral"}
|
||||
onClick={primaryAction.onClick}
|
||||
disabled={primaryAction.disabled}
|
||||
loading={primaryAction.loading}
|
||||
@@ -92,4 +97,3 @@ export function ServiceControlRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
tone?: StatusPillTone;
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function StatusPill({ tone = 'muted', children }: StatusPillProps) {
|
||||
export function StatusPill({ tone = "muted", children }: StatusPillProps) {
|
||||
return (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+9
-8
@@ -1,4 +1,4 @@
|
||||
import { useRef, type KeyboardEvent } from 'react';
|
||||
import { useRef, type KeyboardEvent } from "react";
|
||||
|
||||
export interface TabItem<T extends string> {
|
||||
id: T;
|
||||
@@ -30,24 +30,26 @@ export function Tabs<T extends string>({
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, id: T) {
|
||||
if (event.key === 'ArrowRight') {
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
moveFocus(id, 1);
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
moveFocus(id, -1);
|
||||
} else if (event.key === 'Home') {
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
const first = items[0];
|
||||
if (!first) return;
|
||||
onChange(first.id);
|
||||
window.requestAnimationFrame(() => refs.current[0]?.focus());
|
||||
} else if (event.key === 'End') {
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
const last = items[items.length - 1];
|
||||
if (!last) return;
|
||||
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-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
className={`ui-tab ${active ? 'is-active' : ''}`.trim()}
|
||||
className={`ui-tab ${active ? "is-active" : ""}`.trim()}
|
||||
key={item.id}
|
||||
ref={(node) => {
|
||||
refs.current[index] = node;
|
||||
@@ -78,4 +80,3 @@ export function Tabs<T extends string>({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+28
-23
@@ -1,23 +1,28 @@
|
||||
export { ActionMenu } from './ActionMenu';
|
||||
export type { ActionMenuItem } from './ActionMenu';
|
||||
export { BusyRing } from './BusyRing';
|
||||
export type { BusyRingProps } from './BusyRing';
|
||||
export { Button } from './Button';
|
||||
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
|
||||
export { DetailsPopover } from './DetailsPopover';
|
||||
export type { DetailsPopoverAlign, DetailsPopoverProps } from './DetailsPopover';
|
||||
export { Field } from './Field';
|
||||
export type { FieldProps } from './Field';
|
||||
export { HoverDetails } from './HoverDetails';
|
||||
export type { HoverDetailsProps } from './HoverDetails';
|
||||
export { IconButton } from './IconButton';
|
||||
export type { IconButtonProps, IconButtonVariant } from './IconButton';
|
||||
export { LogDock } from './LogDock';
|
||||
export type { LogDockEntry, LogDockProps } from './LogDock';
|
||||
export { ServiceControlRow } from './ServiceControlRow';
|
||||
export type { ServiceControlRowProps, ServiceControlState } from './ServiceControlRow';
|
||||
export { StatusPill } from './StatusPill';
|
||||
export type { StatusPillProps, StatusPillTone } from './StatusPill';
|
||||
export { Tabs } from './Tabs';
|
||||
export type { TabItem, TabsProps } from './Tabs';
|
||||
|
||||
export { ActionMenu } from "./ActionMenu";
|
||||
export type { ActionMenuItem } from "./ActionMenu";
|
||||
export { BusyRing } from "./BusyRing";
|
||||
export type { BusyRingProps } from "./BusyRing";
|
||||
export { Button } from "./Button";
|
||||
export type { ButtonProps, ButtonSize, ButtonVariant } from "./Button";
|
||||
export { DetailsPopover } from "./DetailsPopover";
|
||||
export type {
|
||||
DetailsPopoverAlign,
|
||||
DetailsPopoverProps,
|
||||
} from "./DetailsPopover";
|
||||
export { Field } from "./Field";
|
||||
export type { FieldProps } from "./Field";
|
||||
export { HoverDetails } from "./HoverDetails";
|
||||
export type { HoverDetailsProps } from "./HoverDetails";
|
||||
export { IconButton } from "./IconButton";
|
||||
export type { IconButtonProps, IconButtonVariant } from "./IconButton";
|
||||
export { LogDock } from "./LogDock";
|
||||
export type { LogDockEntry, LogDockProps } from "./LogDock";
|
||||
export { ServiceControlRow } from "./ServiceControlRow";
|
||||
export type {
|
||||
ServiceControlRowProps,
|
||||
ServiceControlState,
|
||||
} from "./ServiceControlRow";
|
||||
export { StatusPill } from "./StatusPill";
|
||||
export type { StatusPillProps, StatusPillTone } from "./StatusPill";
|
||||
export { Tabs } from "./Tabs";
|
||||
export type { TabItem, TabsProps } from "./Tabs";
|
||||
|
||||
Reference in New Issue
Block a user