Expand README with architecture and setup details
16
.gitattributes
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
28
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
79
scripts/install-control-app.ps1
Normal 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
|
||||
}
|
||||
96
scripts/install-proxyfier.ps1
Normal 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
@@ -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
22
src-tauri/Cargo.toml
Normal 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
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
7
src-tauri/capabilities/default.json
Normal 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
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 142 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
@@ -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
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 994 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 650 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
23
src-tauri/src/activity.rs
Normal 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)
|
||||
}
|
||||
245
src-tauri/src/adapters/proxifyre.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
67
src-tauri/src/adapters/proxy_router.rs
Normal 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>;
|
||||
}
|
||||
338
src-tauri/src/adapters/singbox.rs
Normal 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
564
src-tauri/src/component_detection.rs
Normal 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
@@ -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
@@ -0,0 +1,5 @@
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("не удалось запустить клиент ProxyWarden");
|
||||
}
|
||||
71
src-tauri/src/main.rs
Normal 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
@@ -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}...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
273
src-tauri/src/singbox_service.rs
Normal 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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
269
src-tauri/src/subscription.rs
Normal 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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
528
src-tauri/tests/command_tests.rs
Normal 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()],
|
||||
}
|
||||
}
|
||||
207
src-tauri/tests/component_detection_tests.rs
Normal 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()
|
||||
}
|
||||
128
src-tauri/tests/domain_tests.rs
Normal 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"));
|
||||
}
|
||||
139
src-tauri/tests/helper_tests.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
207
src-tauri/tests/proxifyre_adapter_tests.rs
Normal 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()],
|
||||
}
|
||||
}
|
||||
294
src-tauri/tests/singbox_adapter_tests.rs
Normal 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()],
|
||||
}
|
||||
}
|
||||
397
src-tauri/tests/singbox_command_tests.rs
Normal 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);
|
||||
}
|
||||
128
src-tauri/tests/singbox_service_tests.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
302
src-tauri/tests/storage_tests.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
99
src-tauri/tests/subscription_tests.rs
Normal 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
@@ -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
83
src/app/readiness.ts
Normal 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
@@ -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
@@ -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
@@ -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
54
src/ui/ActionMenu.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||