Refactor proxy routing and installer flows

This commit is contained in:
2026-07-08 11:05:52 +03:00
parent e745633d91
commit 88d5b94133
14 changed files with 1899 additions and 487 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules/
dist/
releases/
src-tauri/target/
src-tauri/gen/

225
README.md
View File

@@ -1,55 +1,127 @@
# ProxyWarden
ProxyWarden - это Windows-приложение для маршрутизации выбранных программ через прокси или VPN-сервер. Оно не меняет глобальные настройки прокси в Windows: вы сами выбираете, какие приложения должны идти через маршрут, например Discord, Telegram, браузер, игру или конкретный `.exe`.
ProxyWarden - это standalone Windows desktop-приложение для маршрутизации выбранных программ через SOCKS5-прокси. По сути это удобная оболочка управления над внешними компонентами: обязательным маршрутизатором приложений ProxiFyre и, опционально, локальным runtime `sing-box`.
## Что умеет приложение
ProxyWarden сам не является VPN-драйвером, прокси-сервером или отдельным gateway/server. Он хранит настройки, показывает состояние компонентов, генерирует конфиги и запускает только явные действия пользователя: установить, запустить, остановить, удалить или применить конфиг.
- добавлять приложения по имени процесса, папке или конкретному EXE-файлу;
- отправлять выбранные приложения через внешний SOCKS5-прокси;
- при необходимости поднимать локальный `sing-box` и использовать сервер из подписки;
- показывать состояние компонентов: установлен ли ProxiFyre, запущены ли службы, выбран ли сервер;
- генерировать конфиги для ProxiFyre и `sing-box` из сохраненных настроек;
- не устанавливать скрыто лишние компоненты при применении профиля.
## Главное
## Из чего состоит ProxyWarden
- Работает как Windows-клиент: Tauri 2 + React/TypeScript UI + Rust backend.
- Маршрутизирует не всю систему, а выбранные приложения: процесс, папку или конкретный `.exe`.
- Не меняет глобальный proxy в Windows.
- Для per-app routing нужен ProxiFyre.
- Local sing-box нужен только для сценария с подпиской и локальным SOCKS5 endpoint.
- Внешний SOCKS5-прокси работает без Local sing-box.
- Применение профиля не устанавливает и не чинит компоненты скрыто.
ProxyWarden разделен на три независимые части.
## Из чего состоит
**Control App** - само desktop-приложение. В нем вы настраиваете маршрут, выбираете приложения, смотрите статус и запускаете явные действия.
| Компонент | Что это | Нужен когда | Откуда берется |
| --- | --- | --- | --- |
| ProxyWarden Control App | Окно управления, настройки, status/readiness, генерация конфигов | Всегда | Этот репозиторий |
| [ProxiFyre](https://github.com/wiresock/proxifyre) | Windows-приложение/служба для перехвата трафика выбранных процессов и отправки его в SOCKS5 | Всегда для маршрутизации приложений | GitHub releases `wiresock/proxifyre` |
| [Windows Packet Filter / NDISAPI](https://github.com/wiresock/ndisapi) | Сетевой драйвер, который нужен ProxiFyre | Устанавливается вместе с ProxiFyre, если отсутствует | GitHub releases `wiresock/ndisapi` |
| [Microsoft Visual C++ Redistributable](https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist) | Runtime-зависимость для `ProxiFyre.exe` | Устанавливается вместе с ProxiFyre, если отсутствует | Официальный `vc_redist` Microsoft |
| [sing-box](https://github.com/SagerNet/sing-box) | Локальный proxy/VPN runtime, который слушает `127.0.0.1:1080` | Только для маршрута через subscription/выбранный сервер | GitHub releases `SagerNet/sing-box` |
| [WinSW](https://github.com/winsw/winsw) | Wrapper, который запускает Local sing-box как Windows-службу | Только для Local sing-box | GitHub releases `winsw/winsw` |
**ProxiFyre** - обязательный слой для маршрутизации отдельных Windows-приложений. Он заставляет выбранные программы ходить через SOCKS5-прокси даже тогда, когда сами программы не умеют работать с прокси.
В UI и коде компонент ProxiFyre иногда проходит через внутренний id `proxyfier`. Это не отдельный продукт Proxifier; текущий backend adapter работает именно с ProxiFyre.
**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. Примените маршрут.
Результат: выбранные приложения идут по цепочке:
Внешний SOCKS5-прокси:
```text
Приложения -> ProxiFyre -> Local sing-box 127.0.0.1:1080 -> выбранный сервер
выбранные приложения -> ProxiFyre -> внешний SOCKS5 proxy
```
## Где хранятся настройки
Local sing-box:
Пользовательские настройки хранятся в `C:\ProgramData\ProxyWarden`:
```text
выбранные приложения -> ProxiFyre -> Local sing-box 127.0.0.1:1080 -> выбранный сервер из подписки
```
Во втором сценарии ProxiFyre все равно обязателен: именно он делает маршрутизацию конкретных Windows-приложений. Local sing-box только дает локальный SOCKS5 endpoint и ходит дальше к выбранному серверу.
## Что устанавливается
### Control App
Обычная сборка Tauri создает desktop-приложение ProxyWarden. Отдельный скрипт `scripts/install-control-app.ps1` сейчас подготавливает стандартные директории:
```text
C:\Program Files\ProxyWarden\ControlApp
C:\ProgramData\ProxyWarden\config
C:\ProgramData\ProxyWarden\state
C:\ProgramData\ProxyWarden\generated
```
### ProxiFyre
Явная установка ProxiFyre из приложения выполняется через elevated PowerShell и ставит/обновляет:
```text
C:\Tools\ProxiFyre
C:\Tools\ProxiFyre\ProxiFyre.exe
C:\Tools\ProxiFyre\app-config.json
Windows service: ProxiFyreService
```
Если на машине не найдены зависимости, установщик также скачивает и ставит Microsoft Visual C++ Redistributable и Windows Packet Filter / NDISAPI.
### Local sing-box
Явная установка Local sing-box ставит:
```text
C:\Program Files\ProxyWarden\sing-box\sing-box.exe
C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe
C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.xml
C:\Program Files\ProxyWarden\sing-box\config.json
Windows service: ProxyWardenSingBox
```
`ProxyWardenSingBox.exe` - это WinSW wrapper. Он нужен только чтобы запускать `sing-box.exe` как Windows-службу.
## Права администратора
Без прав администратора можно открыть приложение, редактировать настройки, добавлять приложения, вводить внешний proxy, загружать/выбирать подписку и смотреть состояние.
Права администратора или UAC confirmation нужны для операций, которые меняют систему:
- установка или удаление ProxiFyre;
- установка Windows Packet Filter / NDISAPI;
- установка Microsoft Visual C++ Redistributable, если его нет;
- установка или удаление Local sing-box;
- создание, запуск и остановка Windows-служб;
- удаление install folder для managed-компонентов.
Применение профиля не запускает установку. Оно генерирует derived config и пытается записать его в найденную установку ProxiFyre. Если прав на запись в папку установки не хватает, операция должна завершиться ошибкой, а не устанавливать что-то скрыто.
## Поддержанная среда
Подтверждено вручную сейчас:
```text
Windows 11
PowerShell 7 как пользовательская shell для запуска команд разработки
```
Важно: Rust backend и elevated-операции сейчас запускают именно `powershell.exe` с `-NoProfile` и `-ExecutionPolicy Bypass`. На Windows это обычно Windows PowerShell 5.1. Скрипты используют стандартные команды вроде `Get-CimInstance`, `Invoke-WebRequest`, `Expand-Archive`, `Get-FileHash`, `Start-Service`, `Stop-Service`, `ConvertTo-Json`, поэтому должны быть близки к Windows PowerShell 5.1, но полный ручной тест пока был только на Windows 11 с PowerShell 7 в окружении разработки.
Ожидаемая, но не полностью подтвержденная область:
- Windows 10/11 desktop;
- x64 как основной сценарий;
- x86 и ARM64 частично учтены в installer-логике через выбор release assets, но не считаются проверенными;
- обычный desktop/laptop без специальных требований к GPU;
- доступ в интернет к GitHub releases и Microsoft download endpoints для установки компонентов.
Linux/macOS не являются целевой платформой для этого клиента.
## Где лежат настройки
Source of truth лежит в JSON под `C:\ProgramData\ProxyWarden`:
```text
C:\ProgramData\ProxyWarden\config\profiles.json
@@ -67,19 +139,45 @@ C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json
C:\ProgramData\ProxyWarden\generated\sing-box-config.json
```
Важно: редактировать вручную лучше исходные настройки, а не generated-файлы. При записи настроек приложение создает backup рядом с исходным JSON.
Не редактируйте generated-файлы как основной источник правды. При следующей генерации они могут быть перезаписаны.
## Установка из исходников
Subscription URL считается секретом. UI и diagnostics должны показывать только редактированную/сокращенную версию ссылки.
## Типовые сценарии
### Внешний SOCKS5
1. Запустите ProxyWarden.
2. Установите или проверьте ProxiFyre.
3. На вкладке `VPN / Прокси` выберите внешний proxy.
4. Введите `host:port` или `socks5://host:port`.
5. На вкладке `ProxiFyre` добавьте приложения.
6. Нажмите `Применить в ProxiFyre`.
Local sing-box для этого сценария не нужен.
### Local sing-box с подпиской
1. Запустите ProxyWarden.
2. Установите ProxiFyre.
3. Установите Local sing-box.
4. Вставьте subscription URL.
5. Загрузите список серверов и выберите сервер.
6. Добавьте приложения.
7. Сгенерируйте/примените маршрут.
## Установка и запуск из исходников
Нужны:
- Windows 10/11;
- Windows 11 для подтвержденного пути разработки;
- Node.js и npm;
- Rust через rustup;
- Visual Studio Build Tools с MSVC и Windows SDK;
- Microsoft Edge WebView2 Runtime.
- Microsoft Edge WebView2 Runtime;
- PowerShell 7 удобно использовать как shell разработки, но elevated runtime-команды приложения запускаются через `powershell.exe`.
Команды:
Установка зависимостей и запуск:
```powershell
cd D:\repos\ProxyWarden
@@ -87,7 +185,13 @@ npm install
npm run tauri -- dev
```
Собрать установочный пакет:
Собрать frontend:
```powershell
npm run build
```
Собрать установочный пакет Tauri:
```powershell
npm run tauri -- build
@@ -99,11 +203,11 @@ npm run tauri -- build
npm run dev -- --host 127.0.0.1
```
В browser-preview можно проверить интерфейс, но нельзя управлять Windows-службами и нативными компонентами.
Browser-preview годится для проверки интерфейса, но не доказывает работу Windows-служб, elevated-операций и Tauri command handlers.
## Явные installer-скрипты
## Installer-скрипты
В репозитории есть отдельные entrypoint-скрипты:
В репозитории есть явные entrypoint-скрипты:
```powershell
& .\scripts\install-control-app.ps1 -PlanOnly
@@ -111,32 +215,45 @@ npm run dev -- --host 127.0.0.1
& .\scripts\install-singbox.ps1 -PlanOnly
```
`-PlanOnly` показывает план в JSON и ничего не устанавливает. Реальная установка требует прав администратора. Компоненты устанавливаются отдельно: применение профиля не должно незаметно устанавливать Control App, ProxiFyre или Local sing-box.
`-PlanOnly` возвращает structured JSON и не должен иметь side effects.
Реальная установка через эти скрипты требует прав администратора. `scripts/install-proxyfier.ps1` как standalone boundary сейчас ожидает локальный `-PackagePath`; путь установки из UI/backend использует отдельный elevated-скрипт, который скачивает ProxiFyre, Windows Packet Filter и runtime-зависимости сам.
## Проверка для разработчика
Frontend:
Frontend/UI:
```powershell
npm run build
```
Rust-тесты:
Rust/backend:
```powershell
cd D:\repos\ProxyWarden\src-tauri
cargo test
```
Информация Tauri:
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
```
## Ограничения текущей версии
- Поддерживается основной путь через SOCKS5.
- ProxiFyre является текущим backend-слоем маршрутизации; архитектура оставляет место для другого proxy-router adapter.
- Local sing-box остается опциональным и не требуется для внешнего SOCKS5-прокси.
- Реальные elevated-операции установки, удаления и управления Windows-службами нужно проверять на Windows с правами администратора.
- Основной поддержанный маршрут - SOCKS5.
- ProxiFyre является текущим backend-слоем для per-app routing.
- Local sing-box остается опциональным и не требуется для внешнего SOCKS5.
- Elevated install/start/stop/uninstall операции считаются реализованными, но требуют дополнительной проверки на реальной Windows-машине с UAC/admin confirmation.
- Windows 10, Windows PowerShell 5.1, ARM64 и x86 нужно отдельно подтвердить перед тем, как называть их официально поддержанными.

606
scripts/prepare-release.ps1 Normal file
View File

@@ -0,0 +1,606 @@
param(
[string]$Version = "",
[ValidateSet("", "patch", "minor", "major")]
[string]$Bump = "",
[string]$OutputRoot = "releases",
[switch]$SkipTests,
[switch]$SkipBuild,
[switch]$PlanOnly,
[switch]$Force
)
$ErrorActionPreference = "Stop"
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
$PackageJsonPath = Join-Path $RepoRoot "package.json"
$PackageLockPath = Join-Path $RepoRoot "package-lock.json"
$TauriConfigPath = Join-Path $RepoRoot "src-tauri\tauri.conf.json"
$CargoTomlPath = Join-Path $RepoRoot "src-tauri\Cargo.toml"
$BundleRoot = Join-Path $RepoRoot "src-tauri\target\release\bundle"
function Write-Utf8NoBomFile {
param(
[string]$Path,
[string]$Value
)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($Path, $Value, $encoding)
}
function Read-JsonFile {
param([string]$Path)
Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json
}
function Write-JsonFile {
param(
[string]$Path,
[object]$Value
)
$json = $Value | ConvertTo-Json -Depth 100
Write-Utf8NoBomFile -Path $Path -Value ($json + [Environment]::NewLine)
}
function Replace-RegexGroup {
param(
[string]$Content,
[string]$Pattern,
[string]$GroupName,
[string]$Value,
[string]$Label
)
$match = [regex]::Match($Content, $Pattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
if (-not $match.Success) {
throw "Cannot find $Label."
}
$group = $match.Groups[$GroupName]
if (-not $group.Success) {
throw "Cannot find $Label value."
}
$Content.Remove($group.Index, $group.Length).Insert($group.Index, $Value)
}
function Get-FirstJsonVersion {
param(
[string]$Path,
[string]$Label
)
$content = Get-Content -Raw -LiteralPath $Path
$match = [regex]::Match($content, '"version"\s*:\s*"(?<value>[^"]+)"')
if (-not $match.Success) {
throw "Cannot find version in $Label."
}
$match.Groups["value"].Value
}
function Set-FirstJsonVersion {
param(
[string]$Path,
[string]$TargetVersion,
[string]$Label
)
$content = Get-Content -Raw -LiteralPath $Path
$updated = Replace-RegexGroup `
-Content $content `
-Pattern '"version"\s*:\s*"(?<value>[^"]+)"' `
-GroupName "value" `
-Value $TargetVersion `
-Label "version in $Label"
Write-Utf8NoBomFile -Path $Path -Value $updated
}
function Get-PackageLockVersions {
$content = Get-Content -Raw -LiteralPath $PackageLockPath
$topMatch = [regex]::Match(
$content,
'^\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*"(?<value>[^"]+)"',
[System.Text.RegularExpressions.RegexOptions]::Singleline
)
if (-not $topMatch.Success) {
throw "Cannot find top-level version in package-lock.json."
}
$rootMatch = [regex]::Match(
$content,
'"packages"\s*:\s*\{\s*""\s*:\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*"(?<value>[^"]+)"',
[System.Text.RegularExpressions.RegexOptions]::Singleline
)
if (-not $rootMatch.Success) {
throw "Cannot find root package version in package-lock.json."
}
[ordered]@{
packageLock = $topMatch.Groups["value"].Value
packageLockRoot = $rootMatch.Groups["value"].Value
}
}
function Set-PackageLockVersions {
param([string]$TargetVersion)
$content = Get-Content -Raw -LiteralPath $PackageLockPath
$updated = Replace-RegexGroup `
-Content $content `
-Pattern '^\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*"(?<value>[^"]+)"' `
-GroupName "value" `
-Value $TargetVersion `
-Label "top-level version in package-lock.json"
$updated = Replace-RegexGroup `
-Content $updated `
-Pattern '"packages"\s*:\s*\{\s*""\s*:\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*"(?<value>[^"]+)"' `
-GroupName "value" `
-Value $TargetVersion `
-Label "root package version in package-lock.json"
Write-Utf8NoBomFile -Path $PackageLockPath -Value $updated
}
function Assert-Semver {
param([string]$Value)
if ($Value -notmatch "^\d+\.\d+\.\d+$") {
throw "Version '$Value' is not supported. Use numeric SemVer like 0.1.0."
}
}
function ConvertTo-VersionParts {
param([string]$Value)
Assert-Semver -Value $Value
$parts = $Value.Split(".")
[ordered]@{
major = [int]$parts[0]
minor = [int]$parts[1]
patch = [int]$parts[2]
}
}
function Compare-Semver {
param(
[string]$Left,
[string]$Right
)
$leftParts = ConvertTo-VersionParts -Value $Left
$rightParts = ConvertTo-VersionParts -Value $Right
foreach ($part in @("major", "minor", "patch")) {
if ($leftParts[$part] -gt $rightParts[$part]) { return 1 }
if ($leftParts[$part] -lt $rightParts[$part]) { return -1 }
}
return 0
}
function Get-NextVersion {
param(
[string]$Current,
[string]$Kind
)
$parts = ConvertTo-VersionParts -Value $Current
switch ($Kind) {
"major" { return "$($parts.major + 1).0.0" }
"minor" { return "$($parts.major).$($parts.minor + 1).0" }
"patch" { return "$($parts.major).$($parts.minor).$($parts.patch + 1)" }
default { throw "Unknown bump kind '$Kind'." }
}
}
function Get-CargoPackageVersion {
$content = Get-Content -Raw -LiteralPath $CargoTomlPath
$packageMatch = [regex]::Match($content, "(?ms)^\[package\]\s*(.*?)(?=^\[|\z)")
if (-not $packageMatch.Success) {
throw "Cannot find [package] block in $CargoTomlPath."
}
$versionMatch = [regex]::Match($packageMatch.Value, '(?m)^version\s*=\s*"([^"]+)"\s*$')
if (-not $versionMatch.Success) {
throw "Cannot find package version in $CargoTomlPath."
}
$versionMatch.Groups[1].Value
}
function Get-VersionState {
$packageLock = Get-PackageLockVersions
[ordered]@{
packageJson = [string](Get-FirstJsonVersion -Path $PackageJsonPath -Label "package.json")
packageLock = [string]$packageLock.packageLock
packageLockRoot = [string]$packageLock.packageLockRoot
tauriConfig = [string](Get-FirstJsonVersion -Path $TauriConfigPath -Label "tauri.conf.json")
cargoToml = [string](Get-CargoPackageVersion)
}
}
function Get-CurrentVersion {
$state = Get-VersionState
$versions = @(@(
$state.packageJson,
$state.packageLock,
$state.packageLockRoot,
$state.tauriConfig,
$state.cargoToml
) | Select-Object -Unique)
if ($versions.Count -ne 1) {
$details = $state.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }
throw "Version mismatch: $($details -join ', ')."
}
Assert-Semver -Value $versions[0]
$versions[0]
}
function Resolve-TargetVersion {
param([string]$Current)
if (-not [string]::IsNullOrWhiteSpace($Version)) {
Assert-Semver -Value $Version
return $Version
}
if (-not [string]::IsNullOrWhiteSpace($Bump)) {
return Get-NextVersion -Current $Current -Kind $Bump
}
if ($PlanOnly -or -not [Environment]::UserInteractive) {
return Get-NextVersion -Current $Current -Kind "patch"
}
$patch = Get-NextVersion -Current $Current -Kind "patch"
$minor = Get-NextVersion -Current $Current -Kind "minor"
$major = Get-NextVersion -Current $Current -Kind "major"
Write-Host ""
Write-Host "Current version: $Current"
Write-Host "Choose release version:"
Write-Host " 1) patch $patch"
Write-Host " 2) minor $minor"
Write-Host " 3) major $major"
Write-Host " 4) custom"
Write-Host " 5) keep current $Current"
$choice = Read-Host "Selection [1]"
if ([string]::IsNullOrWhiteSpace($choice)) { $choice = "1" }
switch ($choice.Trim()) {
"1" { return $patch }
"2" { return $minor }
"3" { return $major }
"4" {
$custom = Read-Host "Enter version"
Assert-Semver -Value $custom
return $custom
}
"5" { return $Current }
default { throw "Unknown selection '$choice'." }
}
}
function Set-CargoPackageVersion {
param([string]$TargetVersion)
$content = Get-Content -Raw -LiteralPath $CargoTomlPath
$packageMatch = [regex]::Match($content, "(?ms)^\[package\]\s*(.*?)(?=^\[|\z)")
if (-not $packageMatch.Success) {
throw "Cannot find [package] block in $CargoTomlPath."
}
$packageBlock = $packageMatch.Value
$versionMatch = [regex]::Match($packageBlock, '(?m)^version\s*=\s*"(?<value>[^"]+)"\s*$')
if (-not $versionMatch.Success) {
throw "Cannot update package version in $CargoTomlPath."
}
if ($versionMatch.Groups["value"].Value -eq $TargetVersion) {
return
}
$valueGroup = $versionMatch.Groups["value"]
$updatedBlock = $packageBlock.Remove($valueGroup.Index, $valueGroup.Length).Insert($valueGroup.Index, $TargetVersion)
$updatedContent = $content.Remove($packageMatch.Index, $packageMatch.Length).Insert($packageMatch.Index, $updatedBlock)
Write-Utf8NoBomFile -Path $CargoTomlPath -Value $updatedContent
}
function Set-ManifestVersions {
param([string]$TargetVersion)
Set-FirstJsonVersion -Path $PackageJsonPath -TargetVersion $TargetVersion -Label "package.json"
Set-PackageLockVersions -TargetVersion $TargetVersion
Set-FirstJsonVersion -Path $TauriConfigPath -TargetVersion $TargetVersion -Label "tauri.conf.json"
Set-CargoPackageVersion -TargetVersion $TargetVersion
}
function Get-FullPath {
param([string]$Path)
[System.IO.Path]::GetFullPath($Path)
}
function Test-IsSubPath {
param(
[string]$Parent,
[string]$Child
)
$parentFull = (Get-FullPath -Path $Parent).TrimEnd("\", "/") + [System.IO.Path]::DirectorySeparatorChar
$childFull = (Get-FullPath -Path $Child).TrimEnd("\", "/") + [System.IO.Path]::DirectorySeparatorChar
$childFull.StartsWith($parentFull, [System.StringComparison]::OrdinalIgnoreCase)
}
function Get-RelativePath {
param(
[string]$BasePath,
[string]$Path
)
$baseUri = [Uri]((Get-FullPath -Path $BasePath).TrimEnd("\", "/") + [System.IO.Path]::DirectorySeparatorChar)
$pathUri = [Uri](Get-FullPath -Path $Path)
[Uri]::UnescapeDataString($baseUri.MakeRelativeUri($pathUri).ToString()).Replace("/", "\")
}
function New-ReleaseDirectory {
param([string]$TargetVersion)
if ([System.IO.Path]::IsPathRooted($OutputRoot)) {
$root = Get-FullPath -Path $OutputRoot
} else {
$root = Get-FullPath -Path (Join-Path $RepoRoot $OutputRoot)
}
$releaseDir = Join-Path $root "proxywarden-v$TargetVersion"
if ((Test-Path -LiteralPath $releaseDir) -and $Force) {
if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) {
throw "Refusing to remove release directory outside OutputRoot: $releaseDir"
}
Remove-Item -LiteralPath $releaseDir -Recurse -Force
} elseif (Test-Path -LiteralPath $releaseDir) {
throw "Release directory already exists: $releaseDir. Use -Force to replace it."
}
New-Item -ItemType Directory -Path (Join-Path $releaseDir "artifacts") -Force | Out-Null
$releaseDir
}
function Invoke-NativeCommand {
param(
[string]$Name,
[string]$FilePath,
[string[]]$Arguments,
[string]$WorkingDirectory = $RepoRoot
)
Write-Host ""
Write-Host "==> $Name"
Push-Location $WorkingDirectory
try {
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Name failed with exit code $LASTEXITCODE."
}
} finally {
Pop-Location
}
}
function Invoke-ReleaseBuild {
if ($SkipBuild) {
Write-Host ""
Write-Host "Skipping build because -SkipBuild was provided."
return
}
Invoke-NativeCommand -Name "Frontend build" -FilePath "npm" -Arguments @("run", "build")
if (-not $SkipTests) {
Invoke-NativeCommand -Name "Rust tests" -FilePath "cargo" -Arguments @("test") -WorkingDirectory (Join-Path $RepoRoot "src-tauri")
} else {
Write-Host ""
Write-Host "Skipping Rust tests because -SkipTests was provided."
}
Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build")
}
function Copy-ReleaseArtifacts {
param([string]$ReleaseDir)
if ($SkipBuild) {
return @()
}
if (-not (Test-Path -LiteralPath $BundleRoot)) {
throw "Tauri bundle output was not found: $BundleRoot"
}
$artifactDir = Join-Path $ReleaseDir "artifacts"
$files = Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") }
if ($files.Count -eq 0) {
throw "No release artifacts were found under $BundleRoot."
}
$copied = @()
foreach ($file in $files) {
$relative = Get-RelativePath -BasePath $BundleRoot -Path $file.FullName
$destination = Join-Path $artifactDir $relative
New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null
Copy-Item -LiteralPath $file.FullName -Destination $destination -Force
$copied += Get-Item -LiteralPath $destination
}
$copied
}
function Write-Checksums {
param(
[string]$ReleaseDir,
[object[]]$Files
)
if ($Files.Count -eq 0) { return $null }
$artifactDir = Join-Path $ReleaseDir "artifacts"
$lines = foreach ($file in $Files) {
$hash = Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256
$relative = Get-RelativePath -BasePath $artifactDir -Path $file.FullName
"$($hash.Hash.ToLowerInvariant()) $relative"
}
$checksumPath = Join-Path $ReleaseDir "SHA256SUMS.txt"
Write-Utf8NoBomFile -Path $checksumPath -Value (($lines -join [Environment]::NewLine) + [Environment]::NewLine)
$checksumPath
}
function Get-GitValue {
param([string[]]$Arguments)
try {
$value = & git @Arguments 2>$null
if ($LASTEXITCODE -eq 0) {
return ($value -join [Environment]::NewLine).Trim()
}
} catch {}
return ""
}
function Write-ReleaseMetadata {
param(
[string]$ReleaseDir,
[string]$TargetVersion,
[object[]]$Artifacts
)
$artifactDir = Join-Path $ReleaseDir "artifacts"
$artifactItems = foreach ($artifact in $Artifacts) {
[ordered]@{
path = Get-RelativePath -BasePath $ReleaseDir -Path $artifact.FullName
sizeBytes = $artifact.Length
sha256 = (Get-FileHash -LiteralPath $artifact.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
}
}
$manifest = [ordered]@{
product = "ProxyWarden"
version = $TargetVersion
builtAt = (Get-Date).ToString("o")
source = "local"
gitCommit = Get-GitValue -Arguments @("rev-parse", "HEAD")
gitStatus = Get-GitValue -Arguments @("status", "--short")
artifacts = @($artifactItems)
}
Write-JsonFile -Path (Join-Path $ReleaseDir "release-manifest.json") -Value $manifest
$artifactList = if ($Artifacts.Count -gt 0) {
($Artifacts | ForEach-Object {
"- " + (Get-RelativePath -BasePath $artifactDir -Path $_.FullName)
}) -join [Environment]::NewLine
} else {
"- Build was skipped; no artifacts were copied."
}
$notes = @"
# ProxyWarden v$TargetVersion
## Artifacts
$artifactList
## Checksums
See `SHA256SUMS.txt`.
## Release boundary
This release contains the ProxyWarden Control App only. ProxiFyre and Local sing-box remain explicit user-managed components.
"@
Write-Utf8NoBomFile -Path (Join-Path $ReleaseDir "release-notes.md") -Value $notes
}
function New-PlanResult {
param(
[string]$Current,
[string]$Target
)
$outputRootFull = if ([System.IO.Path]::IsPathRooted($OutputRoot)) {
Get-FullPath -Path $OutputRoot
} else {
Get-FullPath -Path (Join-Path $RepoRoot $OutputRoot)
}
[ordered]@{
success = $true
action = "prepare-release.plan"
changed = $false
message = "Release plan is ready."
details = [ordered]@{
currentVersion = $Current
targetVersion = $Target
releaseDirectory = (Join-Path $outputRootFull "proxywarden-v$Target")
skipTests = [bool]$SkipTests
skipBuild = [bool]$SkipBuild
manifests = @(
$PackageJsonPath,
$PackageLockPath,
$TauriConfigPath,
$CargoTomlPath
)
commands = @(
"npm run build",
"cd src-tauri; cargo test",
"npm run tauri -- build"
)
}
} | ConvertTo-Json -Depth 8
}
try {
Push-Location $RepoRoot
$currentVersion = Get-CurrentVersion
$targetVersion = Resolve-TargetVersion -Current $currentVersion
Assert-Semver -Value $targetVersion
if ((Compare-Semver -Left $targetVersion -Right $currentVersion) -lt 0) {
throw "Target version $targetVersion is lower than current version $currentVersion."
}
if ($PlanOnly) {
New-PlanResult -Current $currentVersion -Target $targetVersion
exit 0
}
Write-Host ""
Write-Host "Preparing ProxyWarden release $targetVersion..."
Write-Host "Repository: $RepoRoot"
Set-ManifestVersions -TargetVersion $targetVersion
$afterUpdateVersion = Get-CurrentVersion
if ($afterUpdateVersion -ne $targetVersion) {
throw "Version update failed. Current version is $afterUpdateVersion."
}
Invoke-ReleaseBuild
$releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion
$artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir)
Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts
Write-Host ""
Write-Host "Release folder is ready:"
Write-Host $releaseDir
Write-Host ""
Write-Host "Upload the files from the release folder to GitHub release v$targetVersion."
} finally {
Pop-Location
}

View File

@@ -437,39 +437,18 @@ impl Clock for SystemClock {
}
}
pub struct StagedApplyHelper;
impl ProxyApplyHelper for StagedApplyHelper {
fn apply_proxy_config(
&self,
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
Ok(HelperApplyResult {
success: true,
changed: true,
action: format!("{}.stage-generated-config", request.adapter_id),
message: format!(
"Сгенерированный конфиг подготовлен в {}; интеграция привилегированного помощника еще не подключена",
request.config_path.display()
),
})
}
}
pub struct DetectedProxyApplyHelper<H = SystemProxyfierDetectionHost> {
host: H,
}
impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
pub fn system() -> Self {
Self {
host: SystemProxyfierDetectionHost,
}
SystemProxyfierDetectionHost.into()
}
}
impl<H> DetectedProxyApplyHelper<H> {
pub fn new(host: H) -> Self {
impl<H> From<H> for DetectedProxyApplyHelper<H> {
fn from(host: H) -> Self {
Self { host }
}
}

View File

@@ -3,11 +3,7 @@ use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SE
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 {

View File

@@ -75,13 +75,6 @@ impl JsonStorage {
&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)
}
@@ -102,10 +95,6 @@ impl JsonStorage {
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)
}
@@ -135,11 +124,6 @@ impl JsonStorage {
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);

View File

@@ -303,9 +303,10 @@ fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
storage
.write_targets(&[external_socks5_target()])
.expect("write targets");
storage
.write_components(&[proxyfier_running(), singbox_missing()])
.expect("write components");
write_json(
&storage.paths().components_file,
&[proxyfier_running(), singbox_missing()],
);
let response = apply_profiles_with_services(
&storage,
@@ -345,9 +346,7 @@ fn apply_blocks_local_singbox_target_when_component_is_missing() {
storage
.write_targets(&[local_singbox_target()])
.expect("write targets");
storage
.write_components(&[singbox_missing()])
.expect("write components");
write_json(&storage.paths().components_file, &[singbox_missing()]);
let error = apply_profiles_with_services(
&storage,
@@ -405,7 +404,7 @@ fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
.with_registry("ProxiFyre", &install_dir)
.with_path(&install_dir)
.with_path(&install_dir.join("ProxiFyre.exe"));
let helper = DetectedProxyApplyHelper::new(host);
let helper = DetectedProxyApplyHelper::from(host);
let result = helper
.apply_proxy_config(HelperApplyRequest {
@@ -438,7 +437,7 @@ fn detected_proxy_apply_helper_ignores_plain_proxifier_install() {
.with_registry("Proxifier", &install_dir)
.with_path(&install_dir)
.with_path(&install_dir.join("Proxifier.exe"));
let helper = DetectedProxyApplyHelper::new(host);
let helper = DetectedProxyApplyHelper::from(host);
let result = helper
.apply_proxy_config(HelperApplyRequest {
@@ -554,6 +553,14 @@ fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
fn write_json<T: serde::Serialize + ?Sized>(path: &Path, value: &T) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create json parent dir");
}
let contents = serde_json::to_vec_pretty(value).expect("serialize json");
fs::write(path, contents).expect("write json");
}
fn discord_profile(target_id: &str) -> Profile {
Profile {
id: "discord".to_string(),

View File

@@ -39,10 +39,8 @@ fn roundtrips_profiles_targets_components_and_activity() {
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");
write_json(&storage.paths().components_file, &components);
write_json(&storage.paths().activity_file, &activity);
assert_eq!(storage.read_profiles().expect("read profiles"), profiles);
assert_eq!(storage.read_targets().expect("read targets"), targets);
@@ -118,7 +116,7 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
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::create_dir_all(&storage.paths().state_dir).expect("create state dir");
fs::write(
&storage.paths().singbox_subscription_cache_file,
"{not valid json",
@@ -139,7 +137,7 @@ fn invalid_subscription_cache_falls_back_to_none() {
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::create_dir_all(&storage.paths().config_dir).expect("create config dir");
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
assert_eq!(
@@ -227,6 +225,14 @@ fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
fn write_json<T: serde::Serialize + ?Sized>(path: &Path, value: &T) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create json parent dir");
}
let contents = serde_json::to_vec_pretty(value).expect("serialize json");
fs::write(path, contents).expect("write json");
}
fn sample_profile(id: &str) -> Profile {
Profile {
id: id.to_string(),

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, Trash2, Wand2 } from 'lucide-react';
import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Trash2, Wand2 } from 'lucide-react';
import {
applyProfiles,
fetchSingBoxSubscription,
@@ -34,7 +34,7 @@ import {
type SingBoxSetupStatus,
} from '../api/tauriCommands';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
import { Button, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
import { Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
import { getApplyReadiness } from './readiness';
import { serviceControlState } from './viewModel';
@@ -76,6 +76,48 @@ interface ConfigSnapshot {
items: ConfigSnapshotItem[];
}
interface ConnectionCheckView {
tone: StatusTone;
title: string;
text: string;
endpoint: string;
details: string[];
disabledReason?: string;
loading: boolean;
}
interface RouteChainSegment {
id: string;
label: string;
value: string;
tone: StatusTone;
details: string[];
}
interface ConnectionCheckInput {
routeMode: RouteMode;
proxyInput: string;
proxyPing: PingServerResponse | null;
singbox: ComponentStatus | undefined;
singBoxStatus: LocalSingBoxStatusResponse | null;
selectedServer: SubscriptionServer | null;
selectedServerPing: PingServerResponse | undefined;
isDetectingComponents: boolean;
isProxyChecking: boolean;
serverPingTag: string | null;
}
interface RouteChainInput {
routeMode: RouteMode;
proxyInput: string;
proxyfier: ComponentStatus | undefined;
singbox: ComponentStatus | undefined;
singBoxStatus: LocalSingBoxStatusResponse | null;
selectedServer: SubscriptionServer | null;
appCount: number;
isDetectingComponents: boolean;
}
const MAIN_TARGET_ID = 'main-proxy';
const MAIN_PROFILE_ID = 'main-profile';
const LOCAL_SINGBOX_TARGET_ID = 'local-singbox';
@@ -123,8 +165,6 @@ export function App() {
const [subscriptionInput, setSubscriptionInput] = useState('');
const [serverPings, setServerPings] = useState<Record<string, PingServerResponse>>({});
const [proxyPing, setProxyPing] = useState<PingServerResponse | null>(null);
const [isSingBoxSetupOpen, setIsSingBoxSetupOpen] = useState(false);
const [isSingBoxInfoOpen, setIsSingBoxInfoOpen] = useState(false);
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [activeLogId, setActiveLogId] = useState<string | null>(null);
@@ -157,6 +197,11 @@ export function App() {
);
const isSingBoxInstalled = Boolean(singbox?.installed);
const selectedServerTag = singBoxStatus?.config.selectedServerTag;
const selectedServer = useMemo(
() => singBoxStatus?.cache?.servers.find((server) => server.tag === selectedServerTag) ?? null,
[selectedServerTag, singBoxStatus],
);
const selectedServerPing = selectedServerTag ? serverPings[selectedServerTag] : undefined;
const currentSnapshot = useMemo(
() => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag),
[items, proxyInput, routeMode, selectedServerTag],
@@ -820,6 +865,19 @@ export function App() {
}
}
async function pingSelectedSingBoxServer() {
if (!selectedServer) {
showNotice({
kind: 'error',
title: 'Сервер не выбран',
text: 'Выбери сервер Local sing-box перед проверкой.',
});
return;
}
await pingSingleSingBoxServer(selectedServer);
}
async function generateSingBoxNow() {
setSingBoxAction('generate');
try {
@@ -1173,10 +1231,9 @@ export function App() {
externalProxyError,
busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction),
});
const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents;
const disabledReason = !readiness.ready && readiness.title && readiness.text
? `${readiness.title}. ${readiness.text}`
: undefined;
const blockerAlreadyVisible = routeBlockerIsVisibleInProxyPanel(context, routeMode, readiness.title);
const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents && !blockerAlreadyVisible;
const showPending = readiness.ready && hasUnappliedChanges;
return (
<>
@@ -1185,7 +1242,7 @@ export function App() {
<strong>{readiness.title}</strong>
<span>{readiness.text}</span>
</div>
) : showState && hasUnappliedChanges ? (
) : showState && showPending ? (
<div className="apply-state pending" role="status">
<strong>Изменения еще не применены в ProxiFyre</strong>
<span>{applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))}</span>
@@ -1201,7 +1258,6 @@ export function App() {
disabled={!readiness.ready}
loading={isApplying}
loadingLabel={applyButtonLabel(context, true, hasUnappliedChanges, singBoxAction)}
title={disabledReason}
>
{applyButtonLabel(context, isApplying, hasUnappliedChanges, singBoxAction)}
</Button>
@@ -1250,30 +1306,60 @@ export function App() {
placeholder="socks5://127.0.0.1:1080"
spellCheck={false}
/>
{proxyValidation ? <span className="field-error">{proxyValidation}</span> : null}
</label>
<div className="proxy-check-line">
<div className={`proxy-check-state ${proxyPing ? pingTone(proxyPing) : proxyValidation ? 'warning' : 'muted'}`}>
<strong>{proxyPing ? pingResultTitle(proxyPing) : proxyValidation ? 'Проверь формат' : 'Проверка не запускалась'}</strong>
<span>{proxyPing ? pingResultText(proxyPing) : proxyValidation ?? 'TCP check покажет, доступен ли host:port.'}</span>
</div>
<Button
type="button"
variant="neutral"
size="lg"
onClick={() => void pingExternalProxy()}
disabled={Boolean(proxyValidation)}
loading={isProxyChecking}
loadingLabel="Проверяю"
>
Проверить
</Button>
</div>
);
}
function renderConnectionCheck(check: ConnectionCheckView) {
const buttonDisabled = Boolean(check.disabledReason);
return (
<div className="connection-check" aria-label="Проверка соединения">
<div className="connection-check-main">
<span>Проверка endpoint</span>
<strong>{check.title}</strong>
<p>{check.text}</p>
</div>
<DetailsPopover
className="connection-endpoint"
details={check.details}
popoverLabel="Проверка endpoint"
align="end"
aria-label={`Endpoint: ${check.endpoint}. ${check.details.join('. ')}`}
>
<span>Endpoint</span>
<strong>{check.endpoint}</strong>
</DetailsPopover>
<Button
type="button"
variant="neutral"
size="lg"
onClick={() => {
if (routeMode === 'external') {
void pingExternalProxy();
} else {
void pingSelectedSingBoxServer();
}
}}
disabled={buttonDisabled}
loading={check.loading}
loadingLabel="Проверяю"
>
Проверить
</Button>
</div>
);
}
function renderSingBoxCard() {
const state = serviceControlState(singbox, isDetectingComponents);
const setupSummary = singBoxSetupStatus
? singBoxSetupStatus.ready
? 'состав готов'
: `не хватает: ${singBoxSetupStatus.missingCount}`
: 'состав не проверен';
const primaryAction = singbox?.installed
? {
label: singbox.running ? 'Остановить' : 'Запустить',
@@ -1297,7 +1383,7 @@ export function App() {
state={state}
visualState={singBoxAction ? 'working' : null}
className="singbox-card"
title={singBoxTitle(singbox, isDetectingComponents)}
title="Local sing-box"
detail={singBoxDetails(singbox, singBoxStatus, isDetectingComponents)}
primaryAction={primaryAction}
menu={singbox?.installed ? {
@@ -1313,107 +1399,18 @@ export function App() {
}],
} : undefined}
inlineActions={(
<>
<Button
type="button"
variant="neutral"
size="sm"
className="setup-toggle"
onClick={() => setIsSingBoxSetupOpen((current) => !current)}
disabled={isDetectingComponents && !singBoxSetupStatus}
aria-expanded={isSingBoxSetupOpen}
>
{singbox?.installed ? 'Состав Local sing-box' : 'Что будет установлено'}
{singBoxSetupStatus ? (
<span>{singBoxSetupStatus.ready ? 'все есть' : `не хватает: ${singBoxSetupStatus.missingCount}`}</span>
) : null}
</Button>
<IconButton
type="button"
variant="neutral"
className="info-toggle"
onClick={() => setIsSingBoxInfoOpen((current) => !current)}
label="Подробности Local sing-box"
aria-expanded={isSingBoxInfoOpen}
title="Подробности"
icon={<Info size={16} strokeWidth={2} />}
/>
</>
<DetailsPopover
className="setup-summary"
details={singBoxDetailLines(singbox, singBoxStatus, singBoxSetupStatus, selectedServerTag)}
popoverLabel="Состав Local sing-box"
aria-label="Подробности Local sing-box"
>
<span>Детали</span>
<strong>{setupSummary}</strong>
</DetailsPopover>
)}
>
{isSingBoxInfoOpen ? (
<div className="singbox-info-popover">
<div className="singbox-info-grid">
<span>Локально</span>
<strong>{localSingBoxAddress(singBoxStatus)}</strong>
<span>LAN</span>
<strong>{lanSingBoxAddress(singBoxStatus) ?? 'недоступен'}</strong>
<span>Сервер</span>
<strong>
{selectedServerTag
? displayServerTag(selectedServerTag)
: 'не выбран'}
</strong>
<span>Файл</span>
<strong>{singbox?.path ?? 'не найден'}</strong>
<span>Конфиг</span>
<strong>{singBoxStatus?.generatedConfigPath ?? 'не создан'}</strong>
</div>
<div className="singbox-info-actions">
<Button
type="button"
onClick={() => void pingSingBoxServers()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length}
variant="neutral"
size="sm"
>
<Gauge size={15} strokeWidth={1.9} />
Ping все
</Button>
<Button
type="button"
onClick={() => void generateSingBoxNow()}
disabled={Boolean(singBoxAction) || !selectedServerTag}
variant="neutral"
size="sm"
>
<Wand2 size={15} strokeWidth={1.9} />
Конфиг
</Button>
</div>
</div>
) : null}
{isSingBoxSetupOpen ? (
<div className="setup-details">
{singBoxSetupStatus ? (
singBoxSetupStatus.items.map((item) => (
<div className={`setup-item ${item.installed ? 'installed' : 'missing'}`} key={item.id}>
<span className="setup-state-dot" aria-hidden="true" />
<div>
<strong>{item.name}</strong>
<span>{setupItemDetails(item.installed, item.version, item.details)}</span>
</div>
</div>
))
) : (
<div className="setup-item">
<span className="setup-state-dot" aria-hidden="true" />
<div>
<strong>Проверяю состав</strong>
<span>Ищу sing-box, WinSW wrapper и службу.</span>
</div>
</div>
)}
</div>
) : null}
{isSingBoxInstalled ? renderSingBoxWorkspace() : (
<div className="singbox-install-note">
<strong>Local sing-box не установлен</strong>
<span>Установи компонент, чтобы подключить подписку, выбрать сервер и включить локальный маршрут.</span>
</div>
)}
{isSingBoxInstalled ? renderSingBoxWorkspace() : null}
</ServiceControlRow>
);
}
@@ -1446,11 +1443,37 @@ export function App() {
onClick={() => void forgetSingBoxSubscriptionData()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription}
label="Очистить подписку Local sing-box"
title="Очистить"
tooltip="Очистить"
icon={<Trash2 size={18} strokeWidth={1.9} />}
/>
</div>
<div className="singbox-workspace-head">
<span>Серверы подписки</span>
<div className="singbox-workspace-actions">
<Button
type="button"
onClick={() => void pingSingBoxServers()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length}
variant="neutral"
size="sm"
leftIcon={<Gauge size={15} strokeWidth={1.9} />}
>
Ping все
</Button>
<Button
type="button"
onClick={() => void generateSingBoxNow()}
disabled={Boolean(singBoxAction) || !selectedServerTag}
variant="neutral"
size="sm"
leftIcon={<Wand2 size={15} strokeWidth={1.9} />}
>
Конфиг
</Button>
</div>
</div>
{singBoxStatus?.cache?.servers.length ? (
<div className="server-list">
{singBoxStatus.cache.servers.map((server) => {
@@ -1462,7 +1485,8 @@ export function App() {
type="button"
className="server-select-button"
onClick={() => void chooseSingBoxServer(server)}
title={serverTooltip(server, ping)}
data-tooltip={serverTooltip(server, ping)}
aria-label={`Выбрать ${displayServerTag(server.tag)}. ${serverTooltip(server, ping)}`}
>
<span className="server-select-dot" aria-hidden="true" />
<strong>{displayServerTag(server.tag)}</strong>
@@ -1474,7 +1498,7 @@ export function App() {
disabled={Boolean(serverPingTag)}
loading={serverPingTag === server.tag}
label={`Проверить ${displayServerTag(server.tag)}`}
title="Ping"
tooltip="Ping"
icon={<Gauge size={14} strokeWidth={1.9} />}
/>
</div>
@@ -1488,6 +1512,34 @@ export function App() {
);
}
function renderProxyOverview() {
const check = connectionCheckView({
routeMode,
proxyInput,
proxyPing,
singbox,
singBoxStatus,
selectedServer,
selectedServerPing,
isDetectingComponents,
isProxyChecking,
serverPingTag,
});
return (
<section className={`proxy-overview ${check.tone}`} aria-labelledby="proxy-overview-title">
<div className="proxy-overview-head">
<div>
<span>Настройки VPN / Прокси</span>
<h2 id="proxy-overview-title">Маршрут и проверка соединения</h2>
</div>
<strong>{routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'}</strong>
</div>
{renderConnectionCheck(check)}
</section>
);
}
function renderProxyPanel() {
return (
<section
@@ -1496,13 +1548,7 @@ export function App() {
id="panel-proxy"
aria-labelledby="tab-proxy"
>
<div className="panel-section-head">
<div>
<span>Настройки VPN / Прокси</span>
<h2>Маршрут и проверка соединения</h2>
</div>
<strong>{routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'}</strong>
</div>
{renderProxyOverview()}
<section className="route-panel" aria-label="Маршрут приложений">
<div className="route-switch">
@@ -1527,16 +1573,46 @@ export function App() {
{routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()}
</section>
<div className="route-preview">
<span>Маршрут</span>
<strong>{routePreviewText(routeMode, proxyInput, singBoxStatus)}</strong>
</div>
{renderRouteChain()}
{renderApplyActions('proxy')}
</section>
);
}
function renderRouteChain() {
const segments = routeChainSegments({
routeMode,
proxyInput,
proxyfier,
singbox,
singBoxStatus,
selectedServer,
appCount: items.length,
isDetectingComponents,
});
return (
<section className="route-chain" aria-label="Текущий маршрут">
{segments.map((segment, index) => (
<DetailsPopover
className={`route-chain-segment ${segment.tone}`}
details={segment.details}
popoverLabel={segment.label}
align={index >= segments.length - 2 ? 'end' : 'start'}
aria-label={`${segment.label}: ${segment.value}. ${segment.details.join('. ')}`}
key={segment.id}
>
<span className="route-chain-dot" aria-hidden="true" />
<span>{segment.label}</span>
<strong>{segment.value}</strong>
{index < segments.length - 1 ? <span className="route-chain-arrow" aria-hidden="true">-&gt;</span> : null}
</DetailsPopover>
))}
</section>
);
}
function renderActivePanel() {
if (activePanel === 'proxifyre') return renderProxiFyrePanel();
if (activePanel === 'proxy') return renderProxyPanel();
@@ -1772,6 +1848,227 @@ function routeEndpointLabel(
}
}
function connectionCheckView(input: ConnectionCheckInput): ConnectionCheckView {
if (input.routeMode === 'external') {
const proxyValidation = input.proxyInput.trim() ? safeProxyError(input.proxyInput) : 'Введи адрес SOCKS5 прокси.';
const endpoint = routeEndpointLabel('external', input.proxyInput, input.singBoxStatus);
const details = [
'Режим: внешний SOCKS5',
`Endpoint: ${endpoint}`,
'Проверка: TCP connect до proxy host:port.',
'Local sing-box для внешнего маршрута не требуется.',
];
if (proxyValidation) {
return {
tone: 'warning',
title: 'Endpoint не готов',
text: proxyValidation,
endpoint,
details,
disabledReason: proxyValidation,
loading: input.isProxyChecking,
};
}
if (input.proxyPing) {
return {
tone: pingTone(input.proxyPing),
title: input.proxyPing.ok ? 'Endpoint отвечает' : 'Endpoint не отвечает',
text: pingResultText(input.proxyPing),
endpoint,
details,
loading: input.isProxyChecking,
};
}
return {
tone: 'muted',
title: 'Проверка не запускалась',
text: 'Проверит доступность SOCKS5 endpoint без изменения маршрута.',
endpoint,
details,
loading: input.isProxyChecking,
};
}
const localAddress = localSingBoxAddress(input.singBoxStatus);
const selectedEndpoint = input.selectedServer
? `${displayServerTag(input.selectedServer.tag)} · ${formatHostPort(input.selectedServer.server, input.selectedServer.serverPort)}`
: 'сервер не выбран';
const details = [
'Режим: локальный sing-box',
`Local listener: ${localAddress}`,
`LAN: ${lanSingBoxAddress(input.singBoxStatus) ?? 'недоступен'}`,
`Сервер: ${selectedEndpoint}`,
'Проверка: TCP connect до выбранного сервера подписки.',
];
if (input.isDetectingComponents) {
return {
tone: 'checking',
title: 'Проверяю компоненты',
text: 'Обновляю состояние Local sing-box перед проверкой.',
endpoint: localAddress,
details,
disabledReason: 'Дождись завершения проверки компонентов.',
loading: Boolean(input.serverPingTag),
};
}
if (!input.singbox?.installed) {
return {
tone: 'warning',
title: 'Локальный runtime не готов',
text: 'Установи Local sing-box, затем загрузи подписку и выбери сервер.',
endpoint: localAddress,
details,
disabledReason: 'Local sing-box не установлен.',
loading: false,
};
}
if (!input.singbox.running) {
return {
tone: 'warning',
title: 'Служба остановлена',
text: 'Запусти Local sing-box перед проверкой локального маршрута.',
endpoint: localAddress,
details,
disabledReason: 'Local sing-box остановлен.',
loading: false,
};
}
if (!input.selectedServer) {
return {
tone: 'warning',
title: 'Сервер не выбран',
text: 'Выбери сервер подписки для проверки.',
endpoint: selectedEndpoint,
details,
disabledReason: 'Сервер Local sing-box не выбран.',
loading: false,
};
}
if (input.selectedServerPing) {
return {
tone: pingTone(input.selectedServerPing),
title: input.selectedServerPing.ok ? 'Сервер отвечает' : 'Сервер не отвечает',
text: pingResultText(input.selectedServerPing),
endpoint: selectedEndpoint,
details,
loading: input.serverPingTag === input.selectedServer.tag,
};
}
return {
tone: 'muted',
title: 'Проверка не запускалась',
text: 'Проверит выбранный сервер подписки без применения маршрута.',
endpoint: selectedEndpoint,
details,
loading: input.serverPingTag === input.selectedServer.tag,
};
}
function routeChainSegments(input: RouteChainInput): RouteChainSegment[] {
const proxyValidation = input.routeMode === 'external' && input.proxyInput.trim()
? safeProxyError(input.proxyInput)
: null;
const localServer = input.singBoxStatus?.config.selectedServerTag
? displayServerTag(input.singBoxStatus.config.selectedServerTag)
: 'сервер не выбран';
const endpoint = input.routeMode === 'local-singbox'
? localSingBoxAddress(input.singBoxStatus)
: routeEndpointLabel('external', input.proxyInput, input.singBoxStatus);
const endpointTone: StatusTone = input.routeMode === 'external'
? proxyValidation || !input.proxyInput.trim() ? 'warning' : 'ok'
: input.singbox?.running ? 'ok' : input.singbox?.installed ? 'warning' : 'warning';
const exitTone: StatusTone = input.routeMode === 'external'
? proxyValidation || !input.proxyInput.trim() ? 'warning' : 'ok'
: input.singbox?.running && input.selectedServer ? 'ok' : 'warning';
return [
{
id: 'apps',
label: 'Приложения',
value: appCountCompact(input.appCount),
tone: input.appCount > 0 ? 'ok' : 'warning',
details: [
input.appCount > 0 ? appCountText(input.appCount) : 'Добавь хотя бы одно приложение на вкладке ProxiFyre.',
],
},
{
id: 'proxifyre',
label: 'ProxiFyre',
value: input.isDetectingComponents ? 'проверяю' : input.proxyfier?.running ? 'запущен' : input.proxyfier?.installed ? 'остановлен' : 'не найден',
tone: componentChainTone(input.proxyfier, input.isDetectingComponents),
details: [
proxyfierTitle(input.proxyfier, input.isDetectingComponents),
proxyfierDetails(input.proxyfier, input.isDetectingComponents),
],
},
{
id: 'endpoint',
label: input.routeMode === 'local-singbox' ? 'Local endpoint' : 'SOCKS5 endpoint',
value: endpoint,
tone: endpointTone,
details: input.routeMode === 'local-singbox'
? singBoxDetailLines(input.singbox, input.singBoxStatus, null, input.singBoxStatus?.config.selectedServerTag)
: [
`Endpoint: ${endpoint}`,
proxyValidation ?? 'Формат внешнего SOCKS5 корректен.',
'Local sing-box не участвует во внешнем маршруте.',
],
},
{
id: 'exit',
label: input.routeMode === 'local-singbox' ? 'VPN сервер' : 'Выход',
value: input.routeMode === 'local-singbox' ? localServer : 'внешний SOCKS5',
tone: exitTone,
details: input.routeMode === 'local-singbox'
? [
input.selectedServer ? serverLabel(input.selectedServer) : 'Сервер Local sing-box не выбран.',
'Применение создаст sing-box config и обновит ProxiFyre.',
]
: [
'Выбранные приложения идут через внешний SOCKS5.',
'Local sing-box не нужен для этого маршрута.',
],
},
];
}
function routeBlockerIsVisibleInProxyPanel(
context: 'proxifyre' | 'proxy',
routeMode: RouteMode,
title: string | undefined,
) {
if (context !== 'proxy' || !title) return false;
if (routeMode === 'external') {
return title === 'Прокси не указан' || title === 'Проверь формат прокси';
}
return title === 'Local sing-box не установлен' || title === 'Сервер не выбран';
}
function componentChainTone(component: ComponentStatus | undefined, checking: boolean): StatusTone {
if (checking) return 'checking';
if (!component?.installed) return 'warning';
if (!component.running) return 'warning';
return 'ok';
}
function appCountCompact(count: number) {
if (count === 1) return '1 приложение';
if (count > 1 && count < 5) return `${count} приложения`;
return `${count} приложений`;
}
function appCountText(count: number) {
if (count === 1) return '1 приложение маршрутизируется через профиль.';
if (count > 1 && count < 5) return `${count} приложения маршрутизируются через профиль.`;
@@ -1800,21 +2097,6 @@ function pingResultText(ping: PingServerResponse) {
return `${ping.server}:${ping.serverPort}: ${ping.error ?? 'нет ответа'}`;
}
function routePreviewText(
routeMode: RouteMode,
proxyInput: string,
status: LocalSingBoxStatusResponse | null,
) {
if (routeMode === 'local-singbox') {
const server = status?.config.selectedServerTag
? displayServerTag(status.config.selectedServerTag)
: 'сервер не выбран';
return `Выбранные приложения -> ProxiFyre -> Local sing-box ${localSingBoxAddress(status)} -> ${server}`;
}
return `Выбранные приложения -> ProxiFyre -> внешний прокси ${routeEndpointLabel('external', proxyInput, status)}`;
}
function applyButtonLabel(
context: 'proxifyre' | 'proxy',
isApplying: boolean,
@@ -1961,12 +2243,6 @@ function itemIcon(type: DraftItemType) {
return <FileCode2 size={18} strokeWidth={1.9} />;
}
function setupItemDetails(installed: boolean, version: string | undefined, details: string) {
if (!installed) return `Нужно установить. ${details}`;
if (version) return `${version}. ${details}`;
return details;
}
function proxifyreSetupPlaceholders(): ProxiFyreSetupStatus['items'] {
return [
{ id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' },
@@ -2019,14 +2295,6 @@ function proxyfierDetails(component: ComponentStatus | undefined, checking: bool
return component.problems[0] ?? 'Путь установки не найден.';
}
function singBoxTitle(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Проверяю Local sing-box';
if (!component) return 'Local sing-box не проверен';
if (component.running) return 'Local sing-box найден и запущен';
if (component.installed) return 'Local sing-box найден';
return 'Local sing-box не установлен';
}
function singBoxDetails(
component: ComponentStatus | undefined,
status: LocalSingBoxStatusResponse | null,
@@ -2043,7 +2311,30 @@ function singBoxDetails(
if (status?.config.hasSubscription) {
return status.config.subscriptionDisplayUrl ?? 'Подписка сохранена.';
}
return component?.problems[0] ?? 'Установи компонент, чтобы подключить подписку и выбрать сервер.';
return component?.problems[0] ?? 'Установи компонент для подписки и локального маршрута.';
}
function singBoxDetailLines(
component: ComponentStatus | undefined,
status: LocalSingBoxStatusResponse | null,
setupStatus: SingBoxSetupStatus | null,
selectedServerTag: string | undefined,
) {
const setupDetails = setupStatus
? setupStatus.items
.map((item) => `${item.name}: ${setupItemShortStatus(item)}`)
.join('; ')
: 'состав не проверен';
return [
`Локально: ${localSingBoxAddress(status)}`,
`LAN: ${lanSingBoxAddress(status) ?? 'недоступен'}`,
`Сервер: ${selectedServerTag ? displayServerTag(selectedServerTag) : 'не выбран'}`,
`Файл: ${component?.path ?? 'не найден'}`,
`Конфиг: ${status?.generatedConfigPath ?? 'не создан'}`,
`Подписка: ${status?.config.subscriptionDisplayUrl ?? (status?.config.hasSubscription ? 'сохранена' : 'не загружена')}`,
`Состав: ${setupDetails}`,
];
}
function componentDetails(component: ComponentStatus | undefined, checking: boolean) {

View File

@@ -122,7 +122,11 @@ button:disabled {
.ui-button:focus-visible,
.ui-icon-button:focus-visible,
.ui-details-popover-trigger:focus-visible,
.ui-details-popover-close:focus-visible,
.ui-hover-details:focus-visible,
.ui-tab:focus-visible,
.server-select-button:focus-visible,
.ui-action-menu-popover button:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.72);
@@ -196,6 +200,111 @@ button:disabled {
padding: 0;
}
.ui-hover-details {
position: relative;
outline: none;
cursor: help;
}
.ui-details-popover-trigger {
appearance: none;
font: inherit;
outline: none;
text-align: left;
}
.ui-details-popover {
position: fixed;
z-index: 90;
display: grid;
gap: 8px;
border: 1px solid #334155;
border-radius: 5px;
background: #0d1118;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.46);
color: #e5e7eb;
padding: 10px;
animation: ui-popover-in 130ms var(--ease-out);
}
.ui-details-popover::before {
position: absolute;
top: -5px;
left: var(--details-popover-arrow-left);
width: 8px;
height: 8px;
border-top: 1px solid #334155;
border-left: 1px solid #334155;
background: #0d1118;
content: "";
transform: translateX(-50%) rotate(45deg);
}
.ui-details-popover[data-placement="top"]::before {
top: auto;
bottom: -5px;
border: 0;
border-right: 1px solid #334155;
border-bottom: 1px solid #334155;
}
.ui-details-popover-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.ui-details-popover-head strong {
min-width: 0;
color: #f8fafc;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-details-popover-close {
display: grid;
place-items: center;
width: 26px;
min-width: 26px;
height: 26px;
border: 1px solid #263040;
border-radius: 4px;
background: #131923;
color: #cbd5e1;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0;
}
.ui-details-popover-close:hover {
border-color: #3b82f6;
background: #182033;
color: #f8fafc;
}
.ui-details-popover-body {
display: grid;
gap: 5px;
color: #a9b4c5;
font-size: 12px;
font-weight: 650;
line-height: 1.35;
}
.ui-details-popover-body p {
margin: 0;
overflow-wrap: anywhere;
}
.ui-details-popover-body p:first-child {
color: #e5e7eb;
}
.ui-button-icon,
.ui-button-label {
display: inline-flex;
@@ -546,10 +655,7 @@ button:disabled {
gap: 6px;
}
.ui-service-row > .setup-details,
.ui-service-row > .singbox-info-popover,
.ui-service-row > .singbox-workspace,
.ui-service-row > .singbox-install-note {
.ui-service-row > .singbox-workspace {
grid-column: 1 / -1;
}
@@ -600,7 +706,7 @@ button:disabled {
.process-add-line,
.app-row,
.route-switch,
.singbox-info-actions,
.singbox-workspace-actions,
.panel-tabs,
.server-row {
display: flex;
@@ -766,6 +872,60 @@ button:disabled {
text-align: right;
}
.proxy-overview {
display: grid;
gap: 10px;
border: 1px solid #2b3342;
border-radius: 4px;
background: #111720;
padding: 10px;
}
.proxy-overview.ok {
border-color: rgba(34, 197, 94, 0.36);
}
.proxy-overview.warning {
border-color: rgba(245, 158, 11, 0.42);
}
.proxy-overview.error {
border-color: rgba(239, 68, 68, 0.42);
}
.proxy-overview.checking {
border-color: rgba(59, 130, 246, 0.42);
}
.proxy-overview-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.proxy-overview-head span {
display: block;
color: #8d99ae;
font-size: 12px;
}
.proxy-overview-head h2 {
margin: 2px 0 0;
color: #eef2ff;
font-size: 17px;
letter-spacing: 0;
}
.proxy-overview-head > strong {
min-width: 0;
color: #bfdbfe;
font-size: 13px;
overflow-wrap: anywhere;
text-align: right;
}
.summary-panel {
gap: 12px;
}
@@ -1045,7 +1205,7 @@ button.summary-card:hover {
.service-menu-button,
.subscription-line button,
.route-switch button,
.singbox-info-actions button,
.singbox-workspace-actions button,
.server-row {
min-height: 36px;
border: 1px solid #343b49;
@@ -1065,7 +1225,7 @@ button.summary-card:hover {
.service-menu-button:hover,
.subscription-line button:hover,
.route-switch button:hover,
.singbox-info-actions button:hover,
.singbox-workspace-actions button:hover,
.server-row:hover {
background: #2d3543;
}
@@ -1221,35 +1381,6 @@ button.summary-card:hover {
animation: spin 0.75s linear infinite;
}
.setup-toggle {
display: inline-flex;
gap: 7px;
align-items: center;
width: fit-content;
min-height: 0;
border: 0;
background: transparent;
color: #93c5fd;
padding: 4px 0 0;
text-align: left;
cursor: pointer;
}
.setup-toggle:hover {
color: #bfdbfe;
}
.setup-toggle .ui-button-label > span {
display: inline-block;
border: 1px solid #343b49;
border-radius: 4px;
background: #1b202b;
color: #cbd5e1;
padding: 1px 6px;
font-size: 11px;
font-weight: 700;
}
.setup-strip {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -1313,52 +1444,6 @@ button.summary-card:hover {
white-space: nowrap;
}
.setup-details {
display: grid;
grid-column: 2 / -1;
gap: 6px;
border-top: 1px solid #2b3342;
margin-top: 2px;
padding-top: 10px;
}
.setup-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 9px;
align-items: start;
min-height: 34px;
border: 1px solid #263040;
border-radius: 4px;
background: #111720;
padding: 8px 9px;
}
.setup-state-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: #f59e0b;
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.12);
margin-top: 5px;
}
.setup-item.installed .setup-state-dot {
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.12);
}
.setup-item strong,
.setup-item span {
display: block;
overflow-wrap: anywhere;
}
.setup-item span {
color: #9aa8bd;
font-size: 13px;
}
.service-actions {
position: relative;
display: flex;
@@ -1434,62 +1519,71 @@ button.summary-card:hover {
.route-panel {
display: grid;
gap: 8px;
margin-top: 10px;
margin-top: 8px;
border: 1px solid #2b3342;
border-radius: 4px;
background: #151923;
padding: 10px;
}
.external-proxy-card {
.connection-check {
display: grid;
gap: 9px;
}
.proxy-check-line {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px;
grid-template-columns: minmax(0, 1fr) minmax(180px, 260px) 132px;
gap: 8px;
align-items: stretch;
}
.proxy-check-state,
.route-preview {
.connection-check-main,
.connection-endpoint {
display: grid;
align-content: center;
gap: 3px;
min-width: 0;
min-height: 46px;
border: 1px solid #2b3342;
border: 1px solid #263040;
border-radius: 4px;
background: #111720;
padding: 9px 11px;
background: #0d1016;
padding: 8px 10px;
}
.proxy-check-state.ok {
border-color: rgba(34, 197, 94, 0.42);
.connection-endpoint {
cursor: pointer;
}
.proxy-check-state.warning {
border-color: rgba(245, 158, 11, 0.42);
.connection-endpoint:hover {
border-color: #3b82f6;
background: #101827;
}
.proxy-check-state.error {
border-color: rgba(239, 68, 68, 0.42);
}
.proxy-check-state strong,
.route-preview strong {
color: #eef2ff;
overflow-wrap: anywhere;
}
.proxy-check-state span,
.route-preview span {
.connection-check-main > span,
.connection-endpoint > span {
color: #8d99ae;
font-size: 12px;
font-weight: 700;
}
.connection-check-main strong,
.connection-endpoint strong {
min-width: 0;
color: #eef2ff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.connection-check-main p {
color: #9aa8bd;
margin: 0;
overflow-wrap: anywhere;
}
.route-preview {
margin-top: 2px;
.connection-check .ui-button {
width: 100%;
}
.external-proxy-card {
display: grid;
gap: 9px;
}
.simple-field {
@@ -1502,6 +1596,12 @@ button.summary-card:hover {
margin: 0;
}
.field-error {
color: #fcd34d;
font-size: 12px;
overflow-wrap: anywhere;
}
.simple-field input,
.process-add-line input,
.subscription-line input {
@@ -1531,82 +1631,30 @@ button.summary-card:hover {
padding-top: 10px;
}
.singbox-inline-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
.setup-summary {
display: inline-grid;
grid-template-columns: auto auto;
gap: 6px;
}
.info-toggle {
display: grid;
place-items: center;
width: 28px;
height: 24px;
align-items: center;
width: fit-content;
border: 1px solid #263040;
border-radius: 4px;
background: #111720;
color: #bfdbfe;
cursor: pointer;
}
.info-toggle:hover {
background: #1d2735;
}
.info-toggle svg {
display: block;
}
.singbox-info-popover {
position: relative;
z-index: 8;
display: grid;
grid-column: 1 / -1;
gap: 10px;
width: 100%;
border: 1px solid #343b49;
border-radius: 4px;
background: #111720;
box-shadow: none;
padding: 12px;
}
.singbox-info-grid {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 7px 10px;
align-items: start;
}
.singbox-info-grid span {
color: #8d99ae;
padding: 4px 7px;
font-size: 12px;
}
.singbox-info-grid strong {
min-width: 0;
color: #dbeafe;
font-size: 12px;
overflow-wrap: anywhere;
}
.singbox-info-actions {
justify-content: flex-start;
gap: 7px;
}
.singbox-info-actions button {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 32px;
color: #dbeafe;
font-weight: 700;
}
.singbox-info-actions button svg {
display: block;
.setup-summary:hover {
border-color: #3b82f6;
background: #162033;
}
.setup-summary strong {
color: #dbeafe;
font-size: 11px;
}
.route-switch {
@@ -1669,6 +1717,30 @@ button.summary-card:hover {
color: #fecaca;
}
.singbox-workspace-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.singbox-workspace-head > span {
color: #8d99ae;
font-size: 12px;
font-weight: 800;
}
.singbox-workspace-actions {
justify-content: flex-end;
gap: 7px;
}
.singbox-workspace-actions .ui-button {
min-height: 32px;
color: #dbeafe;
}
.server-list {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -1711,6 +1783,7 @@ button.summary-card:hover {
}
.server-select-button {
position: relative;
display: flex;
align-items: center;
gap: 7px;
@@ -1765,18 +1838,90 @@ button.summary-card:hover {
font-weight: 800;
}
.singbox-install-note {
.route-chain {
display: grid;
grid-column: 1 / -1;
gap: 3px;
border-top: 1px solid #2b3342;
color: #9aa8bd;
margin-top: 2px;
padding-top: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
margin-top: 8px;
}
.singbox-install-note strong {
color: #e5e7eb;
.route-chain-segment {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas:
"dot label arrow"
"dot value arrow";
gap: 2px 8px;
align-items: center;
min-width: 0;
min-height: 48px;
border: 1px solid #2b3342;
border-radius: 4px;
background: #111720;
color: #dbeafe;
cursor: pointer;
padding: 8px 9px;
width: 100%;
}
.route-chain-segment:hover {
border-color: #3b82f6;
background: #141d2b;
}
.route-chain-segment > span:not(.route-chain-dot):not(.route-chain-arrow) {
grid-area: label;
color: #8d99ae;
font-size: 11px;
font-weight: 800;
}
.route-chain-segment strong {
grid-area: value;
min-width: 0;
overflow: hidden;
color: #eef2ff;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.route-chain-dot {
grid-area: dot;
width: 9px;
height: 9px;
border-radius: 999px;
background: #64748b;
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.12);
}
.route-chain-arrow {
grid-area: arrow;
color: #64748b;
font-weight: 800;
}
.route-chain-segment.ok .route-chain-dot {
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.12);
}
.route-chain-segment.warning .route-chain-dot {
background: #f59e0b;
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.12);
}
.route-chain-segment.error .route-chain-dot {
background: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);
}
.route-chain-segment.checking .route-chain-dot {
border: 2px solid #3b82f6;
border-top-color: transparent;
background: transparent;
box-shadow: none;
animation: spin 0.75s linear infinite;
}
.apply-state {
@@ -1893,6 +2038,10 @@ button.summary-card:hover {
.ui-icon-button[data-tooltip]::before,
.ui-icon-button[data-tooltip]::after,
.ui-hover-details[data-tooltip]::before,
.ui-hover-details[data-tooltip]::after,
.server-select-button[data-tooltip]::before,
.server-select-button[data-tooltip]::after,
.add-tile[data-tooltip]::before,
.add-tile[data-tooltip]::after {
position: absolute;
@@ -1907,6 +2056,8 @@ button.summary-card:hover {
}
.ui-icon-button[data-tooltip]::before,
.ui-hover-details[data-tooltip]::before,
.server-select-button[data-tooltip]::before,
.add-tile[data-tooltip]::before {
bottom: calc(100% + 3px);
width: 8px;
@@ -1919,8 +2070,12 @@ button.summary-card:hover {
}
.ui-icon-button[data-tooltip]::after,
.ui-hover-details[data-tooltip]::after,
.server-select-button[data-tooltip]::after,
.add-tile[data-tooltip]::after {
bottom: calc(100% + 8px);
width: max-content;
max-width: min(360px, calc(100vw - 32px));
border: 1px solid #334155;
border-radius: 4px;
background: #0d1118;
@@ -1929,16 +2084,25 @@ button.summary-card:hover {
content: attr(data-tooltip);
font-size: 12px;
font-weight: 750;
line-height: 1;
line-height: 1.25;
overflow-wrap: break-word;
padding: 7px 8px;
transform: translate(-50%, 4px);
white-space: nowrap;
white-space: pre-line;
}
.ui-icon-button[data-tooltip]:hover::before,
.ui-icon-button[data-tooltip]:hover::after,
.ui-icon-button[data-tooltip]:focus-visible::before,
.ui-icon-button[data-tooltip]:focus-visible::after,
.ui-hover-details[data-tooltip]:hover::before,
.ui-hover-details[data-tooltip]:hover::after,
.ui-hover-details[data-tooltip]:focus-visible::before,
.ui-hover-details[data-tooltip]:focus-visible::after,
.server-select-button[data-tooltip]:hover::before,
.server-select-button[data-tooltip]:hover::after,
.server-select-button[data-tooltip]:focus-visible::before,
.server-select-button[data-tooltip]:focus-visible::after,
.add-tile[data-tooltip]:hover::before,
.add-tile[data-tooltip]:hover::after,
.add-tile[data-tooltip]:focus-visible::before,
@@ -1948,6 +2112,10 @@ button.summary-card:hover {
.ui-icon-button[data-tooltip]:hover::before,
.ui-icon-button[data-tooltip]:focus-visible::before,
.ui-hover-details[data-tooltip]:hover::before,
.ui-hover-details[data-tooltip]:focus-visible::before,
.server-select-button[data-tooltip]:hover::before,
.server-select-button[data-tooltip]:focus-visible::before,
.add-tile[data-tooltip]:hover::before,
.add-tile[data-tooltip]:focus-visible::before {
transform: translate(-50%, 0) rotate(45deg);
@@ -1955,6 +2123,10 @@ button.summary-card:hover {
.ui-icon-button[data-tooltip]:hover::after,
.ui-icon-button[data-tooltip]:focus-visible::after,
.ui-hover-details[data-tooltip]:hover::after,
.ui-hover-details[data-tooltip]:focus-visible::after,
.server-select-button[data-tooltip]:hover::after,
.server-select-button[data-tooltip]:focus-visible::after,
.add-tile[data-tooltip]:hover::after,
.add-tile[data-tooltip]:focus-visible::after {
transform: translate(-50%, 0);
@@ -2115,7 +2287,6 @@ button.summary-card:hover {
}
.command-row .ui-button,
.proxy-check-line .ui-button,
.subscription-line .ui-button {
width: 100%;
}
@@ -2509,7 +2680,13 @@ button.summary-card:hover {
flex-direction: column;
}
.panel-section-head > strong {
.proxy-overview-head {
align-items: stretch;
flex-direction: column;
}
.panel-section-head > strong,
.proxy-overview-head > strong {
text-align: left;
}
@@ -2558,10 +2735,14 @@ button.summary-card:hover {
grid-template-columns: 1fr;
}
.proxy-check-line {
.connection-check {
grid-template-columns: 1fr;
}
.route-chain {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.add-toolbar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -2625,20 +2806,21 @@ button.summary-card:hover {
overflow: visible;
}
.setup-details {
grid-column: 1 / -1;
}
.service-button {
flex: 1;
}
.singbox-info-popover {
width: auto;
.singbox-workspace-head {
align-items: stretch;
flex-direction: column;
}
.singbox-info-grid {
grid-template-columns: 70px minmax(0, 1fr);
.singbox-workspace-actions {
width: 100%;
}
.singbox-workspace-actions .ui-button {
flex: 1;
}
.server-list {

209
src/ui/DetailsPopover.tsx Normal file
View File

@@ -0,0 +1,209 @@
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type ButtonHTMLAttributes,
type CSSProperties,
type MouseEvent,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
export type DetailsPopoverAlign = 'start' | 'center' | 'end';
export interface DetailsPopoverProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
details: string | string[];
children: ReactNode;
popoverLabel?: string;
align?: DetailsPopoverAlign;
maxWidth?: number;
}
interface DetailsPopoverPosition {
top: number;
left: number;
width: number;
arrowLeft: number;
placement: 'top' | 'bottom';
}
const VIEWPORT_MARGIN = 12;
export function DetailsPopover({
details,
children,
className,
popoverLabel = 'Детали',
align = 'start',
maxWidth = 360,
disabled,
onClick,
...props
}: DetailsPopoverProps) {
const detailsId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<DetailsPopoverPosition>({
top: 0,
left: 0,
width: Math.min(maxWidth, 360),
arrowLeft: 24,
placement: 'bottom',
});
const detailLines = Array.isArray(details)
? details.filter(Boolean)
: [details].filter(Boolean);
const classes = ['ui-details-popover-trigger', className ?? ''].filter(Boolean).join(' ');
useEffect(() => {
if (disabled && open) setOpen(false);
}, [disabled, open]);
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = Math.min(maxWidth, Math.max(220, window.innerWidth - VIEWPORT_MARGIN * 2));
let left = rect.left;
if (align === 'center') left = rect.left + rect.width / 2 - width / 2;
if (align === 'end') left = rect.right - width;
left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - width - VIEWPORT_MARGIN));
const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
let top = rect.bottom + 8;
let placement: DetailsPopoverPosition['placement'] = 'bottom';
if (
popoverHeight
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
&& rect.top > popoverHeight + VIEWPORT_MARGIN + 8
) {
top = rect.top - popoverHeight - 8;
placement = 'top';
} else if (popoverHeight) {
top = Math.min(top, window.innerHeight - popoverHeight - VIEWPORT_MARGIN);
}
const arrowLeft = Math.max(
16,
Math.min(rect.left + rect.width / 2 - left, width - 16),
);
setPosition({
top: Math.max(VIEWPORT_MARGIN, top),
left,
width,
arrowLeft,
placement,
});
};
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [align, maxWidth, open]);
useEffect(() => {
if (!open) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target)) return;
if (popoverRef.current?.contains(target)) return;
setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
setOpen(false);
triggerRef.current?.focus();
};
document.addEventListener('pointerdown', closeOnOutsidePointer);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('pointerdown', closeOnOutsidePointer);
document.removeEventListener('keydown', closeOnEscape);
};
}, [open]);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (!event.defaultPrevented) setOpen((current) => !current);
};
const popoverStyle = {
top: position.top,
left: position.left,
width: position.width,
'--details-popover-arrow-left': `${position.arrowLeft}px`,
} as CSSProperties;
return (
<>
<button
{...props}
ref={triggerRef}
type={props.type ?? 'button'}
className={classes}
aria-controls={open ? detailsId : undefined}
aria-expanded={open}
aria-haspopup="dialog"
disabled={disabled}
onClick={handleClick}
>
{children}
</button>
{open && detailLines.length && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
id={detailsId}
className="ui-details-popover"
data-placement={position.placement}
role="dialog"
aria-label={popoverLabel}
style={popoverStyle}
>
<div className="ui-details-popover-head">
<strong>{popoverLabel}</strong>
<button
type="button"
className="ui-details-popover-close"
aria-label="Закрыть"
onClick={() => {
setOpen(false);
triggerRef.current?.focus();
}}
>
×
</button>
</div>
<div className="ui-details-popover-body">
{detailLines.map((line, index) => (
<p key={`${line}-${index}`}>{line}</p>
))}
</div>
</div>,
document.body,
)
: null}
</>
);
}

29
src/ui/HoverDetails.tsx Normal file
View File

@@ -0,0 +1,29 @@
import type { HTMLAttributes, ReactNode } from 'react';
export interface HoverDetailsProps extends HTMLAttributes<HTMLSpanElement> {
details: string | string[];
children: ReactNode;
}
export function HoverDetails({
details,
children,
className,
...props
}: HoverDetailsProps) {
const detailText = Array.isArray(details)
? details.filter(Boolean).join('\n')
: details;
const classes = ['ui-hover-details', className ?? ''].filter(Boolean).join(' ');
return (
<span
{...props}
className={classes}
data-tooltip={detailText}
tabIndex={props.tabIndex ?? 0}
>
{children}
</span>
);
}

View File

@@ -2,11 +2,12 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react';
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
label: string;
icon: ReactNode;
variant?: IconButtonVariant;
loading?: boolean;
tooltip?: string;
}
export function IconButton({
@@ -14,12 +15,12 @@ export function IconButton({
icon,
variant = 'neutral',
loading = false,
tooltip,
className,
disabled,
title,
...props
}: IconButtonProps) {
const tooltip = title === '' ? undefined : title ?? label;
const tooltipText = tooltip === '' ? undefined : tooltip ?? label;
const classes = [
'ui-icon-button',
`ui-icon-button--${variant}`,
@@ -33,7 +34,7 @@ export function IconButton({
type={props.type ?? 'button'}
className={classes}
aria-label={label}
data-tooltip={tooltip}
data-tooltip={tooltipText}
disabled={disabled || loading}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}

View File

@@ -2,8 +2,12 @@ export { ActionMenu } from './ActionMenu';
export type { ActionMenuItem } from './ActionMenu';
export { Button } from './Button';
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
export { DetailsPopover } from './DetailsPopover';
export type { DetailsPopoverAlign, DetailsPopoverProps } from './DetailsPopover';
export { Field } from './Field';
export type { FieldProps } from './Field';
export { HoverDetails } from './HoverDetails';
export type { HoverDetailsProps } from './HoverDetails';
export { IconButton } from './IconButton';
export type { IconButtonProps, IconButtonVariant } from './IconButton';
export { LogDock } from './LogDock';