Refactor proxy routing and installer flows
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
releases/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
|
||||
225
README.md
225
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 нужно отдельно подтвердить перед тем, как называть их официально поддержанными.
|
||||
|
||||
606
scripts/prepare-release.ps1
Normal file
606
scripts/prepare-release.ps1
Normal 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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
631
src/app/App.tsx
631
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<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">-></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) {
|
||||
|
||||
@@ -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
209
src/ui/DetailsPopover.tsx
Normal 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
29
src/ui/HoverDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user