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
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||