From 88d5b9413394d16b9258bb391e5caab5121670e8 Mon Sep 17 00:00:00 2001 From: Dokril Date: Wed, 8 Jul 2026 11:05:52 +0300 Subject: [PATCH] Refactor proxy routing and installer flows --- .gitignore | 1 + README.md | 225 ++++++++--- scripts/prepare-release.ps1 | 606 +++++++++++++++++++++++++++++ src-tauri/src/commands.rs | 27 +- src-tauri/src/singbox_service.rs | 4 - src-tauri/src/storage.rs | 16 - src-tauri/tests/command_tests.rs | 23 +- src-tauri/tests/storage_tests.rs | 18 +- src/app/App.tsx | 631 ++++++++++++++++++++++--------- src/styles/app.css | 584 ++++++++++++++++++---------- src/ui/DetailsPopover.tsx | 209 ++++++++++ src/ui/HoverDetails.tsx | 29 ++ src/ui/IconButton.tsx | 9 +- src/ui/index.ts | 4 + 14 files changed, 1899 insertions(+), 487 deletions(-) create mode 100644 scripts/prepare-release.ps1 create mode 100644 src/ui/DetailsPopover.tsx create mode 100644 src/ui/HoverDetails.tsx diff --git a/.gitignore b/.gitignore index 51b6032..26e00b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +releases/ src-tauri/target/ src-tauri/gen/ diff --git a/README.md b/README.md index 86737e6..10ee926 100644 --- a/README.md +++ b/README.md @@ -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 нужно отдельно подтвердить перед тем, как называть их официально поддержанными. diff --git a/scripts/prepare-release.ps1 b/scripts/prepare-release.ps1 new file mode 100644 index 0000000..108e26c --- /dev/null +++ b/scripts/prepare-release.ps1 @@ -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*"(?[^"]+)"') + 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*"(?[^"]+)"' ` + -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*"(?[^"]+)"', + [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*"(?[^"]+)"', + [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*"(?[^"]+)"' ` + -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*"(?[^"]+)"' ` + -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*"(?[^"]+)"\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 +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 002657e..43b5dd3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -437,39 +437,18 @@ impl Clock for SystemClock { } } -pub struct StagedApplyHelper; - -impl ProxyApplyHelper for StagedApplyHelper { - fn apply_proxy_config( - &self, - request: HelperApplyRequest<'_>, - ) -> Result { - Ok(HelperApplyResult { - success: true, - changed: true, - action: format!("{}.stage-generated-config", request.adapter_id), - message: format!( - "Сгенерированный конфиг подготовлен в {}; интеграция привилегированного помощника еще не подключена", - request.config_path.display() - ), - }) - } -} - pub struct DetectedProxyApplyHelper { host: H, } impl DetectedProxyApplyHelper { pub fn system() -> Self { - Self { - host: SystemProxyfierDetectionHost, - } + SystemProxyfierDetectionHost.into() } } -impl DetectedProxyApplyHelper { - pub fn new(host: H) -> Self { +impl From for DetectedProxyApplyHelper { + fn from(host: H) -> Self { Self { host } } } diff --git a/src-tauri/src/singbox_service.rs b/src-tauri/src/singbox_service.rs index 0ca27da..f8ca240 100644 --- a/src-tauri/src/singbox_service.rs +++ b/src-tauri/src/singbox_service.rs @@ -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 { diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index f5e494a..ce184a6 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -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> { 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 { 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> { let entries = self.read_activity()?; let entries = append_activity(entries, entry, self.activity_limit); diff --git a/src-tauri/tests/command_tests.rs b/src-tauri/tests/command_tests.rs index 4aaa0f1..506d79a 100644 --- a/src-tauri/tests/command_tests.rs +++ b/src-tauri/tests/command_tests.rs @@ -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(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(), diff --git a/src-tauri/tests/storage_tests.rs b/src-tauri/tests/storage_tests.rs index d51be5f..9f8de0b 100644 --- a/src-tauri/tests/storage_tests.rs +++ b/src-tauri/tests/storage_tests.rs @@ -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(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(), diff --git a/src/app/App.tsx b/src/app/App.tsx index e0bcbd5..74b93a4 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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>({}); const [proxyPing, setProxyPing] = useState(null); - const [isSingBoxSetupOpen, setIsSingBoxSetupOpen] = useState(false); - const [isSingBoxInfoOpen, setIsSingBoxInfoOpen] = useState(false); const [generatedConfigPath, setGeneratedConfigPath] = useState(''); const [logEntries, setLogEntries] = useState([]); const [activeLogId, setActiveLogId] = useState(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() { {readiness.title} {readiness.text} - ) : showState && hasUnappliedChanges ? ( + ) : showState && showPending ? (
Изменения еще не применены в ProxiFyre {applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))} @@ -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)} @@ -1250,30 +1306,60 @@ export function App() { placeholder="socks5://127.0.0.1:1080" spellCheck={false} /> + {proxyValidation ? {proxyValidation} : null} -
-
- {proxyPing ? pingResultTitle(proxyPing) : proxyValidation ? 'Проверь формат' : 'Проверка не запускалась'} - {proxyPing ? pingResultText(proxyPing) : proxyValidation ?? 'TCP check покажет, доступен ли host:port.'} -
- +
+ ); + } + + function renderConnectionCheck(check: ConnectionCheckView) { + const buttonDisabled = Boolean(check.disabledReason); + + return ( +
+
+ Проверка endpoint + {check.title} +

{check.text}

+ + Endpoint + {check.endpoint} + +
); } 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={( - <> - - setIsSingBoxInfoOpen((current) => !current)} - label="Подробности Local sing-box" - aria-expanded={isSingBoxInfoOpen} - title="Подробности" - icon={} - /> - + + Детали + {setupSummary} + )} > - {isSingBoxInfoOpen ? ( -
-
- Локально - {localSingBoxAddress(singBoxStatus)} - LAN - {lanSingBoxAddress(singBoxStatus) ?? 'недоступен'} - Сервер - - {selectedServerTag - ? displayServerTag(selectedServerTag) - : 'не выбран'} - - Файл - {singbox?.path ?? 'не найден'} - Конфиг - {singBoxStatus?.generatedConfigPath ?? 'не создан'} -
-
- - -
-
- ) : null} - - {isSingBoxSetupOpen ? ( -
- {singBoxSetupStatus ? ( - singBoxSetupStatus.items.map((item) => ( -
-
- )) - ) : ( -
-
- )} -
- ) : null} - - {isSingBoxInstalled ? renderSingBoxWorkspace() : ( -
- Local sing-box не установлен - Установи компонент, чтобы подключить подписку, выбрать сервер и включить локальный маршрут. -
- )} + {isSingBoxInstalled ? renderSingBoxWorkspace() : null} ); } @@ -1446,11 +1443,37 @@ export function App() { onClick={() => void forgetSingBoxSubscriptionData()} disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription} label="Очистить подписку Local sing-box" - title="Очистить" + tooltip="Очистить" icon={} />
+
+ Серверы подписки +
+ + +
+
+ {singBoxStatus?.cache?.servers.length ? (
{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)}`} >
@@ -1488,6 +1512,34 @@ export function App() { ); } + function renderProxyOverview() { + const check = connectionCheckView({ + routeMode, + proxyInput, + proxyPing, + singbox, + singBoxStatus, + selectedServer, + selectedServerPing, + isDetectingComponents, + isProxyChecking, + serverPingTag, + }); + + return ( +
+
+
+ Настройки VPN / Прокси +

Маршрут и проверка соединения

+
+ {routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'} +
+ {renderConnectionCheck(check)} +
+ ); + } + function renderProxyPanel() { return (
-
-
- Настройки VPN / Прокси -

Маршрут и проверка соединения

-
- {routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'} -
+ {renderProxyOverview()}
@@ -1527,16 +1573,46 @@ export function App() { {routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()}
-
- Маршрут - {routePreviewText(routeMode, proxyInput, singBoxStatus)} -
+ {renderRouteChain()} {renderApplyActions('proxy')}
); } + function renderRouteChain() { + const segments = routeChainSegments({ + routeMode, + proxyInput, + proxyfier, + singbox, + singBoxStatus, + selectedServer, + appCount: items.length, + isDetectingComponents, + }); + + return ( +
+ {segments.map((segment, index) => ( + = segments.length - 2 ? 'end' : 'start'} + aria-label={`${segment.label}: ${segment.value}. ${segment.details.join('. ')}`} + key={segment.id} + > + + ))} +
+ ); + } + 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 ; } -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) { diff --git a/src/styles/app.css b/src/styles/app.css index a3619b1..85976ac 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -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 { diff --git a/src/ui/DetailsPopover.tsx b/src/ui/DetailsPopover.tsx new file mode 100644 index 0000000..9f0e51d --- /dev/null +++ b/src/ui/DetailsPopover.tsx @@ -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, '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(null); + const popoverRef = useRef(null); + const [open, setOpen] = useState(false); + const [position, setPosition] = useState({ + 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) => { + 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 ( + <> + + {open && detailLines.length && typeof document !== 'undefined' + ? createPortal( + , + document.body, + ) + : null} + + ); +} diff --git a/src/ui/HoverDetails.tsx b/src/ui/HoverDetails.tsx new file mode 100644 index 0000000..95aa4ab --- /dev/null +++ b/src/ui/HoverDetails.tsx @@ -0,0 +1,29 @@ +import type { HTMLAttributes, ReactNode } from 'react'; + +export interface HoverDetailsProps extends HTMLAttributes { + 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 ( + + {children} + + ); +} diff --git a/src/ui/IconButton.tsx b/src/ui/IconButton.tsx index 0576ed9..13bce84 100644 --- a/src/ui/IconButton.tsx +++ b/src/ui/IconButton.tsx @@ -2,11 +2,12 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react'; export type IconButtonVariant = 'neutral' | 'add' | 'danger'; -export interface IconButtonProps extends ButtonHTMLAttributes { +export interface IconButtonProps extends Omit, '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 ?