Expand README with architecture and setup details

This commit is contained in:
2026-07-08 09:58:26 +03:00
parent 81be7e186c
commit c5120669d2
109 changed files with 22311 additions and 0 deletions

16
.gitattributes vendored Normal file
View File

@@ -0,0 +1,16 @@
* text=auto
*.md text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.json text eol=lf
*.rs text eol=lf
*.toml text eol=lf
*.css text eol=lf
*.html text eol=lf
*.ps1 text eol=crlf
*.png binary
*.ico binary
*.icns binary

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
node_modules/
dist/
src-tauri/target/
src-tauri/gen/
# Local planning/evidence generated during agent work.
docs/audits/
docs/goals/
docs/superpowers/
docs/*-brief.md
# Runtime/generated local state.
*.log
*.tmp
*.bak
.env
.env.*
# OS/editor noise.
.DS_Store
Thumbs.db
.idea/
.vscode/

89
AGENTS.md Normal file
View File

@@ -0,0 +1,89 @@
# Инструкции для агентов
## Контекст проекта
ProxyWarden - standalone Windows desktop client в корне репозитория. Это Tauri 2 + React/TypeScript UI + Rust backend для маршрутизации выбранных Windows-приложений через внешний SOCKS5-прокси или опциональный Local sing-box.
Не возвращать старую идею `APP_MODE=windows` и не подключать Windows-клиент к отдельному Node gateway/server. Текущий рабочий путь - `src`, `src-tauri`, `scripts` в корне репозитория.
## Основные инварианты
- Три компонента должны оставаться разделенными: Control App, ProxiFyre, Local sing-box.
- ProxiFyre - обязательный слой для per-app routing; Local sing-box - необязательный runtime.
- Внешний SOCKS5 flow должен работать без установленного Local sing-box.
- Profile apply не должен скрыто устанавливать, удалять, запускать или чинить компоненты. Install/start/stop/uninstall - только явные действия пользователя.
- Source of truth - JSON под `C:\ProgramData\ProxyWarden\config` и `state`.
- `C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json` и `sing-box-config.json` - derived artifacts, их можно пересоздать.
- Subscription URL и другие секреты нельзя показывать полностью в UI, diagnostics или логах.
- Summary panel должен оставаться read-only: без apply/install/start/stop/delete/input/subscription mutations.
## Структура
- `src/app/App.tsx` - основная React-оркестрация, вкладки `Сводка`, `ProxiFyre`, `VPN / Прокси`, вызовы Tauri-команд и transient UI state.
- `src/app/readiness.ts` - gating применимости маршрута. Не обходить его локальными проверками в JSX.
- `src/app/viewModel.ts` - маленькие display/view-model helpers.
- `src/ui/*` - общие presentational-компоненты. Для новых кнопок, вкладок, service rows, pills, полей и лог-дока сначала расширять эти компоненты.
- `src/api/tauriCommands.ts` - единственная TypeScript-обертка над `invoke(...)`; держать DTO в синхронизации с Rust.
- `src/domain/types.ts` - TypeScript-зеркало доменных DTO.
- `src-tauri/src/models.rs` - Rust-модели и default values.
- `src-tauri/src/validation.rs` - нормализация входов.
- `src-tauri/src/storage.rs` и `activity.rs` - JSON storage, backup/tmp writes, activity cap/sort.
- `src-tauri/src/adapters/proxy_router.rs` - adapter boundary для proxy-router.
- `src-tauri/src/adapters/proxifyre.rs` - первый adapter, генерирует ProxiFyre `app-config.json`.
- `src-tauri/src/adapters/singbox.rs` - генерация локального `sing-box` конфига из subscription cache и выбранного сервера.
- `src-tauri/src/component_detection.rs` - detection ProxiFyre/Proxifier/Local sing-box.
- `src-tauri/src/commands.rs` - Tauri command handlers, installer/service orchestration, structured errors.
- `src-tauri/src/main.rs` - реальная Tauri entrypoint-регистрация команд.
- `src-tauri/src/lib.rs` сейчас scaffold/stale; не считать его источником регистрации команд без отдельной cleanup-задачи.
- `scripts/*.ps1` - явные installer entrypoints. `-PlanOnly` должен возвращать structured JSON без side effects.
## Правила изменений
- Не создавать второй источник правды для профилей, targets, components, subscription или activity.
- Не писать generated config напрямую из React.
- Не парсить raw PowerShell/stdout в UI. Backend/helper boundary должен возвращать structured JSON/error DTO.
- Не привязывать UI напрямую к деталям ProxiFyre, если изменение относится к общему proxy-router поведению.
- Не делать Local sing-box обязательным для external target.
- Для service/install операций сохранять UAC/admin boundary и человекочитаемые ошибки.
- При удалении install folders сохранять safe-path checks; не ослаблять рекурсивное удаление.
- В UI держать стиль компактной Windows-утилиты, а не landing/dashboard. Использовать existing `Button`, `Tabs`, `ServiceControlRow`, `StatusPill`, `Field`, `ActionMenu`, `LogDock`.
- Apply actions должны быть disabled с объяснением, когда нет приложений, ProxiFyre отсутствует, proxy input неверный или local route не готов.
## Проверка
Минимум для frontend/UI:
```powershell
npm run build
```
Rust/backend:
```powershell
cd D:\repos\ProxyWarden\src-tauri
cargo test
```
Tauri/toolchain:
```powershell
npm run tauri -- info
npm run tauri -- dev
npm run tauri -- build
```
Installer boundaries:
```powershell
& .\scripts\install-control-app.ps1 -PlanOnly
& .\scripts\install-proxyfier.ps1 -PlanOnly
& .\scripts\install-singbox.ps1 -PlanOnly
```
Для UI-изменений проверять browser-preview на desktop и narrow viewport. Browser-preview не доказывает native Tauri commands или elevated service lane.
## Известные риски
- Реальные elevated install/start/stop/uninstall операции для ProxiFyre и Local sing-box считаются `implemented but unproven`, пока они не проверены на Windows с UAC/admin confirmation.
- Исторические planning/evidence файлы лежат в ignored `docs`-папках и не должны попадать в коммиты.
- Старые документы могут ссылаться на `apps/windows-client`; текущая структура репозитория - standalone client в корне.

140
README.md
View File

@@ -1,2 +1,142 @@
# ProxyWarden
ProxyWarden - это Windows-приложение для маршрутизации выбранных программ через прокси или VPN-сервер. Оно не меняет глобальные настройки прокси в Windows: вы сами выбираете, какие приложения должны идти через маршрут, например Discord, Telegram, браузер, игру или конкретный `.exe`.
## Что умеет приложение
- добавлять приложения по имени процесса, папке или конкретному EXE-файлу;
- отправлять выбранные приложения через внешний SOCKS5-прокси;
- при необходимости поднимать локальный `sing-box` и использовать сервер из подписки;
- показывать состояние компонентов: установлен ли ProxiFyre, запущены ли службы, выбран ли сервер;
- генерировать конфиги для ProxiFyre и `sing-box` из сохраненных настроек;
- не устанавливать скрыто лишние компоненты при применении профиля.
## Из чего состоит ProxyWarden
ProxyWarden разделен на три независимые части.
**Control App** - само desktop-приложение. В нем вы настраиваете маршрут, выбираете приложения, смотрите статус и запускаете явные действия.
**ProxiFyre** - обязательный слой для маршрутизации отдельных Windows-приложений. Он заставляет выбранные программы ходить через SOCKS5-прокси даже тогда, когда сами программы не умеют работать с прокси.
**Local sing-box** - необязательный локальный VPN/proxy runtime. Он нужен только если вы хотите вставить subscription URL, выбрать сервер и получить локальный SOCKS5 endpoint `127.0.0.1:1080`. Если у вас уже есть внешний SOCKS5-прокси, `sing-box` можно не устанавливать.
## Типичный сценарий с внешним прокси
1. Запустите ProxyWarden.
2. Установите или проверьте ProxiFyre.
3. На вкладке `VPN / Прокси` выберите `Внешний прокси`.
4. Введите адрес в формате `host:port` или `socks5://host:port`.
5. На вкладке `ProxiFyre` добавьте приложения, которые нужно маршрутизировать.
6. Нажмите `Применить в ProxiFyre`.
Результат: выбранные приложения идут через внешний SOCKS5-прокси. Local sing-box для этого сценария не нужен.
## Типичный сценарий с Local sing-box
1. Запустите ProxyWarden.
2. Установите ProxiFyre.
3. Установите Local sing-box.
4. Вставьте subscription URL, загрузите список серверов и выберите сервер.
5. Добавьте приложения для маршрутизации.
6. Примените маршрут.
Результат: выбранные приложения идут по цепочке:
```text
Приложения -> ProxiFyre -> Local sing-box 127.0.0.1:1080 -> выбранный сервер
```
## Где хранятся настройки
Пользовательские настройки хранятся в `C:\ProgramData\ProxyWarden`:
```text
C:\ProgramData\ProxyWarden\config\profiles.json
C:\ProgramData\ProxyWarden\config\targets.json
C:\ProgramData\ProxyWarden\config\components.json
C:\ProgramData\ProxyWarden\config\local-singbox.json
C:\ProgramData\ProxyWarden\state\activity.json
C:\ProgramData\ProxyWarden\state\singbox-subscription-cache.json
```
Сгенерированные файлы лежат отдельно и могут быть пересозданы:
```text
C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json
C:\ProgramData\ProxyWarden\generated\sing-box-config.json
```
Важно: редактировать вручную лучше исходные настройки, а не generated-файлы. При записи настроек приложение создает backup рядом с исходным JSON.
## Установка из исходников
Нужны:
- Windows 10/11;
- Node.js и npm;
- Rust через rustup;
- Visual Studio Build Tools с MSVC и Windows SDK;
- Microsoft Edge WebView2 Runtime.
Команды:
```powershell
cd D:\repos\ProxyWarden
npm install
npm run tauri -- dev
```
Собрать установочный пакет:
```powershell
npm run tauri -- build
```
Запустить только browser-preview без нативных Tauri-команд:
```powershell
npm run dev -- --host 127.0.0.1
```
В browser-preview можно проверить интерфейс, но нельзя управлять Windows-службами и нативными компонентами.
## Явные installer-скрипты
В репозитории есть отдельные entrypoint-скрипты:
```powershell
& .\scripts\install-control-app.ps1 -PlanOnly
& .\scripts\install-proxyfier.ps1 -PlanOnly
& .\scripts\install-singbox.ps1 -PlanOnly
```
`-PlanOnly` показывает план в JSON и ничего не устанавливает. Реальная установка требует прав администратора. Компоненты устанавливаются отдельно: применение профиля не должно незаметно устанавливать Control App, ProxiFyre или Local sing-box.
## Проверка для разработчика
Frontend:
```powershell
npm run build
```
Rust-тесты:
```powershell
cd D:\repos\ProxyWarden\src-tauri
cargo test
```
Информация Tauri:
```powershell
npm run tauri -- info
```
## Ограничения текущей версии
- Поддерживается основной путь через SOCKS5.
- ProxiFyre является текущим backend-слоем маршрутизации; архитектура оставляет место для другого proxy-router adapter.
- Local sing-box остается опциональным и не требуется для внешнего SOCKS5-прокси.
- Реальные elevated-операции установки, удаления и управления Windows-службами нужно проверять на Windows с правами администратора.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxyWarden</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2126
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "proxywarden",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Standalone Windows desktop proxy management app for ProxyWarden.",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.8.0",
"vite": "^7.0.0"
}
}

View File

@@ -0,0 +1,79 @@
param(
[string]$InstallRoot = "C:\Program Files\ProxyWarden\ControlApp",
[string]$DataRoot = "C:\ProgramData\ProxyWarden",
[switch]$PlanOnly,
[switch]$Force
)
$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 6
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Ensure-Directory {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
return $true
}
return $false
}
try {
$details = @{
installRoot = $InstallRoot
dataRoot = $DataRoot
planOnly = [bool]$PlanOnly
}
if ($PlanOnly) {
New-Result -Success $true -Action "install-control-app" -Changed $false -Message "Control App install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-control-app" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
$changed = $false
$changed = (Ensure-Directory -Path $InstallRoot) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "config")) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "state")) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "generated")) -or $changed
$markerPath = Join-Path $InstallRoot "install-control-app.marker.json"
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
@{ component = "control-app"; installedAt = (Get-Date).ToString("o") } |
ConvertTo-Json -Depth 4 |
Set-Content -LiteralPath $markerPath -Encoding UTF8
$changed = $true
}
$details.markerPath = $markerPath
New-Result -Success $true -Action "install-control-app" -Changed $changed -Message "Control App directories are installed." -Details $details
} catch {
New-Result -Success $false -Action "install-control-app" -Changed $false -Message $_.Exception.Message
exit 1
}

View File

@@ -0,0 +1,96 @@
param(
[string]$InstallRoot = "C:\Tools\ProxiFyre",
[string]$PackagePath = "",
[string]$ServiceName = "ProxiFyreService",
[switch]$PlanOnly,
[switch]$Force
)
$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 6
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Backup-File {
param([string]$Path)
if (Test-Path -LiteralPath $Path) {
$backup = "$Path.bak"
Copy-Item -LiteralPath $Path -Destination $backup -Force
return $backup
}
return $null
}
try {
$details = @{
installRoot = $InstallRoot
packagePath = $PackagePath
serviceName = $ServiceName
planOnly = [bool]$PlanOnly
}
if ($PlanOnly) {
New-Result -Success $true -Action "install-proxyfier" -Changed $false -Message "Proxyfier install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath)) {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "PackagePath is required and must point to a local ProxiFyre package." -Details $details
exit 2
}
$changed = $false
if (-not (Test-Path -LiteralPath $InstallRoot)) {
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
$changed = $true
}
$configPath = Join-Path $InstallRoot "app-config.json"
$backupPath = Backup-File -Path $configPath
if ($backupPath) {
$details.backupPath = $backupPath
}
$markerPath = Join-Path $InstallRoot "install-proxyfier.marker.json"
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
@{
component = "proxyfier"
packagePath = $PackagePath
serviceName = $ServiceName
installedAt = (Get-Date).ToString("o")
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
$changed = $true
}
$details.markerPath = $markerPath
New-Result -Success $true -Action "install-proxyfier" -Changed $changed -Message "Proxyfier install boundary completed." -Details $details
} catch {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message $_.Exception.Message
exit 1
}

270
scripts/install-singbox.ps1 Normal file
View File

@@ -0,0 +1,270 @@
param(
[string]$InstallRoot = "C:\Program Files\ProxyWarden\sing-box",
[string]$ServiceName = "ProxyWardenSingBox",
[string]$ConfigSource = "C:\ProgramData\ProxyWarden\generated\sing-box-config.json",
[switch]$PlanOnly,
[switch]$Force,
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$SingBoxReleaseApi = "https://api.github.com/repos/SagerNet/sing-box/releases/latest"
$WinSwReleaseApi = "https://api.github.com/repos/winsw/winsw/releases/latest"
$WrapperFile = "$ServiceName.exe"
$ConfigFile = "config.json"
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 Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
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 "amd64" }
return "386"
}
function Get-WinSwArchitecture {
param([string]$Arch)
if ($Arch -eq "arm64") { return "arm64" }
if ($Arch -eq "386") { return "x86" }
return "x64"
}
function Invoke-Download {
param([string]$Uri, [string]$Path)
Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $Path -Headers @{ "User-Agent" = "proxywarden" }
}
function Select-Asset {
param(
[object[]]$Assets,
[string]$Pattern,
[string]$Label
)
$asset = $Assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1
if ($null -eq $asset) {
throw "Не найден release asset для $Label по шаблону $Pattern."
}
return $asset
}
function Test-SafeInstallRoot {
param([string]$Path)
$full = [System.IO.Path]::GetFullPath($Path).TrimEnd("\")
$leaf = Split-Path -Leaf $full
$parent = Split-Path -Parent $full
if ($leaf -ne "sing-box") { return $false }
return $parent -match "\\ProxyWarden$|\\proxywarden$"
}
function Backup-File {
param([string]$Path)
if (Test-Path -LiteralPath $Path) {
$backup = "$Path.bak"
Copy-Item -LiteralPath $Path -Destination $backup -Force
return $backup
}
return $null
}
function Write-Utf8NoBomFile {
param(
[string]$Path,
[string]$Value
)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($Path, $Value, $encoding)
}
function Write-WinSwConfig {
param(
[string]$Root,
[string]$Name
)
$xmlPath = Join-Path $Root "$Name.xml"
$logDir = Join-Path $Root "logs"
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
$xml = @"
<service>
<id>$Name</id>
<name>ProxyWarden Local sing-box</name>
<description>Local sing-box runtime managed by ProxyWarden.</description>
<executable>%BASE%\sing-box.exe</executable>
<arguments>run -c "%BASE%\config.json"</arguments>
<logpath>%BASE%\logs</logpath>
<log mode="roll-by-size">
<sizeThreshold>10485760</sizeThreshold>
<keepFiles>4</keepFiles>
</log>
<onfailure action="restart" delay="5 sec"/>
</service>
"@
Write-Utf8NoBomFile -Path $xmlPath -Value $xml
return $xmlPath
}
function Stop-And-Uninstall-Service {
param(
[string]$Root,
[string]$Name
)
$wrapper = Join-Path $Root "$Name.exe"
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
if ($null -ne $service -and $service.Status -ne "Stopped") {
Stop-Service -Name $Name -Force -ErrorAction SilentlyContinue
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
if ($null -ne $service) {
try { $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(15)) } catch {}
}
}
if (Test-Path -LiteralPath $wrapper) {
Push-Location $Root
try { & $wrapper uninstall | Out-Null } finally { Pop-Location }
}
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
if ($null -ne $service) {
sc.exe delete $Name | Out-Null
}
}
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$installRootFull = [System.IO.Path]::GetFullPath($InstallRoot)
$details = @{
installRoot = $installRootFull
serviceName = $ServiceName
configSource = $ConfigSource
singboxReleaseApi = $SingBoxReleaseApi
winswReleaseApi = $WinSwReleaseApi
planOnly = [bool]$PlanOnly
uninstall = [bool]$Uninstall
}
if ($PlanOnly) {
$details.items = @(
@{ id = "sing-box-binary"; name = "sing-box.exe"; source = $SingBoxReleaseApi; target = (Join-Path $installRootFull "sing-box.exe") },
@{ id = "winsw-wrapper"; name = $WrapperFile; source = $WinSwReleaseApi; target = (Join-Path $installRootFull $WrapperFile) },
@{ id = "windows-service"; name = $ServiceName; target = "Windows Service" },
@{ id = "config"; name = $ConfigFile; source = $ConfigSource; target = (Join-Path $installRootFull $ConfigFile) }
)
New-Result -Success $true -Action "install-singbox.plan" -Changed $false -Message "Local sing-box install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
if ($Uninstall) {
if (-not (Test-SafeInstallRoot -Path $installRootFull)) {
New-Result -Success $false -Action "uninstall-singbox" -Changed $false -Message "Unsafe InstallRoot for recursive uninstall." -Details $details
exit 2
}
Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName
if (Test-Path -LiteralPath $installRootFull) {
Remove-Item -LiteralPath $installRootFull -Recurse -Force
}
New-Result -Success $true -Action "uninstall-singbox" -Changed $true -Message "Local sing-box service and install folder were removed." -Details $details
exit 0
}
$changed = $false
New-Item -ItemType Directory -Path $installRootFull -Force | Out-Null
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("proxywarden-singbox-" + [guid]::NewGuid().ToString("N"))
$extractDir = Join-Path $workDir "extract"
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
try {
$arch = Get-NativeArchitecture
$winswArch = Get-WinSwArchitecture -Arch $arch
$details.architecture = $arch
$details.winswArchitecture = $winswArch
$singboxRelease = Invoke-RestMethod -Uri $SingBoxReleaseApi -Headers @{ "User-Agent" = "proxywarden" }
$singboxAsset = Select-Asset $singboxRelease.assets "windows-$arch\.zip$" "sing-box"
$singboxZip = Join-Path $workDir $singboxAsset.name
Invoke-Download $singboxAsset.browser_download_url $singboxZip
Expand-Archive -LiteralPath $singboxZip -DestinationPath $extractDir -Force
$singboxExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter "sing-box.exe" | Select-Object -First 1
if ($null -eq $singboxExe) { throw "В архиве sing-box не найден sing-box.exe." }
Copy-Item -LiteralPath $singboxExe.FullName -Destination (Join-Path $installRootFull "sing-box.exe") -Force
$changed = $true
$winswRelease = Invoke-RestMethod -Uri $WinSwReleaseApi -Headers @{ "User-Agent" = "proxywarden" }
$winswAsset = Select-Asset $winswRelease.assets "WinSW-$winswArch\.exe$" "WinSW"
Invoke-Download $winswAsset.browser_download_url (Join-Path $installRootFull $WrapperFile)
$changed = $true
$configTarget = Join-Path $installRootFull $ConfigFile
$backupPath = Backup-File -Path $configTarget
if ($backupPath) { $details.backupPath = $backupPath }
if (Test-Path -LiteralPath $ConfigSource) {
Copy-Item -LiteralPath $ConfigSource -Destination $configTarget -Force
} elseif (-not (Test-Path -LiteralPath $configTarget)) {
Write-Utf8NoBomFile -Path $configTarget -Value '{"log":{"level":"info","timestamp":true},"inbounds":[],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"direct"}}'
}
$xmlPath = Write-WinSwConfig -Root $installRootFull -Name $ServiceName
$details.configPath = $configTarget
$details.wrapperConfigPath = $xmlPath
if ($Force) {
Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName
}
Push-Location $installRootFull
try {
$service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($null -eq $service) {
& ".\$WrapperFile" install
if ($LASTEXITCODE -ne 0) { throw "WinSW install завершился с кодом $LASTEXITCODE." }
$changed = $true
}
& ".\$WrapperFile" start
if ($LASTEXITCODE -ne 0) {
Start-Service -Name $ServiceName -ErrorAction Stop
}
} finally {
Pop-Location
}
} finally {
if (Test-Path -LiteralPath $workDir) {
Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box service is installed and started." -Details $details
} catch {
New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message
exit 1
}

4850
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

22
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "proxywarden"
version = "0.1.0"
description = "Standalone Windows desktop proxy management app for ProxyWarden."
authors = ["ProxyWarden"]
edition = "2021"
[lib]
name = "proxywarden_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-dialog = "2.7.1"
base64 = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
url = "2"

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for the main ProxyWarden Windows shell. Task 8 keeps helper/install launch explicit: no shell or sidecar permission is granted here until a packaged helper is declared.",
"windows": ["main"],
"permissions": ["core:default", "dialog:allow-open"]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

23
src-tauri/src/activity.rs Normal file
View File

@@ -0,0 +1,23 @@
use crate::models::ActivityEntry;
pub const DEFAULT_ACTIVITY_LIMIT: usize = 200;
pub fn sort_activity_desc(mut entries: Vec<ActivityEntry>) -> Vec<ActivityEntry> {
entries.sort_by(|left, right| right.at.cmp(&left.at).then_with(|| right.id.cmp(&left.id)));
entries
}
pub fn cap_activity(entries: Vec<ActivityEntry>, limit: usize) -> Vec<ActivityEntry> {
let mut entries = sort_activity_desc(entries);
entries.truncate(limit);
entries
}
pub fn append_activity(
mut entries: Vec<ActivityEntry>,
entry: ActivityEntry,
limit: usize,
) -> Vec<ActivityEntry> {
entries.push(entry);
cap_activity(entries, limit)
}

View File

@@ -0,0 +1,245 @@
#[cfg(not(test))]
use crate::adapters::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
use crate::models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
ProxyProtocol, Target,
};
#[cfg(test)]
use crate::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
use serde::{Deserialize, Serialize};
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
pub const PROXIFYRE_OUTPUT_FILE: &str = "proxifyre-app-config.json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxiFyreAdapter {
log_level: String,
bypass_lan: bool,
}
impl ProxiFyreAdapter {
pub fn new(log_level: impl Into<String>, bypass_lan: bool) -> Self {
Self {
log_level: log_level.into(),
bypass_lan,
}
}
pub fn generate_proxifyre_config(
&self,
request: ProxyRouterRequest<'_>,
) -> Result<ProxiFyreConfig, ProxyRouterError> {
let mut proxies = Vec::new();
for profile in request.profiles.iter().filter(|profile| profile.enabled) {
let target = find_target(profile, request.targets)?;
ensure_target_supported(profile, target, request.components)?;
let app_names = app_names_for_profile(profile);
if app_names.is_empty() {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::EmptyProfileItems,
format!(
"В профиле '{}' нет приложений для маршрутизации",
profile.id
),
));
}
proxies.push(ProxiFyreProxy {
app_names,
socks5_proxy_endpoint: format!("{}:{}", target.host, target.port),
supported_protocols: protocols_for_profile(profile),
});
}
Ok(ProxiFyreConfig {
log_level: self.log_level.clone(),
bypass_lan: self.bypass_lan,
proxies,
})
}
}
impl Default for ProxiFyreAdapter {
fn default() -> Self {
Self::new("Info", true)
}
}
impl ProxyRouterAdapter for ProxiFyreAdapter {
fn id(&self) -> &'static str {
PROXIFYRE_ADAPTER_ID
}
fn output_file_name(&self) -> &'static str {
PROXIFYRE_OUTPUT_FILE
}
fn generate_config(
&self,
request: ProxyRouterRequest<'_>,
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError> {
let config = self.generate_proxifyre_config(request)?;
let enabled_profiles = config.proxies.len();
let routed_apps = config
.proxies
.iter()
.map(|proxy| proxy.app_names.len())
.sum();
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
ProxyRouterError::new(
ProxyRouterErrorKind::Serialization,
format!("Не удалось сериализовать конфиг ProxiFyre: {error}"),
)
})?;
Ok(ProxyRouterGeneratedConfig {
adapter_id: self.id().to_string(),
output_file_name: self.output_file_name().to_string(),
contents,
enabled_profiles,
routed_apps,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProxiFyreConfig {
#[serde(rename = "logLevel")]
pub log_level: String,
#[serde(rename = "bypassLan")]
pub bypass_lan: bool,
pub proxies: Vec<ProxiFyreProxy>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProxiFyreProxy {
#[serde(rename = "appNames")]
pub app_names: Vec<String>,
#[serde(rename = "socks5ProxyEndpoint")]
pub socks5_proxy_endpoint: String,
#[serde(rename = "supportedProtocols")]
pub supported_protocols: Vec<String>,
}
fn find_target<'a>(
profile: &Profile,
targets: &'a [Target],
) -> Result<&'a Target, ProxyRouterError> {
targets
.iter()
.find(|target| target.id == profile.target_id)
.ok_or_else(|| {
ProxyRouterError::new(
ProxyRouterErrorKind::MissingTarget,
format!(
"Профиль '{}' ссылается на отсутствующую цель '{}'",
profile.id, profile.target_id
),
)
})
}
fn ensure_target_supported(
profile: &Profile,
target: &Target,
components: &[ComponentStatus],
) -> Result<(), ProxyRouterError> {
if target.protocol != ProxyProtocol::Socks5 {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::UnsupportedTargetProtocol,
format!(
"Цель '{}' использует HTTP, но ProxiFyre требует SOCKS5",
target.id
),
));
}
if let Some(required_component) = &target.requires_component {
let Some(status) = components
.iter()
.find(|component| &component.id == required_component)
else {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::MissingRequiredComponent,
format!(
"Цель '{}' профиля '{}' требует отсутствующий компонент '{}'",
target.id,
profile.id,
component_id_label(required_component)
),
));
};
if !component_is_running(status) {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::RequiredComponentNotRunning,
format!(
"Цель '{}' профиля '{}' требует запущенный компонент '{}'",
target.id,
profile.id,
component_id_label(required_component)
),
));
}
}
Ok(())
}
fn component_is_running(status: &ComponentStatus) -> bool {
status.installed && status.running && status.state == ComponentState::Running
}
fn app_names_for_profile(profile: &Profile) -> Vec<String> {
let mut names = Vec::new();
for item in &profile.items {
let value = item.value.trim();
if value.is_empty() {
continue;
}
let app_name = match item.item_type {
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value,
};
if !names.iter().any(|existing| existing == app_name) {
names.push(app_name.to_string());
}
}
names
}
fn protocols_for_profile(profile: &Profile) -> Vec<String> {
let mut protocols = Vec::new();
for protocol in &profile.protocols {
let value = match protocol {
Protocol::Tcp => "TCP",
Protocol::Udp => "UDP",
};
if !protocols.iter().any(|existing| existing == value) {
protocols.push(value.to_string());
}
}
protocols
}
fn component_id_label(component_id: &ComponentId) -> &'static str {
match component_id {
ComponentId::ControlApp => "control-app",
ComponentId::Proxyfier => "proxyfier",
ComponentId::Singbox => "singbox",
}
}

View File

@@ -0,0 +1,67 @@
use crate::models::{ComponentStatus, Profile, Target};
#[derive(Debug, Clone, Copy)]
pub struct ProxyRouterRequest<'a> {
pub profiles: &'a [Profile],
pub targets: &'a [Target],
pub components: &'a [ComponentStatus],
}
impl<'a> ProxyRouterRequest<'a> {
pub fn new(
profiles: &'a [Profile],
targets: &'a [Target],
components: &'a [ComponentStatus],
) -> Self {
Self {
profiles,
targets,
components,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyRouterGeneratedConfig {
pub adapter_id: String,
pub output_file_name: String,
pub contents: String,
pub enabled_profiles: usize,
pub routed_apps: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyRouterError {
pub kind: ProxyRouterErrorKind,
pub message: String,
}
impl ProxyRouterError {
pub fn new(kind: ProxyRouterErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyRouterErrorKind {
EmptyProfileItems,
MissingTarget,
MissingRequiredComponent,
RequiredComponentNotRunning,
UnsupportedTargetProtocol,
Serialization,
}
pub trait ProxyRouterAdapter {
fn id(&self) -> &'static str;
fn output_file_name(&self) -> &'static str;
fn generate_config(
&self,
request: ProxyRouterRequest<'_>,
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError>;
}

View File

@@ -0,0 +1,338 @@
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
env, fs,
path::Path,
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
pub const DEFAULT_MIXED_INBOUND_TAG: &str = "proxywarden-mixed-in";
pub const DEFAULT_VPN_OUTBOUND_TAG: &str = "vpn";
pub const DEFAULT_DIRECT_OUTBOUND_TAG: &str = "direct";
pub const DEFAULT_BLOCK_OUTBOUND_TAG: &str = "block";
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxAdapter {
log_level: String,
inbound_tag: String,
vpn_outbound_tag: String,
}
impl SingBoxAdapter {
pub fn new(
log_level: impl Into<String>,
inbound_tag: impl Into<String>,
vpn_outbound_tag: impl Into<String>,
) -> Self {
Self {
log_level: log_level.into(),
inbound_tag: inbound_tag.into(),
vpn_outbound_tag: vpn_outbound_tag.into(),
}
}
pub fn generate_config<C>(
&self,
request: SingBoxGenerationRequest<'_>,
checker: &C,
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
where
C: SingBoxConfigChecker,
{
let selected_server_tag = request
.config
.selected_server_tag
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedServer,
"Сервер Local sing-box не выбран",
)
})?;
let vpn_outbound = selected_outbound(
&request.subscription_cache.config,
selected_server_tag,
&self.vpn_outbound_tag,
)?;
let generated_config = json!({
"log": {
"disabled": false,
"level": self.log_level,
"timestamp": true
},
"inbounds": [
{
"type": "mixed",
"tag": self.inbound_tag,
"listen": request.config.listen_host,
"listen_port": request.config.listen_port,
"users": [],
"set_system_proxy": false
}
],
"outbounds": [
vpn_outbound,
{ "type": "direct", "tag": DEFAULT_DIRECT_OUTBOUND_TAG },
{ "type": "block", "tag": DEFAULT_BLOCK_OUTBOUND_TAG }
],
"route": {
"rules": [
{ "ip_is_private": true, "outbound": DEFAULT_DIRECT_OUTBOUND_TAG }
],
"final": self.vpn_outbound_tag
}
});
let contents = serde_json::to_string_pretty(&generated_config).map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::Serialization,
format!("Не удалось сериализовать конфиг sing-box: {error}"),
)
})?;
let check = match request.binary_path {
Some(binary_path) => Some(checker.check_config(binary_path, &contents)?),
None => None,
};
Ok(SingBoxGeneratedConfig {
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
contents,
selected_server_tag: selected_server_tag.to_string(),
listen: request.config.listen_host.clone(),
listen_port: request.config.listen_port,
check,
})
}
}
impl Default for SingBoxAdapter {
fn default() -> Self {
Self::new("info", DEFAULT_MIXED_INBOUND_TAG, DEFAULT_VPN_OUTBOUND_TAG)
}
}
#[derive(Debug, Clone, Copy)]
pub struct SingBoxGenerationRequest<'a> {
pub config: &'a LocalSingBoxConfig,
pub subscription_cache: &'a SubscriptionCache,
pub binary_path: Option<&'a Path>,
}
impl<'a> SingBoxGenerationRequest<'a> {
pub fn new(
config: &'a LocalSingBoxConfig,
subscription_cache: &'a SubscriptionCache,
binary_path: Option<&'a Path>,
) -> Self {
Self {
config,
subscription_cache,
binary_path,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxGeneratedConfig {
pub adapter_id: String,
pub output_file_name: String,
pub contents: String,
pub selected_server_tag: String,
pub listen: String,
pub listen_port: u16,
pub check: Option<SingBoxCheckResult>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxCheckResult {
pub checked: bool,
pub success: bool,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxConfigError {
pub kind: SingBoxConfigErrorKind,
pub message: String,
}
impl SingBoxConfigError {
pub fn new(kind: SingBoxConfigErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SingBoxConfigErrorKind {
MissingSelectedServer,
MissingSelectedOutbound,
UnsupportedSelectedOutbound,
Serialization,
CheckFailed,
}
pub trait SingBoxConfigChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SingBoxCommandChecker;
impl SingBoxConfigChecker for SingBoxCommandChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
let config_path = env::temp_dir().join(format!(
"proxywarden-sing-box-{}-{}.json",
std::process::id(),
now_millis()
));
fs::write(&config_path, config_json).map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!(
"Не удалось записать временный конфиг sing-box '{}': {error}",
config_path.display()
),
)
})?;
let output = Command::new(binary_path)
.arg("check")
.arg("-c")
.arg(&config_path)
.output()
.map_err(|error| {
let _ = fs::remove_file(&config_path);
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!(
"Не удалось выполнить '{} check': {error}",
binary_path.display()
),
)
})?;
let _ = fs::remove_file(&config_path);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let message = command_message(&stdout, &stderr);
if !output.status.success() {
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!("Проверка sing-box не прошла: {message}"),
));
}
Ok(SingBoxCheckResult {
checked: true,
success: true,
message: if message.is_empty() {
"Проверка sing-box прошла успешно".to_string()
} else {
message
},
})
}
}
fn selected_outbound(
subscription_config: &Value,
selected_server_tag: &str,
vpn_outbound_tag: &str,
) -> Result<Value, SingBoxConfigError> {
let outbounds = subscription_config
.get("outbounds")
.and_then(Value::as_array)
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedOutbound,
"В cache подписки нет outbounds",
)
})?;
let outbound = outbounds
.iter()
.find(|outbound| {
outbound
.get("tag")
.and_then(Value::as_str)
.is_some_and(|tag| tag.trim() == selected_server_tag)
})
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedOutbound,
format!("Outbound не найден: {selected_server_tag}"),
)
})?;
let outbound_type = outbound
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if !SUPPORTED_PROXY_TYPES.contains(&outbound_type) {
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
format!(
"Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'"
),
));
}
let mut outbound = outbound.clone();
let object = outbound.as_object_mut().ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"),
)
})?;
object.insert(
"tag".to_string(),
Value::String(vpn_outbound_tag.to_string()),
);
if outbound_type == "vless" && !object.contains_key("packet_encoding") {
object.insert(
"packet_encoding".to_string(),
Value::String("xudp".to_string()),
);
}
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();
match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(false, false) => format!("{stdout}\n{stderr}"),
}
}

3530
src-tauri/src/commands.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,564 @@
use crate::models::{
ComponentId, ComponentState, ComponentStatus, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT,
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME,
};
use serde::Deserialize;
use std::{
env,
path::{Path, PathBuf},
process::Command,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyfierEngine {
ProxiFyre,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectedProxyfier {
pub engine: ProxyfierEngine,
pub name: String,
pub install_dir: PathBuf,
pub executable_path: PathBuf,
pub config_path: Option<PathBuf>,
pub running: bool,
pub service_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectedSingBox {
pub install_dir: PathBuf,
pub executable_path: PathBuf,
pub wrapper_path: PathBuf,
pub binary_exists: bool,
pub wrapper_exists: bool,
pub running: bool,
pub service_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistryInstallEntry {
pub display_name: String,
pub install_location: Option<PathBuf>,
pub display_icon: Option<PathBuf>,
}
pub trait ProxyfierDetectionHost {
fn env_var(&self, name: &str) -> Option<String>;
fn path_exists(&self, path: &Path) -> bool;
fn process_running(&self, process_name: &str) -> bool;
fn service_running(&self, service_name: &str) -> bool;
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemProxyfierDetectionHost;
impl ProxyfierDetectionHost for SystemProxyfierDetectionHost {
fn env_var(&self, name: &str) -> Option<String> {
env::var(name).ok().filter(|value| !value.trim().is_empty())
}
fn path_exists(&self, path: &Path) -> bool {
path.exists()
}
fn process_running(&self, process_name: &str) -> bool {
let process_name = process_name.trim_end_matches(".exe");
let script = format!(
"if (Get-Process -Name '{}' -ErrorAction SilentlyContinue) {{ 'true' }} else {{ 'false' }}",
escape_powershell_single(process_name)
);
powershell_bool(&script)
}
fn service_running(&self, service_name: &str) -> bool {
let script = format!(
"$s = Get-Service -Name '{}' -ErrorAction SilentlyContinue; if ($s -and $s.Status -eq 'Running') {{ 'true' }} else {{ 'false' }}",
escape_powershell_single(service_name)
);
powershell_bool(&script)
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
read_registry_install_entries()
}
}
pub fn detect_proxyfier_install() -> Option<DetectedProxyfier> {
detect_proxyfier_install_with_host(&SystemProxyfierDetectionHost)
}
pub fn detect_proxyfier_install_with_host(
host: &impl ProxyfierDetectionHost,
) -> Option<DetectedProxyfier> {
let proxifyre_running = host.process_running("ProxiFyre.exe")
|| host.service_running("ProxiFyreService")
|| host.service_running("ProxiFyre");
proxyfier_candidates(host)
.into_iter()
.filter_map(|candidate| candidate.into_detected(host, proxifyre_running))
.next()
}
pub fn proxyfier_component_from_detection(detected: Option<&DetectedProxyfier>) -> ComponentStatus {
match detected {
Some(proxyfier) => detected_proxyfier_component(proxyfier),
None => missing_proxyfier_component(),
}
}
pub fn detect_singbox_install() -> Option<DetectedSingBox> {
detect_singbox_install_with_host(&SystemProxyfierDetectionHost)
}
pub fn detect_singbox_install_with_host(
host: &impl ProxyfierDetectionHost,
) -> Option<DetectedSingBox> {
let running = host.process_running("sing-box.exe")
|| host.service_running(DEFAULT_LOCAL_SINGBOX_SERVICE_NAME);
singbox_candidates(host)
.into_iter()
.filter_map(|install_dir| detected_singbox_from_dir(host, install_dir, running))
.next()
}
pub fn singbox_component_from_detection(detected: Option<&DetectedSingBox>) -> ComponentStatus {
match detected {
Some(singbox) => detected_singbox_component(singbox),
None => missing_singbox_component(),
}
}
fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatus {
let state = if proxyfier.running {
ComponentState::Running
} else {
ComponentState::Installed
};
let actions = match proxyfier.engine {
ProxyfierEngine::ProxiFyre => {
if proxyfier.running {
vec![
"Применить сгенерированный конфиг".to_string(),
"Открыть папку конфига".to_string(),
"Остановить".to_string(),
]
} else {
vec![
"Применить сгенерированный конфиг".to_string(),
"Открыть папку конфига".to_string(),
"Запустить".to_string(),
]
}
}
};
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state,
installed: true,
running: proxyfier.running,
version: Some(match proxyfier.engine {
ProxyfierEngine::ProxiFyre => "ProxiFyre найден".to_string(),
}),
path: Some(proxyfier.install_dir.display().to_string()),
problems: Vec::new(),
actions,
}
}
fn missing_proxyfier_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()],
actions: vec!["Установить ProxiFyre".to_string()],
}
}
fn detected_singbox_component(singbox: &DetectedSingBox) -> ComponentStatus {
let state = if singbox.running {
ComponentState::Running
} else {
ComponentState::Stopped
};
let actions = if singbox.running {
vec![
"Сгенерировать конфиг".to_string(),
"Остановить".to_string(),
"Открыть папку".to_string(),
]
} else {
vec![
"Сгенерировать конфиг".to_string(),
"Запустить".to_string(),
"Открыть папку".to_string(),
]
};
let problems = if singbox.running {
Vec::new()
} else {
vec!["Служба Local sing-box остановлена".to_string()]
};
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state,
installed: true,
running: singbox.running,
version: Some("sing-box найден".to_string()),
path: Some(singbox.executable_path.display().to_string()),
problems,
actions,
}
}
fn missing_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: Vec::new(),
actions: vec!["Установить Local sing-box".to_string()],
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProxyfierCandidate {
engine: ProxyfierEngine,
name: String,
install_dir: PathBuf,
}
impl ProxyfierCandidate {
fn into_detected(
self,
host: &impl ProxyfierDetectionHost,
proxifyre_running: bool,
) -> Option<DetectedProxyfier> {
let executable_path = self.install_dir.join(executable_name(&self.engine));
let config_path = config_path(&self.engine, &self.install_dir);
let exists = host.path_exists(&self.install_dir)
|| host.path_exists(&executable_path)
|| config_path
.as_ref()
.is_some_and(|path| host.path_exists(path));
if !exists {
return None;
}
Some(DetectedProxyfier {
service_name: service_name(&self.engine).map(str::to_string),
engine: self.engine,
name: self.name,
install_dir: self.install_dir,
executable_path,
config_path,
running: proxifyre_running,
})
}
}
fn proxyfier_candidates(host: &impl ProxyfierDetectionHost) -> Vec<ProxyfierCandidate> {
let mut candidates = Vec::new();
push_env_candidate(
&mut candidates,
host,
ProxyfierEngine::ProxiFyre,
"ProxiFyre",
"PROXYWARDEN_PROXIFYRE_ROOT",
);
for entry in host.registry_install_entries() {
if let Some(engine) = engine_from_name(&entry.display_name) {
let install_dir = entry
.install_location
.or_else(|| entry.display_icon.and_then(|path| executable_parent(&path)));
if let Some(install_dir) = install_dir {
push_candidate(
&mut candidates,
ProxyfierCandidate {
name: entry.display_name,
engine,
install_dir,
},
);
}
}
}
for install_dir in common_install_dirs(host, "ProxiFyre") {
push_candidate(
&mut candidates,
ProxyfierCandidate {
engine: ProxyfierEngine::ProxiFyre,
name: "ProxiFyre".to_string(),
install_dir,
},
);
}
candidates
}
fn push_env_candidate(
candidates: &mut Vec<ProxyfierCandidate>,
host: &impl ProxyfierDetectionHost,
engine: ProxyfierEngine,
name: &str,
env_name: &str,
) {
if let Some(path) = host.env_var(env_name) {
push_candidate(
candidates,
ProxyfierCandidate {
engine,
name: name.to_string(),
install_dir: PathBuf::from(path),
},
);
}
}
fn push_candidate(candidates: &mut Vec<ProxyfierCandidate>, candidate: ProxyfierCandidate) {
if !candidates.iter().any(|existing| {
existing.engine == candidate.engine
&& same_path(&existing.install_dir, &candidate.install_dir)
}) {
candidates.push(candidate);
}
}
fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) -> Vec<PathBuf> {
let mut dirs = vec![PathBuf::from(format!(r"C:\Tools\{folder_name}"))];
for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
if let Some(root) = host.env_var(env_name) {
dirs.push(PathBuf::from(root).join(folder_name));
}
}
dirs
}
fn singbox_candidates(host: &impl ProxyfierDetectionHost) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(path) = host.env_var("PROXYWARDEN_SINGBOX_ROOT") {
push_path_candidate(&mut candidates, PathBuf::from(path));
}
push_path_candidate(
&mut candidates,
PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT),
);
push_path_candidate(
&mut candidates,
PathBuf::from(r"C:\Tools\ProxyWarden\sing-box"),
);
for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
if let Some(root) = host.env_var(env_name) {
push_path_candidate(
&mut candidates,
PathBuf::from(&root).join("ProxyWarden").join("sing-box"),
);
push_path_candidate(&mut candidates, PathBuf::from(root).join("sing-box"));
}
}
candidates
}
fn push_path_candidate(candidates: &mut Vec<PathBuf>, candidate: PathBuf) {
if !candidates
.iter()
.any(|existing| same_path(existing, &candidate))
{
candidates.push(candidate);
}
}
fn detected_singbox_from_dir(
host: &impl ProxyfierDetectionHost,
install_dir: PathBuf,
running: bool,
) -> Option<DetectedSingBox> {
let executable_path = install_dir.join("sing-box.exe");
let wrapper_path = install_dir.join("ProxyWardenSingBox.exe");
let binary_exists = host.path_exists(&executable_path);
let wrapper_exists = host.path_exists(&wrapper_path);
let exists = host.path_exists(&install_dir) || binary_exists || wrapper_exists;
if !exists {
return None;
}
Some(DetectedSingBox {
install_dir,
executable_path,
wrapper_path,
binary_exists,
wrapper_exists,
running,
service_name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
})
}
fn executable_name(engine: &ProxyfierEngine) -> &'static str {
match engine {
ProxyfierEngine::ProxiFyre => "ProxiFyre.exe",
}
}
fn config_path(engine: &ProxyfierEngine, install_dir: &Path) -> Option<PathBuf> {
match engine {
ProxyfierEngine::ProxiFyre => Some(install_dir.join("app-config.json")),
}
}
fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> {
match engine {
ProxyfierEngine::ProxiFyre => Some("ProxiFyreService"),
}
}
fn engine_from_name(name: &str) -> Option<ProxyfierEngine> {
let normalized = name.to_ascii_lowercase();
if normalized.contains("proxifyre") {
Some(ProxyfierEngine::ProxiFyre)
} else {
None
}
}
fn executable_parent(path: &Path) -> Option<PathBuf> {
path.parent().map(Path::to_path_buf)
}
fn same_path(left: &Path, right: &Path) -> bool {
left.to_string_lossy()
.eq_ignore_ascii_case(&right.to_string_lossy())
}
fn powershell_bool(script: &str) -> bool {
Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.is_some_and(|stdout| stdout.trim().eq_ignore_ascii_case("true"))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct RegistryInstallJson {
display_name: Option<String>,
install_location: Option<String>,
display_icon: Option<String>,
}
fn read_registry_install_entries() -> Vec<RegistryInstallEntry> {
let script = r#"
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
$items = foreach ($path in $paths) {
Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
}
$items |
Where-Object { $_.DisplayName -match 'ProxiFyre' } |
Select-Object DisplayName,InstallLocation,DisplayIcon |
ConvertTo-Json -Compress
"#;
let Ok(output) = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let Ok(stdout) = String::from_utf8(output.stdout) else {
return Vec::new();
};
let stdout = stdout.trim();
if stdout.is_empty() {
return Vec::new();
}
parse_registry_json(stdout)
}
fn parse_registry_json(json: &str) -> Vec<RegistryInstallEntry> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return Vec::new();
};
match value {
serde_json::Value::Array(entries) => entries
.into_iter()
.filter_map(registry_entry_from_value)
.collect(),
entry => registry_entry_from_value(entry).into_iter().collect(),
}
}
fn registry_entry_from_value(value: serde_json::Value) -> Option<RegistryInstallEntry> {
let parsed = serde_json::from_value::<RegistryInstallJson>(value).ok()?;
let display_name = parsed.display_name?;
Some(RegistryInstallEntry {
display_name,
install_location: parsed
.install_location
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from),
display_icon: parsed
.display_icon
.and_then(|value| display_icon_path(&value)),
})
}
fn display_icon_path(value: &str) -> Option<PathBuf> {
let trimmed = value.trim().trim_matches('"');
if trimmed.is_empty() {
return None;
}
let without_icon_index = trimmed
.split_once(',')
.map(|(path, _)| path)
.unwrap_or(trimmed)
.trim()
.trim_matches('"');
Some(PathBuf::from(without_icon_index))
}
fn escape_powershell_single(value: &str) -> String {
value.replace('\'', "''")
}

184
src-tauri/src/helper.rs Normal file
View File

@@ -0,0 +1,184 @@
use crate::models::ComponentId;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HelperAction {
#[serde(rename = "install-control-app")]
InstallControlApp,
#[serde(rename = "install-proxyfier")]
InstallProxyfier,
#[serde(rename = "install-singbox")]
InstallSingbox,
#[serde(rename = "proxyfier.apply")]
ProxyfierApply,
#[serde(rename = "service.status")]
ServiceStatus,
#[serde(rename = "service.start")]
ServiceStart,
#[serde(rename = "service.stop")]
ServiceStop,
#[serde(rename = "service.restart")]
ServiceRestart,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperRequest {
pub action: HelperAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub component: Option<ComponentId>,
#[serde(default)]
pub payload: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperResponse {
pub success: bool,
pub action: HelperAction,
pub changed: bool,
pub message: String,
#[serde(default)]
pub details: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperCommandSpec {
pub program: PathBuf,
pub args: Vec<String>,
pub stdin: String,
pub requires_elevation: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperCommandOutput {
pub status_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperError {
pub code: String,
pub message: String,
}
impl HelperError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
pub trait HelperCommandRunner {
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError>;
}
#[derive(Debug, Clone)]
pub struct StructuredHelper<R> {
helper_program: PathBuf,
runner: R,
}
impl<R> StructuredHelper<R>
where
R: HelperCommandRunner,
{
pub fn new(helper_program: impl Into<PathBuf>, runner: R) -> Self {
Self {
helper_program: helper_program.into(),
runner,
}
}
pub fn runner(&self) -> &R {
&self.runner
}
pub fn execute(&self, request: &HelperRequest) -> Result<HelperResponse, HelperError> {
let stdin = serde_json::to_string(request)
.map_err(|error| HelperError::new("helper_request_encode", error.to_string()))?;
let spec = HelperCommandSpec {
program: self.helper_program.clone(),
args: vec!["--json".to_string()],
stdin,
requires_elevation: helper_action_requires_elevation(&request.action),
};
let output = self.runner.run(&spec)?;
if output.status_code != 0 {
return Err(HelperError::new(
"helper_exit",
format!(
"Помощник завершился с кодом {}: {}",
output.status_code, output.stderr
),
));
}
parse_helper_response(&output.stdout)
}
}
pub fn parse_helper_response(stdout: &str) -> Result<HelperResponse, HelperError> {
serde_json::from_str(stdout).map_err(|error| {
HelperError::new(
"helper_response_decode",
format!("Помощник вернул не JSON или некорректный JSON: {error}"),
)
})
}
pub fn install_request(component: ComponentId) -> HelperRequest {
let action = match component {
ComponentId::ControlApp => HelperAction::InstallControlApp,
ComponentId::Proxyfier => HelperAction::InstallProxyfier,
ComponentId::Singbox => HelperAction::InstallSingbox,
};
HelperRequest {
action,
component: Some(component),
payload: json!({}),
}
}
pub fn service_request(component: ComponentId, action: HelperAction) -> HelperRequest {
HelperRequest {
action,
component: Some(component),
payload: json!({}),
}
}
pub fn proxifyre_apply_request(
config_path: impl AsRef<Path>,
service_name: impl Into<String>,
) -> HelperRequest {
HelperRequest {
action: HelperAction::ProxyfierApply,
component: Some(ComponentId::Proxyfier),
payload: json!({
"configPath": config_path.as_ref().display().to_string(),
"serviceName": service_name.into(),
}),
}
}
pub fn helper_action_requires_elevation(action: &HelperAction) -> bool {
matches!(
action,
HelperAction::InstallControlApp
| HelperAction::InstallProxyfier
| HelperAction::InstallSingbox
| HelperAction::ProxyfierApply
| HelperAction::ServiceStart
| HelperAction::ServiceStop
| HelperAction::ServiceRestart
)
}

5
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,5 @@
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("не удалось запустить клиент ProxyWarden");
}

71
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,71 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod activity;
mod commands;
mod component_detection;
mod models;
mod singbox_service;
mod storage;
mod subscription;
mod validation;
mod adapters {
pub mod proxifyre;
pub mod proxy_router;
pub mod singbox;
}
#[cfg(test)]
pub(crate) mod proxifyre {
pub use crate::adapters::proxifyre::*;
}
#[cfg(test)]
pub(crate) mod proxy_router {
pub use crate::adapters::proxy_router::*;
}
#[cfg(test)]
pub(crate) mod singbox {
pub use crate::adapters::singbox::*;
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(commands::CommandState::default())
.invoke_handler(tauri::generate_handler![
commands::get_status,
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_singbox_status,
commands::get_singbox_setup_status,
commands::resolve_profile_preview,
commands::save_singbox_subscription,
commands::fetch_singbox_subscription,
commands::forget_singbox_subscription,
commands::select_singbox_server,
commands::ping_singbox_server,
commands::ping_all_singbox_servers,
commands::ping_proxy_target,
commands::generate_singbox_config,
commands::apply_profiles,
commands::get_logs,
commands::open_config_location,
commands::start_proxifyre_service,
commands::stop_proxifyre_service,
commands::install_proxifyre,
commands::uninstall_proxifyre,
commands::start_singbox_service,
commands::stop_singbox_service,
commands::install_singbox,
commands::uninstall_singbox
])
.run(tauri::generate_context!())
.expect("не удалось запустить клиент ProxyWarden");
}

273
src-tauri/src/models.rs Normal file
View File

@@ -0,0 +1,273 @@
use serde::{Deserialize, Serialize};
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
pub const DEFAULT_LOCAL_SINGBOX_SERVICE_NAME: &str = "ProxyWardenSingBox";
pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str = r"C:\Program Files\ProxyWarden\sing-box";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Protocol {
Tcp,
Udp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ProfileItemType {
Process,
Folder,
Exe,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TargetKind {
Local,
External,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProxyProtocol {
Socks5,
Http,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ComponentId {
ControlApp,
Proxyfier,
Singbox,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ComponentState {
Installed,
Missing,
Stopped,
Running,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileItemInput {
#[serde(rename = "type")]
pub item_type: String,
pub value: String,
#[serde(default)]
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileInput {
pub id: Option<String>,
pub name: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_target_id")]
pub target_id: String,
#[serde(default = "default_protocols")]
pub protocols: Vec<String>,
#[serde(default)]
pub items: Vec<ProfileItemInput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileItem {
#[serde(rename = "type")]
pub item_type: ProfileItemType,
pub value: String,
pub recursive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Profile {
pub id: String,
pub name: String,
pub enabled: bool,
pub target_id: String,
pub protocols: Vec<Protocol>,
pub items: Vec<ProfileItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TargetInput {
pub id: Option<String>,
pub name: String,
#[serde(default = "default_target_kind")]
pub kind: String,
#[serde(default = "default_proxy_protocol")]
pub protocol: String,
pub host: String,
pub port: u32,
#[serde(default)]
pub requires_component: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Target {
pub id: String,
pub name: String,
pub kind: TargetKind,
pub protocol: ProxyProtocol,
pub host: String,
pub port: u16,
pub requires_component: Option<ComponentId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentStatus {
pub id: ComponentId,
pub name: String,
pub state: ComponentState,
pub installed: bool,
pub running: bool,
pub version: Option<String>,
pub path: Option<String>,
#[serde(default)]
pub problems: Vec<String>,
#[serde(default)]
pub actions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalSingBoxConfig {
#[serde(default)]
pub subscription_url: Option<String>,
#[serde(default)]
pub selected_server_tag: Option<String>,
#[serde(default = "default_local_singbox_listen_host")]
pub listen_host: String,
#[serde(default = "default_local_singbox_listen_port")]
pub listen_port: u16,
#[serde(default = "default_local_singbox_service_name")]
pub service_name: String,
#[serde(default = "default_local_singbox_install_root")]
pub install_root: String,
#[serde(default)]
pub updated_at: Option<String>,
}
impl LocalSingBoxConfig {
pub fn subscription_display_url(&self) -> Option<String> {
self.subscription_url
.as_deref()
.map(redact_subscription_url)
}
}
impl Default for LocalSingBoxConfig {
fn default() -> Self {
Self {
subscription_url: None,
selected_server_tag: None,
listen_host: default_local_singbox_listen_host(),
listen_port: default_local_singbox_listen_port(),
service_name: default_local_singbox_service_name(),
install_root: default_local_singbox_install_root(),
updated_at: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SubscriptionCache {
pub config: serde_json::Value,
#[serde(default)]
pub servers: Vec<SubscriptionServer>,
#[serde(default)]
pub user_info: serde_json::Map<String, serde_json::Value>,
pub fetched_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscriptionServer {
pub tag: String,
#[serde(rename = "type")]
pub server_type: String,
pub server: String,
pub server_port: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActivityEntry {
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 = "lowercase")]
pub enum ActivityLevel {
Info,
Warning,
Error,
Success,
}
fn default_enabled() -> bool {
true
}
fn default_target_id() -> String {
"local-singbox".to_string()
}
fn default_protocols() -> Vec<String> {
vec!["TCP".to_string(), "UDP".to_string()]
}
fn default_target_kind() -> String {
"external".to_string()
}
fn default_proxy_protocol() -> String {
"socks5".to_string()
}
fn default_local_singbox_listen_host() -> String {
DEFAULT_LOCAL_SINGBOX_LISTEN_HOST.to_string()
}
fn default_local_singbox_listen_port() -> u16 {
DEFAULT_LOCAL_SINGBOX_LISTEN_PORT
}
fn default_local_singbox_service_name() -> String {
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()
}
fn default_local_singbox_install_root() -> String {
DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string()
}
pub fn redact_subscription_url(raw_url: &str) -> String {
let trimmed = raw_url.trim();
if trimmed.is_empty() {
return String::new();
}
match trimmed.split_once("://") {
Some((scheme, rest)) => {
let host = rest
.split(['/', '?', '#'])
.next()
.filter(|value| !value.is_empty())
.unwrap_or("subscription");
format!("{scheme}://{host}/...")
}
None => {
let visible = trimmed.chars().take(18).collect::<String>();
if trimmed.chars().count() <= 18 {
"***".to_string()
} else {
format!("{visible}...")
}
}
}
}

View File

@@ -0,0 +1,273 @@
use crate::component_detection::DetectedSingBox;
use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME};
use serde::{Deserialize, Serialize};
use std::path::Path;
pub const SINGBOX_RELEASE_API_URL: &str =
"https://api.github.com/repos/SagerNet/sing-box/releases/latest";
pub const WINSW_RELEASE_API_URL: &str = "https://api.github.com/repos/winsw/winsw/releases/latest";
pub const WINSW_WRAPPER_FILE: &str = "ProxyWardenSingBox.exe";
pub const SINGBOX_BINARY_FILE: &str = "sing-box.exe";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SingBoxServiceAction {
Start,
Stop,
}
impl SingBoxServiceAction {
pub fn action_name(self) -> &'static str {
match self {
SingBoxServiceAction::Start => "start",
SingBoxServiceAction::Stop => "stop",
}
}
pub fn label(self) -> &'static str {
match self {
SingBoxServiceAction::Start => "запустить",
SingBoxServiceAction::Stop => "остановить",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SingBoxSetupStatus {
pub ready: bool,
pub missing_count: usize,
pub items: Vec<SingBoxSetupItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SingBoxSetupItem {
pub id: String,
pub name: String,
pub installed: bool,
pub version: Option<String>,
pub details: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServiceCommandOutput {
pub success: bool,
pub code: String,
pub service_name: Option<String>,
pub status: Option<String>,
pub process_id: Option<u32>,
}
pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBoxSetupStatus {
let install_root = detected
.map(|singbox| singbox.install_dir.display().to_string())
.unwrap_or_else(|| DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string());
let binary_item = match detected {
Some(singbox) if singbox.binary_exists => SingBoxSetupItem {
id: "sing-box-binary".to_string(),
name: "sing-box".to_string(),
installed: true,
version: Some("binary найден".to_string()),
details: singbox.executable_path.display().to_string(),
},
_ => SingBoxSetupItem {
id: "sing-box-binary".to_string(),
name: "sing-box".to_string(),
installed: false,
version: None,
details: format!(
"Будет скачан из GitHub releases SagerNet/sing-box и установлен в {install_root}."
),
},
};
let wrapper_item = match detected {
Some(singbox) if singbox.wrapper_exists => SingBoxSetupItem {
id: "winsw-wrapper".to_string(),
name: "WinSW service wrapper".to_string(),
installed: true,
version: Some("wrapper найден".to_string()),
details: singbox.wrapper_path.display().to_string(),
},
_ => SingBoxSetupItem {
id: "winsw-wrapper".to_string(),
name: "WinSW service wrapper".to_string(),
installed: false,
version: None,
details: format!(
"Будет скачан из GitHub releases winsw/winsw как {WINSW_WRAPPER_FILE}."
),
},
};
let service_item = match detected {
Some(singbox) if singbox.running => SingBoxSetupItem {
id: "windows-service".to_string(),
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
installed: true,
version: Some("служба запущена".to_string()),
details: format!("Служба {}", singbox.service_name),
},
Some(singbox) => SingBoxSetupItem {
id: "windows-service".to_string(),
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
installed: true,
version: Some("служба остановлена".to_string()),
details: format!("Служба {}", singbox.service_name),
},
None => SingBoxSetupItem {
id: "windows-service".to_string(),
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
installed: false,
version: None,
details: "Будет создана Windows-служба Local sing-box.".to_string(),
},
};
let items = vec![binary_item, wrapper_item, service_item];
let missing_count = items.iter().filter(|item| !item.installed).count();
SingBoxSetupStatus {
ready: missing_count == 0,
missing_count,
items,
}
}
pub fn parse_service_command_output(stdout: &[u8]) -> Option<ServiceCommandOutput> {
let stdout = String::from_utf8_lossy(stdout);
let payload = stdout
.lines()
.rev()
.map(str::trim)
.find(|line| line.starts_with('{') && line.ends_with('}'))?;
serde_json::from_str(payload).ok()
}
pub fn ensure_safe_singbox_install_dir(path: &Path) -> Result<(), String> {
let normalized = path
.display()
.to_string()
.replace('/', "\\")
.to_ascii_lowercase();
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if file_name == "sing-box"
&& (normalized.contains("\\proxywarden\\") || normalized.contains("\\proxywarden\\"))
{
return Ok(());
}
Err(format!(
"Отказываюсь рекурсивно удалять Local sing-box с небезопасным путем: {}",
path.display()
))
}
pub fn service_control_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 = 'Stop'
$serviceName = '{escaped_service_name}'
$action = '{action_name}'
$configSource = '{escaped_config_source}'
$configTarget = '{escaped_config_target}'
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
}}
function Get-ServiceStatus([string]$name) {{
$current = Get-Service -Name $name -ErrorAction SilentlyContinue
if ($null -eq $current) {{ return $null }}
return $current.Status.ToString()
}}
function Write-ServiceResult([bool]$success, [string]$code, [string]$status, [int]$processId) {{
[PSCustomObject]@{{
success = $success
code = $code
serviceName = $serviceName
status = $status
processId = $processId
}} | ConvertTo-Json -Compress
exit 0
}}
function Sync-ServiceConfig {{
if ($action -ne 'start' -or [string]::IsNullOrWhiteSpace($configSource)) {{ return }}
if (-not (Test-Path -LiteralPath $configSource)) {{
Write-ServiceResult $false 'config_source_missing' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
}}
if ([string]::IsNullOrWhiteSpace($configTarget)) {{ return }}
try {{
Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop
}} catch {{
Write-ServiceResult $false 'config_sync_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
}}
}}
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($null -eq $service) {{
Write-ServiceResult $false 'service_not_found' $null 0
}}
if ($action -eq 'start') {{
Sync-ServiceConfig
if ($service.Status -eq 'Running') {{
Write-ServiceResult $true 'already_running' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
}}
try {{
Start-Service -Name $serviceName -ErrorAction Stop
$service = Get-Service -Name $serviceName -ErrorAction Stop
$service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15))
}} catch {{
Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
}}
Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
}}
if ($service.Status -eq 'Stopped') {{
Write-ServiceResult $true 'already_stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
}}
try {{
Stop-Service -Name $serviceName -Force -ErrorAction Stop
$service = Get-Service -Name $serviceName -ErrorAction Stop
$service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15))
}} catch {{
Write-ServiceResult $false 'stop_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
}}
Write-ServiceResult ($service.Status -eq 'Stopped') 'stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
"#
)
}
fn escape_powershell_single(value: &str) -> String {
value.replace('\'', "''")
}

228
src-tauri/src/storage.rs Normal file
View File

@@ -0,0 +1,228 @@
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
use crate::models::{
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target,
};
use serde::{de::DeserializeOwned, Serialize};
use std::fs;
use std::io::{self, ErrorKind};
use std::path::{Path, PathBuf};
pub fn default_config_root() -> PathBuf {
PathBuf::from(r"C:\ProgramData\ProxyWarden")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoragePaths {
pub root: PathBuf,
pub config_dir: PathBuf,
pub state_dir: PathBuf,
pub generated_dir: PathBuf,
pub profiles_file: PathBuf,
pub targets_file: PathBuf,
pub components_file: PathBuf,
pub local_singbox_file: PathBuf,
pub singbox_subscription_cache_file: PathBuf,
pub activity_file: PathBuf,
}
impl StoragePaths {
pub fn new(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let config_dir = root.join("config");
let state_dir = root.join("state");
let generated_dir = root.join("generated");
Self {
root,
profiles_file: config_dir.join("profiles.json"),
targets_file: config_dir.join("targets.json"),
components_file: config_dir.join("components.json"),
local_singbox_file: config_dir.join("local-singbox.json"),
singbox_subscription_cache_file: state_dir.join("singbox-subscription-cache.json"),
activity_file: state_dir.join("activity.json"),
config_dir,
state_dir,
generated_dir,
}
}
}
impl Default for StoragePaths {
fn default() -> Self {
Self::new(default_config_root())
}
}
#[derive(Debug, Clone)]
pub struct JsonStorage {
paths: StoragePaths,
activity_limit: usize,
}
impl JsonStorage {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self::with_activity_limit(root, DEFAULT_ACTIVITY_LIMIT)
}
pub fn with_activity_limit(root: impl Into<PathBuf>, activity_limit: usize) -> Self {
Self {
paths: StoragePaths::new(root),
activity_limit,
}
}
pub fn paths(&self) -> &StoragePaths {
&self.paths
}
pub fn ensure_dirs(&self) -> io::Result<()> {
fs::create_dir_all(&self.paths.config_dir)?;
fs::create_dir_all(&self.paths.state_dir)?;
fs::create_dir_all(&self.paths.generated_dir)?;
Ok(())
}
pub fn read_profiles(&self) -> io::Result<Vec<Profile>> {
self.read_json_or_default(&self.paths.profiles_file)
}
pub fn write_profiles(&self, profiles: &[Profile]) -> io::Result<()> {
self.write_json(&self.paths.profiles_file, profiles)
}
pub fn read_targets(&self) -> io::Result<Vec<Target>> {
self.read_json_or_default(&self.paths.targets_file)
}
pub fn write_targets(&self, targets: &[Target]) -> io::Result<()> {
self.write_json(&self.paths.targets_file, targets)
}
pub fn read_components(&self) -> io::Result<Vec<ComponentStatus>> {
self.read_json_or_default(&self.paths.components_file)
}
pub fn write_components(&self, components: &[ComponentStatus]) -> io::Result<()> {
self.write_json(&self.paths.components_file, components)
}
pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
self.read_json_or_default(&self.paths.local_singbox_file)
}
pub fn write_local_singbox_config(&self, config: &LocalSingBoxConfig) -> io::Result<()> {
self.write_json(&self.paths.local_singbox_file, config)
}
pub fn read_singbox_subscription_cache(&self) -> io::Result<Option<SubscriptionCache>> {
self.read_optional_json(&self.paths.singbox_subscription_cache_file)
}
pub fn write_singbox_subscription_cache(&self, cache: &SubscriptionCache) -> io::Result<()> {
self.write_json(&self.paths.singbox_subscription_cache_file, cache)
}
pub fn remove_singbox_subscription_cache(&self) -> io::Result<()> {
match fs::remove_file(&self.paths.singbox_subscription_cache_file) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
pub fn read_activity(&self) -> io::Result<Vec<ActivityEntry>> {
let entries = self.read_json_or_default(&self.paths.activity_file)?;
Ok(cap_activity(entries, self.activity_limit))
}
pub fn write_activity(&self, entries: &[ActivityEntry]) -> io::Result<()> {
let entries = cap_activity(entries.to_vec(), self.activity_limit);
self.write_json(&self.paths.activity_file, &entries)
}
pub fn append_activity(&self, entry: ActivityEntry) -> io::Result<Vec<ActivityEntry>> {
let entries = self.read_activity()?;
let entries = append_activity(entries, entry, self.activity_limit);
self.write_json(&self.paths.activity_file, &entries)?;
Ok(entries)
}
fn read_json_or_default<T>(&self, path: &Path) -> io::Result<T>
where
T: DeserializeOwned + Default,
{
match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(value) => Ok(value),
Err(_) => Ok(T::default()),
},
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
Err(error) => Err(error),
}
}
fn write_json<T>(&self, path: &Path, value: &T) -> io::Result<()>
where
T: Serialize + ?Sized,
{
let contents = serde_json::to_vec_pretty(value)
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
write_atomic(path, &contents)
}
fn read_optional_json<T>(&self, path: &Path) -> io::Result<Option<T>>
where
T: DeserializeOwned,
{
match fs::read_to_string(path) {
Ok(contents) => Ok(serde_json::from_str(&contents).ok()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
}
impl Default for JsonStorage {
fn default() -> Self {
Self::new(default_config_root())
}
}
pub fn backup_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "bak")
}
fn temp_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "tmp")
}
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("storage.json");
path.with_file_name(format!("{file_name}.{suffix}"))
}
fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp_path = temp_path(path);
fs::write(&temp_path, contents)?;
if path.exists() {
fs::copy(path, backup_path(path))?;
fs::remove_file(path)?;
}
match fs::rename(&temp_path, path) {
Ok(()) => Ok(()),
Err(error) => {
let _ = fs::remove_file(&temp_path);
Err(error)
}
}
}

View File

@@ -0,0 +1,269 @@
use crate::models::{SubscriptionCache, SubscriptionServer};
use base64::{engine::general_purpose, Engine};
use serde_json::{json, Map, Value};
use std::time::{SystemTime, UNIX_EPOCH};
use url::Url;
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionError {
pub message: String,
}
impl SubscriptionError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for SubscriptionError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for SubscriptionError {}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedSubscription {
pub config: Value,
pub servers: Vec<SubscriptionServer>,
}
pub fn parse_subscription_body(body: &str) -> Result<ParsedSubscription, SubscriptionError> {
let config = match serde_json::from_str::<Value>(body) {
Ok(value) => value,
Err(_) => parse_link_subscription(body)?,
};
let servers = servers_from_config(&config)?;
Ok(ParsedSubscription { config, servers })
}
pub fn parse_user_info(header_value: Option<&str>) -> Map<String, Value> {
let mut result = Map::new();
let Some(header_value) = header_value else {
return result;
};
for part in header_value.split(';') {
let Some((key, value)) = part.trim().split_once('=') else {
continue;
};
let key = key.trim();
if key.is_empty() {
continue;
}
if let Ok(parsed) = value.trim().parse::<i64>() {
result.insert(key.to_string(), Value::Number(parsed.into()));
}
}
result
}
pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionError> {
let parsed_url =
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
if !matches!(parsed_url.scheme(), "http" | "https") {
return Err(SubscriptionError::new(
"Subscription URL must use http or https",
));
}
let response = reqwest::blocking::Client::new()
.get(parsed_url)
.header("user-agent", "singbox")
.header("x-device-os", std::env::consts::OS)
.header("x-device-model", "proxywarden")
.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(),
})
}
fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
let decoded = maybe_decode_base64(body);
let links = decoded
.lines()
.map(str::trim)
.filter(|line| line.starts_with("vless://"))
.collect::<Vec<_>>();
if links.is_empty() {
return Err(SubscriptionError::new(
"Subscription does not contain JSON config or VLESS links",
));
}
let outbounds = links
.into_iter()
.map(parse_vless_url)
.collect::<Result<Vec<_>, _>>()?;
Ok(json!({ "outbounds": outbounds }))
}
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://"));
}
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?;
let tag = parsed.fragment().unwrap_or("vless-out").to_string();
let uuid = 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);
let public_key = query_value(&parsed, "pbk").unwrap_or_default();
let short_id = query_value(&parsed, "sid").unwrap_or_default();
let server_name = query_value(&parsed, "sni").unwrap_or_else(|| server.clone());
let fingerprint = query_value(&parsed, "fp").unwrap_or_else(|| "chrome".to_string());
let flow = query_value(&parsed, "flow").unwrap_or_default();
if uuid.is_empty() || server.is_empty() {
return Err(SubscriptionError::new(
"VLESS URL misses uuid, host or port",
));
}
if public_key.is_empty() || short_id.is_empty() {
return Err(SubscriptionError::new(
"VLESS REALITY parameters pbk and sid are required",
));
}
Ok(json!({
"type": "vless",
"tag": tag,
"server": server,
"server_port": server_port,
"uuid": uuid,
"flow": flow,
"tls": {
"enabled": true,
"server_name": server_name,
"utls": {
"enabled": true,
"fingerprint": fingerprint
},
"reality": {
"enabled": true,
"public_key": public_key,
"short_id": short_id
}
},
"packet_encoding": "xudp"
}))
}
fn servers_from_config(config: &Value) -> Result<Vec<SubscriptionServer>, SubscriptionError> {
let servers = config
.get("outbounds")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(server_from_outbound)
.collect::<Vec<_>>();
if servers.is_empty() {
return Err(SubscriptionError::new(
"No supported proxy outbounds found in subscription",
));
}
Ok(servers)
}
fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
let server_type = outbound.get("type")?.as_str()?.to_string();
if !SUPPORTED_PROXY_TYPES.contains(&server_type.as_str()) {
return None;
}
let server = outbound
.get("server")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let server_port = outbound
.get("server_port")
.and_then(Value::as_u64)
.and_then(|value| u16::try_from(value).ok())
.unwrap_or(443);
let tag = outbound
.get("tag")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| format!("{server_type}-{server}"));
Some(SubscriptionServer {
tag,
server_type,
server,
server_port,
})
}
fn maybe_decode_base64(content: &str) -> String {
let compact = content.split_whitespace().collect::<String>();
if compact.is_empty()
|| !compact
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '-' | '_'))
{
return content.to_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('{') {
return decoded;
}
}
}
}
content.to_string()
}
fn query_value(url: &Url, key: &str) -> Option<String> {
url.query_pairs()
.find(|(name, _)| name == key)
.map(|(_, value)| value.into_owned())
}
fn now_timestamp() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
format!("unix:{seconds}")
}

223
src-tauri/src/validation.rs Normal file
View File

@@ -0,0 +1,223 @@
use crate::models::{
ComponentId, Profile, ProfileInput, ProfileItem, ProfileItemType, Protocol, ProxyProtocol,
Target, TargetInput, TargetKind,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
pub type ValidationResult<T> = Result<T, Vec<ValidationError>>;
fn error(field: impl Into<String>, message: impl Into<String>) -> ValidationError {
ValidationError {
field: field.into(),
message: message.into(),
}
}
fn clean(value: &str) -> String {
value.trim().to_string()
}
fn slug(value: &str, fallback: &str) -> String {
let mut output = String::new();
let mut previous_dash = false;
for ch in value.trim().to_lowercase().chars() {
if ch.is_ascii_alphanumeric() {
output.push(ch);
previous_dash = false;
} else if !previous_dash {
output.push('-');
previous_dash = true;
}
}
let output = output.trim_matches('-').to_string();
if output.is_empty() {
fallback.to_string()
} else {
output
}
}
fn process_name(value: &str) -> String {
let base = value.trim().rsplit(['\\', '/']).next().unwrap_or("").trim();
base.strip_suffix(".exe")
.or_else(|| base.strip_suffix(".EXE"))
.unwrap_or(base)
.trim()
.to_string()
}
pub fn parse_protocol(value: &str) -> Result<Protocol, ValidationError> {
match value.trim().to_ascii_uppercase().as_str() {
"TCP" => Ok(Protocol::Tcp),
"UDP" => Ok(Protocol::Udp),
_ => Err(error(
"protocols",
format!("Неподдерживаемый протокол: {value}"),
)),
}
}
pub fn parse_profile_item_type(value: &str) -> Result<ProfileItemType, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"process" => Ok(ProfileItemType::Process),
"folder" => Ok(ProfileItemType::Folder),
"exe" => Ok(ProfileItemType::Exe),
_ => Err(error(
"items.type",
format!("Неподдерживаемый тип элемента: {value}"),
)),
}
}
pub fn parse_target_kind(value: &str) -> Result<TargetKind, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"local" => Ok(TargetKind::Local),
"external" => Ok(TargetKind::External),
_ => Err(error("kind", format!("Неподдерживаемый тип цели: {value}"))),
}
}
pub fn parse_proxy_protocol(value: &str) -> Result<ProxyProtocol, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"socks5" => Ok(ProxyProtocol::Socks5),
"http" => Ok(ProxyProtocol::Http),
_ => Err(error(
"protocol",
format!("Неподдерживаемый протокол прокси: {value}"),
)),
}
}
pub fn parse_component_id(value: &str) -> Result<ComponentId, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"control-app" | "controlapp" => Ok(ComponentId::ControlApp),
"proxyfier" => Ok(ComponentId::Proxyfier),
"singbox" | "sing-box" => Ok(ComponentId::Singbox),
_ => Err(error(
"requires_component",
format!("Неподдерживаемый компонент: {value}"),
)),
}
}
pub fn normalize_profile(input: ProfileInput) -> ValidationResult<Profile> {
let mut errors = Vec::new();
let name = clean(&input.name);
if name.is_empty() {
errors.push(error("name", "Укажите название профиля"));
}
let target_id = clean(&input.target_id);
if target_id.is_empty() {
errors.push(error("target_id", "Укажите цель профиля"));
}
let mut protocols = Vec::new();
for value in input.protocols {
match parse_protocol(&value) {
Ok(protocol) if !protocols.contains(&protocol) => protocols.push(protocol),
Ok(_) => {}
Err(err) => errors.push(err),
}
}
if protocols.is_empty() {
errors.push(error("protocols", "Выберите хотя бы один протокол"));
}
let mut items = Vec::new();
for raw_item in input.items {
let item_type = match parse_profile_item_type(&raw_item.item_type) {
Ok(item_type) => item_type,
Err(err) => {
errors.push(err);
continue;
}
};
let value = match item_type {
ProfileItemType::Process => process_name(&raw_item.value),
ProfileItemType::Folder | ProfileItemType::Exe => clean(&raw_item.value),
};
if value.is_empty() {
errors.push(error("items.value", "Укажите значение элемента профиля"));
continue;
}
let recursive =
matches!(item_type, ProfileItemType::Folder) && raw_item.recursive.unwrap_or(true);
items.push(ProfileItem {
item_type,
value,
recursive,
});
}
if !errors.is_empty() {
return Err(errors);
}
Ok(Profile {
id: slug(input.id.as_deref().unwrap_or(&name), "profile"),
name,
enabled: input.enabled,
target_id,
protocols,
items,
})
}
pub fn normalize_target(input: TargetInput) -> ValidationResult<Target> {
let mut errors = Vec::new();
let name = clean(&input.name);
let host = clean(&input.host);
if name.is_empty() {
errors.push(error("name", "Укажите название цели"));
}
if host.is_empty() {
errors.push(error("host", "Укажите хост цели"));
}
if input.port == 0 || input.port > u16::MAX as u32 {
errors.push(error("port", "Порт цели должен быть от 1 до 65535"));
}
let kind = parse_target_kind(&input.kind).unwrap_or_else(|err| {
errors.push(err);
TargetKind::External
});
let protocol = parse_proxy_protocol(&input.protocol).unwrap_or_else(|err| {
errors.push(err);
ProxyProtocol::Socks5
});
let requires_component = match input.requires_component {
Some(value) if !value.trim().is_empty() => match parse_component_id(&value) {
Ok(component) => Some(component),
Err(err) => {
errors.push(err);
None
}
},
_ => None,
};
if !errors.is_empty() {
return Err(errors);
}
Ok(Target {
id: slug(input.id.as_deref().unwrap_or(&name), "target"),
name,
kind,
protocol,
host,
port: input.port as u16,
requires_component,
})
}

37
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,37 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyWarden",
"version": "0.1.0",
"identifier": "ru.dokops.proxywarden.windows",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://127.0.0.1:5173",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "ProxyWarden",
"width": 1120,
"height": 760,
"minWidth": 760,
"minHeight": 560,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
]
}
}

View File

@@ -0,0 +1,528 @@
#[path = "../src/activity.rs"]
mod activity;
#[path = "../src/commands.rs"]
mod commands;
#[path = "../src/component_detection.rs"]
mod component_detection;
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
#[path = "../src/adapters/singbox.rs"]
mod singbox;
#[path = "../src/singbox_service.rs"]
mod singbox_service;
#[path = "../src/storage.rs"]
mod storage;
#[path = "../src/subscription.rs"]
mod subscription;
#[path = "../src/validation.rs"]
mod validation;
use commands::{
apply_profiles_with_services, build_status, resolve_component_statuses, resolve_preview,
save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper,
HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper,
TargetInputDto,
};
use component_detection::{
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
};
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind,
};
use proxifyre::ProxiFyreAdapter;
use std::collections::HashSet;
use std::fs;
use std::net::TcpListener;
use std::path::{Path, PathBuf};
#[cfg(windows)]
use std::process::Command as ProcessCommand;
use std::time::{SystemTime, UNIX_EPOCH};
use storage::JsonStorage;
#[test]
fn save_commands_normalize_and_persist_profile_and_target() {
let root = test_root("save");
let storage = JsonStorage::new(root.clone());
let target = save_target_to_storage(
&storage,
TargetInputDto {
id: Some("Home Gateway".to_string()),
name: " Home Gateway ".to_string(),
kind: Some("external".to_string()),
protocol: Some("socks5".to_string()),
host: " 192.168.50.111 ".to_string(),
port: 8080,
requires_component: None,
},
)
.expect("target command should normalize");
let profile = save_profile_to_storage(
&storage,
ProfileInputDto {
id: Some("Discord".to_string()),
name: " Discord ".to_string(),
enabled: Some(true),
target_id: Some("home-gateway".to_string()),
protocols: Some(vec!["tcp".to_string(), "UDP".to_string()]),
items: Some(vec![ProfileItemInputDto {
item_type: "process".to_string(),
value: "Discord.exe".to_string(),
recursive: None,
}]),
},
)
.expect("profile command should normalize");
assert_eq!(target.id, "home-gateway");
assert_eq!(profile.id, "discord");
assert_eq!(profile.target_id, "home-gateway");
assert_eq!(profile.items[0].value, "Discord");
let status = build_status(&storage).expect("status command should read stored state");
assert_eq!(
status.route_line,
"Выбранные приложения -> ProxiFyre -> внешний прокси 192.168.50.111:8080"
);
assert_eq!(status.active_profile_count, 1);
assert_eq!(status.routed_app_count, 1);
cleanup(&root);
}
#[test]
fn resolve_preview_returns_structured_apps_without_filesystem_scan() {
let preview = resolve_preview(ProfileInputDto {
id: Some("Game".to_string()),
name: "Game".to_string(),
enabled: Some(true),
target_id: Some("home-gateway".to_string()),
protocols: Some(vec!["TCP".to_string()]),
items: Some(vec![
ProfileItemInputDto {
item_type: "process".to_string(),
value: "Discord.exe".to_string(),
recursive: None,
},
ProfileItemInputDto {
item_type: "folder".to_string(),
value: r"C:\Games\Launcher".to_string(),
recursive: Some(true),
},
]),
})
.expect("preview should normalize profile input");
assert_eq!(preview.profile_id, "game");
assert_eq!(preview.apps.len(), 2);
assert_eq!(preview.apps[0].app_name, "Discord");
assert_eq!(preview.apps[1].source_type, ProfileItemType::Folder);
assert!(preview
.warnings
.iter()
.any(|warning| warning.contains("Сканирование папок отложено")));
}
#[test]
fn ping_proxy_target_reports_open_tcp_endpoint() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
let port = listener.local_addr().expect("read local addr").port();
let result = commands::ping_proxy_target_endpoint(commands::PingProxyTargetInputDto {
host: "127.0.0.1".to_string(),
port,
})
.expect("ping should return response");
assert_eq!(result.tag, "external-proxy");
assert_eq!(result.server, "127.0.0.1");
assert_eq!(result.server_port, port);
assert!(result.ok);
assert!(result.latency.is_some());
}
#[test]
#[cfg(windows)]
fn proxifyre_install_script_parses_as_powershell() {
let root = test_root("proxifyre-install-script");
fs::create_dir_all(&root).expect("test root should be created");
let script = commands::wrap_elevated_package_script(
&commands::install_proxifyre_script(&root.join("proxifyre-app-config.json")),
&root.join("install.log"),
);
let script_path = root.join("install.ps1");
let mut script_bytes = vec![0xEF, 0xBB, 0xBF];
script_bytes.extend_from_slice(script.as_bytes());
fs::write(&script_path, script_bytes).expect("script should be written");
let escaped_path = script_path.display().to_string().replace('\'', "''");
let parser = format!(
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
);
let output = ProcessCommand::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
.output()
.expect("powershell parser should run");
assert!(
output.status.success(),
"install script should parse\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
cleanup(&root);
}
#[test]
fn singbox_runner_preserves_installer_args_with_spaces() {
let script = commands::singbox_installer_runner_script(
Path::new(r"C:\ProgramData\ProxyWarden\state\install-singbox.ps1"),
Path::new(r"C:\ProgramData\ProxyWarden\state\install.log"),
&[
"-InstallRoot".to_string(),
r"C:\Program Files\ProxyWarden\sing-box".to_string(),
"-ServiceName".to_string(),
"ProxyWardenSingBox".to_string(),
"-Uninstall".to_string(),
],
);
assert!(script.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'"));
assert!(script.contains("& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs"));
assert!(!script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList"));
}
#[test]
fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
let root = test_root("apply");
let storage = JsonStorage::new(root.clone());
storage
.write_profiles(&[discord_profile("home-gateway")])
.expect("write profiles");
storage
.write_targets(&[external_socks5_target()])
.expect("write targets");
storage
.write_components(&[proxyfier_running(), singbox_missing()])
.expect("write components");
let response = apply_profiles_with_services(
&storage,
&ProxiFyreAdapter::default(),
&MockApplyHelper,
&FixedClock,
)
.expect("apply command should generate config and call helper");
let generated_path = PathBuf::from(&response.generated_config_path);
let generated_contents = fs::read_to_string(&generated_path).expect("read generated config");
let activity = storage.read_activity().expect("read activity");
assert!(response.success);
assert!(response.changed);
assert_eq!(response.adapter_id, "proxifyre");
assert_eq!(response.enabled_profiles, 1);
assert_eq!(response.routed_apps, 1);
assert_eq!(response.helper.action, "proxyfier.apply.mock");
assert!(generated_contents.contains("\"appNames\""));
assert!(generated_contents.contains("Discord"));
assert!(generated_path.ends_with("proxifyre-app-config.json"));
assert_eq!(activity.len(), 1);
assert_eq!(activity[0].at, "2026-07-03T00:00:00Z");
assert_eq!(activity[0].title, "Конфиг ProxiFyre создан");
cleanup(&root);
}
#[test]
fn apply_blocks_local_singbox_target_when_component_is_missing() {
let root = test_root("missing-singbox");
let storage = JsonStorage::new(root.clone());
storage
.write_profiles(&[discord_profile("local-singbox")])
.expect("write profiles");
storage
.write_targets(&[local_singbox_target()])
.expect("write targets");
storage
.write_components(&[singbox_missing()])
.expect("write components");
let error = apply_profiles_with_services(
&storage,
&ProxiFyreAdapter::default(),
&MockApplyHelper,
&FixedClock,
)
.expect_err("missing sing-box should block local target apply");
let activity = storage.read_activity().expect("read blocked activity");
assert_eq!(error.code, "required_component_not_running");
assert_eq!(activity.len(), 1);
assert_eq!(activity[0].level, models::ActivityLevel::Error);
assert_eq!(activity[0].title, "Применение ProxiFyre заблокировано");
cleanup(&root);
}
#[test]
fn component_status_merges_detected_existing_proxifyre() {
let components = resolve_component_statuses(
Vec::new(),
Some(DetectedProxyfier {
engine: ProxyfierEngine::ProxiFyre,
name: "ProxiFyre".to_string(),
install_dir: PathBuf::from(r"C:\Tools\ProxiFyre"),
executable_path: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"),
config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")),
running: true,
service_name: Some("ProxiFyreService".to_string()),
}),
None,
);
let proxyfier = components
.iter()
.find(|component| component.id == ComponentId::Proxyfier)
.expect("proxyfier component");
assert_eq!(proxyfier.state, ComponentState::Running);
assert!(proxyfier.installed);
assert!(proxyfier.running);
assert_eq!(proxyfier.path, Some(r"C:\Tools\ProxiFyre".to_string()));
assert!(proxyfier.problems.is_empty());
}
#[test]
fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
let root = test_root("detected-proxifyre");
let install_dir = root.join("ProxiFyre");
fs::create_dir_all(&install_dir).expect("install dir");
fs::write(install_dir.join("ProxiFyre.exe"), "mock exe").expect("mock exe");
fs::write(install_dir.join("app-config.json"), "{}").expect("existing config");
let generated_config = root.join("generated").join("proxifyre-app-config.json");
let host = DetectionHost::new()
.with_registry("ProxiFyre", &install_dir)
.with_path(&install_dir)
.with_path(&install_dir.join("ProxiFyre.exe"));
let helper = DetectedProxyApplyHelper::new(host);
let result = helper
.apply_proxy_config(HelperApplyRequest {
adapter_id: "proxifyre",
config_path: &generated_config,
config_contents: r#"{"proxies":[]}"#,
})
.expect("detected helper should apply");
let applied =
fs::read_to_string(install_dir.join("app-config.json")).expect("read applied app-config");
assert!(result.success);
assert!(result.changed);
assert_eq!(result.action, "proxifyre.apply-detected-config");
assert_eq!(applied, r#"{"proxies":[]}"#);
assert!(install_dir.join("app-config.json.bak").exists());
cleanup(&root);
}
#[test]
fn detected_proxy_apply_helper_ignores_plain_proxifier_install() {
let root = test_root("detected-proxifier");
let install_dir = root.join("Proxifier");
fs::create_dir_all(&install_dir).expect("install dir");
fs::write(install_dir.join("Proxifier.exe"), "mock exe").expect("mock exe");
let generated_config = root.join("generated").join("proxifyre-app-config.json");
let host = DetectionHost::new()
.with_registry("Proxifier", &install_dir)
.with_path(&install_dir)
.with_path(&install_dir.join("Proxifier.exe"));
let helper = DetectedProxyApplyHelper::new(host);
let result = helper
.apply_proxy_config(HelperApplyRequest {
adapter_id: "proxifyre",
config_path: &generated_config,
config_contents: r#"{"proxies":[]}"#,
})
.expect("plain Proxifier should be ignored and config should be staged");
assert!(result.success);
assert!(result.changed);
assert_eq!(result.action, "proxifyre.stage-generated-config");
assert!(result
.message
.contains("совместимая установка ProxiFyre не найдена"));
cleanup(&root);
}
struct MockApplyHelper;
impl ProxyApplyHelper for MockApplyHelper {
fn apply_proxy_config(
&self,
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
assert_eq!(request.adapter_id, "proxifyre");
assert!(request.config_contents.contains("Discord"));
assert!(request.config_path.ends_with("proxifyre-app-config.json"));
Ok(HelperApplyResult {
success: true,
changed: true,
action: "proxyfier.apply.mock".to_string(),
message: "Mock helper accepted generated ProxiFyre config".to_string(),
})
}
}
struct FixedClock;
impl Clock for FixedClock {
fn now(&self) -> String {
"2026-07-03T00:00:00Z".to_string()
}
}
#[derive(Default)]
struct DetectionHost {
paths: HashSet<String>,
registry: Vec<RegistryInstallEntry>,
}
impl DetectionHost {
fn new() -> Self {
Self::default()
}
fn with_path(mut self, path: &Path) -> Self {
self.paths.insert(normalize_path(path));
self
}
fn with_registry(mut self, display_name: &str, install_location: &Path) -> Self {
self.registry.push(RegistryInstallEntry {
display_name: display_name.to_string(),
install_location: Some(install_location.to_path_buf()),
display_icon: None,
});
self
}
}
impl ProxyfierDetectionHost for DetectionHost {
fn env_var(&self, _name: &str) -> Option<String> {
None
}
fn path_exists(&self, path: &Path) -> bool {
self.paths.contains(&normalize_path(path))
}
fn process_running(&self, _process_name: &str) -> bool {
false
}
fn service_running(&self, _service_name: &str) -> bool {
false
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
self.registry.clone()
}
}
fn normalize_path(path: &Path) -> String {
path.display()
.to_string()
.replace('/', "\\")
.to_ascii_lowercase()
}
fn test_root(name: &str) -> PathBuf {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("proxywarden-commands-{name}-{timestamp}"))
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
fn discord_profile(target_id: &str) -> Profile {
Profile {
id: "discord".to_string(),
name: "Discord".to_string(),
enabled: true,
target_id: target_id.to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn external_socks5_target() -> Target {
Target {
id: "home-gateway".to_string(),
name: "Домашний шлюз".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn local_singbox_target() -> Target {
Target {
id: "local-singbox".to_string(),
name: "Локальный sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some(ComponentId::Singbox),
}
}
fn proxyfier_running() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: Some("2.2.1".to_string()),
path: Some(r"C:\Tools\ProxiFyre".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string()],
}
}
fn singbox_missing() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Локальный sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["Локальный sing-box не установлен".to_string()],
actions: vec!["Установить локальный sing-box".to_string()],
}
}

View File

@@ -0,0 +1,207 @@
#[path = "../src/component_detection.rs"]
mod component_detection;
#[path = "../src/models.rs"]
mod models;
use component_detection::{
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost,
ProxyfierEngine, RegistryInstallEntry,
};
use models::ComponentState;
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
};
#[test]
fn detects_existing_proxifyre_from_registry_install_location() {
let host = MockHost::new()
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre")
.with_service("ProxiFyreService");
let detected = detect_proxyfier_install_with_host(&host)
.expect("existing ProxiFyre install should be detected");
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
assert_eq!(detected.install_dir, PathBuf::from(r"C:\Tools\ProxiFyre"));
assert_eq!(
detected.config_path,
Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json"))
);
assert!(detected.running);
let component = proxyfier_component_from_detection(Some(&detected));
assert_eq!(component.state, ComponentState::Running);
assert!(component.installed);
assert!(component.running);
assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string()));
assert!(component.problems.is_empty());
}
#[test]
fn ignores_plain_proxifier_install() {
let host = MockHost::new()
.with_registry("Proxifier", r"C:\Program Files\Proxifier")
.with_path(r"C:\Program Files\Proxifier")
.with_process("Proxifier.exe");
assert!(detect_proxyfier_install_with_host(&host).is_none());
}
#[test]
fn env_override_can_point_to_portable_proxifyre_install() {
let host = MockHost::new()
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"D:\Portable\ProxiFyre")
.with_path(r"D:\Portable\ProxiFyre\ProxiFyre.exe");
let detected = detect_proxyfier_install_with_host(&host)
.expect("env override should be checked before common paths");
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
assert_eq!(
detected.executable_path,
PathBuf::from(r"D:\Portable\ProxiFyre\ProxiFyre.exe")
);
}
#[test]
fn missing_proxyfier_returns_install_action_status() {
let component = proxyfier_component_from_detection(None);
assert_eq!(component.state, ComponentState::Missing);
assert!(!component.installed);
assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
}
#[test]
fn detects_running_local_singbox_from_default_install_root_and_service() {
let host = MockHost::new()
.with_path(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe")
.with_service("ProxyWardenSingBox");
let detected =
detect_singbox_install_with_host(&host).expect("existing sing-box should be detected");
assert_eq!(
detected.executable_path,
PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe")
);
assert_eq!(detected.service_name, "ProxyWardenSingBox");
assert!(detected.running);
let component = singbox_component_from_detection(Some(&detected));
assert_eq!(component.state, ComponentState::Running);
assert!(component.installed);
assert!(component.running);
assert_eq!(
component.path,
Some(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe".to_string())
);
assert!(component.problems.is_empty());
}
#[test]
fn detects_stopped_local_singbox_from_env_override() {
let host = MockHost::new()
.with_env("PROXYWARDEN_SINGBOX_ROOT", r"D:\Portable\sing-box")
.with_path(r"D:\Portable\sing-box\sing-box.exe");
let detected = detect_singbox_install_with_host(&host).expect("env override should be checked");
let component = singbox_component_from_detection(Some(&detected));
assert_eq!(
detected.executable_path,
PathBuf::from(r"D:\Portable\sing-box\sing-box.exe")
);
assert_eq!(component.state, ComponentState::Stopped);
assert!(component.installed);
assert!(!component.running);
assert!(component
.problems
.iter()
.any(|problem| problem.contains("остановлена")));
}
#[test]
fn missing_local_singbox_returns_optional_install_action_status() {
let component = singbox_component_from_detection(None);
assert_eq!(component.state, ComponentState::Missing);
assert!(!component.installed);
assert!(!component.running);
assert_eq!(component.actions, vec!["Установить Local sing-box"]);
assert!(component.problems.is_empty());
}
#[derive(Default)]
struct MockHost {
env: HashMap<String, String>,
paths: HashSet<String>,
processes: HashSet<String>,
services: HashSet<String>,
registry: Vec<RegistryInstallEntry>,
}
impl MockHost {
fn new() -> Self {
Self::default()
}
fn with_env(mut self, name: &str, value: &str) -> Self {
self.env.insert(name.to_string(), value.to_string());
self
}
fn with_path(mut self, path: &str) -> Self {
self.paths.insert(normalize_path(path));
self
}
fn with_process(mut self, process: &str) -> Self {
self.processes.insert(process.to_ascii_lowercase());
self
}
fn with_service(mut self, service: &str) -> Self {
self.services.insert(service.to_ascii_lowercase());
self
}
fn with_registry(mut self, display_name: &str, install_location: &str) -> Self {
self.registry.push(RegistryInstallEntry {
display_name: display_name.to_string(),
install_location: Some(PathBuf::from(install_location)),
display_icon: None,
});
self
}
}
impl ProxyfierDetectionHost for MockHost {
fn env_var(&self, name: &str) -> Option<String> {
self.env.get(name).cloned()
}
fn path_exists(&self, path: &Path) -> bool {
self.paths
.contains(&normalize_path(&path.display().to_string()))
}
fn process_running(&self, process_name: &str) -> bool {
self.processes.contains(&process_name.to_ascii_lowercase())
}
fn service_running(&self, service_name: &str) -> bool {
self.services.contains(&service_name.to_ascii_lowercase())
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
self.registry.clone()
}
}
fn normalize_path(path: &str) -> String {
path.replace('/', "\\").to_ascii_lowercase()
}

View File

@@ -0,0 +1,128 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/validation.rs"]
mod validation;
use models::{
ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
TargetInput, TargetKind,
};
use validation::{normalize_profile, normalize_target};
#[test]
fn normalizes_profile_source_items() {
let profile = normalize_profile(ProfileInput {
id: Some("Discord + Vesktop".to_string()),
name: " Discord + Vesktop ".to_string(),
enabled: true,
target_id: " home-gateway ".to_string(),
protocols: vec!["tcp".to_string(), "UDP".to_string(), "TCP".to_string()],
items: vec![
ProfileItemInput {
item_type: "process".to_string(),
value: "Discord.exe".to_string(),
recursive: None,
},
ProfileItemInput {
item_type: "folder".to_string(),
value: "%LOCALAPPDATA%\\Vesktop".to_string(),
recursive: Some(true),
},
ProfileItemInput {
item_type: "exe".to_string(),
value: "C:\\Games\\Game\\game.exe".to_string(),
recursive: Some(true),
},
],
})
.expect("profile should normalize");
assert_eq!(profile.id, "discord-vesktop");
assert_eq!(profile.name, "Discord + Vesktop");
assert_eq!(profile.target_id, "home-gateway");
assert_eq!(profile.protocols, vec![Protocol::Tcp, Protocol::Udp]);
assert_eq!(profile.items[0].item_type, ProfileItemType::Process);
assert_eq!(profile.items[0].value, "Discord");
assert!(!profile.items[0].recursive);
assert_eq!(profile.items[1].item_type, ProfileItemType::Folder);
assert!(profile.items[1].recursive);
assert_eq!(profile.items[2].item_type, ProfileItemType::Exe);
assert!(!profile.items[2].recursive);
}
#[test]
fn rejects_unsupported_profile_protocols() {
let error = normalize_profile(ProfileInput {
id: None,
name: "Bad protocol".to_string(),
enabled: true,
target_id: "home-gateway".to_string(),
protocols: vec!["icmp".to_string()],
items: vec![ProfileItemInput {
item_type: "process".to_string(),
value: "Discord".to_string(),
recursive: None,
}],
})
.expect_err("unsupported protocol should fail");
assert!(error.iter().any(|item| item.field == "protocols"));
}
#[test]
fn normalizes_external_target_without_local_singbox() {
let target = normalize_target(TargetInput {
id: Some("Home Gateway".to_string()),
name: " Home Gateway ".to_string(),
kind: "external".to_string(),
protocol: "socks5".to_string(),
host: " 192.168.50.111 ".to_string(),
port: 8080,
requires_component: None,
})
.expect("external target should normalize");
assert_eq!(target.id, "home-gateway");
assert_eq!(target.kind, TargetKind::External);
assert_eq!(target.protocol, ProxyProtocol::Socks5);
assert_eq!(target.host, "192.168.50.111");
assert_eq!(target.port, 8080);
assert_eq!(target.requires_component, None);
}
#[test]
fn local_singbox_target_can_exist_before_component_is_installed() {
let target = normalize_target(TargetInput {
id: Some("local-singbox".to_string()),
name: "Local sing-box".to_string(),
kind: "local".to_string(),
protocol: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some("singbox".to_string()),
})
.expect("local target definition should not require installed component");
assert_eq!(target.kind, TargetKind::Local);
assert_eq!(target.requires_component, Some(ComponentId::Singbox));
}
#[test]
fn rejects_malformed_target_fields() {
let error = normalize_target(TargetInput {
id: None,
name: "".to_string(),
kind: "external".to_string(),
protocol: "ftp".to_string(),
host: "".to_string(),
port: 70_000,
requires_component: Some("unknown".to_string()),
})
.expect_err("invalid target should fail");
assert!(error.iter().any(|item| item.field == "name"));
assert!(error.iter().any(|item| item.field == "host"));
assert!(error.iter().any(|item| item.field == "port"));
assert!(error.iter().any(|item| item.field == "protocol"));
assert!(error.iter().any(|item| item.field == "requires_component"));
}

View File

@@ -0,0 +1,139 @@
#[path = "../src/helper.rs"]
mod helper;
#[path = "../src/models.rs"]
mod models;
use helper::{
helper_action_requires_elevation, install_request, parse_helper_response,
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
};
use models::ComponentId;
use serde_json::json;
use std::cell::RefCell;
use std::path::PathBuf;
#[test]
fn structured_helper_serializes_request_and_parses_json_response() {
let runner = MockRunner {
output: HelperCommandOutput {
status_code: 0,
stdout: serde_json::to_string(&HelperResponse {
success: true,
action: HelperAction::ProxyfierApply,
changed: true,
message: "Applied".to_string(),
details: json!({ "serviceName": "ProxiFyreService" }),
})
.expect("response json"),
stderr: String::new(),
},
seen: RefCell::new(Vec::new()),
};
let helper = StructuredHelper::new("proxywarden-helper.exe", runner);
let response = helper
.execute(&proxifyre_apply_request(
r"C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json",
"ProxiFyreService",
))
.expect("helper response");
assert!(response.success);
assert_eq!(response.action, HelperAction::ProxyfierApply);
assert_eq!(response.details["serviceName"], "ProxiFyreService");
}
#[test]
fn helper_runner_receives_json_stdin_and_elevation_flag() {
let runner = MockRunner {
output: HelperCommandOutput {
status_code: 0,
stdout: r#"{"success":true,"action":"service.restart","changed":true,"message":"Restarted","details":{}}"#.to_string(),
stderr: String::new(),
},
seen: RefCell::new(Vec::new()),
};
let helper = StructuredHelper::new("proxywarden-helper.exe", runner);
let request = service_request(ComponentId::Proxyfier, HelperAction::ServiceRestart);
let _ = helper.execute(&request).expect("helper response");
let seen = helper.runner().seen.borrow();
let spec = seen.first().expect("runner should be called");
let stdin: serde_json::Value = serde_json::from_str(&spec.stdin).expect("stdin json");
assert_eq!(spec.program, PathBuf::from("proxywarden-helper.exe"));
assert_eq!(spec.args, vec!["--json"]);
assert!(spec.requires_elevation);
assert_eq!(stdin["action"], "service.restart");
assert_eq!(stdin["component"], "proxyfier");
}
#[test]
fn install_requests_are_explicit_component_actions() {
let control = install_request(ComponentId::ControlApp);
let proxyfier = install_request(ComponentId::Proxyfier);
let singbox = install_request(ComponentId::Singbox);
assert_eq!(control.action, HelperAction::InstallControlApp);
assert_eq!(proxyfier.action, HelperAction::InstallProxyfier);
assert_eq!(singbox.action, HelperAction::InstallSingbox);
assert!(helper_action_requires_elevation(&proxyfier.action));
}
#[test]
fn apply_request_does_not_encode_installer_action() {
let request = proxifyre_apply_request(
r"C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json",
"ProxiFyreService",
);
assert_eq!(request.action, HelperAction::ProxyfierApply);
assert_eq!(request.component, Some(ComponentId::Proxyfier));
assert_eq!(
request.payload["configPath"],
r"C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json"
);
}
#[test]
fn non_json_helper_stdout_is_rejected() {
let error = parse_helper_response("Proxyfier restarted successfully")
.expect_err("raw stdout should not be accepted");
assert_eq!(error.code, "helper_response_decode");
}
#[test]
fn failed_helper_exit_is_structured_error() {
let runner = MockRunner {
output: HelperCommandOutput {
status_code: 5,
stdout: String::new(),
stderr: "Access denied".to_string(),
},
seen: RefCell::new(Vec::new()),
};
let helper = StructuredHelper::new("proxywarden-helper.exe", runner);
let error = helper
.execute(&service_request(
ComponentId::Proxyfier,
HelperAction::ServiceRestart,
))
.expect_err("failed exit should become helper error");
assert_eq!(error.code, "helper_exit");
assert!(error.message.contains("Access denied"));
}
struct MockRunner {
output: HelperCommandOutput,
seen: RefCell<Vec<HelperCommandSpec>>,
}
impl HelperCommandRunner for MockRunner {
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError> {
self.seen.borrow_mut().push(spec.clone());
Ok(self.output.clone())
}
}

View File

@@ -0,0 +1,207 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind,
};
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE};
use proxy_router::{ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest};
#[test]
fn generates_proxifyre_config_for_discord_external_socks5_target() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("home-gateway")];
let targets = vec![external_socks5_target()];
let components = vec![missing_singbox_component()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("external socks5 target should not require sing-box");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(generated.adapter_id, "proxifyre");
assert_eq!(generated.output_file_name, PROXIFYRE_OUTPUT_FILE);
assert_eq!(generated.enabled_profiles, 1);
assert_eq!(generated.routed_apps, 1);
assert_eq!(config.log_level, "Info");
assert!(config.bypass_lan);
assert_eq!(config.proxies.len(), 1);
assert_eq!(config.proxies[0].app_names, vec!["Discord"]);
assert_eq!(
config.proxies[0].socks5_proxy_endpoint,
"192.168.50.111:8080"
);
assert_eq!(config.proxies[0].supported_protocols, vec!["TCP", "UDP"]);
}
#[test]
fn skips_disabled_profiles_when_generating_proxifyre_config() {
let adapter = ProxiFyreAdapter::default();
let mut disabled = discord_profile("home-gateway");
disabled.enabled = false;
let profiles = vec![disabled];
let targets = vec![external_socks5_target()];
let components = Vec::new();
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("disabled profiles should produce empty config");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(generated.enabled_profiles, 0);
assert_eq!(generated.routed_apps, 0);
assert!(config.proxies.is_empty());
}
#[test]
fn includes_folder_paths_when_generating_proxifyre_config() {
let adapter = ProxiFyreAdapter::default();
let mut profile = discord_profile("home-gateway");
profile.items.push(ProfileItem {
item_type: ProfileItemType::Folder,
value: r"C:\Games\MyGame".to_string(),
recursive: true,
});
let profiles = vec![profile];
let targets = vec![external_socks5_target()];
let components = Vec::new();
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("folder paths should be accepted by ProxiFyre config generation");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(generated.routed_apps, 2);
assert_eq!(
config.proxies[0].app_names,
vec!["Discord", r"C:\Games\MyGame"]
);
}
#[test]
fn blocks_local_singbox_target_when_required_component_is_missing() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("local-singbox")];
let targets = vec![local_singbox_target()];
let components = Vec::new();
let error = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect_err("local sing-box target should require installed running sing-box");
assert_eq!(error.kind, ProxyRouterErrorKind::MissingRequiredComponent);
}
#[test]
fn local_singbox_target_generates_when_required_component_is_running() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("local-singbox")];
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("running sing-box should satisfy local target dependency");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(config.proxies.len(), 1);
assert_eq!(config.proxies[0].socks5_proxy_endpoint, "127.0.0.1:1080");
}
#[test]
fn rejects_http_target_because_proxifyre_adapter_is_socks5_only() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("office-http")];
let targets = vec![Target {
id: "office-http".to_string(),
name: "Office HTTP".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Http,
host: "192.168.50.111".to_string(),
port: 3128,
requires_component: None,
}];
let components = Vec::new();
let error = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect_err("ProxiFyre should reject HTTP targets");
assert_eq!(error.kind, ProxyRouterErrorKind::UnsupportedTargetProtocol);
}
fn discord_profile(target_id: &str) -> Profile {
Profile {
id: "discord".to_string(),
name: "Discord".to_string(),
enabled: true,
target_id: target_id.to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn external_socks5_target() -> Target {
Target {
id: "home-gateway".to_string(),
name: "Home Gateway".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn local_singbox_target() -> Target {
Target {
id: "local-singbox".to_string(),
name: "Local sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some(ComponentId::Singbox),
}
}
fn missing_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["Local sing-box is not installed".to_string()],
actions: vec!["Install Local sing-box".to_string()],
}
}
fn running_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\ProxyWarden\sing-box\sing-box.exe".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string(), "Stop".to_string()],
}
}

View File

@@ -0,0 +1,294 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
#[path = "../src/adapters/singbox.rs"]
mod singbox;
use models::{
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
TargetKind,
};
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
use singbox::{
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
SINGBOX_OUTPUT_FILE,
};
use std::{
cell::RefCell,
path::{Path, PathBuf},
};
#[test]
fn generates_selected_outbound_config_and_runs_check_when_binary_path_is_supplied() {
let adapter = SingBoxAdapter::default();
let config = local_singbox_config("nl-1");
let cache = subscription_cache();
let checker = RecordingChecker::ok("configuration OK");
let binary_path = Path::new(r"C:\Tools\ProxyWarden\sing-box\sing-box.exe");
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&config, &cache, Some(binary_path)),
&checker,
)
.expect("selected outbound should generate config");
let generated_config: serde_json::Value =
serde_json::from_str(&generated.contents).expect("generated sing-box json");
assert_eq!(generated.adapter_id, "singbox");
assert_eq!(generated.output_file_name, SINGBOX_OUTPUT_FILE);
assert_eq!(generated.selected_server_tag, "nl-1");
assert_eq!(generated.listen, "127.0.0.1");
assert_eq!(generated.listen_port, 1080);
assert_eq!(
generated.check,
Some(SingBoxCheckResult {
checked: true,
success: true,
message: "configuration OK".to_string(),
})
);
assert_eq!(generated_config["log"]["level"], "info");
assert_eq!(generated_config["inbounds"][0]["type"], "mixed");
assert_eq!(generated_config["inbounds"][0]["listen"], "127.0.0.1");
assert_eq!(generated_config["inbounds"][0]["listen_port"], 1080);
assert_eq!(generated_config["inbounds"][0]["set_system_proxy"], false);
assert_eq!(generated_config["outbounds"][0]["type"], "vless");
assert_eq!(
generated_config["outbounds"][0]["tag"],
DEFAULT_VPN_OUTBOUND_TAG
);
assert_eq!(
generated_config["outbounds"][0]["server"],
"nl.example.test"
);
assert_eq!(generated_config["outbounds"][0]["packet_encoding"], "xudp");
assert_eq!(generated_config["route"]["final"], DEFAULT_VPN_OUTBOUND_TAG);
let calls = checker.calls.borrow();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0.as_path(), binary_path);
assert!(calls[0].1.contains(r#""type": "mixed""#));
assert!(calls[0].1.contains(r#""tag": "vpn""#));
}
#[test]
fn skips_singbox_check_when_binary_path_is_not_supplied() {
let adapter = SingBoxAdapter::default();
let config = local_singbox_config("nl-1");
let cache = subscription_cache();
let checker = RecordingChecker::ok("should not run");
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&config, &cache, None),
&checker,
)
.expect("binary path is optional");
assert_eq!(generated.check, None);
assert!(checker.calls.borrow().is_empty());
}
#[test]
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;
let cache = subscription_cache();
let checker = RecordingChecker::ok("should not run");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(&config, &cache, None),
&checker,
)
.expect_err("missing selected server should block config");
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedServer);
assert!(checker.calls.borrow().is_empty());
}
#[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 checker = RecordingChecker::ok("should not run");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(&config, &cache, None),
&checker,
)
.expect_err("missing outbound should block config");
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound);
assert!(error.message.contains("missing-server"));
assert!(checker.calls.borrow().is_empty());
}
#[test]
fn propagates_failed_singbox_check_as_structured_error() {
let adapter = SingBoxAdapter::default();
let config = local_singbox_config("nl-1");
let cache = subscription_cache();
let checker = RecordingChecker::err("invalid config");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(&config, &cache, Some(Path::new("sing-box.exe"))),
&checker,
)
.expect_err("failed sing-box check should block generated config");
assert_eq!(error.kind, SingBoxConfigErrorKind::CheckFailed);
assert!(error.message.contains("invalid config"));
}
#[test]
fn external_proxifyre_apply_does_not_require_singbox_component() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("home-gateway")];
let targets = vec![external_socks5_target()];
let components = vec![missing_singbox_component()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("external SOCKS5 target should not require local sing-box");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated proxifyre json");
assert_eq!(config.proxies.len(), 1);
assert_eq!(
config.proxies[0].socks5_proxy_endpoint,
"192.168.50.111:8080"
);
}
struct RecordingChecker {
calls: RefCell<Vec<(PathBuf, String)>>,
result: Result<SingBoxCheckResult, SingBoxConfigError>,
}
impl RecordingChecker {
fn ok(message: &str) -> Self {
Self {
calls: RefCell::new(Vec::new()),
result: Ok(SingBoxCheckResult {
checked: true,
success: true,
message: message.to_string(),
}),
}
}
fn err(message: &str) -> Self {
Self {
calls: RefCell::new(Vec::new()),
result: Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
message,
)),
}
}
}
impl SingBoxConfigChecker for RecordingChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
self.calls
.borrow_mut()
.push((binary_path.to_path_buf(), config_json.to_string()));
self.result.clone()
}
}
fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/list".to_string()),
selected_server_tag: Some(selected_server_tag.to_string()),
listen_host: "127.0.0.1".to_string(),
listen_port: 1080,
service_name: "ProxyWardenSingBox".to_string(),
install_root: r"C:\Program Files\ProxyWarden\sing-box".to_string(),
updated_at: Some("2026-07-07T10:00:00Z".to_string()),
}
}
fn subscription_cache() -> SubscriptionCache {
SubscriptionCache {
config: serde_json::json!({
"outbounds": [
{
"type": "vless",
"tag": "nl-1",
"server": "nl.example.test",
"server_port": 443,
"uuid": "11111111-1111-1111-1111-111111111111"
},
{
"type": "direct",
"tag": "direct"
}
]
}),
servers: vec![SubscriptionServer {
tag: "nl-1".to_string(),
server_type: "vless".to_string(),
server: "nl.example.test".to_string(),
server_port: 443,
}],
user_info: serde_json::Map::new(),
fetched_at: "2026-07-07T10:00:00Z".to_string(),
}
}
fn discord_profile(target_id: &str) -> Profile {
Profile {
id: "discord".to_string(),
name: "Discord".to_string(),
enabled: true,
target_id: target_id.to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn external_socks5_target() -> Target {
Target {
id: "home-gateway".to_string(),
name: "Home Gateway".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn missing_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["Local sing-box is not installed".to_string()],
actions: vec!["Install Local sing-box".to_string()],
}
}

View File

@@ -0,0 +1,397 @@
#[path = "../src/activity.rs"]
mod activity;
#[path = "../src/commands.rs"]
mod commands;
#[path = "../src/component_detection.rs"]
mod component_detection;
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
#[path = "../src/adapters/singbox.rs"]
mod singbox;
#[path = "../src/singbox_service.rs"]
mod singbox_service;
#[path = "../src/storage.rs"]
mod storage;
#[path = "../src/subscription.rs"]
mod subscription;
#[path = "../src/validation.rs"]
mod validation;
use commands::{
fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage,
generate_singbox_config_with_services, save_singbox_subscription_to_storage,
select_singbox_server_in_storage, Clock, SaveSingBoxSubscriptionInputDto,
SelectSingBoxServerInputDto, SubscriptionFetcher,
};
use models::{
ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache,
SubscriptionServer, TargetKind,
};
use serde_json::{json, Map};
use singbox::{SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use storage::JsonStorage;
#[test]
fn saves_subscription_url_without_exposing_secret_query() {
let root = test_root("save-subscription");
let storage = JsonStorage::new(root.clone());
let status = save_singbox_subscription_to_storage(
&storage,
SaveSingBoxSubscriptionInputDto {
subscription_url: " https://sub.example.test/path?token=secret ".to_string(),
},
&FixedClock,
)
.expect("subscription URL should be saved");
let config = storage
.read_local_singbox_config()
.expect("read local sing-box config");
assert_eq!(
config.subscription_url,
Some("https://sub.example.test/path?token=secret".to_string())
);
assert!(status.config.has_subscription);
assert_eq!(
status.config.subscription_display_url,
Some("https://sub.example.test/...".to_string())
);
cleanup(&root);
}
#[test]
fn rejects_non_http_subscription_url() {
let root = test_root("invalid-subscription");
let storage = JsonStorage::new(root.clone());
let error = save_singbox_subscription_to_storage(
&storage,
SaveSingBoxSubscriptionInputDto {
subscription_url: "file:///C:/sub.txt".to_string(),
},
&FixedClock,
)
.expect_err("non-http subscription URL should fail");
assert_eq!(error.code, "singbox_subscription_url_invalid");
cleanup(&root);
}
#[test]
fn fetches_subscription_cache_and_selects_first_server() {
let root = test_root("fetch-subscription");
let storage = JsonStorage::new(root.clone());
save_singbox_subscription_to_storage(
&storage,
SaveSingBoxSubscriptionInputDto {
subscription_url: "https://sub.example.test/path?token=secret".to_string(),
},
&FixedClock,
)
.expect("save subscription URL");
let status = fetch_singbox_subscription_with_fetcher(
&storage,
&MockFetcher(sample_cache()),
&FixedClock,
)
.expect("fetch subscription through mock");
let config = storage
.read_local_singbox_config()
.expect("read local sing-box config");
let cache = storage
.read_singbox_subscription_cache()
.expect("read cache")
.expect("cache should exist");
let activity = storage.read_activity().expect("read activity");
assert_eq!(status.config.selected_server_tag, Some("nl-1".to_string()));
assert_eq!(status.cache.expect("status cache").servers.len(), 2);
assert_eq!(config.selected_server_tag, Some("nl-1".to_string()));
assert_eq!(cache.servers.len(), 2);
assert_eq!(activity[0].level, ActivityLevel::Success);
assert_eq!(activity[0].title, "Подписка Local sing-box обновлена");
cleanup(&root);
}
#[test]
fn selects_server_from_cached_subscription() {
let root = test_root("select-server");
let storage = JsonStorage::new(root.clone());
storage
.write_singbox_subscription_cache(&sample_cache())
.expect("write cache");
let status = select_singbox_server_in_storage(
&storage,
SelectSingBoxServerInputDto {
tag: "de-1".to_string(),
server: None,
server_port: None,
},
&FixedClock,
)
.expect("server should be selected");
let config = storage
.read_local_singbox_config()
.expect("read local sing-box config");
assert_eq!(status.config.selected_server_tag, Some("de-1".to_string()));
assert_eq!(config.selected_server_tag, Some("de-1".to_string()));
cleanup(&root);
}
#[test]
fn selects_server_by_endpoint_when_display_tag_is_sanitized() {
let root = test_root("select-server-sanitized-tag");
let storage = JsonStorage::new(root.clone());
storage
.write_singbox_subscription_cache(&sample_cache_with_flag_tag())
.expect("write cache");
let status = select_singbox_server_in_storage(
&storage,
SelectSingBoxServerInputDto {
tag: "Умный".to_string(),
server: Some("media.example.test".to_string()),
server_port: Some(443),
},
&FixedClock,
)
.expect("server should be selected by endpoint fallback");
let config = storage
.read_local_singbox_config()
.expect("read local sing-box config");
assert_eq!(
status.config.selected_server_tag,
Some("Умный 🇳🇱->🇷🇺".to_string())
);
assert_eq!(config.selected_server_tag, Some("Умный 🇳🇱->🇷🇺".to_string()));
cleanup(&root);
}
#[test]
fn generate_writes_config_and_local_singbox_target() {
let root = test_root("generate-config");
let storage = JsonStorage::new(root.clone());
storage
.write_local_singbox_config(&LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/path".to_string()),
selected_server_tag: Some("nl-1".to_string()),
..LocalSingBoxConfig::default()
})
.expect("write local sing-box config");
storage
.write_singbox_subscription_cache(&sample_cache())
.expect("write cache");
let response = generate_singbox_config_with_services(
&storage,
&SingBoxAdapter::default(),
&MockChecker,
&FixedClock,
Some(Path::new("sing-box.exe")),
)
.expect("generate sing-box config");
let generated = fs::read_to_string(&response.generated_config_path).expect("read generated");
let targets = storage.read_targets().expect("read targets");
let target = targets
.iter()
.find(|target| target.id == "local-singbox")
.expect("local sing-box target");
let activity = storage.read_activity().expect("read activity");
assert!(response.success);
assert_eq!(response.adapter_id, "singbox");
assert_eq!(response.selected_server_tag, "nl-1");
assert!(response.check.expect("check result").success);
assert!(generated.contains("\"type\": \"mixed\""));
assert!(generated.contains("\"tag\": \"vpn\""));
assert_eq!(target.kind, TargetKind::Local);
assert_eq!(target.protocol, ProxyProtocol::Socks5);
assert_eq!(target.host, "127.0.0.1");
assert_eq!(target.port, 1080);
assert_eq!(target.requires_component, Some(ComponentId::Singbox));
assert_eq!(activity[0].title, "Конфиг Local sing-box создан");
cleanup(&root);
}
#[test]
fn generate_requires_cached_subscription() {
let root = test_root("generate-missing-cache");
let storage = JsonStorage::new(root.clone());
let error = generate_singbox_config_with_services(
&storage,
&SingBoxAdapter::default(),
&MockChecker,
&FixedClock,
None,
)
.expect_err("missing cache should block generation");
assert_eq!(error.code, "singbox_subscription_cache_missing");
cleanup(&root);
}
#[test]
fn forget_subscription_clears_url_selection_and_cache() {
let root = test_root("forget-subscription");
let storage = JsonStorage::new(root.clone());
storage
.write_local_singbox_config(&LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/path".to_string()),
selected_server_tag: Some("nl-1".to_string()),
..LocalSingBoxConfig::default()
})
.expect("write local sing-box config");
storage
.write_singbox_subscription_cache(&sample_cache())
.expect("write cache");
let status =
forget_singbox_subscription_in_storage(&storage, &FixedClock).expect("forget subscription");
let config = storage
.read_local_singbox_config()
.expect("read local sing-box config");
let cache = storage
.read_singbox_subscription_cache()
.expect("read cache");
assert!(!status.config.has_subscription);
assert_eq!(config.subscription_url, None);
assert_eq!(config.selected_server_tag, None);
assert_eq!(cache, None);
cleanup(&root);
}
struct MockFetcher(SubscriptionCache);
impl SubscriptionFetcher for MockFetcher {
fn fetch_subscription(
&self,
url: &str,
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
assert_eq!(url, "https://sub.example.test/path?token=secret");
Ok(self.0.clone())
}
}
struct MockChecker;
impl SingBoxConfigChecker for MockChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
assert_eq!(binary_path, Path::new("sing-box.exe"));
assert!(config_json.contains("\"final\": \"vpn\""));
Ok(SingBoxCheckResult {
checked: true,
success: true,
message: "mock check passed".to_string(),
})
}
}
struct FixedClock;
impl Clock for FixedClock {
fn now(&self) -> String {
"2026-07-07T00:00:00Z".to_string()
}
}
fn sample_cache() -> SubscriptionCache {
SubscriptionCache {
config: json!({
"outbounds": [
{
"type": "vless",
"tag": "nl-1",
"server": "nl.example.test",
"server_port": 443,
"uuid": "11111111-1111-1111-1111-111111111111"
},
{
"type": "trojan",
"tag": "de-1",
"server": "de.example.test",
"server_port": 443,
"password": "secret"
}
]
}),
servers: vec![
SubscriptionServer {
tag: "nl-1".to_string(),
server_type: "vless".to_string(),
server: "nl.example.test".to_string(),
server_port: 443,
},
SubscriptionServer {
tag: "de-1".to_string(),
server_type: "trojan".to_string(),
server: "de.example.test".to_string(),
server_port: 443,
},
],
user_info: Map::new(),
fetched_at: "2026-07-07T00:00:00Z".to_string(),
}
}
fn sample_cache_with_flag_tag() -> SubscriptionCache {
SubscriptionCache {
config: json!({
"outbounds": [
{
"type": "vless",
"tag": "Умный 🇳🇱->🇷🇺",
"server": "media.example.test",
"server_port": 443,
"uuid": "11111111-1111-1111-1111-111111111111"
}
]
}),
servers: vec![SubscriptionServer {
tag: "Умный 🇳🇱->🇷🇺".to_string(),
server_type: "vless".to_string(),
server: "media.example.test".to_string(),
server_port: 443,
}],
user_info: Map::new(),
fetched_at: "2026-07-07T00:00:00Z".to_string(),
}
}
fn test_root(name: &str) -> PathBuf {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("proxywarden-singbox-commands-{name}-{timestamp}"))
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}

View File

@@ -0,0 +1,128 @@
#[path = "../src/component_detection.rs"]
mod component_detection;
#[path = "../src/models.rs"]
mod models;
#[path = "../src/singbox_service.rs"]
mod singbox_service;
use component_detection::DetectedSingBox;
use singbox_service::{
build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output,
service_control_script, SingBoxServiceAction,
};
use std::path::{Path, PathBuf};
#[cfg(windows)]
use std::process::Command as ProcessCommand;
#[test]
fn setup_status_reports_missing_items_when_singbox_is_absent() {
let status = build_singbox_setup_status(None);
assert!(!status.ready);
assert_eq!(status.missing_count, 3);
assert_eq!(status.items[0].id, "sing-box-binary");
assert!(status.items[0].details.contains("SagerNet/sing-box"));
assert_eq!(status.items[1].id, "winsw-wrapper");
assert!(status.items[1].details.contains("winsw/winsw"));
assert_eq!(status.items[2].id, "windows-service");
}
#[test]
fn setup_status_reports_ready_when_binary_wrapper_and_service_exist() {
let detected = detected_singbox(true, true, true);
let status = build_singbox_setup_status(Some(&detected));
assert!(status.ready);
assert_eq!(status.missing_count, 0);
assert!(status.items.iter().all(|item| item.installed));
}
#[test]
fn parses_last_json_service_command_output_line() {
let output = br#"
noise
{"success":true,"code":"started","serviceName":"ProxyWardenSingBox","status":"Running","processId":42}
"#;
let parsed = parse_service_command_output(output).expect("service json should parse");
assert!(parsed.success);
assert_eq!(parsed.code, "started");
assert_eq!(parsed.service_name, Some("ProxyWardenSingBox".to_string()));
assert_eq!(parsed.status, Some("Running".to_string()));
assert_eq!(parsed.process_id, Some(42));
}
#[test]
fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
assert!(
ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\ProxyWarden\sing-box")).is_ok()
);
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Windows")).is_err());
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err());
}
#[test]
fn service_control_script_targets_named_service_and_action() {
let script = service_control_script(SingBoxServiceAction::Start, "ProxyWardenSingBox", None, None);
assert!(script.contains("$serviceName = 'ProxyWardenSingBox'"));
assert!(script.contains("$action = 'start'"));
assert!(script.contains("ConvertTo-Json -Compress"));
}
#[test]
fn service_control_script_syncs_generated_config_before_start() {
let source = Path::new(r"C:\ProgramData\ProxyWarden\generated\sing-box-config.json");
let target = Path::new(r"C:\Program Files\ProxyWarden\sing-box\config.json");
let script = service_control_script(
SingBoxServiceAction::Start,
"ProxyWardenSingBox",
Some(source),
Some(target),
);
assert!(script.contains(
"$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'"
));
assert!(script.contains(
"$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'"
));
assert!(script.contains("Copy-Item -LiteralPath $configSource"));
assert!(script.contains("'config_sync_failed'"));
}
#[test]
#[cfg(windows)]
fn install_singbox_script_parses_as_powershell() {
let script_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("scripts")
.join("install-singbox.ps1");
let escaped_path = script_path.display().to_string().replace('\'', "''");
let parser = format!(
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
);
let output = ProcessCommand::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
.output()
.expect("powershell parser should run");
assert!(
output.status.success(),
"install-singbox.ps1 should parse\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> DetectedSingBox {
DetectedSingBox {
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box"),
executable_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe"),
wrapper_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe"),
binary_exists,
wrapper_exists,
running,
service_name: "ProxyWardenSingBox".to_string(),
}
}

View File

@@ -0,0 +1,302 @@
#[path = "../src/activity.rs"]
mod activity;
#[path = "../src/models.rs"]
mod models;
#[path = "../src/storage.rs"]
mod storage;
use models::{
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
SubscriptionServer, Target, TargetKind,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
#[test]
fn storage_defaults_to_programdata_root() {
let expected = PathBuf::from(r"C:\ProgramData\ProxyWarden");
assert_eq!(default_config_root(), expected);
assert_eq!(StoragePaths::default().root, expected);
}
#[test]
fn roundtrips_profiles_targets_components_and_activity() {
let root = test_root("roundtrip");
let storage = JsonStorage::new(root.clone());
let profiles = vec![sample_profile("discord")];
let targets = vec![sample_target("home-gateway")];
let components = vec![sample_component()];
let activity = vec![sample_activity(
"created",
"2026-01-01T10:00:00Z",
ActivityLevel::Success,
)];
storage.write_profiles(&profiles).expect("write profiles");
storage.write_targets(&targets).expect("write targets");
storage
.write_components(&components)
.expect("write components");
storage.write_activity(&activity).expect("write activity");
assert_eq!(storage.read_profiles().expect("read profiles"), profiles);
assert_eq!(storage.read_targets().expect("read targets"), targets);
assert_eq!(
storage.read_components().expect("read components"),
components
);
assert_eq!(storage.read_activity().expect("read activity"), activity);
cleanup(&root);
}
#[test]
fn roundtrips_local_singbox_config_and_subscription_cache() {
let root = test_root("local-singbox");
let storage = JsonStorage::new(root.clone());
let config = LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
selected_server_tag: Some("nl-1".to_string()),
listen_host: "127.0.0.1".to_string(),
listen_port: 1080,
service_name: "ProxyWardenSingBox".to_string(),
install_root: r"C:\Program Files\ProxyWarden\sing-box".to_string(),
updated_at: Some("2026-07-07T10:00:00Z".to_string()),
};
let cache = sample_subscription_cache();
storage
.write_local_singbox_config(&config)
.expect("write local sing-box config");
storage
.write_singbox_subscription_cache(&cache)
.expect("write subscription cache");
assert_eq!(
storage
.read_local_singbox_config()
.expect("read local sing-box config"),
config
);
assert_eq!(
storage
.read_singbox_subscription_cache()
.expect("read subscription cache"),
Some(cache)
);
assert_eq!(
config.subscription_display_url(),
Some("https://sub.example.test/...".to_string())
);
cleanup(&root);
}
#[test]
fn missing_local_singbox_config_defaults_to_optional_empty_state() {
let root = test_root("local-singbox-default");
let storage = JsonStorage::new(root.clone());
let config = storage
.read_local_singbox_config()
.expect("read default local sing-box config");
assert_eq!(config.subscription_url, None);
assert_eq!(config.selected_server_tag, None);
assert_eq!(config.listen_host, "127.0.0.1");
assert_eq!(config.listen_port, 1080);
assert_eq!(config.service_name, "ProxyWardenSingBox");
cleanup(&root);
}
#[test]
fn invalid_subscription_cache_falls_back_to_none() {
let root = test_root("invalid-subscription-cache");
let storage = JsonStorage::new(root.clone());
storage.ensure_dirs().expect("create storage dirs");
fs::write(
&storage.paths().singbox_subscription_cache_file,
"{not valid json",
)
.expect("write invalid cache");
assert_eq!(
storage
.read_singbox_subscription_cache()
.expect("invalid cache fallback"),
None
);
cleanup(&root);
}
#[test]
fn invalid_json_falls_back_to_empty_collection() {
let root = test_root("invalid-json");
let storage = JsonStorage::new(root.clone());
storage.ensure_dirs().expect("create storage dirs");
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
assert_eq!(
storage.read_profiles().expect("invalid profiles fallback"),
Vec::<Profile>::new()
);
cleanup(&root);
}
#[test]
fn write_creates_backup_before_overwriting_source_file() {
let root = test_root("backup");
let storage = JsonStorage::new(root.clone());
let first = vec![sample_profile("first")];
let second = vec![sample_profile("second")];
storage.write_profiles(&first).expect("first write");
storage.write_profiles(&second).expect("second write");
let backup = backup_path(&storage.paths().profiles_file);
assert!(backup.exists(), "backup file should exist");
let backup_contents = fs::read_to_string(backup).expect("read backup");
let backup_profiles: Vec<Profile> =
serde_json::from_str(&backup_contents).expect("backup json");
assert_eq!(backup_profiles, first);
assert_eq!(storage.read_profiles().expect("current profiles"), second);
cleanup(&root);
}
#[test]
fn activity_entries_are_sorted_and_capped() {
let root = test_root("activity");
let storage = JsonStorage::with_activity_limit(root.clone(), 2);
storage
.append_activity(sample_activity(
"old",
"2026-01-01T10:00:00Z",
ActivityLevel::Info,
))
.expect("append old");
storage
.append_activity(sample_activity(
"new",
"2026-01-03T10:00:00Z",
ActivityLevel::Success,
))
.expect("append new");
storage
.append_activity(sample_activity(
"middle",
"2026-01-02T10:00:00Z",
ActivityLevel::Warning,
))
.expect("append middle");
let entries = storage.read_activity().expect("read capped activity");
assert_eq!(entries.len(), 2);
assert_eq!(
entries
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>(),
vec!["new", "middle"]
);
cleanup(&root);
}
fn test_root(name: &str) -> PathBuf {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("proxywarden-storage-{name}-{timestamp}"))
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
fn sample_profile(id: &str) -> Profile {
Profile {
id: id.to_string(),
name: format!("Profile {id}"),
enabled: true,
target_id: "home-gateway".to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn sample_target(id: &str) -> Target {
Target {
id: id.to_string(),
name: "Home Gateway".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn sample_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["ProxiFyre не установлен".to_string()],
actions: vec!["Установить ProxiFyre".to_string()],
}
}
fn sample_subscription_cache() -> SubscriptionCache {
SubscriptionCache {
config: serde_json::json!({
"outbounds": [
{
"type": "vless",
"tag": "nl-1",
"server": "nl.example.test",
"server_port": 443
}
]
}),
servers: vec![SubscriptionServer {
tag: "nl-1".to_string(),
server_type: "vless".to_string(),
server: "nl.example.test".to_string(),
server_port: 443,
}],
user_info: serde_json::Map::new(),
fetched_at: "2026-07-07T10:00:00Z".to_string(),
}
}
fn sample_activity(id: &str, at: &str, level: ActivityLevel) -> ActivityEntry {
ActivityEntry {
id: id.to_string(),
at: at.to_string(),
level,
title: format!("Activity {id}"),
message: "Storage test activity".to_string(),
}
}

View File

@@ -0,0 +1,99 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/subscription.rs"]
mod subscription;
use base64::{engine::general_purpose, Engine};
use models::redact_subscription_url;
use subscription::{parse_subscription_body, parse_user_info};
#[test]
fn parses_singbox_json_config_servers() {
let parsed = parse_subscription_body(
r#"{
"outbounds": [
{ "type": "direct", "tag": "direct" },
{ "type": "vless", "tag": "nl-1", "server": "nl.example.test", "server_port": 443 },
{ "type": "trojan", "tag": "de-1", "server": "de.example.test", "server_port": 8443 }
]
}"#,
)
.expect("json subscription should parse");
assert_eq!(parsed.servers.len(), 2);
assert_eq!(parsed.servers[0].tag, "nl-1");
assert_eq!(parsed.servers[0].server_type, "vless");
assert_eq!(parsed.servers[1].server_port, 8443);
}
#[test]
fn parses_base64_vless_link_list() {
let link = sample_vless_link("nl-1");
let encoded = general_purpose::STANDARD.encode(format!("{link}\n"));
let parsed = parse_subscription_body(&encoded).expect("base64 vless list should parse");
let outbound = &parsed.config["outbounds"][0];
assert_eq!(parsed.servers.len(), 1);
assert_eq!(parsed.servers[0].tag, "nl-1");
assert_eq!(outbound["type"], "vless");
assert_eq!(outbound["server"], "nl.example.test");
assert_eq!(outbound["packet_encoding"], "xudp");
}
#[test]
fn rejects_body_without_supported_outbounds() {
let error = parse_subscription_body(r#"{"outbounds":[{"type":"direct","tag":"direct"}]}"#)
.expect_err("unsupported subscription should fail");
assert!(error.message.contains("No supported proxy outbounds found"));
}
#[test]
fn rejects_vless_without_reality_parameters() {
let error = parse_subscription_body("vless://uuid@nl.example.test:443#nl-1")
.expect_err("missing reality params should fail");
assert!(error.message.contains("pbk and sid"));
}
#[test]
fn parses_subscription_user_info_header() {
let user_info = parse_user_info(Some("upload=10; download=20; total=30; expire=bad"));
assert_eq!(user_info["upload"], 10);
assert_eq!(user_info["download"], 20);
assert_eq!(user_info["total"], 30);
assert!(!user_info.contains_key("expire"));
}
#[test]
fn rejects_invalid_or_non_http_subscription_url_before_network() {
let invalid = subscription::fetch_subscription("not a url")
.expect_err("invalid url should fail before request");
let unsupported = subscription::fetch_subscription("file:///C:/subscription.txt")
.expect_err("non-http url should fail before request");
assert!(invalid.message.contains("Invalid subscription URL"));
assert!(unsupported.message.contains("http or https"));
}
#[test]
fn redacts_subscription_url_for_display() {
assert_eq!(
redact_subscription_url("https://sub.example.test/path?token=secret"),
"https://sub.example.test/..."
);
assert_eq!(
redact_subscription_url("vless://uuid@example.test"),
"vless://uuid@example.test/..."
);
}
fn sample_vless_link(tag: &str) -> String {
format!(
"vless://{}@nl.example.test:443?security=reality&sni=example.test&fp=chrome&pbk=public-key&sid=short-id&flow=xtls-rprx-vision#{}",
"11111111-1111-1111-1111-111111111111",
tag
)
}

235
src/api/tauriCommands.ts Normal file
View File

@@ -0,0 +1,235 @@
import { invoke } from '@tauri-apps/api/core';
import type {
ActivityEntry,
ComponentStatus,
LocalSingBoxConfig,
Profile,
ProfileInput,
SubscriptionCache,
SubscriptionServer,
Target,
TargetInput,
} from '../domain/types';
export interface CommandError {
code: string;
message: string;
details?: Array<{
field: string;
message: string;
}>;
}
export interface StatusResponse {
routeLine: string;
activeProfileCount: number;
routedAppCount: number;
activeTarget?: Target;
components: ComponentStatus[];
recentActivity: ActivityEntry[];
generatedConfigPath: string;
}
export interface SavedStateResponse {
profiles: Profile[];
targets: Target[];
generatedConfigPath: string;
}
export interface ProxiFyreSetupItem {
id: string;
name: string;
installed: boolean;
version?: string;
details: string;
}
export interface ProxiFyreSetupStatus {
ready: boolean;
missingCount: number;
items: ProxiFyreSetupItem[];
}
export type SingBoxSetupItem = ProxiFyreSetupItem;
export interface SingBoxSetupStatus {
ready: boolean;
missingCount: number;
items: SingBoxSetupItem[];
}
export interface LocalSingBoxStatusResponse {
config: LocalSingBoxConfig;
cache?: SubscriptionCache;
component: ComponentStatus;
generatedConfigPath: string;
lanListenHost?: string;
}
export interface PingServerResponse {
tag: string;
server: string;
serverPort: number;
ok: boolean;
latency?: number;
error?: string;
}
export interface GenerateSingBoxConfigResponse {
success: boolean;
message: string;
adapterId: string;
generatedConfigPath: string;
selectedServerTag: string;
listenHost: string;
listenPort: number;
check?: {
checked: boolean;
success: boolean;
message: string;
};
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 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 });
}
export function getComponents(): Promise<ComponentStatus[]> {
return invoke<ComponentStatus[]>('get_components');
}
export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status');
}
export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('get_singbox_status');
}
export function getSingBoxSetupStatus(): Promise<SingBoxSetupStatus> {
return invoke<SingBoxSetupStatus>('get_singbox_setup_status');
}
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');
}
export function forgetSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('forget_singbox_subscription');
}
export function selectSingBoxServer(server: SubscriptionServer): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('select_singbox_server', {
input: {
tag: server.tag,
server: server.server,
serverPort: server.serverPort,
},
});
}
export function pingSingBoxServer(tag: string): Promise<PingServerResponse> {
return invoke<PingServerResponse>('ping_singbox_server', {
input: { tag },
});
}
export function pingAllSingBoxServers(): Promise<PingServerResponse[]> {
return invoke<PingServerResponse[]>('ping_all_singbox_servers');
}
export function pingProxyTarget(host: string, port: number): Promise<PingServerResponse> {
return invoke<PingServerResponse>('ping_proxy_target', {
input: { host, port },
});
}
export function generateSingBoxConfig(): Promise<GenerateSingBoxConfigResponse> {
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 startProxiFyreService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('start_proxifyre_service');
}
export function stopProxiFyreService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('stop_proxifyre_service');
}
export function installProxiFyre(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('install_proxifyre');
}
export function uninstallProxiFyre(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('uninstall_proxifyre');
}
export function startSingBoxService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('start_singbox_service');
}
export function stopSingBoxService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('stop_singbox_service');
}
export function installSingBox(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('install_singbox');
}
export function uninstallSingBox(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('uninstall_singbox');
}

2162
src/app/App.tsx Normal file

File diff suppressed because it is too large Load Diff

83
src/app/readiness.ts Normal file
View File

@@ -0,0 +1,83 @@
export type RouteMode = 'external' | 'local-singbox';
export interface ApplyReadinessInput {
routeMode: RouteMode;
appCount: number;
proxiFyreInstalled: boolean;
singBoxInstalled: boolean;
selectedServerTag?: string;
externalProxyValue: string;
externalProxyError?: string | null;
busy: boolean;
}
export interface ApplyReadiness {
ready: boolean;
title?: string;
text?: string;
}
export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness {
if (input.busy) {
return {
ready: false,
title: 'Операция уже выполняется',
text: 'Дождись завершения текущего действия перед повторным применением.',
};
}
if (!input.proxiFyreInstalled) {
return {
ready: false,
title: 'ProxiFyre не установлен',
text: 'Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.',
};
}
if (input.appCount < 1) {
return {
ready: false,
title: 'Нет приложений',
text: 'Добавь хотя бы один процесс, EXE-файл или папку.',
};
}
if (input.routeMode === 'external') {
if (!input.externalProxyValue.trim()) {
return {
ready: false,
title: 'Прокси не указан',
text: 'Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.',
};
}
if (input.externalProxyError) {
return {
ready: false,
title: 'Проверь формат прокси',
text: input.externalProxyError,
};
}
}
if (input.routeMode === 'local-singbox') {
if (!input.singBoxInstalled) {
return {
ready: false,
title: 'Local sing-box не установлен',
text: 'Установи Local sing-box, чтобы применить локальный маршрут.',
};
}
if (!input.selectedServerTag) {
return {
ready: false,
title: 'Сервер не выбран',
text: 'Выбери сервер Local sing-box перед применением маршрута.',
};
}
}
return { ready: true };
}

15
src/app/viewModel.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { ComponentStatus } from '../domain/types';
import type { ServiceControlState } from '../ui';
export function serviceControlState(
component: ComponentStatus | undefined,
checking: boolean,
): ServiceControlState {
if (checking) return 'checking';
if (!component) return 'missing';
if (component.state === 'error') return 'error';
if (component.running) return 'running';
if (component.installed) return 'stopped';
return 'missing';
}

101
src/domain/types.ts Normal file
View File

@@ -0,0 +1,101 @@
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;
value: string;
recursive?: boolean;
}
export interface ProfileInput {
id?: string;
name: string;
enabled?: boolean;
targetId?: string;
protocols?: string[];
items?: ProfileItemInput[];
}
export interface ProfileItem {
type: ProfileItemType;
value: string;
recursive: boolean;
}
export interface Profile {
id: string;
name: string;
enabled: boolean;
targetId: string;
protocols: Protocol[];
items: ProfileItem[];
}
export interface TargetInput {
id?: string;
name: string;
kind?: TargetKind | string;
protocol?: ProxyProtocol | string;
host: string;
port: number;
requiresComponent?: ComponentId | string;
}
export interface Target {
id: string;
name: string;
kind: TargetKind;
protocol: ProxyProtocol;
host: string;
port: number;
requiresComponent?: ComponentId;
}
export interface ComponentStatus {
id: ComponentId;
name: string;
state: ComponentState;
installed: boolean;
running: boolean;
version?: string;
path?: string;
problems: string[];
actions: string[];
}
export interface LocalSingBoxConfig {
subscriptionDisplayUrl?: string;
hasSubscription: boolean;
selectedServerTag?: string;
listenHost: string;
listenPort: number;
serviceName: string;
installRoot: string;
updatedAt?: string;
}
export interface SubscriptionServer {
tag: string;
type: string;
server: string;
serverPort: number;
}
export interface SubscriptionCache {
servers: SubscriptionServer[];
userInfo: Record<string, number | string | boolean | null>;
fetchedAt: string;
}
export interface ActivityEntry {
id: string;
at: string;
level: ActivityLevel;
title: string;
message: string;
}

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import './styles/app.css';
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

2668
src/styles/app.css Normal file

File diff suppressed because it is too large Load Diff

54
src/ui/ActionMenu.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { MoreHorizontal } from 'lucide-react';
import { IconButton } from './IconButton';
export interface ActionMenuItem {
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
}
export interface ActionMenuProps {
open: boolean;
onOpenChange: (open: boolean) => void;
label: string;
items: ActionMenuItem[];
disabled?: boolean;
}
export function ActionMenu({
open,
onOpenChange,
label,
items,
disabled,
}: ActionMenuProps) {
return (
<div className="ui-action-menu">
<IconButton
label={label}
icon={<MoreHorizontal size={20} strokeWidth={2} />}
onClick={() => onOpenChange(!open)}
disabled={disabled}
aria-expanded={open}
/>
{open ? (
<div className="ui-action-menu-popover" role="menu">
{items.map((item) => (
<button
type="button"
role="menuitem"
className={item.danger ? 'is-danger' : ''}
onClick={item.onClick}
disabled={item.disabled}
key={item.label}
>
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}

49
src/ui/Button.tsx Normal file
View File

@@ -0,0 +1,49 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
loadingLabel?: string;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
}
export function Button({
variant = 'neutral',
size = 'md',
loading = false,
loadingLabel,
leftIcon,
rightIcon,
className,
children,
disabled,
...props
}: ButtonProps) {
const classes = [
'ui-button',
`ui-button--${variant}`,
`ui-button--${size}`,
loading ? 'is-loading' : '',
className ?? '',
].filter(Boolean).join(' ');
return (
<button
{...props}
className={classes}
disabled={disabled || loading}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : leftIcon ? (
<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}
</button>
);
}

Some files were not shown because too many files have changed in this diff Show More