Clarify active Windows client architecture
This commit is contained in:
54
README.md
54
README.md
@@ -53,6 +53,60 @@ docker compose -f docker-compose.client.yml logs -f
|
|||||||
docker compose -f docker-compose.client.yml restart
|
docker compose -f docker-compose.client.yml restart
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Windows: standalone desktop client direction
|
||||||
|
|
||||||
|
Windows app routing lives in a separate Tauri 2 desktop utility, not as
|
||||||
|
`APP_MODE=windows` inside the current Node gateway/client server. The active
|
||||||
|
workspace slice is `apps/windows-client`.
|
||||||
|
|
||||||
|
Active design documents:
|
||||||
|
|
||||||
|
- Product/tech brief: `docs/windows-client-product-tech-brief.md`
|
||||||
|
- Execution plan: `docs/goals/windows-modular-client/PLAN.md`
|
||||||
|
- Windows client README: `apps/windows-client/README.md`
|
||||||
|
|
||||||
|
Target shape:
|
||||||
|
|
||||||
|
- Control App: compact Windows UI for status, profiles, targets, components,
|
||||||
|
logs, and diagnostics.
|
||||||
|
- Proxyfier Layer: adapter boundary with ProxiFyre as the first engine for
|
||||||
|
per-app TCP/UDP routing.
|
||||||
|
- Local sing-box: optional local runtime; external SOCKS5/HTTP targets must
|
||||||
|
work without it.
|
||||||
|
|
||||||
|
Development checks:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd apps/windows-client
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm run tauri -- info
|
||||||
|
|
||||||
|
cd src-tauri
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Native Tauri build requires WebView2, Rust/rustup, and Visual Studio Build
|
||||||
|
Tools with MSVC and Windows SDK components. In the current checkpoint, frontend
|
||||||
|
builds pass, while native Rust/Tauri tests require that Windows toolchain.
|
||||||
|
|
||||||
|
The three Windows pieces are installed and operated separately:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd apps/windows-client
|
||||||
|
& .\scripts\install-control-app.ps1 -PlanOnly
|
||||||
|
& .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||||
|
& .\scripts\install-singbox.ps1 -PlanOnly
|
||||||
|
```
|
||||||
|
|
||||||
|
`-PlanOnly` returns structured JSON without install side effects. Real install
|
||||||
|
or service operations must be explicit; profile apply must not silently install
|
||||||
|
Proxyfier or Local sing-box.
|
||||||
|
|
||||||
|
Windows source configuration is owned by JSON under
|
||||||
|
`C:\ProgramData\VpnProxy\config`. Generated ProxiFyre and sing-box files under
|
||||||
|
`C:\ProgramData\VpnProxy\generated` are derived artifacts.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# VPN Proxy Gateway
|
# VPN Proxy Gateway
|
||||||
|
|||||||
4
apps/windows-client/.gitignore
vendored
Normal file
4
apps/windows-client/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
|
||||||
116
apps/windows-client/README.md
Normal file
116
apps/windows-client/README.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# VPN Proxy Windows Client
|
||||||
|
|
||||||
|
Standalone Windows desktop utility for app-level proxy routing. This app is
|
||||||
|
separate from the current Docker gateway/client runtime and must not be wired
|
||||||
|
through `APP_MODE=windows`.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- Control App: Tauri 2 + React/TypeScript UI and Rust command layer.
|
||||||
|
- Proxyfier Layer: ProxiFyre adapter for per-application routing.
|
||||||
|
- Local sing-box: optional local runtime, used only by targets that explicitly
|
||||||
|
require `singbox`.
|
||||||
|
|
||||||
|
External SOCKS5 targets are the MVP path and do not require Local sing-box.
|
||||||
|
|
||||||
|
## Source And Generated Files
|
||||||
|
|
||||||
|
Source configuration is owned by Rust domain models and JSON files under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\ProgramData\VpnProxy\config\profiles.json
|
||||||
|
C:\ProgramData\VpnProxy\config\targets.json
|
||||||
|
C:\ProgramData\VpnProxy\config\components.json
|
||||||
|
C:\ProgramData\VpnProxy\state\activity.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated artifacts are derived and can be recreated:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json
|
||||||
|
C:\ProgramData\VpnProxy\generated\sing-box-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd apps/windows-client
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the browser preview shell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev -- --host 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Run Tauri checks when the native Windows toolchain is installed:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run tauri -- info
|
||||||
|
npm run tauri -- dev
|
||||||
|
npm run tauri -- build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run Rust tests when Rust/Cargo are installed:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd apps/windows-client/src-tauri
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Native Tauri build requires WebView2, Rust via rustup, and Visual Studio Build
|
||||||
|
Tools with MSVC and Windows SDK components.
|
||||||
|
|
||||||
|
## Explicit Installer Boundaries
|
||||||
|
|
||||||
|
Installer scripts are explicit per component and return structured JSON in
|
||||||
|
`-PlanOnly` mode:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& .\scripts\install-control-app.ps1 -PlanOnly
|
||||||
|
& .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||||
|
& .\scripts\install-singbox.ps1 -PlanOnly
|
||||||
|
```
|
||||||
|
|
||||||
|
Installers must be launched intentionally by the user or by a future narrow
|
||||||
|
helper permission. Profile apply must not silently install Control App,
|
||||||
|
Proxyfier, or Local sing-box.
|
||||||
|
|
||||||
|
## Existing Proxyfier Detection
|
||||||
|
|
||||||
|
The app detects an already installed Proxyfier layer before showing component
|
||||||
|
status or applying profiles. Detection checks:
|
||||||
|
|
||||||
|
- uninstall registry entries for `ProxiFyre` and `Proxifier`;
|
||||||
|
- common install folders such as `C:\Tools\ProxiFyre`,
|
||||||
|
`%ProgramFiles%\ProxiFyre`, and `%ProgramFiles%\Proxifier`;
|
||||||
|
- running `ProxiFyre` / `Proxifier` processes and the `ProxiFyreService`
|
||||||
|
service.
|
||||||
|
|
||||||
|
For portable installs, set an override before launching the app:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:VPN_PROXY_PROXIFYRE_ROOT = 'D:\Tools\ProxiFyre'
|
||||||
|
npm run tauri -- dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProxiFyre` installs are compatible with the current generated
|
||||||
|
`app-config.json` apply path. Plain `Proxifier` installs are detected and shown,
|
||||||
|
but automatic profile apply is not enabled for them yet because they use a
|
||||||
|
different profile format.
|
||||||
|
|
||||||
|
## MVP Verification Flow
|
||||||
|
|
||||||
|
1. Start the Control App or browser preview.
|
||||||
|
2. Confirm Components shows Control App, Proxyfier Layer, and optional Local
|
||||||
|
sing-box separately.
|
||||||
|
3. Add or keep an external SOCKS5 target.
|
||||||
|
4. Add a process/folder/exe profile such as Discord.
|
||||||
|
5. Apply profiles and verify generated ProxiFyre config plus activity entry.
|
||||||
|
6. Install Proxyfier separately before applying to a real service.
|
||||||
|
7. Install and start Local sing-box only when using a local target.
|
||||||
|
|
||||||
|
Task evidence is recorded in
|
||||||
|
`docs/goals/windows-modular-client/EVIDENCE.md`.
|
||||||
12
apps/windows-client/index.html
Normal file
12
apps/windows-client/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>VPN Proxy для Windows</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2106
apps/windows-client/package-lock.json
generated
Normal file
2106
apps/windows-client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
apps/windows-client/package.json
Normal file
27
apps/windows-client/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "vpn-proxy-windows-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Standalone Windows desktop proxy management app for VPN Proxy.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
79
apps/windows-client/scripts/install-control-app.ps1
Normal file
79
apps/windows-client/scripts/install-control-app.ps1
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
param(
|
||||||
|
[string]$InstallRoot = "C:\Program Files\VpnProxy\ControlApp",
|
||||||
|
[string]$DataRoot = "C:\ProgramData\VpnProxy",
|
||||||
|
[switch]$PlanOnly,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function New-Result {
|
||||||
|
param(
|
||||||
|
[bool]$Success,
|
||||||
|
[string]$Action,
|
||||||
|
[bool]$Changed,
|
||||||
|
[string]$Message,
|
||||||
|
[hashtable]$Details = @{}
|
||||||
|
)
|
||||||
|
|
||||||
|
[ordered]@{
|
||||||
|
success = $Success
|
||||||
|
action = $Action
|
||||||
|
changed = $Changed
|
||||||
|
message = $Message
|
||||||
|
details = $Details
|
||||||
|
} | ConvertTo-Json -Depth 6
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-IsAdministrator {
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-Directory {
|
||||||
|
param([string]$Path)
|
||||||
|
if (-not (Test-Path -LiteralPath $Path)) {
|
||||||
|
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$details = @{
|
||||||
|
installRoot = $InstallRoot
|
||||||
|
dataRoot = $DataRoot
|
||||||
|
planOnly = [bool]$PlanOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PlanOnly) {
|
||||||
|
New-Result -Success $true -Action "install-control-app" -Changed $false -Message "Control App install plan is ready." -Details $details
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-IsAdministrator)) {
|
||||||
|
New-Result -Success $false -Action "install-control-app" -Changed $false -Message "Administrator rights are required." -Details $details
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = $false
|
||||||
|
$changed = (Ensure-Directory -Path $InstallRoot) -or $changed
|
||||||
|
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "config")) -or $changed
|
||||||
|
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "state")) -or $changed
|
||||||
|
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "generated")) -or $changed
|
||||||
|
|
||||||
|
$markerPath = Join-Path $InstallRoot "install-control-app.marker.json"
|
||||||
|
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
|
||||||
|
@{ component = "control-app"; installedAt = (Get-Date).ToString("o") } |
|
||||||
|
ConvertTo-Json -Depth 4 |
|
||||||
|
Set-Content -LiteralPath $markerPath -Encoding UTF8
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$details.markerPath = $markerPath
|
||||||
|
New-Result -Success $true -Action "install-control-app" -Changed $changed -Message "Control App directories are installed." -Details $details
|
||||||
|
} catch {
|
||||||
|
New-Result -Success $false -Action "install-control-app" -Changed $false -Message $_.Exception.Message
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
96
apps/windows-client/scripts/install-proxyfier.ps1
Normal file
96
apps/windows-client/scripts/install-proxyfier.ps1
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
param(
|
||||||
|
[string]$InstallRoot = "C:\Tools\ProxiFyre",
|
||||||
|
[string]$PackagePath = "",
|
||||||
|
[string]$ServiceName = "ProxiFyreService",
|
||||||
|
[switch]$PlanOnly,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function New-Result {
|
||||||
|
param(
|
||||||
|
[bool]$Success,
|
||||||
|
[string]$Action,
|
||||||
|
[bool]$Changed,
|
||||||
|
[string]$Message,
|
||||||
|
[hashtable]$Details = @{}
|
||||||
|
)
|
||||||
|
|
||||||
|
[ordered]@{
|
||||||
|
success = $Success
|
||||||
|
action = $Action
|
||||||
|
changed = $Changed
|
||||||
|
message = $Message
|
||||||
|
details = $Details
|
||||||
|
} | ConvertTo-Json -Depth 6
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-IsAdministrator {
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Backup-File {
|
||||||
|
param([string]$Path)
|
||||||
|
if (Test-Path -LiteralPath $Path) {
|
||||||
|
$backup = "$Path.bak"
|
||||||
|
Copy-Item -LiteralPath $Path -Destination $backup -Force
|
||||||
|
return $backup
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$details = @{
|
||||||
|
installRoot = $InstallRoot
|
||||||
|
packagePath = $PackagePath
|
||||||
|
serviceName = $ServiceName
|
||||||
|
planOnly = [bool]$PlanOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PlanOnly) {
|
||||||
|
New-Result -Success $true -Action "install-proxyfier" -Changed $false -Message "Proxyfier install plan is ready." -Details $details
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-IsAdministrator)) {
|
||||||
|
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "Administrator rights are required." -Details $details
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath)) {
|
||||||
|
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "PackagePath is required and must point to a local ProxiFyre package." -Details $details
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = $false
|
||||||
|
if (-not (Test-Path -LiteralPath $InstallRoot)) {
|
||||||
|
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$configPath = Join-Path $InstallRoot "app-config.json"
|
||||||
|
$backupPath = Backup-File -Path $configPath
|
||||||
|
if ($backupPath) {
|
||||||
|
$details.backupPath = $backupPath
|
||||||
|
}
|
||||||
|
|
||||||
|
$markerPath = Join-Path $InstallRoot "install-proxyfier.marker.json"
|
||||||
|
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
|
||||||
|
@{
|
||||||
|
component = "proxyfier"
|
||||||
|
packagePath = $PackagePath
|
||||||
|
serviceName = $ServiceName
|
||||||
|
installedAt = (Get-Date).ToString("o")
|
||||||
|
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$details.markerPath = $markerPath
|
||||||
|
New-Result -Success $true -Action "install-proxyfier" -Changed $changed -Message "Proxyfier install boundary completed." -Details $details
|
||||||
|
} catch {
|
||||||
|
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message $_.Exception.Message
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
96
apps/windows-client/scripts/install-singbox.ps1
Normal file
96
apps/windows-client/scripts/install-singbox.ps1
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
param(
|
||||||
|
[string]$InstallRoot = "C:\Program Files\VpnProxy\sing-box",
|
||||||
|
[string]$BinaryPath = "",
|
||||||
|
[string]$ServiceName = "VpnProxySingBox",
|
||||||
|
[switch]$PlanOnly,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function New-Result {
|
||||||
|
param(
|
||||||
|
[bool]$Success,
|
||||||
|
[string]$Action,
|
||||||
|
[bool]$Changed,
|
||||||
|
[string]$Message,
|
||||||
|
[hashtable]$Details = @{}
|
||||||
|
)
|
||||||
|
|
||||||
|
[ordered]@{
|
||||||
|
success = $Success
|
||||||
|
action = $Action
|
||||||
|
changed = $Changed
|
||||||
|
message = $Message
|
||||||
|
details = $Details
|
||||||
|
} | ConvertTo-Json -Depth 6
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-IsAdministrator {
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Backup-File {
|
||||||
|
param([string]$Path)
|
||||||
|
if (Test-Path -LiteralPath $Path) {
|
||||||
|
$backup = "$Path.bak"
|
||||||
|
Copy-Item -LiteralPath $Path -Destination $backup -Force
|
||||||
|
return $backup
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$details = @{
|
||||||
|
installRoot = $InstallRoot
|
||||||
|
binaryPath = $BinaryPath
|
||||||
|
serviceName = $ServiceName
|
||||||
|
planOnly = [bool]$PlanOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PlanOnly) {
|
||||||
|
New-Result -Success $true -Action "install-singbox" -Changed $false -Message "Local sing-box install plan is ready." -Details $details
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-IsAdministrator)) {
|
||||||
|
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "Administrator rights are required." -Details $details
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($BinaryPath) -or -not (Test-Path -LiteralPath $BinaryPath)) {
|
||||||
|
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "BinaryPath is required and must point to sing-box.exe." -Details $details
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = $false
|
||||||
|
if (-not (Test-Path -LiteralPath $InstallRoot)) {
|
||||||
|
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$configPath = Join-Path $InstallRoot "config.json"
|
||||||
|
$backupPath = Backup-File -Path $configPath
|
||||||
|
if ($backupPath) {
|
||||||
|
$details.backupPath = $backupPath
|
||||||
|
}
|
||||||
|
|
||||||
|
$markerPath = Join-Path $InstallRoot "install-singbox.marker.json"
|
||||||
|
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
|
||||||
|
@{
|
||||||
|
component = "singbox"
|
||||||
|
binaryPath = $BinaryPath
|
||||||
|
serviceName = $ServiceName
|
||||||
|
installedAt = (Get-Date).ToString("o")
|
||||||
|
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$details.markerPath = $markerPath
|
||||||
|
New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box install boundary completed." -Details $details
|
||||||
|
} catch {
|
||||||
|
New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
4390
apps/windows-client/src-tauri/Cargo.lock
generated
Normal file
4390
apps/windows-client/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
apps/windows-client/src-tauri/Cargo.toml
Normal file
19
apps/windows-client/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "vpn-proxy-windows-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Standalone Windows desktop proxy management app for VPN Proxy."
|
||||||
|
authors = ["VPN Proxy"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "vpn_proxy_windows_client_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
4
apps/windows-client/src-tauri/build.rs
Normal file
4
apps/windows-client/src-tauri/build.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
|
|
||||||
7
apps/windows-client/src-tauri/capabilities/default.json
Normal file
7
apps/windows-client/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability for the main VPN Proxy Windows shell. Task 8 keeps helper/install launch explicit: no shell or sidecar permission is granted here until a packaged helper is declared.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Default capability for the main VPN Proxy Windows shell. Task 8 keeps helper/install launch explicit: no shell or sidecar permission is granted here until a packaged helper is declared.","local":true,"windows":["main"],"permissions":["core:default"]}}
|
||||||
2292
apps/windows-client/src-tauri/gen/schemas/desktop-schema.json
Normal file
2292
apps/windows-client/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2292
apps/windows-client/src-tauri/gen/schemas/windows-schema.json
Normal file
2292
apps/windows-client/src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
apps/windows-client/src-tauri/icons/128x128.png
Normal file
BIN
apps/windows-client/src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
apps/windows-client/src-tauri/icons/128x128@2x.png
Normal file
BIN
apps/windows-client/src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
apps/windows-client/src-tauri/icons/32x32.png
Normal file
BIN
apps/windows-client/src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 940 B |
BIN
apps/windows-client/src-tauri/icons/icon.ico
Normal file
BIN
apps/windows-client/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
23
apps/windows-client/src-tauri/src/activity.rs
Normal file
23
apps/windows-client/src-tauri/src/activity.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use crate::models::ActivityEntry;
|
||||||
|
|
||||||
|
pub const DEFAULT_ACTIVITY_LIMIT: usize = 200;
|
||||||
|
|
||||||
|
pub fn sort_activity_desc(mut entries: Vec<ActivityEntry>) -> Vec<ActivityEntry> {
|
||||||
|
entries.sort_by(|left, right| right.at.cmp(&left.at).then_with(|| right.id.cmp(&left.id)));
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cap_activity(entries: Vec<ActivityEntry>, limit: usize) -> Vec<ActivityEntry> {
|
||||||
|
let mut entries = sort_activity_desc(entries);
|
||||||
|
entries.truncate(limit);
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_activity(
|
||||||
|
mut entries: Vec<ActivityEntry>,
|
||||||
|
entry: ActivityEntry,
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<ActivityEntry> {
|
||||||
|
entries.push(entry);
|
||||||
|
cap_activity(entries, limit)
|
||||||
|
}
|
||||||
242
apps/windows-client/src-tauri/src/adapters/proxifyre.rs
Normal file
242
apps/windows-client/src-tauri/src/adapters/proxifyre.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
use crate::models::{
|
||||||
|
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
|
||||||
|
ProxyProtocol, Target,
|
||||||
|
};
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::proxy_router::{
|
||||||
|
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||||
|
ProxyRouterRequest,
|
||||||
|
};
|
||||||
|
#[cfg(not(test))]
|
||||||
|
use crate::adapters::proxy_router::{
|
||||||
|
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||||
|
ProxyRouterRequest,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
|
||||||
|
pub const PROXIFYRE_OUTPUT_FILE: &str = "proxifyre-app-config.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProxiFyreAdapter {
|
||||||
|
log_level: String,
|
||||||
|
bypass_lan: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxiFyreAdapter {
|
||||||
|
pub fn new(log_level: impl Into<String>, bypass_lan: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
log_level: log_level.into(),
|
||||||
|
bypass_lan,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_proxifyre_config(
|
||||||
|
&self,
|
||||||
|
request: ProxyRouterRequest<'_>,
|
||||||
|
) -> Result<ProxiFyreConfig, ProxyRouterError> {
|
||||||
|
let mut proxies = Vec::new();
|
||||||
|
|
||||||
|
for profile in request.profiles.iter().filter(|profile| profile.enabled) {
|
||||||
|
let target = find_target(profile, request.targets)?;
|
||||||
|
ensure_target_supported(profile, target, request.components)?;
|
||||||
|
|
||||||
|
let app_names = app_names_for_profile(profile);
|
||||||
|
if app_names.is_empty() {
|
||||||
|
return Err(ProxyRouterError::new(
|
||||||
|
ProxyRouterErrorKind::EmptyProfileItems,
|
||||||
|
format!("В профиле '{}' нет приложений для маршрутизации", profile.id),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
proxies.push(ProxiFyreProxy {
|
||||||
|
app_names,
|
||||||
|
socks5_proxy_endpoint: format!("{}:{}", target.host, target.port),
|
||||||
|
supported_protocols: protocols_for_profile(profile),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ProxiFyreConfig {
|
||||||
|
log_level: self.log_level.clone(),
|
||||||
|
bypass_lan: self.bypass_lan,
|
||||||
|
proxies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProxiFyreAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new("Info", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyRouterAdapter for ProxiFyreAdapter {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
PROXIFYRE_ADAPTER_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_file_name(&self) -> &'static str {
|
||||||
|
PROXIFYRE_OUTPUT_FILE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_config(
|
||||||
|
&self,
|
||||||
|
request: ProxyRouterRequest<'_>,
|
||||||
|
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError> {
|
||||||
|
let config = self.generate_proxifyre_config(request)?;
|
||||||
|
let enabled_profiles = config.proxies.len();
|
||||||
|
let routed_apps = config
|
||||||
|
.proxies
|
||||||
|
.iter()
|
||||||
|
.map(|proxy| proxy.app_names.len())
|
||||||
|
.sum();
|
||||||
|
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
|
||||||
|
ProxyRouterError::new(
|
||||||
|
ProxyRouterErrorKind::Serialization,
|
||||||
|
format!("Не удалось сериализовать конфиг ProxiFyre: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(ProxyRouterGeneratedConfig {
|
||||||
|
adapter_id: self.id().to_string(),
|
||||||
|
output_file_name: self.output_file_name().to_string(),
|
||||||
|
contents,
|
||||||
|
enabled_profiles,
|
||||||
|
routed_apps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProxiFyreConfig {
|
||||||
|
#[serde(rename = "logLevel")]
|
||||||
|
pub log_level: String,
|
||||||
|
#[serde(rename = "bypassLan")]
|
||||||
|
pub bypass_lan: bool,
|
||||||
|
pub proxies: Vec<ProxiFyreProxy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProxiFyreProxy {
|
||||||
|
#[serde(rename = "appNames")]
|
||||||
|
pub app_names: Vec<String>,
|
||||||
|
#[serde(rename = "socks5ProxyEndpoint")]
|
||||||
|
pub socks5_proxy_endpoint: String,
|
||||||
|
#[serde(rename = "supportedProtocols")]
|
||||||
|
pub supported_protocols: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_target<'a>(
|
||||||
|
profile: &Profile,
|
||||||
|
targets: &'a [Target],
|
||||||
|
) -> Result<&'a Target, ProxyRouterError> {
|
||||||
|
targets
|
||||||
|
.iter()
|
||||||
|
.find(|target| target.id == profile.target_id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyRouterError::new(
|
||||||
|
ProxyRouterErrorKind::MissingTarget,
|
||||||
|
format!(
|
||||||
|
"Профиль '{}' ссылается на отсутствующую цель '{}'",
|
||||||
|
profile.id, profile.target_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_target_supported(
|
||||||
|
profile: &Profile,
|
||||||
|
target: &Target,
|
||||||
|
components: &[ComponentStatus],
|
||||||
|
) -> Result<(), ProxyRouterError> {
|
||||||
|
if target.protocol != ProxyProtocol::Socks5 {
|
||||||
|
return Err(ProxyRouterError::new(
|
||||||
|
ProxyRouterErrorKind::UnsupportedTargetProtocol,
|
||||||
|
format!(
|
||||||
|
"Цель '{}' использует HTTP, но ProxiFyre требует SOCKS5",
|
||||||
|
target.id
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(required_component) = &target.requires_component {
|
||||||
|
let Some(status) = components
|
||||||
|
.iter()
|
||||||
|
.find(|component| &component.id == required_component)
|
||||||
|
else {
|
||||||
|
return Err(ProxyRouterError::new(
|
||||||
|
ProxyRouterErrorKind::MissingRequiredComponent,
|
||||||
|
format!(
|
||||||
|
"Цель '{}' профиля '{}' требует отсутствующий компонент '{}'",
|
||||||
|
target.id,
|
||||||
|
profile.id,
|
||||||
|
component_id_label(required_component)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if !component_is_running(status) {
|
||||||
|
return Err(ProxyRouterError::new(
|
||||||
|
ProxyRouterErrorKind::RequiredComponentNotRunning,
|
||||||
|
format!(
|
||||||
|
"Цель '{}' профиля '{}' требует запущенный компонент '{}'",
|
||||||
|
target.id,
|
||||||
|
profile.id,
|
||||||
|
component_id_label(required_component)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_is_running(status: &ComponentStatus) -> bool {
|
||||||
|
status.installed && status.running && status.state == ComponentState::Running
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_names_for_profile(profile: &Profile) -> Vec<String> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
|
||||||
|
for item in &profile.items {
|
||||||
|
let value = item.value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_name = match item.item_type {
|
||||||
|
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !names.iter().any(|existing| existing == app_name) {
|
||||||
|
names.push(app_name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocols_for_profile(profile: &Profile) -> Vec<String> {
|
||||||
|
let mut protocols = Vec::new();
|
||||||
|
|
||||||
|
for protocol in &profile.protocols {
|
||||||
|
let value = match protocol {
|
||||||
|
Protocol::Tcp => "TCP",
|
||||||
|
Protocol::Udp => "UDP",
|
||||||
|
};
|
||||||
|
|
||||||
|
if !protocols.iter().any(|existing| existing == value) {
|
||||||
|
protocols.push(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocols
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_id_label(component_id: &ComponentId) -> &'static str {
|
||||||
|
match component_id {
|
||||||
|
ComponentId::ControlApp => "control-app",
|
||||||
|
ComponentId::Proxyfier => "proxyfier",
|
||||||
|
ComponentId::Singbox => "singbox",
|
||||||
|
}
|
||||||
|
}
|
||||||
67
apps/windows-client/src-tauri/src/adapters/proxy_router.rs
Normal file
67
apps/windows-client/src-tauri/src/adapters/proxy_router.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use crate::models::{ComponentStatus, Profile, Target};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ProxyRouterRequest<'a> {
|
||||||
|
pub profiles: &'a [Profile],
|
||||||
|
pub targets: &'a [Target],
|
||||||
|
pub components: &'a [ComponentStatus],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ProxyRouterRequest<'a> {
|
||||||
|
pub fn new(
|
||||||
|
profiles: &'a [Profile],
|
||||||
|
targets: &'a [Target],
|
||||||
|
components: &'a [ComponentStatus],
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
profiles,
|
||||||
|
targets,
|
||||||
|
components,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProxyRouterGeneratedConfig {
|
||||||
|
pub adapter_id: String,
|
||||||
|
pub output_file_name: String,
|
||||||
|
pub contents: String,
|
||||||
|
pub enabled_profiles: usize,
|
||||||
|
pub routed_apps: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProxyRouterError {
|
||||||
|
pub kind: ProxyRouterErrorKind,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyRouterError {
|
||||||
|
pub fn new(kind: ProxyRouterErrorKind, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ProxyRouterErrorKind {
|
||||||
|
EmptyProfileItems,
|
||||||
|
MissingTarget,
|
||||||
|
MissingRequiredComponent,
|
||||||
|
RequiredComponentNotRunning,
|
||||||
|
UnsupportedTargetProtocol,
|
||||||
|
Serialization,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ProxyRouterAdapter {
|
||||||
|
fn id(&self) -> &'static str;
|
||||||
|
|
||||||
|
fn output_file_name(&self) -> &'static str;
|
||||||
|
|
||||||
|
fn generate_config(
|
||||||
|
&self,
|
||||||
|
request: ProxyRouterRequest<'_>,
|
||||||
|
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError>;
|
||||||
|
}
|
||||||
358
apps/windows-client/src-tauri/src/adapters/singbox.rs
Normal file
358
apps/windows-client/src-tauri/src/adapters/singbox.rs
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
use crate::models::{
|
||||||
|
ComponentId, ComponentState, ComponentStatus, ProxyProtocol, Target, TargetKind,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
path::Path,
|
||||||
|
process::Command,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
|
||||||
|
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
|
||||||
|
pub const DEFAULT_MIXED_INBOUND_TAG: &str = "vpn-proxy-mixed-in";
|
||||||
|
pub const DEFAULT_DIRECT_OUTBOUND_TAG: &str = "direct";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SingBoxAdapter {
|
||||||
|
log_level: String,
|
||||||
|
inbound_tag: String,
|
||||||
|
outbound_tag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SingBoxAdapter {
|
||||||
|
pub fn new(
|
||||||
|
log_level: impl Into<String>,
|
||||||
|
inbound_tag: impl Into<String>,
|
||||||
|
outbound_tag: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
log_level: log_level.into(),
|
||||||
|
inbound_tag: inbound_tag.into(),
|
||||||
|
outbound_tag: outbound_tag.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_config<C>(
|
||||||
|
&self,
|
||||||
|
request: SingBoxGenerationRequest<'_>,
|
||||||
|
checker: &C,
|
||||||
|
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
|
||||||
|
where
|
||||||
|
C: SingBoxConfigChecker,
|
||||||
|
{
|
||||||
|
let target = find_local_singbox_target(request.targets)?;
|
||||||
|
ensure_local_singbox_target(target, request.components)?;
|
||||||
|
|
||||||
|
let config = SingBoxConfig {
|
||||||
|
log: SingBoxLog {
|
||||||
|
disabled: false,
|
||||||
|
level: self.log_level.clone(),
|
||||||
|
timestamp: true,
|
||||||
|
},
|
||||||
|
inbounds: vec![SingBoxInbound {
|
||||||
|
inbound_type: "mixed".to_string(),
|
||||||
|
tag: self.inbound_tag.clone(),
|
||||||
|
listen: target.host.clone(),
|
||||||
|
listen_port: target.port,
|
||||||
|
users: Vec::new(),
|
||||||
|
set_system_proxy: false,
|
||||||
|
}],
|
||||||
|
outbounds: vec![SingBoxOutbound {
|
||||||
|
outbound_type: "direct".to_string(),
|
||||||
|
tag: self.outbound_tag.clone(),
|
||||||
|
}],
|
||||||
|
route: SingBoxRoute {
|
||||||
|
final_outbound: self.outbound_tag.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
|
||||||
|
SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::Serialization,
|
||||||
|
format!("Не удалось сериализовать конфиг sing-box: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let check = match request.binary_path {
|
||||||
|
Some(binary_path) => Some(checker.check_config(binary_path, &contents)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SingBoxGeneratedConfig {
|
||||||
|
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
|
||||||
|
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
|
||||||
|
contents,
|
||||||
|
local_target_id: target.id.clone(),
|
||||||
|
listen: target.host.clone(),
|
||||||
|
listen_port: target.port,
|
||||||
|
check,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SingBoxAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(
|
||||||
|
"info",
|
||||||
|
DEFAULT_MIXED_INBOUND_TAG,
|
||||||
|
DEFAULT_DIRECT_OUTBOUND_TAG,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct SingBoxGenerationRequest<'a> {
|
||||||
|
pub targets: &'a [Target],
|
||||||
|
pub components: &'a [ComponentStatus],
|
||||||
|
pub binary_path: Option<&'a Path>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SingBoxGenerationRequest<'a> {
|
||||||
|
pub fn new(
|
||||||
|
targets: &'a [Target],
|
||||||
|
components: &'a [ComponentStatus],
|
||||||
|
binary_path: Option<&'a Path>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
targets,
|
||||||
|
components,
|
||||||
|
binary_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SingBoxGeneratedConfig {
|
||||||
|
pub adapter_id: String,
|
||||||
|
pub output_file_name: String,
|
||||||
|
pub contents: String,
|
||||||
|
pub local_target_id: String,
|
||||||
|
pub listen: String,
|
||||||
|
pub listen_port: u16,
|
||||||
|
pub check: Option<SingBoxCheckResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxCheckResult {
|
||||||
|
pub checked: bool,
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SingBoxConfigError {
|
||||||
|
pub kind: SingBoxConfigErrorKind,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SingBoxConfigError {
|
||||||
|
pub fn new(kind: SingBoxConfigErrorKind, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SingBoxConfigErrorKind {
|
||||||
|
MissingLocalTarget,
|
||||||
|
MissingRequiredComponent,
|
||||||
|
RequiredComponentNotRunning,
|
||||||
|
UnsupportedTarget,
|
||||||
|
Serialization,
|
||||||
|
CheckFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait SingBoxConfigChecker {
|
||||||
|
fn check_config(
|
||||||
|
&self,
|
||||||
|
binary_path: &Path,
|
||||||
|
config_json: &str,
|
||||||
|
) -> Result<SingBoxCheckResult, SingBoxConfigError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct SingBoxCommandChecker;
|
||||||
|
|
||||||
|
impl SingBoxConfigChecker for SingBoxCommandChecker {
|
||||||
|
fn check_config(
|
||||||
|
&self,
|
||||||
|
binary_path: &Path,
|
||||||
|
config_json: &str,
|
||||||
|
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||||
|
let config_path = env::temp_dir().join(format!(
|
||||||
|
"vpn-proxy-sing-box-{}-{}.json",
|
||||||
|
std::process::id(),
|
||||||
|
now_millis()
|
||||||
|
));
|
||||||
|
|
||||||
|
fs::write(&config_path, config_json).map_err(|error| {
|
||||||
|
SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::CheckFailed,
|
||||||
|
format!(
|
||||||
|
"Не удалось записать временный конфиг sing-box '{}': {error}",
|
||||||
|
config_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let output = Command::new(binary_path)
|
||||||
|
.arg("check")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&config_path)
|
||||||
|
.output()
|
||||||
|
.map_err(|error| {
|
||||||
|
let _ = fs::remove_file(&config_path);
|
||||||
|
SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::CheckFailed,
|
||||||
|
format!("Не удалось выполнить '{} check': {error}", binary_path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let _ = fs::remove_file(&config_path);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let message = command_message(&stdout, &stderr);
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::CheckFailed,
|
||||||
|
format!("Проверка sing-box не прошла: {message}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SingBoxCheckResult {
|
||||||
|
checked: true,
|
||||||
|
success: true,
|
||||||
|
message: if message.is_empty() {
|
||||||
|
"Проверка sing-box прошла успешно".to_string()
|
||||||
|
} else {
|
||||||
|
message
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxConfig {
|
||||||
|
pub log: SingBoxLog,
|
||||||
|
pub inbounds: Vec<SingBoxInbound>,
|
||||||
|
pub outbounds: Vec<SingBoxOutbound>,
|
||||||
|
pub route: SingBoxRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxLog {
|
||||||
|
pub disabled: bool,
|
||||||
|
pub level: String,
|
||||||
|
pub timestamp: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxInbound {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub inbound_type: String,
|
||||||
|
pub tag: String,
|
||||||
|
pub listen: String,
|
||||||
|
#[serde(rename = "listen_port")]
|
||||||
|
pub listen_port: u16,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub users: Vec<SingBoxUser>,
|
||||||
|
#[serde(rename = "set_system_proxy")]
|
||||||
|
pub set_system_proxy: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxUser {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxOutbound {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub outbound_type: String,
|
||||||
|
pub tag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxRoute {
|
||||||
|
#[serde(rename = "final")]
|
||||||
|
pub final_outbound: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_local_singbox_target(targets: &[Target]) -> Result<&Target, SingBoxConfigError> {
|
||||||
|
targets
|
||||||
|
.iter()
|
||||||
|
.find(|target| {
|
||||||
|
target.kind == TargetKind::Local
|
||||||
|
&& target.requires_component.as_ref() == Some(&ComponentId::Singbox)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::MissingLocalTarget,
|
||||||
|
"Локальная цель, требующая sing-box, не настроена",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_local_singbox_target(
|
||||||
|
target: &Target,
|
||||||
|
components: &[ComponentStatus],
|
||||||
|
) -> Result<(), SingBoxConfigError> {
|
||||||
|
if target.kind != TargetKind::Local
|
||||||
|
|| target.protocol != ProxyProtocol::Socks5
|
||||||
|
|| target.requires_component.as_ref() != Some(&ComponentId::Singbox)
|
||||||
|
{
|
||||||
|
return Err(SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::UnsupportedTarget,
|
||||||
|
format!(
|
||||||
|
"Цель '{}' должна быть локальной SOCKS5-целью, требующей sing-box",
|
||||||
|
target.id
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(status) = components
|
||||||
|
.iter()
|
||||||
|
.find(|component| component.id == ComponentId::Singbox)
|
||||||
|
else {
|
||||||
|
return Err(SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::MissingRequiredComponent,
|
||||||
|
format!("Локальная цель '{}' требует состояние компонента sing-box", target.id),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if !component_is_running(status) {
|
||||||
|
return Err(SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::RequiredComponentNotRunning,
|
||||||
|
format!("Локальная цель '{}' требует установленный и запущенный sing-box", target.id),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_is_running(status: &ComponentStatus) -> bool {
|
||||||
|
status.installed && status.running && status.state == ComponentState::Running
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_millis() -> u128 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_message(stdout: &str, stderr: &str) -> String {
|
||||||
|
let stdout = stdout.trim();
|
||||||
|
let stderr = stderr.trim();
|
||||||
|
|
||||||
|
match (stdout.is_empty(), stderr.is_empty()) {
|
||||||
|
(true, true) => String::new(),
|
||||||
|
(false, true) => stdout.to_string(),
|
||||||
|
(true, false) => stderr.to_string(),
|
||||||
|
(false, false) => format!("{stdout}\n{stderr}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
971
apps/windows-client/src-tauri/src/commands.rs
Normal file
971
apps/windows-client/src-tauri/src/commands.rs
Normal file
@@ -0,0 +1,971 @@
|
|||||||
|
#[cfg(not(test))]
|
||||||
|
use crate::adapters::proxifyre::ProxiFyreAdapter;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
use crate::adapters::proxy_router::{
|
||||||
|
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||||
|
ProxyRouterRequest,
|
||||||
|
};
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::proxifyre::ProxiFyreAdapter;
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::proxy_router::{
|
||||||
|
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||||
|
ProxyRouterRequest,
|
||||||
|
};
|
||||||
|
use crate::component_detection::{
|
||||||
|
detect_proxyfier_install, detect_proxyfier_install_with_host,
|
||||||
|
proxyfier_component_from_detection, DetectedProxyfier, ProxyfierDetectionHost,
|
||||||
|
SystemProxyfierDetectionHost,
|
||||||
|
};
|
||||||
|
use crate::models::{
|
||||||
|
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, Profile,
|
||||||
|
ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol, Target,
|
||||||
|
TargetInput, TargetKind,
|
||||||
|
};
|
||||||
|
use crate::storage::{default_config_root, JsonStorage};
|
||||||
|
use crate::validation::{normalize_profile, normalize_target, ValidationError};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommandState {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandState {
|
||||||
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { root: root.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn storage(&self) -> JsonStorage {
|
||||||
|
JsonStorage::new(self.root.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CommandState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(default_config_root())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CommandError {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub details: Vec<ValidationIssue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandError {
|
||||||
|
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
details: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_details(
|
||||||
|
code: impl Into<String>,
|
||||||
|
message: impl Into<String>,
|
||||||
|
details: Vec<ValidationIssue>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ValidationIssue {
|
||||||
|
pub field: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StatusResponse {
|
||||||
|
pub route_line: String,
|
||||||
|
pub active_profile_count: usize,
|
||||||
|
pub routed_app_count: usize,
|
||||||
|
pub active_target: Option<TargetDto>,
|
||||||
|
pub components: Vec<ComponentStatusDto>,
|
||||||
|
pub recent_activity: Vec<ActivityEntryDto>,
|
||||||
|
pub generated_config_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProfileInputDto {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub target_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub protocols: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub items: Option<Vec<ProfileItemInputDto>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileItemInputDto {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub item_type: String,
|
||||||
|
pub value: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub recursive: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TargetInputDto {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub protocol: Option<String>,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub requires_component: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProfileDto {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub target_id: String,
|
||||||
|
pub protocols: Vec<Protocol>,
|
||||||
|
pub items: Vec<ProfileItemDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileItemDto {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub item_type: ProfileItemType,
|
||||||
|
pub value: String,
|
||||||
|
pub recursive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TargetDto {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: TargetKind,
|
||||||
|
pub protocol: ProxyProtocol,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub requires_component: Option<ComponentId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ComponentStatusDto {
|
||||||
|
pub id: ComponentId,
|
||||||
|
pub name: String,
|
||||||
|
pub state: ComponentState,
|
||||||
|
pub installed: bool,
|
||||||
|
pub running: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub problems: Vec<String>,
|
||||||
|
pub actions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ActivityEntryDto {
|
||||||
|
pub id: String,
|
||||||
|
pub at: String,
|
||||||
|
pub level: ActivityLevel,
|
||||||
|
pub title: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ResolveProfilePreviewResponse {
|
||||||
|
pub profile_id: String,
|
||||||
|
pub apps: Vec<ResolvedAppDto>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ResolvedAppDto {
|
||||||
|
pub source_type: ProfileItemType,
|
||||||
|
pub source_value: String,
|
||||||
|
pub app_name: String,
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ApplyProfilesResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub changed: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub adapter_id: String,
|
||||||
|
pub generated_config_path: String,
|
||||||
|
pub enabled_profiles: usize,
|
||||||
|
pub routed_apps: usize,
|
||||||
|
pub helper: HelperApplyResult,
|
||||||
|
pub activity: ActivityEntryDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HelperApplyResult {
|
||||||
|
pub success: bool,
|
||||||
|
pub changed: bool,
|
||||||
|
pub action: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HelperApplyRequest<'a> {
|
||||||
|
pub adapter_id: &'a str,
|
||||||
|
pub config_path: &'a Path,
|
||||||
|
pub config_contents: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ProxyApplyHelper {
|
||||||
|
fn apply_proxy_config(
|
||||||
|
&self,
|
||||||
|
request: HelperApplyRequest<'_>,
|
||||||
|
) -> Result<HelperApplyResult, CommandError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Clock {
|
||||||
|
fn now(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SystemClock;
|
||||||
|
|
||||||
|
impl Clock for SystemClock {
|
||||||
|
fn now(&self) -> String {
|
||||||
|
let seconds = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
format!("unix:{seconds}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H> DetectedProxyApplyHelper<H> {
|
||||||
|
pub fn new(host: H) -> Self {
|
||||||
|
Self { host }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H> ProxyApplyHelper for DetectedProxyApplyHelper<H>
|
||||||
|
where
|
||||||
|
H: ProxyfierDetectionHost,
|
||||||
|
{
|
||||||
|
fn apply_proxy_config(
|
||||||
|
&self,
|
||||||
|
request: HelperApplyRequest<'_>,
|
||||||
|
) -> Result<HelperApplyResult, CommandError> {
|
||||||
|
let Some(detected) = detect_proxyfier_install_with_host(&self.host) else {
|
||||||
|
return staged_apply_result(request);
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_to_detected_proxyfier(request, &detected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_status(state: tauri::State<'_, CommandState>) -> Result<StatusResponse, CommandError> {
|
||||||
|
build_status(&state.storage())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_profiles(
|
||||||
|
state: tauri::State<'_, CommandState>,
|
||||||
|
) -> Result<Vec<ProfileDto>, CommandError> {
|
||||||
|
read_profiles(&state.storage())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_profile(
|
||||||
|
state: tauri::State<'_, CommandState>,
|
||||||
|
input: ProfileInputDto,
|
||||||
|
) -> Result<ProfileDto, CommandError> {
|
||||||
|
save_profile_to_storage(&state.storage(), input)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_targets(state: tauri::State<'_, CommandState>) -> Result<Vec<TargetDto>, CommandError> {
|
||||||
|
read_targets(&state.storage())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_target(
|
||||||
|
state: tauri::State<'_, CommandState>,
|
||||||
|
input: TargetInputDto,
|
||||||
|
) -> Result<TargetDto, CommandError> {
|
||||||
|
save_target_to_storage(&state.storage(), input)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_components(
|
||||||
|
state: tauri::State<'_, CommandState>,
|
||||||
|
) -> Result<Vec<ComponentStatusDto>, CommandError> {
|
||||||
|
read_components(&state.storage())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn resolve_profile_preview(
|
||||||
|
input: ProfileInputDto,
|
||||||
|
) -> Result<ResolveProfilePreviewResponse, CommandError> {
|
||||||
|
resolve_preview(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn apply_profiles(
|
||||||
|
state: tauri::State<'_, CommandState>,
|
||||||
|
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||||
|
let storage = state.storage();
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let helper = DetectedProxyApplyHelper::system();
|
||||||
|
let clock = SystemClock;
|
||||||
|
|
||||||
|
apply_profiles_with_services(&storage, &adapter, &helper, &clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_logs(
|
||||||
|
state: tauri::State<'_, CommandState>,
|
||||||
|
) -> Result<Vec<ActivityEntryDto>, CommandError> {
|
||||||
|
read_activity(&state.storage())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_config_location(state: tauri::State<'_, CommandState>) -> Result<String, CommandError> {
|
||||||
|
let storage = state.storage();
|
||||||
|
let generated_path = storage.paths().generated_dir.join("proxifyre-app-config.json");
|
||||||
|
let config_path = detect_proxyfier_install()
|
||||||
|
.and_then(|detected| detected.config_path)
|
||||||
|
.filter(|path| path.exists())
|
||||||
|
.or_else(|| generated_path.exists().then_some(generated_path.clone()));
|
||||||
|
|
||||||
|
let Some(config_path) = config_path else {
|
||||||
|
return open_folder(&storage.paths().generated_dir);
|
||||||
|
};
|
||||||
|
|
||||||
|
open_file_or_select(&config_path)?;
|
||||||
|
Ok(config_path.display().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandError> {
|
||||||
|
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||||
|
let targets = storage.read_targets().map_err(storage_error)?;
|
||||||
|
let components = components_or_defaults(storage)?;
|
||||||
|
let activity = storage.read_activity().map_err(storage_error)?;
|
||||||
|
let active_profile_count = profiles.iter().filter(|profile| profile.enabled).count();
|
||||||
|
let routed_app_count = profiles
|
||||||
|
.iter()
|
||||||
|
.filter(|profile| profile.enabled)
|
||||||
|
.map(|profile| profile.items.len())
|
||||||
|
.sum();
|
||||||
|
let active_target = profiles
|
||||||
|
.iter()
|
||||||
|
.find(|profile| profile.enabled)
|
||||||
|
.and_then(|profile| targets.iter().find(|target| target.id == profile.target_id));
|
||||||
|
let route_line = route_line(active_target);
|
||||||
|
|
||||||
|
Ok(StatusResponse {
|
||||||
|
route_line,
|
||||||
|
active_profile_count,
|
||||||
|
routed_app_count,
|
||||||
|
active_target: active_target.map(TargetDto::from),
|
||||||
|
components: components.iter().map(ComponentStatusDto::from).collect(),
|
||||||
|
recent_activity: activity.iter().take(10).map(ActivityEntryDto::from).collect(),
|
||||||
|
generated_config_path: storage
|
||||||
|
.paths()
|
||||||
|
.generated_dir
|
||||||
|
.join("proxifyre-app-config.json")
|
||||||
|
.display()
|
||||||
|
.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_profiles(storage: &JsonStorage) -> Result<Vec<ProfileDto>, CommandError> {
|
||||||
|
storage
|
||||||
|
.read_profiles()
|
||||||
|
.map_err(storage_error)
|
||||||
|
.map(|profiles| profiles.iter().map(ProfileDto::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_profile_to_storage(
|
||||||
|
storage: &JsonStorage,
|
||||||
|
input: ProfileInputDto,
|
||||||
|
) -> Result<ProfileDto, CommandError> {
|
||||||
|
let profile = normalize_profile(input.into()).map_err(validation_error)?;
|
||||||
|
let mut profiles = storage.read_profiles().map_err(storage_error)?;
|
||||||
|
|
||||||
|
match profiles
|
||||||
|
.iter()
|
||||||
|
.position(|existing| existing.id == profile.id)
|
||||||
|
{
|
||||||
|
Some(index) => profiles[index] = profile.clone(),
|
||||||
|
None => profiles.push(profile.clone()),
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.write_profiles(&profiles).map_err(storage_error)?;
|
||||||
|
Ok(ProfileDto::from(&profile))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_targets(storage: &JsonStorage) -> Result<Vec<TargetDto>, CommandError> {
|
||||||
|
storage
|
||||||
|
.read_targets()
|
||||||
|
.map_err(storage_error)
|
||||||
|
.map(|targets| targets.iter().map(TargetDto::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_target_to_storage(
|
||||||
|
storage: &JsonStorage,
|
||||||
|
input: TargetInputDto,
|
||||||
|
) -> Result<TargetDto, CommandError> {
|
||||||
|
let target = normalize_target(input.into()).map_err(validation_error)?;
|
||||||
|
let mut targets = storage.read_targets().map_err(storage_error)?;
|
||||||
|
|
||||||
|
match targets.iter().position(|existing| existing.id == target.id) {
|
||||||
|
Some(index) => targets[index] = target.clone(),
|
||||||
|
None => targets.push(target.clone()),
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.write_targets(&targets).map_err(storage_error)?;
|
||||||
|
Ok(TargetDto::from(&target))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_components(storage: &JsonStorage) -> Result<Vec<ComponentStatusDto>, CommandError> {
|
||||||
|
components_or_defaults(storage).map(|components| {
|
||||||
|
components
|
||||||
|
.iter()
|
||||||
|
.map(ComponentStatusDto::from)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_activity(storage: &JsonStorage) -> Result<Vec<ActivityEntryDto>, CommandError> {
|
||||||
|
storage
|
||||||
|
.read_activity()
|
||||||
|
.map_err(storage_error)
|
||||||
|
.map(|entries| entries.iter().map(ActivityEntryDto::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_preview(
|
||||||
|
input: ProfileInputDto,
|
||||||
|
) -> Result<ResolveProfilePreviewResponse, CommandError> {
|
||||||
|
let profile = normalize_profile(input.into()).map_err(validation_error)?;
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
let apps = profile
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| resolved_app(item, &mut warnings))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(ResolveProfilePreviewResponse {
|
||||||
|
profile_id: profile.id,
|
||||||
|
apps,
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_profiles_with_services(
|
||||||
|
storage: &JsonStorage,
|
||||||
|
adapter: &impl ProxyRouterAdapter,
|
||||||
|
helper: &impl ProxyApplyHelper,
|
||||||
|
clock: &impl Clock,
|
||||||
|
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||||
|
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||||
|
let targets = storage.read_targets().map_err(storage_error)?;
|
||||||
|
let components = components_or_defaults(storage)?;
|
||||||
|
let generated =
|
||||||
|
match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) {
|
||||||
|
Ok(generated) => generated,
|
||||||
|
Err(error) => {
|
||||||
|
let command_error = adapter_error(error);
|
||||||
|
let activity = activity_for_apply_error(clock, &command_error);
|
||||||
|
storage.append_activity(activity).map_err(storage_error)?;
|
||||||
|
return Err(command_error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let generated_path = storage
|
||||||
|
.paths()
|
||||||
|
.generated_dir
|
||||||
|
.join(generated.output_file_name.as_str());
|
||||||
|
write_generated_config(&generated_path, &generated.contents)?;
|
||||||
|
|
||||||
|
let helper_result = helper.apply_proxy_config(HelperApplyRequest {
|
||||||
|
adapter_id: generated.adapter_id.as_str(),
|
||||||
|
config_path: &generated_path,
|
||||||
|
config_contents: generated.contents.as_str(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result);
|
||||||
|
storage
|
||||||
|
.append_activity(activity.clone())
|
||||||
|
.map_err(storage_error)?;
|
||||||
|
|
||||||
|
Ok(ApplyProfilesResponse {
|
||||||
|
success: helper_result.success,
|
||||||
|
changed: helper_result.changed,
|
||||||
|
message: helper_result.message.clone(),
|
||||||
|
adapter_id: generated.adapter_id,
|
||||||
|
generated_config_path: generated_path.display().to_string(),
|
||||||
|
enabled_profiles: generated.enabled_profiles,
|
||||||
|
routed_apps: generated.routed_apps,
|
||||||
|
helper: helper_result,
|
||||||
|
activity: ActivityEntryDto::from(&activity),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn components_or_defaults(storage: &JsonStorage) -> Result<Vec<ComponentStatus>, CommandError> {
|
||||||
|
let components = storage.read_components().map_err(storage_error)?;
|
||||||
|
Ok(resolve_component_statuses(
|
||||||
|
components,
|
||||||
|
detect_proxyfier_install(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_component_statuses(
|
||||||
|
stored_components: Vec<ComponentStatus>,
|
||||||
|
detected_proxyfier: Option<DetectedProxyfier>,
|
||||||
|
) -> Vec<ComponentStatus> {
|
||||||
|
let mut components = default_components();
|
||||||
|
|
||||||
|
for component in stored_components {
|
||||||
|
upsert_component(&mut components, component);
|
||||||
|
}
|
||||||
|
|
||||||
|
if detected_proxyfier.is_some() {
|
||||||
|
upsert_component(
|
||||||
|
&mut components,
|
||||||
|
proxyfier_component_from_detection(detected_proxyfier.as_ref()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
components
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_components() -> Vec<ComponentStatus> {
|
||||||
|
vec![
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::ControlApp,
|
||||||
|
name: "Приложение управления".to_string(),
|
||||||
|
state: ComponentState::Running,
|
||||||
|
installed: true,
|
||||||
|
running: true,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: Vec::new(),
|
||||||
|
actions: vec!["Открыть журнал".to_string(), "Скопировать диагностику".to_string()],
|
||||||
|
},
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Proxyfier,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
state: ComponentState::Missing,
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()],
|
||||||
|
actions: vec!["Установить ProxiFyre".to_string()],
|
||||||
|
},
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Локальный sing-box".to_string(),
|
||||||
|
state: ComponentState::Missing,
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: Vec::new(),
|
||||||
|
actions: vec!["Установить локальный sing-box".to_string()],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_component(components: &mut Vec<ComponentStatus>, component: ComponentStatus) {
|
||||||
|
match components
|
||||||
|
.iter()
|
||||||
|
.position(|existing| existing.id == component.id)
|
||||||
|
{
|
||||||
|
Some(index) => components[index] = component,
|
||||||
|
None => components.push(component),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route_line(active_target: Option<&Target>) -> String {
|
||||||
|
match active_target {
|
||||||
|
Some(target) if target.id == "local-singbox" => {
|
||||||
|
format!(
|
||||||
|
"Выбранные приложения -> ProxiFyre -> локальный sing-box {}:{} -> VPN",
|
||||||
|
target.host, target.port
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Some(target) => format!(
|
||||||
|
"Выбранные приложения -> ProxiFyre -> внешний прокси {}:{}",
|
||||||
|
target.host, target.port
|
||||||
|
),
|
||||||
|
None => "Выбранные приложения -> ProxiFyre -> внешний прокси".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved_app(item: &ProfileItem, warnings: &mut Vec<String>) -> ResolvedAppDto {
|
||||||
|
let mut notes = Vec::new();
|
||||||
|
match item.item_type {
|
||||||
|
ProfileItemType::Process => notes.push("Имя процесса используется напрямую".to_string()),
|
||||||
|
ProfileItemType::Folder => {
|
||||||
|
let note = "Сканирование папок отложено; ProxiFyre получает путь к папке";
|
||||||
|
notes.push(note.to_string());
|
||||||
|
warnings.push(note.to_string());
|
||||||
|
}
|
||||||
|
ProfileItemType::Exe => {
|
||||||
|
notes.push("Путь к EXE сохраняется для сопоставления в ProxiFyre".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ResolvedAppDto {
|
||||||
|
source_type: item.item_type.clone(),
|
||||||
|
source_value: item.value.clone(),
|
||||||
|
app_name: item.value.clone(),
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(storage_error)?;
|
||||||
|
}
|
||||||
|
fs::write(path, contents).map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_file_or_select(path: &Path) -> Result<(), CommandError> {
|
||||||
|
let status = Command::new("notepad.exe")
|
||||||
|
.arg(path)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|error| {
|
||||||
|
CommandError::new(
|
||||||
|
"open_config_failed",
|
||||||
|
format!("Не удалось открыть конфиг '{}': {error}", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
drop(status);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_folder(path: &Path) -> Result<String, CommandError> {
|
||||||
|
fs::create_dir_all(path).map_err(storage_error)?;
|
||||||
|
Command::new("explorer.exe")
|
||||||
|
.arg(path)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|error| {
|
||||||
|
CommandError::new(
|
||||||
|
"open_config_failed",
|
||||||
|
format!("Не удалось открыть папку '{}': {error}", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(path.display().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_to_detected_proxyfier(
|
||||||
|
request: HelperApplyRequest<'_>,
|
||||||
|
detected: &DetectedProxyfier,
|
||||||
|
) -> Result<HelperApplyResult, CommandError> {
|
||||||
|
let Some(config_path) = &detected.config_path else {
|
||||||
|
return staged_apply_result(request);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|error| {
|
||||||
|
CommandError::new(
|
||||||
|
"proxyfier_apply_failed",
|
||||||
|
format!(
|
||||||
|
"Не удалось создать папку конфига ProxiFyre '{}': {error}",
|
||||||
|
parent.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if config_path.exists() {
|
||||||
|
let backup_path = config_path.with_file_name(format!(
|
||||||
|
"{}.bak",
|
||||||
|
config_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("app-config.json")
|
||||||
|
));
|
||||||
|
fs::copy(config_path, backup_path).map_err(|error| {
|
||||||
|
CommandError::new(
|
||||||
|
"proxyfier_apply_failed",
|
||||||
|
format!(
|
||||||
|
"Не удалось создать backup текущего конфига ProxiFyre '{}': {error}",
|
||||||
|
config_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(config_path, request.config_contents).map_err(|error| {
|
||||||
|
CommandError::new(
|
||||||
|
"proxyfier_apply_failed",
|
||||||
|
format!(
|
||||||
|
"Не удалось записать конфиг ProxiFyre '{}': {error}",
|
||||||
|
config_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(HelperApplyResult {
|
||||||
|
success: true,
|
||||||
|
changed: true,
|
||||||
|
action: "proxifyre.apply-detected-config".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Сгенерированный конфиг записан в найденную установку ProxiFyre: {}",
|
||||||
|
config_path.display()
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result<HelperApplyResult, CommandError> {
|
||||||
|
Ok(HelperApplyResult {
|
||||||
|
success: true,
|
||||||
|
changed: true,
|
||||||
|
action: format!("{}.stage-generated-config", request.adapter_id),
|
||||||
|
message: format!(
|
||||||
|
"Сгенерированный конфиг подготовлен в {}; совместимая установка ProxiFyre не найдена",
|
||||||
|
request.config_path.display()
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activity_for_apply(
|
||||||
|
clock: &impl Clock,
|
||||||
|
generated: &ProxyRouterGeneratedConfig,
|
||||||
|
generated_path: &Path,
|
||||||
|
helper_result: &HelperApplyResult,
|
||||||
|
) -> ActivityEntry {
|
||||||
|
let level = if helper_result.success {
|
||||||
|
ActivityLevel::Success
|
||||||
|
} else {
|
||||||
|
ActivityLevel::Error
|
||||||
|
};
|
||||||
|
|
||||||
|
ActivityEntry {
|
||||||
|
id: format!("apply-{}", generated.adapter_id),
|
||||||
|
at: clock.now(),
|
||||||
|
level,
|
||||||
|
title: "Конфиг ProxiFyre создан".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Профилей: {}, приложений: {}, конфиг: {}",
|
||||||
|
generated.enabled_profiles,
|
||||||
|
generated.routed_apps,
|
||||||
|
generated_path.display()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activity_for_apply_error(clock: &impl Clock, error: &CommandError) -> ActivityEntry {
|
||||||
|
ActivityEntry {
|
||||||
|
id: format!("apply-error-{}", error.code),
|
||||||
|
at: clock.now(),
|
||||||
|
level: ActivityLevel::Error,
|
||||||
|
title: "Применение ProxiFyre заблокировано".to_string(),
|
||||||
|
message: error.message.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn storage_error(error: std::io::Error) -> CommandError {
|
||||||
|
CommandError::new("storage_error", error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validation_error(errors: Vec<ValidationError>) -> CommandError {
|
||||||
|
CommandError::with_details(
|
||||||
|
"validation_error",
|
||||||
|
"Проверка введенных данных не прошла",
|
||||||
|
errors
|
||||||
|
.into_iter()
|
||||||
|
.map(|error| ValidationIssue {
|
||||||
|
field: error.field,
|
||||||
|
message: error.message,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adapter_error(error: ProxyRouterError) -> CommandError {
|
||||||
|
let code = match error.kind {
|
||||||
|
ProxyRouterErrorKind::EmptyProfileItems => "empty_profile_items",
|
||||||
|
ProxyRouterErrorKind::MissingTarget => "missing_target",
|
||||||
|
ProxyRouterErrorKind::MissingRequiredComponent => "missing_required_component",
|
||||||
|
ProxyRouterErrorKind::RequiredComponentNotRunning => "required_component_not_running",
|
||||||
|
ProxyRouterErrorKind::UnsupportedTargetProtocol => "unsupported_target_protocol",
|
||||||
|
ProxyRouterErrorKind::Serialization => "serialization_error",
|
||||||
|
};
|
||||||
|
|
||||||
|
CommandError::new(code, error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ProfileInputDto> for ProfileInput {
|
||||||
|
fn from(input: ProfileInputDto) -> Self {
|
||||||
|
Self {
|
||||||
|
id: input.id,
|
||||||
|
name: input.name,
|
||||||
|
enabled: input.enabled.unwrap_or(true),
|
||||||
|
target_id: input
|
||||||
|
.target_id
|
||||||
|
.unwrap_or_else(|| "local-singbox".to_string()),
|
||||||
|
protocols: input
|
||||||
|
.protocols
|
||||||
|
.unwrap_or_else(|| vec!["TCP".to_string(), "UDP".to_string()]),
|
||||||
|
items: input
|
||||||
|
.items
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(ProfileItemInput::from)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ProfileItemInputDto> for ProfileItemInput {
|
||||||
|
fn from(input: ProfileItemInputDto) -> Self {
|
||||||
|
Self {
|
||||||
|
item_type: input.item_type,
|
||||||
|
value: input.value,
|
||||||
|
recursive: input.recursive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TargetInputDto> for TargetInput {
|
||||||
|
fn from(input: TargetInputDto) -> Self {
|
||||||
|
Self {
|
||||||
|
id: input.id,
|
||||||
|
name: input.name,
|
||||||
|
kind: input.kind.unwrap_or_else(|| "external".to_string()),
|
||||||
|
protocol: input.protocol.unwrap_or_else(|| "socks5".to_string()),
|
||||||
|
host: input.host,
|
||||||
|
port: input.port,
|
||||||
|
requires_component: input.requires_component,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Profile> for ProfileDto {
|
||||||
|
fn from(profile: &Profile) -> Self {
|
||||||
|
Self {
|
||||||
|
id: profile.id.clone(),
|
||||||
|
name: profile.name.clone(),
|
||||||
|
enabled: profile.enabled,
|
||||||
|
target_id: profile.target_id.clone(),
|
||||||
|
protocols: profile.protocols.clone(),
|
||||||
|
items: profile.items.iter().map(ProfileItemDto::from).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ProfileItem> for ProfileItemDto {
|
||||||
|
fn from(item: &ProfileItem) -> Self {
|
||||||
|
Self {
|
||||||
|
item_type: item.item_type.clone(),
|
||||||
|
value: item.value.clone(),
|
||||||
|
recursive: item.recursive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Target> for TargetDto {
|
||||||
|
fn from(target: &Target) -> Self {
|
||||||
|
Self {
|
||||||
|
id: target.id.clone(),
|
||||||
|
name: target.name.clone(),
|
||||||
|
kind: target.kind.clone(),
|
||||||
|
protocol: target.protocol.clone(),
|
||||||
|
host: target.host.clone(),
|
||||||
|
port: target.port,
|
||||||
|
requires_component: target.requires_component.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ComponentStatus> for ComponentStatusDto {
|
||||||
|
fn from(component: &ComponentStatus) -> Self {
|
||||||
|
Self {
|
||||||
|
id: component.id.clone(),
|
||||||
|
name: component.name.clone(),
|
||||||
|
state: component.state.clone(),
|
||||||
|
installed: component.installed,
|
||||||
|
running: component.running,
|
||||||
|
version: component.version.clone(),
|
||||||
|
path: component.path.clone(),
|
||||||
|
problems: component.problems.clone(),
|
||||||
|
actions: component.actions.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ActivityEntry> for ActivityEntryDto {
|
||||||
|
fn from(entry: &ActivityEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
id: entry.id.clone(),
|
||||||
|
at: entry.at.clone(),
|
||||||
|
level: entry.level.clone(),
|
||||||
|
title: entry.title.clone(),
|
||||||
|
message: entry.message.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
414
apps/windows-client/src-tauri/src/component_detection.rs
Normal file
414
apps/windows-client/src-tauri/src/component_detection.rs
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
use crate::models::{ComponentId, ComponentState, ComponentStatus};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Command,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ProxyfierEngine {
|
||||||
|
ProxiFyre,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DetectedProxyfier {
|
||||||
|
pub engine: ProxyfierEngine,
|
||||||
|
pub name: String,
|
||||||
|
pub install_dir: PathBuf,
|
||||||
|
pub executable_path: PathBuf,
|
||||||
|
pub config_path: Option<PathBuf>,
|
||||||
|
pub running: bool,
|
||||||
|
pub service_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RegistryInstallEntry {
|
||||||
|
pub display_name: String,
|
||||||
|
pub install_location: Option<PathBuf>,
|
||||||
|
pub display_icon: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ProxyfierDetectionHost {
|
||||||
|
fn env_var(&self, name: &str) -> Option<String>;
|
||||||
|
|
||||||
|
fn path_exists(&self, path: &Path) -> bool;
|
||||||
|
|
||||||
|
fn process_running(&self, process_name: &str) -> bool;
|
||||||
|
|
||||||
|
fn service_running(&self, service_name: &str) -> bool;
|
||||||
|
|
||||||
|
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct SystemProxyfierDetectionHost;
|
||||||
|
|
||||||
|
impl ProxyfierDetectionHost for SystemProxyfierDetectionHost {
|
||||||
|
fn env_var(&self, name: &str) -> Option<String> {
|
||||||
|
env::var(name).ok().filter(|value| !value.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_exists(&self, path: &Path) -> bool {
|
||||||
|
path.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_running(&self, process_name: &str) -> bool {
|
||||||
|
let process_name = process_name.trim_end_matches(".exe");
|
||||||
|
let script = format!(
|
||||||
|
"if (Get-Process -Name '{}' -ErrorAction SilentlyContinue) {{ 'true' }} else {{ 'false' }}",
|
||||||
|
escape_powershell_single(process_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
powershell_bool(&script)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_running(&self, service_name: &str) -> bool {
|
||||||
|
let script = format!(
|
||||||
|
"$s = Get-Service -Name '{}' -ErrorAction SilentlyContinue; if ($s -and $s.Status -eq 'Running') {{ 'true' }} else {{ 'false' }}",
|
||||||
|
escape_powershell_single(service_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
powershell_bool(&script)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||||
|
read_registry_install_entries()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_proxyfier_install() -> Option<DetectedProxyfier> {
|
||||||
|
detect_proxyfier_install_with_host(&SystemProxyfierDetectionHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_proxyfier_install_with_host(
|
||||||
|
host: &impl ProxyfierDetectionHost,
|
||||||
|
) -> Option<DetectedProxyfier> {
|
||||||
|
let proxifyre_running = host.process_running("ProxiFyre.exe")
|
||||||
|
|| host.service_running("ProxiFyreService")
|
||||||
|
|| host.service_running("ProxiFyre");
|
||||||
|
|
||||||
|
proxyfier_candidates(host)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|candidate| candidate.into_detected(host, proxifyre_running))
|
||||||
|
.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn proxyfier_component_from_detection(
|
||||||
|
detected: Option<&DetectedProxyfier>,
|
||||||
|
) -> ComponentStatus {
|
||||||
|
match detected {
|
||||||
|
Some(proxyfier) => detected_proxyfier_component(proxyfier),
|
||||||
|
None => missing_proxyfier_component(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatus {
|
||||||
|
let state = if proxyfier.running {
|
||||||
|
ComponentState::Running
|
||||||
|
} else {
|
||||||
|
ComponentState::Installed
|
||||||
|
};
|
||||||
|
let actions = match proxyfier.engine {
|
||||||
|
ProxyfierEngine::ProxiFyre => {
|
||||||
|
if proxyfier.running {
|
||||||
|
vec![
|
||||||
|
"Применить сгенерированный конфиг".to_string(),
|
||||||
|
"Открыть папку конфига".to_string(),
|
||||||
|
"Перезапустить".to_string(),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
"Применить сгенерированный конфиг".to_string(),
|
||||||
|
"Открыть папку конфига".to_string(),
|
||||||
|
"Запустить".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Proxyfier,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
state,
|
||||||
|
installed: true,
|
||||||
|
running: proxyfier.running,
|
||||||
|
version: Some(match proxyfier.engine {
|
||||||
|
ProxyfierEngine::ProxiFyre => "ProxiFyre найден".to_string(),
|
||||||
|
}),
|
||||||
|
path: Some(proxyfier.install_dir.display().to_string()),
|
||||||
|
problems: Vec::new(),
|
||||||
|
actions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_proxyfier_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Proxyfier,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
state: ComponentState::Missing,
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()],
|
||||||
|
actions: vec!["Установить ProxiFyre".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct ProxyfierCandidate {
|
||||||
|
engine: ProxyfierEngine,
|
||||||
|
name: String,
|
||||||
|
install_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyfierCandidate {
|
||||||
|
fn into_detected(
|
||||||
|
self,
|
||||||
|
host: &impl ProxyfierDetectionHost,
|
||||||
|
proxifyre_running: bool,
|
||||||
|
) -> Option<DetectedProxyfier> {
|
||||||
|
let executable_path = self.install_dir.join(executable_name(&self.engine));
|
||||||
|
let config_path = config_path(&self.engine, &self.install_dir);
|
||||||
|
let exists = host.path_exists(&self.install_dir)
|
||||||
|
|| host.path_exists(&executable_path)
|
||||||
|
|| config_path
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|path| host.path_exists(path));
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(DetectedProxyfier {
|
||||||
|
service_name: service_name(&self.engine).map(str::to_string),
|
||||||
|
engine: self.engine,
|
||||||
|
name: self.name,
|
||||||
|
install_dir: self.install_dir,
|
||||||
|
executable_path,
|
||||||
|
config_path,
|
||||||
|
running: proxifyre_running,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxyfier_candidates(host: &impl ProxyfierDetectionHost) -> Vec<ProxyfierCandidate> {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
|
||||||
|
push_env_candidate(
|
||||||
|
&mut candidates,
|
||||||
|
host,
|
||||||
|
ProxyfierEngine::ProxiFyre,
|
||||||
|
"ProxiFyre",
|
||||||
|
"VPN_PROXY_PROXIFYRE_ROOT",
|
||||||
|
);
|
||||||
|
for entry in host.registry_install_entries() {
|
||||||
|
if let Some(engine) = engine_from_name(&entry.display_name) {
|
||||||
|
let install_dir = entry
|
||||||
|
.install_location
|
||||||
|
.or_else(|| entry.display_icon.and_then(|path| executable_parent(&path)));
|
||||||
|
if let Some(install_dir) = install_dir {
|
||||||
|
push_candidate(
|
||||||
|
&mut candidates,
|
||||||
|
ProxyfierCandidate {
|
||||||
|
name: entry.display_name,
|
||||||
|
engine,
|
||||||
|
install_dir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for install_dir in common_install_dirs(host, "ProxiFyre") {
|
||||||
|
push_candidate(
|
||||||
|
&mut candidates,
|
||||||
|
ProxyfierCandidate {
|
||||||
|
engine: ProxyfierEngine::ProxiFyre,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
install_dir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_env_candidate(
|
||||||
|
candidates: &mut Vec<ProxyfierCandidate>,
|
||||||
|
host: &impl ProxyfierDetectionHost,
|
||||||
|
engine: ProxyfierEngine,
|
||||||
|
name: &str,
|
||||||
|
env_name: &str,
|
||||||
|
) {
|
||||||
|
if let Some(path) = host.env_var(env_name) {
|
||||||
|
push_candidate(
|
||||||
|
candidates,
|
||||||
|
ProxyfierCandidate {
|
||||||
|
engine,
|
||||||
|
name: name.to_string(),
|
||||||
|
install_dir: PathBuf::from(path),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_candidate(candidates: &mut Vec<ProxyfierCandidate>, candidate: ProxyfierCandidate) {
|
||||||
|
if !candidates.iter().any(|existing| {
|
||||||
|
existing.engine == candidate.engine && same_path(&existing.install_dir, &candidate.install_dir)
|
||||||
|
}) {
|
||||||
|
candidates.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) -> Vec<PathBuf> {
|
||||||
|
let mut dirs = vec![PathBuf::from(format!(r"C:\Tools\{folder_name}"))];
|
||||||
|
|
||||||
|
for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
|
||||||
|
if let Some(root) = host.env_var(env_name) {
|
||||||
|
dirs.push(PathBuf::from(root).join(folder_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn executable_name(engine: &ProxyfierEngine) -> &'static str {
|
||||||
|
match engine {
|
||||||
|
ProxyfierEngine::ProxiFyre => "ProxiFyre.exe",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path(engine: &ProxyfierEngine, install_dir: &Path) -> Option<PathBuf> {
|
||||||
|
match engine {
|
||||||
|
ProxyfierEngine::ProxiFyre => Some(install_dir.join("app-config.json")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> {
|
||||||
|
match engine {
|
||||||
|
ProxyfierEngine::ProxiFyre => Some("ProxiFyreService"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn engine_from_name(name: &str) -> Option<ProxyfierEngine> {
|
||||||
|
let normalized = name.to_ascii_lowercase();
|
||||||
|
if normalized.contains("proxifyre") {
|
||||||
|
Some(ProxyfierEngine::ProxiFyre)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn executable_parent(path: &Path) -> Option<PathBuf> {
|
||||||
|
path.parent().map(Path::to_path_buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_path(left: &Path, right: &Path) -> bool {
|
||||||
|
left.to_string_lossy()
|
||||||
|
.eq_ignore_ascii_case(&right.to_string_lossy())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn powershell_bool(script: &str) -> bool {
|
||||||
|
Command::new("powershell")
|
||||||
|
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|output| String::from_utf8(output.stdout).ok())
|
||||||
|
.is_some_and(|stdout| stdout.trim().eq_ignore_ascii_case("true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct RegistryInstallJson {
|
||||||
|
display_name: Option<String>,
|
||||||
|
install_location: Option<String>,
|
||||||
|
display_icon: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_registry_install_entries() -> Vec<RegistryInstallEntry> {
|
||||||
|
let script = r#"
|
||||||
|
$paths = @(
|
||||||
|
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||||
|
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||||
|
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||||||
|
)
|
||||||
|
$items = foreach ($path in $paths) {
|
||||||
|
Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
$items |
|
||||||
|
Where-Object { $_.DisplayName -match 'ProxiFyre' } |
|
||||||
|
Select-Object DisplayName,InstallLocation,DisplayIcon |
|
||||||
|
ConvertTo-Json -Compress
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let Ok(output) = Command::new("powershell")
|
||||||
|
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(stdout) = String::from_utf8(output.stdout) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let stdout = stdout.trim();
|
||||||
|
if stdout.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_registry_json(stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_registry_json(json: &str) -> Vec<RegistryInstallEntry> {
|
||||||
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Array(entries) => entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(registry_entry_from_value)
|
||||||
|
.collect(),
|
||||||
|
entry => registry_entry_from_value(entry).into_iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_entry_from_value(value: serde_json::Value) -> Option<RegistryInstallEntry> {
|
||||||
|
let parsed = serde_json::from_value::<RegistryInstallJson>(value).ok()?;
|
||||||
|
let display_name = parsed.display_name?;
|
||||||
|
Some(RegistryInstallEntry {
|
||||||
|
display_name,
|
||||||
|
install_location: parsed
|
||||||
|
.install_location
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(PathBuf::from),
|
||||||
|
display_icon: parsed
|
||||||
|
.display_icon
|
||||||
|
.and_then(|value| display_icon_path(&value)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_icon_path(value: &str) -> Option<PathBuf> {
|
||||||
|
let trimmed = value.trim().trim_matches('"');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let without_icon_index = trimmed
|
||||||
|
.split_once(',')
|
||||||
|
.map(|(path, _)| path)
|
||||||
|
.unwrap_or(trimmed)
|
||||||
|
.trim()
|
||||||
|
.trim_matches('"');
|
||||||
|
|
||||||
|
Some(PathBuf::from(without_icon_index))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_powershell_single(value: &str) -> String {
|
||||||
|
value.replace('\'', "''")
|
||||||
|
}
|
||||||
184
apps/windows-client/src-tauri/src/helper.rs
Normal file
184
apps/windows-client/src-tauri/src/helper.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
use crate::models::ComponentId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum HelperAction {
|
||||||
|
#[serde(rename = "install-control-app")]
|
||||||
|
InstallControlApp,
|
||||||
|
#[serde(rename = "install-proxyfier")]
|
||||||
|
InstallProxyfier,
|
||||||
|
#[serde(rename = "install-singbox")]
|
||||||
|
InstallSingbox,
|
||||||
|
#[serde(rename = "proxyfier.apply")]
|
||||||
|
ProxyfierApply,
|
||||||
|
#[serde(rename = "service.status")]
|
||||||
|
ServiceStatus,
|
||||||
|
#[serde(rename = "service.start")]
|
||||||
|
ServiceStart,
|
||||||
|
#[serde(rename = "service.stop")]
|
||||||
|
ServiceStop,
|
||||||
|
#[serde(rename = "service.restart")]
|
||||||
|
ServiceRestart,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HelperRequest {
|
||||||
|
pub action: HelperAction,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub component: Option<ComponentId>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub payload: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HelperResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub action: HelperAction,
|
||||||
|
pub changed: bool,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub details: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HelperCommandSpec {
|
||||||
|
pub program: PathBuf,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub stdin: String,
|
||||||
|
pub requires_elevation: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HelperCommandOutput {
|
||||||
|
pub status_code: i32,
|
||||||
|
pub stdout: String,
|
||||||
|
pub stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HelperError {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HelperError {
|
||||||
|
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait HelperCommandRunner {
|
||||||
|
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StructuredHelper<R> {
|
||||||
|
helper_program: PathBuf,
|
||||||
|
runner: R,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R> StructuredHelper<R>
|
||||||
|
where
|
||||||
|
R: HelperCommandRunner,
|
||||||
|
{
|
||||||
|
pub fn new(helper_program: impl Into<PathBuf>, runner: R) -> Self {
|
||||||
|
Self {
|
||||||
|
helper_program: helper_program.into(),
|
||||||
|
runner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runner(&self) -> &R {
|
||||||
|
&self.runner
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute(&self, request: &HelperRequest) -> Result<HelperResponse, HelperError> {
|
||||||
|
let stdin = serde_json::to_string(request)
|
||||||
|
.map_err(|error| HelperError::new("helper_request_encode", error.to_string()))?;
|
||||||
|
let spec = HelperCommandSpec {
|
||||||
|
program: self.helper_program.clone(),
|
||||||
|
args: vec!["--json".to_string()],
|
||||||
|
stdin,
|
||||||
|
requires_elevation: helper_action_requires_elevation(&request.action),
|
||||||
|
};
|
||||||
|
let output = self.runner.run(&spec)?;
|
||||||
|
|
||||||
|
if output.status_code != 0 {
|
||||||
|
return Err(HelperError::new(
|
||||||
|
"helper_exit",
|
||||||
|
format!(
|
||||||
|
"Помощник завершился с кодом {}: {}",
|
||||||
|
output.status_code, output.stderr
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_helper_response(&output.stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_helper_response(stdout: &str) -> Result<HelperResponse, HelperError> {
|
||||||
|
serde_json::from_str(stdout).map_err(|error| {
|
||||||
|
HelperError::new(
|
||||||
|
"helper_response_decode",
|
||||||
|
format!("Помощник вернул не JSON или некорректный JSON: {error}"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install_request(component: ComponentId) -> HelperRequest {
|
||||||
|
let action = match component {
|
||||||
|
ComponentId::ControlApp => HelperAction::InstallControlApp,
|
||||||
|
ComponentId::Proxyfier => HelperAction::InstallProxyfier,
|
||||||
|
ComponentId::Singbox => HelperAction::InstallSingbox,
|
||||||
|
};
|
||||||
|
|
||||||
|
HelperRequest {
|
||||||
|
action,
|
||||||
|
component: Some(component),
|
||||||
|
payload: json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn service_request(component: ComponentId, action: HelperAction) -> HelperRequest {
|
||||||
|
HelperRequest {
|
||||||
|
action,
|
||||||
|
component: Some(component),
|
||||||
|
payload: json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn proxifyre_apply_request(
|
||||||
|
config_path: impl AsRef<Path>,
|
||||||
|
service_name: impl Into<String>,
|
||||||
|
) -> HelperRequest {
|
||||||
|
HelperRequest {
|
||||||
|
action: HelperAction::ProxyfierApply,
|
||||||
|
component: Some(ComponentId::Proxyfier),
|
||||||
|
payload: json!({
|
||||||
|
"configPath": config_path.as_ref().display().to_string(),
|
||||||
|
"serviceName": service_name.into(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn helper_action_requires_elevation(action: &HelperAction) -> bool {
|
||||||
|
matches!(
|
||||||
|
action,
|
||||||
|
HelperAction::InstallControlApp
|
||||||
|
| HelperAction::InstallProxyfier
|
||||||
|
| HelperAction::InstallSingbox
|
||||||
|
| HelperAction::ProxyfierApply
|
||||||
|
| HelperAction::ServiceStart
|
||||||
|
| HelperAction::ServiceStop
|
||||||
|
| HelperAction::ServiceRestart
|
||||||
|
)
|
||||||
|
}
|
||||||
5
apps/windows-client/src-tauri/src/lib.rs
Normal file
5
apps/windows-client/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("не удалось запустить клиент VPN Proxy для Windows");
|
||||||
|
}
|
||||||
42
apps/windows-client/src-tauri/src/main.rs
Normal file
42
apps/windows-client/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod activity;
|
||||||
|
mod component_detection;
|
||||||
|
mod commands;
|
||||||
|
mod models;
|
||||||
|
mod storage;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
mod adapters {
|
||||||
|
pub mod proxifyre;
|
||||||
|
pub mod proxy_router;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod proxifyre {
|
||||||
|
pub use crate::adapters::proxifyre::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod proxy_router {
|
||||||
|
pub use crate::adapters::proxy_router::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.manage(commands::CommandState::default())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::get_status,
|
||||||
|
commands::get_profiles,
|
||||||
|
commands::save_profile,
|
||||||
|
commands::get_targets,
|
||||||
|
commands::save_target,
|
||||||
|
commands::get_components,
|
||||||
|
commands::resolve_profile_preview,
|
||||||
|
commands::apply_profiles,
|
||||||
|
commands::get_logs,
|
||||||
|
commands::open_config_location
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("не удалось запустить клиент VPN Proxy для Windows");
|
||||||
|
}
|
||||||
167
apps/windows-client/src-tauri/src/models.rs
Normal file
167
apps/windows-client/src-tauri/src/models.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum Protocol {
|
||||||
|
Tcp,
|
||||||
|
Udp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ProfileItemType {
|
||||||
|
Process,
|
||||||
|
Folder,
|
||||||
|
Exe,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum TargetKind {
|
||||||
|
Local,
|
||||||
|
External,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ProxyProtocol {
|
||||||
|
Socks5,
|
||||||
|
Http,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum ComponentId {
|
||||||
|
ControlApp,
|
||||||
|
Proxyfier,
|
||||||
|
Singbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ComponentState {
|
||||||
|
Installed,
|
||||||
|
Missing,
|
||||||
|
Stopped,
|
||||||
|
Running,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileItemInput {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub item_type: String,
|
||||||
|
pub value: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub recursive: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileInput {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default = "default_enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_target_id")]
|
||||||
|
pub target_id: String,
|
||||||
|
#[serde(default = "default_protocols")]
|
||||||
|
pub protocols: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub items: Vec<ProfileItemInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileItem {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub item_type: ProfileItemType,
|
||||||
|
pub value: String,
|
||||||
|
pub recursive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Profile {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub target_id: String,
|
||||||
|
pub protocols: Vec<Protocol>,
|
||||||
|
pub items: Vec<ProfileItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TargetInput {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default = "default_target_kind")]
|
||||||
|
pub kind: String,
|
||||||
|
#[serde(default = "default_proxy_protocol")]
|
||||||
|
pub protocol: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub requires_component: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Target {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: TargetKind,
|
||||||
|
pub protocol: ProxyProtocol,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub requires_component: Option<ComponentId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ComponentStatus {
|
||||||
|
pub id: ComponentId,
|
||||||
|
pub name: String,
|
||||||
|
pub state: ComponentState,
|
||||||
|
pub installed: bool,
|
||||||
|
pub running: bool,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub problems: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub actions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ActivityEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub at: String,
|
||||||
|
pub level: ActivityLevel,
|
||||||
|
pub title: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ActivityLevel {
|
||||||
|
Info,
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
Success,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_target_id() -> String {
|
||||||
|
"local-singbox".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_protocols() -> Vec<String> {
|
||||||
|
vec!["TCP".to_string(), "UDP".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_target_kind() -> String {
|
||||||
|
"external".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_proxy_protocol() -> String {
|
||||||
|
"socks5".to_string()
|
||||||
|
}
|
||||||
187
apps/windows-client/src-tauri/src/storage.rs
Normal file
187
apps/windows-client/src-tauri/src/storage.rs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
|
||||||
|
use crate::models::{ActivityEntry, ComponentStatus, Profile, Target};
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, ErrorKind};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub fn default_config_root() -> PathBuf {
|
||||||
|
PathBuf::from(r"C:\ProgramData\VpnProxy")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct StoragePaths {
|
||||||
|
pub root: PathBuf,
|
||||||
|
pub config_dir: PathBuf,
|
||||||
|
pub state_dir: PathBuf,
|
||||||
|
pub generated_dir: PathBuf,
|
||||||
|
pub profiles_file: PathBuf,
|
||||||
|
pub targets_file: PathBuf,
|
||||||
|
pub components_file: PathBuf,
|
||||||
|
pub activity_file: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoragePaths {
|
||||||
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
|
let root = root.into();
|
||||||
|
let config_dir = root.join("config");
|
||||||
|
let state_dir = root.join("state");
|
||||||
|
let generated_dir = root.join("generated");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
root,
|
||||||
|
profiles_file: config_dir.join("profiles.json"),
|
||||||
|
targets_file: config_dir.join("targets.json"),
|
||||||
|
components_file: config_dir.join("components.json"),
|
||||||
|
activity_file: state_dir.join("activity.json"),
|
||||||
|
config_dir,
|
||||||
|
state_dir,
|
||||||
|
generated_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StoragePaths {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(default_config_root())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JsonStorage {
|
||||||
|
paths: StoragePaths,
|
||||||
|
activity_limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonStorage {
|
||||||
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
|
Self::with_activity_limit(root, DEFAULT_ACTIVITY_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_activity_limit(root: impl Into<PathBuf>, activity_limit: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
paths: StoragePaths::new(root),
|
||||||
|
activity_limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn paths(&self) -> &StoragePaths {
|
||||||
|
&self.paths
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_dirs(&self) -> io::Result<()> {
|
||||||
|
fs::create_dir_all(&self.paths.config_dir)?;
|
||||||
|
fs::create_dir_all(&self.paths.state_dir)?;
|
||||||
|
fs::create_dir_all(&self.paths.generated_dir)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_profiles(&self) -> io::Result<Vec<Profile>> {
|
||||||
|
self.read_json_or_default(&self.paths.profiles_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_profiles(&self, profiles: &[Profile]) -> io::Result<()> {
|
||||||
|
self.write_json(&self.paths.profiles_file, profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_targets(&self) -> io::Result<Vec<Target>> {
|
||||||
|
self.read_json_or_default(&self.paths.targets_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_targets(&self, targets: &[Target]) -> io::Result<()> {
|
||||||
|
self.write_json(&self.paths.targets_file, targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_components(&self) -> io::Result<Vec<ComponentStatus>> {
|
||||||
|
self.read_json_or_default(&self.paths.components_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_components(&self, components: &[ComponentStatus]) -> io::Result<()> {
|
||||||
|
self.write_json(&self.paths.components_file, components)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_activity(&self) -> io::Result<Vec<ActivityEntry>> {
|
||||||
|
let entries = self.read_json_or_default(&self.paths.activity_file)?;
|
||||||
|
Ok(cap_activity(entries, self.activity_limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_activity(&self, entries: &[ActivityEntry]) -> io::Result<()> {
|
||||||
|
let entries = cap_activity(entries.to_vec(), self.activity_limit);
|
||||||
|
self.write_json(&self.paths.activity_file, &entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_activity(&self, entry: ActivityEntry) -> io::Result<Vec<ActivityEntry>> {
|
||||||
|
let entries = self.read_activity()?;
|
||||||
|
let entries = append_activity(entries, entry, self.activity_limit);
|
||||||
|
self.write_json(&self.paths.activity_file, &entries)?;
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json_or_default<T>(&self, path: &Path) -> io::Result<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned + Default,
|
||||||
|
{
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(contents) => match serde_json::from_str(&contents) {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(_) => Ok(T::default()),
|
||||||
|
},
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_json<T>(&self, path: &Path, value: &T) -> io::Result<()>
|
||||||
|
where
|
||||||
|
T: Serialize + ?Sized,
|
||||||
|
{
|
||||||
|
let contents = serde_json::to_vec_pretty(value)
|
||||||
|
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
|
||||||
|
write_atomic(path, &contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for JsonStorage {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(default_config_root())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backup_path(path: &Path) -> PathBuf {
|
||||||
|
sibling_with_suffix(path, "bak")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_path(path: &Path) -> PathBuf {
|
||||||
|
sibling_with_suffix(path, "tmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("storage.json");
|
||||||
|
|
||||||
|
path.with_file_name(format!("{file_name}.{suffix}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp_path = temp_path(path);
|
||||||
|
fs::write(&temp_path, contents)?;
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
fs::copy(path, backup_path(path))?;
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
match fs::rename(&temp_path, path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) => {
|
||||||
|
let _ = fs::remove_file(&temp_path);
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
apps/windows-client/src-tauri/src/validation.rs
Normal file
222
apps/windows-client/src-tauri/src/validation.rs
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
use crate::models::{
|
||||||
|
ComponentId, Profile, ProfileInput, ProfileItem, ProfileItemType, Protocol, ProxyProtocol,
|
||||||
|
Target, TargetInput, TargetKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ValidationError {
|
||||||
|
pub field: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ValidationResult<T> = Result<T, Vec<ValidationError>>;
|
||||||
|
|
||||||
|
fn error(field: impl Into<String>, message: impl Into<String>) -> ValidationError {
|
||||||
|
ValidationError {
|
||||||
|
field: field.into(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean(value: &str) -> String {
|
||||||
|
value.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slug(value: &str, fallback: &str) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut previous_dash = false;
|
||||||
|
|
||||||
|
for ch in value.trim().to_lowercase().chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
output.push(ch);
|
||||||
|
previous_dash = false;
|
||||||
|
} else if !previous_dash {
|
||||||
|
output.push('-');
|
||||||
|
previous_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = output.trim_matches('-').to_string();
|
||||||
|
if output.is_empty() {
|
||||||
|
fallback.to_string()
|
||||||
|
} else {
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_name(value: &str) -> String {
|
||||||
|
let base = value
|
||||||
|
.trim()
|
||||||
|
.rsplit(['\\', '/'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
base.strip_suffix(".exe")
|
||||||
|
.or_else(|| base.strip_suffix(".EXE"))
|
||||||
|
.unwrap_or(base)
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_protocol(value: &str) -> Result<Protocol, ValidationError> {
|
||||||
|
match value.trim().to_ascii_uppercase().as_str() {
|
||||||
|
"TCP" => Ok(Protocol::Tcp),
|
||||||
|
"UDP" => Ok(Protocol::Udp),
|
||||||
|
_ => Err(error("protocols", format!("Неподдерживаемый протокол: {value}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_profile_item_type(value: &str) -> Result<ProfileItemType, ValidationError> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"process" => Ok(ProfileItemType::Process),
|
||||||
|
"folder" => Ok(ProfileItemType::Folder),
|
||||||
|
"exe" => Ok(ProfileItemType::Exe),
|
||||||
|
_ => Err(error("items.type", format!("Неподдерживаемый тип элемента: {value}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_target_kind(value: &str) -> Result<TargetKind, ValidationError> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"local" => Ok(TargetKind::Local),
|
||||||
|
"external" => Ok(TargetKind::External),
|
||||||
|
_ => Err(error("kind", format!("Неподдерживаемый тип цели: {value}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_proxy_protocol(value: &str) -> Result<ProxyProtocol, ValidationError> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"socks5" => Ok(ProxyProtocol::Socks5),
|
||||||
|
"http" => Ok(ProxyProtocol::Http),
|
||||||
|
_ => Err(error(
|
||||||
|
"protocol",
|
||||||
|
format!("Неподдерживаемый протокол прокси: {value}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_component_id(value: &str) -> Result<ComponentId, ValidationError> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"control-app" | "controlapp" => Ok(ComponentId::ControlApp),
|
||||||
|
"proxyfier" => Ok(ComponentId::Proxyfier),
|
||||||
|
"singbox" | "sing-box" => Ok(ComponentId::Singbox),
|
||||||
|
_ => Err(error(
|
||||||
|
"requires_component",
|
||||||
|
format!("Неподдерживаемый компонент: {value}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_profile(input: ProfileInput) -> ValidationResult<Profile> {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let name = clean(&input.name);
|
||||||
|
if name.is_empty() {
|
||||||
|
errors.push(error("name", "Укажите название профиля"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_id = clean(&input.target_id);
|
||||||
|
if target_id.is_empty() {
|
||||||
|
errors.push(error("target_id", "Укажите цель профиля"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut protocols = Vec::new();
|
||||||
|
for value in input.protocols {
|
||||||
|
match parse_protocol(&value) {
|
||||||
|
Ok(protocol) if !protocols.contains(&protocol) => protocols.push(protocol),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => errors.push(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if protocols.is_empty() {
|
||||||
|
errors.push(error("protocols", "Выберите хотя бы один протокол"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for raw_item in input.items {
|
||||||
|
let item_type = match parse_profile_item_type(&raw_item.item_type) {
|
||||||
|
Ok(item_type) => item_type,
|
||||||
|
Err(err) => {
|
||||||
|
errors.push(err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = match item_type {
|
||||||
|
ProfileItemType::Process => process_name(&raw_item.value),
|
||||||
|
ProfileItemType::Folder | ProfileItemType::Exe => clean(&raw_item.value),
|
||||||
|
};
|
||||||
|
if value.is_empty() {
|
||||||
|
errors.push(error("items.value", "Укажите значение элемента профиля"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recursive = matches!(item_type, ProfileItemType::Folder)
|
||||||
|
&& raw_item.recursive.unwrap_or(true);
|
||||||
|
items.push(ProfileItem {
|
||||||
|
item_type,
|
||||||
|
value,
|
||||||
|
recursive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
return Err(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Profile {
|
||||||
|
id: slug(input.id.as_deref().unwrap_or(&name), "profile"),
|
||||||
|
name,
|
||||||
|
enabled: input.enabled,
|
||||||
|
target_id,
|
||||||
|
protocols,
|
||||||
|
items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_target(input: TargetInput) -> ValidationResult<Target> {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let name = clean(&input.name);
|
||||||
|
let host = clean(&input.host);
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
errors.push(error("name", "Укажите название цели"));
|
||||||
|
}
|
||||||
|
if host.is_empty() {
|
||||||
|
errors.push(error("host", "Укажите хост цели"));
|
||||||
|
}
|
||||||
|
if input.port == 0 || input.port > u16::MAX as u32 {
|
||||||
|
errors.push(error("port", "Порт цели должен быть от 1 до 65535"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind = parse_target_kind(&input.kind).unwrap_or_else(|err| {
|
||||||
|
errors.push(err);
|
||||||
|
TargetKind::External
|
||||||
|
});
|
||||||
|
let protocol = parse_proxy_protocol(&input.protocol).unwrap_or_else(|err| {
|
||||||
|
errors.push(err);
|
||||||
|
ProxyProtocol::Socks5
|
||||||
|
});
|
||||||
|
let requires_component = match input.requires_component {
|
||||||
|
Some(value) if !value.trim().is_empty() => match parse_component_id(&value) {
|
||||||
|
Ok(component) => Some(component),
|
||||||
|
Err(err) => {
|
||||||
|
errors.push(err);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
return Err(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Target {
|
||||||
|
id: slug(input.id.as_deref().unwrap_or(&name), "target"),
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
protocol,
|
||||||
|
host,
|
||||||
|
port: input.port as u16,
|
||||||
|
requires_component,
|
||||||
|
})
|
||||||
|
}
|
||||||
37
apps/windows-client/src-tauri/tauri.conf.json
Normal file
37
apps/windows-client/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "VPN Proxy для Windows",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "ru.dokops.vpn-proxy.windows",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"devUrl": "http://localhost:5173",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "VPN Proxy для Windows",
|
||||||
|
"width": 1120,
|
||||||
|
"height": 760,
|
||||||
|
"minWidth": 760,
|
||||||
|
"minHeight": 560,
|
||||||
|
"resizable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
442
apps/windows-client/src-tauri/tests/command_tests.rs
Normal file
442
apps/windows-client/src-tauri/tests/command_tests.rs
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
#[path = "../src/activity.rs"]
|
||||||
|
mod activity;
|
||||||
|
#[path = "../src/component_detection.rs"]
|
||||||
|
mod component_detection;
|
||||||
|
#[path = "../src/commands.rs"]
|
||||||
|
mod commands;
|
||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/adapters/proxifyre.rs"]
|
||||||
|
mod proxifyre;
|
||||||
|
#[path = "../src/adapters/proxy_router.rs"]
|
||||||
|
mod proxy_router;
|
||||||
|
#[path = "../src/storage.rs"]
|
||||||
|
mod storage;
|
||||||
|
#[path = "../src/validation.rs"]
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
use commands::{
|
||||||
|
apply_profiles_with_services, build_status, resolve_component_statuses, resolve_preview,
|
||||||
|
save_profile_to_storage, save_target_to_storage, Clock, CommandError,
|
||||||
|
DetectedProxyApplyHelper, HelperApplyRequest, HelperApplyResult, ProfileInputDto,
|
||||||
|
ProfileItemInputDto, ProxyApplyHelper, TargetInputDto,
|
||||||
|
};
|
||||||
|
use component_detection::{
|
||||||
|
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
|
||||||
|
};
|
||||||
|
use models::{
|
||||||
|
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||||
|
ProxyProtocol, Target, TargetKind,
|
||||||
|
};
|
||||||
|
use proxifyre::ProxiFyreAdapter;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use storage::JsonStorage;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_commands_normalize_and_persist_profile_and_target() {
|
||||||
|
let root = test_root("save");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
|
||||||
|
let target = save_target_to_storage(
|
||||||
|
&storage,
|
||||||
|
TargetInputDto {
|
||||||
|
id: Some("Home Gateway".to_string()),
|
||||||
|
name: " Home Gateway ".to_string(),
|
||||||
|
kind: Some("external".to_string()),
|
||||||
|
protocol: Some("socks5".to_string()),
|
||||||
|
host: " 192.168.50.111 ".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
requires_component: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("target command should normalize");
|
||||||
|
let profile = save_profile_to_storage(
|
||||||
|
&storage,
|
||||||
|
ProfileInputDto {
|
||||||
|
id: Some("Discord".to_string()),
|
||||||
|
name: " Discord ".to_string(),
|
||||||
|
enabled: Some(true),
|
||||||
|
target_id: Some("home-gateway".to_string()),
|
||||||
|
protocols: Some(vec!["tcp".to_string(), "UDP".to_string()]),
|
||||||
|
items: Some(vec![ProfileItemInputDto {
|
||||||
|
item_type: "process".to_string(),
|
||||||
|
value: "Discord.exe".to_string(),
|
||||||
|
recursive: None,
|
||||||
|
}]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("profile command should normalize");
|
||||||
|
|
||||||
|
assert_eq!(target.id, "home-gateway");
|
||||||
|
assert_eq!(profile.id, "discord");
|
||||||
|
assert_eq!(profile.target_id, "home-gateway");
|
||||||
|
assert_eq!(profile.items[0].value, "Discord");
|
||||||
|
|
||||||
|
let status = build_status(&storage).expect("status command should read stored state");
|
||||||
|
assert_eq!(
|
||||||
|
status.route_line,
|
||||||
|
"Выбранные приложения -> ProxiFyre -> внешний прокси 192.168.50.111:8080"
|
||||||
|
);
|
||||||
|
assert_eq!(status.active_profile_count, 1);
|
||||||
|
assert_eq!(status.routed_app_count, 1);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_preview_returns_structured_apps_without_filesystem_scan() {
|
||||||
|
let preview = resolve_preview(ProfileInputDto {
|
||||||
|
id: Some("Game".to_string()),
|
||||||
|
name: "Game".to_string(),
|
||||||
|
enabled: Some(true),
|
||||||
|
target_id: Some("home-gateway".to_string()),
|
||||||
|
protocols: Some(vec!["TCP".to_string()]),
|
||||||
|
items: Some(vec![
|
||||||
|
ProfileItemInputDto {
|
||||||
|
item_type: "process".to_string(),
|
||||||
|
value: "Discord.exe".to_string(),
|
||||||
|
recursive: None,
|
||||||
|
},
|
||||||
|
ProfileItemInputDto {
|
||||||
|
item_type: "folder".to_string(),
|
||||||
|
value: r"C:\Games\Launcher".to_string(),
|
||||||
|
recursive: Some(true),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
.expect("preview should normalize profile input");
|
||||||
|
|
||||||
|
assert_eq!(preview.profile_id, "game");
|
||||||
|
assert_eq!(preview.apps.len(), 2);
|
||||||
|
assert_eq!(preview.apps[0].app_name, "Discord");
|
||||||
|
assert_eq!(preview.apps[1].source_type, ProfileItemType::Folder);
|
||||||
|
assert!(preview
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.any(|warning| warning.contains("Сканирование папок отложено")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
|
||||||
|
let root = test_root("apply");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
storage
|
||||||
|
.write_profiles(&[discord_profile("home-gateway")])
|
||||||
|
.expect("write profiles");
|
||||||
|
storage
|
||||||
|
.write_targets(&[external_socks5_target()])
|
||||||
|
.expect("write targets");
|
||||||
|
storage
|
||||||
|
.write_components(&[proxyfier_running(), singbox_missing()])
|
||||||
|
.expect("write components");
|
||||||
|
|
||||||
|
let response = apply_profiles_with_services(
|
||||||
|
&storage,
|
||||||
|
&ProxiFyreAdapter::default(),
|
||||||
|
&MockApplyHelper,
|
||||||
|
&FixedClock,
|
||||||
|
)
|
||||||
|
.expect("apply command should generate config and call helper");
|
||||||
|
|
||||||
|
let generated_path = PathBuf::from(&response.generated_config_path);
|
||||||
|
let generated_contents = fs::read_to_string(&generated_path).expect("read generated config");
|
||||||
|
let activity = storage.read_activity().expect("read activity");
|
||||||
|
|
||||||
|
assert!(response.success);
|
||||||
|
assert!(response.changed);
|
||||||
|
assert_eq!(response.adapter_id, "proxifyre");
|
||||||
|
assert_eq!(response.enabled_profiles, 1);
|
||||||
|
assert_eq!(response.routed_apps, 1);
|
||||||
|
assert_eq!(response.helper.action, "proxyfier.apply.mock");
|
||||||
|
assert!(generated_contents.contains("\"appNames\""));
|
||||||
|
assert!(generated_contents.contains("Discord"));
|
||||||
|
assert!(generated_path.ends_with("proxifyre-app-config.json"));
|
||||||
|
assert_eq!(activity.len(), 1);
|
||||||
|
assert_eq!(activity[0].at, "2026-07-03T00:00:00Z");
|
||||||
|
assert_eq!(activity[0].title, "Конфиг ProxiFyre создан");
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_blocks_local_singbox_target_when_component_is_missing() {
|
||||||
|
let root = test_root("missing-singbox");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
storage
|
||||||
|
.write_profiles(&[discord_profile("local-singbox")])
|
||||||
|
.expect("write profiles");
|
||||||
|
storage
|
||||||
|
.write_targets(&[local_singbox_target()])
|
||||||
|
.expect("write targets");
|
||||||
|
storage
|
||||||
|
.write_components(&[singbox_missing()])
|
||||||
|
.expect("write components");
|
||||||
|
|
||||||
|
let error = apply_profiles_with_services(
|
||||||
|
&storage,
|
||||||
|
&ProxiFyreAdapter::default(),
|
||||||
|
&MockApplyHelper,
|
||||||
|
&FixedClock,
|
||||||
|
)
|
||||||
|
.expect_err("missing sing-box should block local target apply");
|
||||||
|
let activity = storage.read_activity().expect("read blocked activity");
|
||||||
|
|
||||||
|
assert_eq!(error.code, "required_component_not_running");
|
||||||
|
assert_eq!(activity.len(), 1);
|
||||||
|
assert_eq!(activity[0].level, models::ActivityLevel::Error);
|
||||||
|
assert_eq!(activity[0].title, "Применение ProxiFyre заблокировано");
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_status_merges_detected_existing_proxifyre() {
|
||||||
|
let components = resolve_component_statuses(
|
||||||
|
Vec::new(),
|
||||||
|
Some(DetectedProxyfier {
|
||||||
|
engine: ProxyfierEngine::ProxiFyre,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
install_dir: PathBuf::from(r"C:\Tools\ProxiFyre"),
|
||||||
|
executable_path: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"),
|
||||||
|
config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")),
|
||||||
|
running: true,
|
||||||
|
service_name: Some("ProxiFyreService".to_string()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let proxyfier = components
|
||||||
|
.iter()
|
||||||
|
.find(|component| component.id == ComponentId::Proxyfier)
|
||||||
|
.expect("proxyfier component");
|
||||||
|
|
||||||
|
assert_eq!(proxyfier.state, ComponentState::Running);
|
||||||
|
assert!(proxyfier.installed);
|
||||||
|
assert!(proxyfier.running);
|
||||||
|
assert_eq!(proxyfier.path, Some(r"C:\Tools\ProxiFyre".to_string()));
|
||||||
|
assert!(proxyfier.problems.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
|
||||||
|
let root = test_root("detected-proxifyre");
|
||||||
|
let install_dir = root.join("ProxiFyre");
|
||||||
|
fs::create_dir_all(&install_dir).expect("install dir");
|
||||||
|
fs::write(install_dir.join("ProxiFyre.exe"), "mock exe").expect("mock exe");
|
||||||
|
fs::write(install_dir.join("app-config.json"), "{}").expect("existing config");
|
||||||
|
let generated_config = root.join("generated").join("proxifyre-app-config.json");
|
||||||
|
let host = DetectionHost::new()
|
||||||
|
.with_registry("ProxiFyre", &install_dir)
|
||||||
|
.with_path(&install_dir)
|
||||||
|
.with_path(&install_dir.join("ProxiFyre.exe"));
|
||||||
|
let helper = DetectedProxyApplyHelper::new(host);
|
||||||
|
|
||||||
|
let result = helper
|
||||||
|
.apply_proxy_config(HelperApplyRequest {
|
||||||
|
adapter_id: "proxifyre",
|
||||||
|
config_path: &generated_config,
|
||||||
|
config_contents: r#"{"proxies":[]}"#,
|
||||||
|
})
|
||||||
|
.expect("detected helper should apply");
|
||||||
|
|
||||||
|
let applied = fs::read_to_string(install_dir.join("app-config.json"))
|
||||||
|
.expect("read applied app-config");
|
||||||
|
|
||||||
|
assert!(result.success);
|
||||||
|
assert!(result.changed);
|
||||||
|
assert_eq!(result.action, "proxifyre.apply-detected-config");
|
||||||
|
assert_eq!(applied, r#"{"proxies":[]}"#);
|
||||||
|
assert!(install_dir.join("app-config.json.bak").exists());
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detected_proxy_apply_helper_ignores_plain_proxifier_install() {
|
||||||
|
let root = test_root("detected-proxifier");
|
||||||
|
let install_dir = root.join("Proxifier");
|
||||||
|
fs::create_dir_all(&install_dir).expect("install dir");
|
||||||
|
fs::write(install_dir.join("Proxifier.exe"), "mock exe").expect("mock exe");
|
||||||
|
let generated_config = root.join("generated").join("proxifyre-app-config.json");
|
||||||
|
let host = DetectionHost::new()
|
||||||
|
.with_registry("Proxifier", &install_dir)
|
||||||
|
.with_path(&install_dir)
|
||||||
|
.with_path(&install_dir.join("Proxifier.exe"));
|
||||||
|
let helper = DetectedProxyApplyHelper::new(host);
|
||||||
|
|
||||||
|
let result = helper
|
||||||
|
.apply_proxy_config(HelperApplyRequest {
|
||||||
|
adapter_id: "proxifyre",
|
||||||
|
config_path: &generated_config,
|
||||||
|
config_contents: r#"{"proxies":[]}"#,
|
||||||
|
})
|
||||||
|
.expect("plain Proxifier should be ignored and config should be staged");
|
||||||
|
|
||||||
|
assert!(result.success);
|
||||||
|
assert!(result.changed);
|
||||||
|
assert_eq!(result.action, "proxifyre.stage-generated-config");
|
||||||
|
assert!(result.message.contains("совместимая установка ProxiFyre не найдена"));
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockApplyHelper;
|
||||||
|
|
||||||
|
impl ProxyApplyHelper for MockApplyHelper {
|
||||||
|
fn apply_proxy_config(
|
||||||
|
&self,
|
||||||
|
request: HelperApplyRequest<'_>,
|
||||||
|
) -> Result<HelperApplyResult, CommandError> {
|
||||||
|
assert_eq!(request.adapter_id, "proxifyre");
|
||||||
|
assert!(request.config_contents.contains("Discord"));
|
||||||
|
assert!(request.config_path.ends_with("proxifyre-app-config.json"));
|
||||||
|
|
||||||
|
Ok(HelperApplyResult {
|
||||||
|
success: true,
|
||||||
|
changed: true,
|
||||||
|
action: "proxyfier.apply.mock".to_string(),
|
||||||
|
message: "Mock helper accepted generated ProxiFyre config".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixedClock;
|
||||||
|
|
||||||
|
impl Clock for FixedClock {
|
||||||
|
fn now(&self) -> String {
|
||||||
|
"2026-07-03T00:00:00Z".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DetectionHost {
|
||||||
|
paths: HashSet<String>,
|
||||||
|
registry: Vec<RegistryInstallEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetectionHost {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_path(mut self, path: &Path) -> Self {
|
||||||
|
self.paths.insert(normalize_path(path));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_registry(mut self, display_name: &str, install_location: &Path) -> Self {
|
||||||
|
self.registry.push(RegistryInstallEntry {
|
||||||
|
display_name: display_name.to_string(),
|
||||||
|
install_location: Some(install_location.to_path_buf()),
|
||||||
|
display_icon: None,
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyfierDetectionHost for DetectionHost {
|
||||||
|
fn env_var(&self, _name: &str) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_exists(&self, path: &Path) -> bool {
|
||||||
|
self.paths.contains(&normalize_path(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_running(&self, _process_name: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_running(&self, _service_name: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||||
|
self.registry.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_path(path: &Path) -> String {
|
||||||
|
path.display().to_string().replace('/', "\\").to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_root(name: &str) -> PathBuf {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system clock before unix epoch")
|
||||||
|
.as_nanos();
|
||||||
|
|
||||||
|
std::env::temp_dir().join(format!("vpn-proxy-commands-{name}-{timestamp}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup(root: &Path) {
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discord_profile(target_id: &str) -> Profile {
|
||||||
|
Profile {
|
||||||
|
id: "discord".to_string(),
|
||||||
|
name: "Discord".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
target_id: target_id.to_string(),
|
||||||
|
protocols: vec![Protocol::Tcp, Protocol::Udp],
|
||||||
|
items: vec![ProfileItem {
|
||||||
|
item_type: ProfileItemType::Process,
|
||||||
|
value: "Discord".to_string(),
|
||||||
|
recursive: false,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn external_socks5_target() -> Target {
|
||||||
|
Target {
|
||||||
|
id: "home-gateway".to_string(),
|
||||||
|
name: "Домашний шлюз".to_string(),
|
||||||
|
kind: TargetKind::External,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "192.168.50.111".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
requires_component: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_singbox_target() -> Target {
|
||||||
|
Target {
|
||||||
|
id: "local-singbox".to_string(),
|
||||||
|
name: "Локальный sing-box".to_string(),
|
||||||
|
kind: TargetKind::Local,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 1080,
|
||||||
|
requires_component: Some(ComponentId::Singbox),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxyfier_running() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Proxyfier,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
state: ComponentState::Running,
|
||||||
|
installed: true,
|
||||||
|
running: true,
|
||||||
|
version: Some("2.2.1".to_string()),
|
||||||
|
path: Some(r"C:\Tools\ProxiFyre".to_string()),
|
||||||
|
problems: Vec::new(),
|
||||||
|
actions: vec!["Restart".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn singbox_missing() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Локальный sing-box".to_string(),
|
||||||
|
state: ComponentState::Missing,
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: vec!["Локальный sing-box не установлен".to_string()],
|
||||||
|
actions: vec!["Установить локальный sing-box".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apps/windows-client/src-tauri/tests/component_detection_tests.rs
Normal file
141
apps/windows-client/src-tauri/tests/component_detection_tests.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#[path = "../src/component_detection.rs"]
|
||||||
|
mod component_detection;
|
||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
|
||||||
|
use component_detection::{
|
||||||
|
detect_proxyfier_install_with_host, proxyfier_component_from_detection, ProxyfierDetectionHost,
|
||||||
|
ProxyfierEngine, RegistryInstallEntry,
|
||||||
|
};
|
||||||
|
use models::{ComponentState};
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_existing_proxifyre_from_registry_install_location() {
|
||||||
|
let host = MockHost::new()
|
||||||
|
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
|
||||||
|
.with_path(r"C:\Tools\ProxiFyre")
|
||||||
|
.with_service("ProxiFyreService");
|
||||||
|
|
||||||
|
let detected = detect_proxyfier_install_with_host(&host)
|
||||||
|
.expect("existing ProxiFyre install should be detected");
|
||||||
|
|
||||||
|
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
|
||||||
|
assert_eq!(detected.install_dir, PathBuf::from(r"C:\Tools\ProxiFyre"));
|
||||||
|
assert_eq!(detected.config_path, Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")));
|
||||||
|
assert!(detected.running);
|
||||||
|
|
||||||
|
let component = proxyfier_component_from_detection(Some(&detected));
|
||||||
|
assert_eq!(component.state, ComponentState::Running);
|
||||||
|
assert!(component.installed);
|
||||||
|
assert!(component.running);
|
||||||
|
assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string()));
|
||||||
|
assert!(component.problems.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_plain_proxifier_install() {
|
||||||
|
let host = MockHost::new()
|
||||||
|
.with_registry("Proxifier", r"C:\Program Files\Proxifier")
|
||||||
|
.with_path(r"C:\Program Files\Proxifier")
|
||||||
|
.with_process("Proxifier.exe");
|
||||||
|
|
||||||
|
assert!(detect_proxyfier_install_with_host(&host).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_override_can_point_to_portable_proxifyre_install() {
|
||||||
|
let host = MockHost::new()
|
||||||
|
.with_env("VPN_PROXY_PROXIFYRE_ROOT", r"D:\Portable\ProxiFyre")
|
||||||
|
.with_path(r"D:\Portable\ProxiFyre\ProxiFyre.exe");
|
||||||
|
|
||||||
|
let detected = detect_proxyfier_install_with_host(&host)
|
||||||
|
.expect("env override should be checked before common paths");
|
||||||
|
|
||||||
|
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
|
||||||
|
assert_eq!(detected.executable_path, PathBuf::from(r"D:\Portable\ProxiFyre\ProxiFyre.exe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_proxyfier_returns_install_action_status() {
|
||||||
|
let component = proxyfier_component_from_detection(None);
|
||||||
|
|
||||||
|
assert_eq!(component.state, ComponentState::Missing);
|
||||||
|
assert!(!component.installed);
|
||||||
|
assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MockHost {
|
||||||
|
env: HashMap<String, String>,
|
||||||
|
paths: HashSet<String>,
|
||||||
|
processes: HashSet<String>,
|
||||||
|
services: HashSet<String>,
|
||||||
|
registry: Vec<RegistryInstallEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockHost {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_env(mut self, name: &str, value: &str) -> Self {
|
||||||
|
self.env.insert(name.to_string(), value.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_path(mut self, path: &str) -> Self {
|
||||||
|
self.paths.insert(normalize_path(path));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_process(mut self, process: &str) -> Self {
|
||||||
|
self.processes.insert(process.to_ascii_lowercase());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_service(mut self, service: &str) -> Self {
|
||||||
|
self.services.insert(service.to_ascii_lowercase());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_registry(mut self, display_name: &str, install_location: &str) -> Self {
|
||||||
|
self.registry.push(RegistryInstallEntry {
|
||||||
|
display_name: display_name.to_string(),
|
||||||
|
install_location: Some(PathBuf::from(install_location)),
|
||||||
|
display_icon: None,
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyfierDetectionHost for MockHost {
|
||||||
|
fn env_var(&self, name: &str) -> Option<String> {
|
||||||
|
self.env.get(name).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_exists(&self, path: &Path) -> bool {
|
||||||
|
self.paths.contains(&normalize_path(&path.display().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_running(&self, process_name: &str) -> bool {
|
||||||
|
self.processes
|
||||||
|
.contains(&process_name.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_running(&self, service_name: &str) -> bool {
|
||||||
|
self.services
|
||||||
|
.contains(&service_name.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||||
|
self.registry.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_path(path: &str) -> String {
|
||||||
|
path.replace('/', "\\").to_ascii_lowercase()
|
||||||
|
}
|
||||||
130
apps/windows-client/src-tauri/tests/domain_tests.rs
Normal file
130
apps/windows-client/src-tauri/tests/domain_tests.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/validation.rs"]
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
use models::{
|
||||||
|
ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||||||
|
TargetInput, TargetKind,
|
||||||
|
};
|
||||||
|
use validation::{normalize_profile, normalize_target};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_profile_source_items() {
|
||||||
|
let profile = normalize_profile(ProfileInput {
|
||||||
|
id: Some("Discord + Vesktop".to_string()),
|
||||||
|
name: " Discord + Vesktop ".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
target_id: " home-gateway ".to_string(),
|
||||||
|
protocols: vec!["tcp".to_string(), "UDP".to_string(), "TCP".to_string()],
|
||||||
|
items: vec![
|
||||||
|
ProfileItemInput {
|
||||||
|
item_type: "process".to_string(),
|
||||||
|
value: "Discord.exe".to_string(),
|
||||||
|
recursive: None,
|
||||||
|
},
|
||||||
|
ProfileItemInput {
|
||||||
|
item_type: "folder".to_string(),
|
||||||
|
value: "%LOCALAPPDATA%\\Vesktop".to_string(),
|
||||||
|
recursive: Some(true),
|
||||||
|
},
|
||||||
|
ProfileItemInput {
|
||||||
|
item_type: "exe".to_string(),
|
||||||
|
value: "C:\\Games\\Game\\game.exe".to_string(),
|
||||||
|
recursive: Some(true),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.expect("profile should normalize");
|
||||||
|
|
||||||
|
assert_eq!(profile.id, "discord-vesktop");
|
||||||
|
assert_eq!(profile.name, "Discord + Vesktop");
|
||||||
|
assert_eq!(profile.target_id, "home-gateway");
|
||||||
|
assert_eq!(profile.protocols, vec![Protocol::Tcp, Protocol::Udp]);
|
||||||
|
assert_eq!(profile.items[0].item_type, ProfileItemType::Process);
|
||||||
|
assert_eq!(profile.items[0].value, "Discord");
|
||||||
|
assert!(!profile.items[0].recursive);
|
||||||
|
assert_eq!(profile.items[1].item_type, ProfileItemType::Folder);
|
||||||
|
assert!(profile.items[1].recursive);
|
||||||
|
assert_eq!(profile.items[2].item_type, ProfileItemType::Exe);
|
||||||
|
assert!(!profile.items[2].recursive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unsupported_profile_protocols() {
|
||||||
|
let error = normalize_profile(ProfileInput {
|
||||||
|
id: None,
|
||||||
|
name: "Bad protocol".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
target_id: "home-gateway".to_string(),
|
||||||
|
protocols: vec!["icmp".to_string()],
|
||||||
|
items: vec![ProfileItemInput {
|
||||||
|
item_type: "process".to_string(),
|
||||||
|
value: "Discord".to_string(),
|
||||||
|
recursive: None,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.expect_err("unsupported protocol should fail");
|
||||||
|
|
||||||
|
assert!(error.iter().any(|item| item.field == "protocols"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_external_target_without_local_singbox() {
|
||||||
|
let target = normalize_target(TargetInput {
|
||||||
|
id: Some("Home Gateway".to_string()),
|
||||||
|
name: " Home Gateway ".to_string(),
|
||||||
|
kind: "external".to_string(),
|
||||||
|
protocol: "socks5".to_string(),
|
||||||
|
host: " 192.168.50.111 ".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
requires_component: None,
|
||||||
|
})
|
||||||
|
.expect("external target should normalize");
|
||||||
|
|
||||||
|
assert_eq!(target.id, "home-gateway");
|
||||||
|
assert_eq!(target.kind, TargetKind::External);
|
||||||
|
assert_eq!(target.protocol, ProxyProtocol::Socks5);
|
||||||
|
assert_eq!(target.host, "192.168.50.111");
|
||||||
|
assert_eq!(target.port, 8080);
|
||||||
|
assert_eq!(target.requires_component, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_singbox_target_can_exist_before_component_is_installed() {
|
||||||
|
let target = normalize_target(TargetInput {
|
||||||
|
id: Some("local-singbox".to_string()),
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
kind: "local".to_string(),
|
||||||
|
protocol: "socks5".to_string(),
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 1080,
|
||||||
|
requires_component: Some("singbox".to_string()),
|
||||||
|
})
|
||||||
|
.expect("local target definition should not require installed component");
|
||||||
|
|
||||||
|
assert_eq!(target.kind, TargetKind::Local);
|
||||||
|
assert_eq!(target.requires_component, Some(ComponentId::Singbox));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_target_fields() {
|
||||||
|
let error = normalize_target(TargetInput {
|
||||||
|
id: None,
|
||||||
|
name: "".to_string(),
|
||||||
|
kind: "external".to_string(),
|
||||||
|
protocol: "ftp".to_string(),
|
||||||
|
host: "".to_string(),
|
||||||
|
port: 70_000,
|
||||||
|
requires_component: Some("unknown".to_string()),
|
||||||
|
})
|
||||||
|
.expect_err("invalid target should fail");
|
||||||
|
|
||||||
|
assert!(error.iter().any(|item| item.field == "name"));
|
||||||
|
assert!(error.iter().any(|item| item.field == "host"));
|
||||||
|
assert!(error.iter().any(|item| item.field == "port"));
|
||||||
|
assert!(error.iter().any(|item| item.field == "protocol"));
|
||||||
|
assert!(error
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.field == "requires_component"));
|
||||||
|
}
|
||||||
139
apps/windows-client/src-tauri/tests/helper_tests.rs
Normal file
139
apps/windows-client/src-tauri/tests/helper_tests.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
#[path = "../src/helper.rs"]
|
||||||
|
mod helper;
|
||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
|
||||||
|
use helper::{
|
||||||
|
helper_action_requires_elevation, install_request, parse_helper_response,
|
||||||
|
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
|
||||||
|
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
|
||||||
|
};
|
||||||
|
use models::ComponentId;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_helper_serializes_request_and_parses_json_response() {
|
||||||
|
let runner = MockRunner {
|
||||||
|
output: HelperCommandOutput {
|
||||||
|
status_code: 0,
|
||||||
|
stdout: serde_json::to_string(&HelperResponse {
|
||||||
|
success: true,
|
||||||
|
action: HelperAction::ProxyfierApply,
|
||||||
|
changed: true,
|
||||||
|
message: "Applied".to_string(),
|
||||||
|
details: json!({ "serviceName": "ProxiFyreService" }),
|
||||||
|
})
|
||||||
|
.expect("response json"),
|
||||||
|
stderr: String::new(),
|
||||||
|
},
|
||||||
|
seen: RefCell::new(Vec::new()),
|
||||||
|
};
|
||||||
|
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
|
||||||
|
|
||||||
|
let response = helper
|
||||||
|
.execute(&proxifyre_apply_request(
|
||||||
|
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json",
|
||||||
|
"ProxiFyreService",
|
||||||
|
))
|
||||||
|
.expect("helper response");
|
||||||
|
|
||||||
|
assert!(response.success);
|
||||||
|
assert_eq!(response.action, HelperAction::ProxyfierApply);
|
||||||
|
assert_eq!(response.details["serviceName"], "ProxiFyreService");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn helper_runner_receives_json_stdin_and_elevation_flag() {
|
||||||
|
let runner = MockRunner {
|
||||||
|
output: HelperCommandOutput {
|
||||||
|
status_code: 0,
|
||||||
|
stdout: r#"{"success":true,"action":"service.restart","changed":true,"message":"Restarted","details":{}}"#.to_string(),
|
||||||
|
stderr: String::new(),
|
||||||
|
},
|
||||||
|
seen: RefCell::new(Vec::new()),
|
||||||
|
};
|
||||||
|
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
|
||||||
|
let request = service_request(ComponentId::Proxyfier, HelperAction::ServiceRestart);
|
||||||
|
|
||||||
|
let _ = helper.execute(&request).expect("helper response");
|
||||||
|
let seen = helper.runner().seen.borrow();
|
||||||
|
let spec = seen.first().expect("runner should be called");
|
||||||
|
let stdin: serde_json::Value = serde_json::from_str(&spec.stdin).expect("stdin json");
|
||||||
|
|
||||||
|
assert_eq!(spec.program, PathBuf::from("vpn-proxy-helper.exe"));
|
||||||
|
assert_eq!(spec.args, vec!["--json"]);
|
||||||
|
assert!(spec.requires_elevation);
|
||||||
|
assert_eq!(stdin["action"], "service.restart");
|
||||||
|
assert_eq!(stdin["component"], "proxyfier");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_requests_are_explicit_component_actions() {
|
||||||
|
let control = install_request(ComponentId::ControlApp);
|
||||||
|
let proxyfier = install_request(ComponentId::Proxyfier);
|
||||||
|
let singbox = install_request(ComponentId::Singbox);
|
||||||
|
|
||||||
|
assert_eq!(control.action, HelperAction::InstallControlApp);
|
||||||
|
assert_eq!(proxyfier.action, HelperAction::InstallProxyfier);
|
||||||
|
assert_eq!(singbox.action, HelperAction::InstallSingbox);
|
||||||
|
assert!(helper_action_requires_elevation(&proxyfier.action));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_request_does_not_encode_installer_action() {
|
||||||
|
let request = proxifyre_apply_request(
|
||||||
|
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json",
|
||||||
|
"ProxiFyreService",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(request.action, HelperAction::ProxyfierApply);
|
||||||
|
assert_eq!(request.component, Some(ComponentId::Proxyfier));
|
||||||
|
assert_eq!(
|
||||||
|
request.payload["configPath"],
|
||||||
|
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_json_helper_stdout_is_rejected() {
|
||||||
|
let error = parse_helper_response("Proxyfier restarted successfully")
|
||||||
|
.expect_err("raw stdout should not be accepted");
|
||||||
|
|
||||||
|
assert_eq!(error.code, "helper_response_decode");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_helper_exit_is_structured_error() {
|
||||||
|
let runner = MockRunner {
|
||||||
|
output: HelperCommandOutput {
|
||||||
|
status_code: 5,
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: "Access denied".to_string(),
|
||||||
|
},
|
||||||
|
seen: RefCell::new(Vec::new()),
|
||||||
|
};
|
||||||
|
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
|
||||||
|
let error = helper
|
||||||
|
.execute(&service_request(
|
||||||
|
ComponentId::Proxyfier,
|
||||||
|
HelperAction::ServiceRestart,
|
||||||
|
))
|
||||||
|
.expect_err("failed exit should become helper error");
|
||||||
|
|
||||||
|
assert_eq!(error.code, "helper_exit");
|
||||||
|
assert!(error.message.contains("Access denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockRunner {
|
||||||
|
output: HelperCommandOutput,
|
||||||
|
seen: RefCell<Vec<HelperCommandSpec>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HelperCommandRunner for MockRunner {
|
||||||
|
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError> {
|
||||||
|
self.seen.borrow_mut().push(spec.clone());
|
||||||
|
Ok(self.output.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
178
apps/windows-client/src-tauri/tests/proxifyre_adapter_tests.rs
Normal file
178
apps/windows-client/src-tauri/tests/proxifyre_adapter_tests.rs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/adapters/proxy_router.rs"]
|
||||||
|
mod proxy_router;
|
||||||
|
#[path = "../src/adapters/proxifyre.rs"]
|
||||||
|
mod proxifyre;
|
||||||
|
|
||||||
|
use models::{
|
||||||
|
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||||
|
ProxyProtocol, Target, TargetKind,
|
||||||
|
};
|
||||||
|
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE};
|
||||||
|
use proxy_router::{ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generates_proxifyre_config_for_discord_external_socks5_target() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let profiles = vec![discord_profile("home-gateway")];
|
||||||
|
let targets = vec![external_socks5_target()];
|
||||||
|
let components = vec![missing_singbox_component()];
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect("external socks5 target should not require sing-box");
|
||||||
|
let config: ProxiFyreConfig =
|
||||||
|
serde_json::from_str(&generated.contents).expect("generated config json");
|
||||||
|
|
||||||
|
assert_eq!(generated.adapter_id, "proxifyre");
|
||||||
|
assert_eq!(generated.output_file_name, PROXIFYRE_OUTPUT_FILE);
|
||||||
|
assert_eq!(generated.enabled_profiles, 1);
|
||||||
|
assert_eq!(generated.routed_apps, 1);
|
||||||
|
assert_eq!(config.log_level, "Info");
|
||||||
|
assert!(config.bypass_lan);
|
||||||
|
assert_eq!(config.proxies.len(), 1);
|
||||||
|
assert_eq!(config.proxies[0].app_names, vec!["Discord"]);
|
||||||
|
assert_eq!(config.proxies[0].socks5_proxy_endpoint, "192.168.50.111:8080");
|
||||||
|
assert_eq!(config.proxies[0].supported_protocols, vec!["TCP", "UDP"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_disabled_profiles_when_generating_proxifyre_config() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let mut disabled = discord_profile("home-gateway");
|
||||||
|
disabled.enabled = false;
|
||||||
|
let profiles = vec![disabled];
|
||||||
|
let targets = vec![external_socks5_target()];
|
||||||
|
let components = Vec::new();
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect("disabled profiles should produce empty config");
|
||||||
|
let config: ProxiFyreConfig =
|
||||||
|
serde_json::from_str(&generated.contents).expect("generated config json");
|
||||||
|
|
||||||
|
assert_eq!(generated.enabled_profiles, 0);
|
||||||
|
assert_eq!(generated.routed_apps, 0);
|
||||||
|
assert!(config.proxies.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_local_singbox_target_when_required_component_is_missing() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let profiles = vec![discord_profile("local-singbox")];
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = Vec::new();
|
||||||
|
|
||||||
|
let error = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect_err("local sing-box target should require installed running sing-box");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, ProxyRouterErrorKind::MissingRequiredComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_singbox_target_generates_when_required_component_is_running() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let profiles = vec![discord_profile("local-singbox")];
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = vec![running_singbox_component()];
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect("running sing-box should satisfy local target dependency");
|
||||||
|
let config: ProxiFyreConfig =
|
||||||
|
serde_json::from_str(&generated.contents).expect("generated config json");
|
||||||
|
|
||||||
|
assert_eq!(config.proxies.len(), 1);
|
||||||
|
assert_eq!(config.proxies[0].socks5_proxy_endpoint, "127.0.0.1:1080");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_http_target_because_proxifyre_adapter_is_socks5_only() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let profiles = vec![discord_profile("office-http")];
|
||||||
|
let targets = vec![Target {
|
||||||
|
id: "office-http".to_string(),
|
||||||
|
name: "Office HTTP".to_string(),
|
||||||
|
kind: TargetKind::External,
|
||||||
|
protocol: ProxyProtocol::Http,
|
||||||
|
host: "192.168.50.111".to_string(),
|
||||||
|
port: 3128,
|
||||||
|
requires_component: None,
|
||||||
|
}];
|
||||||
|
let components = Vec::new();
|
||||||
|
|
||||||
|
let error = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect_err("ProxiFyre should reject HTTP targets");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, ProxyRouterErrorKind::UnsupportedTargetProtocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discord_profile(target_id: &str) -> Profile {
|
||||||
|
Profile {
|
||||||
|
id: "discord".to_string(),
|
||||||
|
name: "Discord".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
target_id: target_id.to_string(),
|
||||||
|
protocols: vec![Protocol::Tcp, Protocol::Udp],
|
||||||
|
items: vec![ProfileItem {
|
||||||
|
item_type: ProfileItemType::Process,
|
||||||
|
value: "Discord".to_string(),
|
||||||
|
recursive: false,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn external_socks5_target() -> Target {
|
||||||
|
Target {
|
||||||
|
id: "home-gateway".to_string(),
|
||||||
|
name: "Home Gateway".to_string(),
|
||||||
|
kind: TargetKind::External,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "192.168.50.111".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
requires_component: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_singbox_target() -> Target {
|
||||||
|
Target {
|
||||||
|
id: "local-singbox".to_string(),
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
kind: TargetKind::Local,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 1080,
|
||||||
|
requires_component: Some(ComponentId::Singbox),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_singbox_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
state: ComponentState::Missing,
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: vec!["Local sing-box is not installed".to_string()],
|
||||||
|
actions: vec!["Install Local sing-box".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn running_singbox_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
state: ComponentState::Running,
|
||||||
|
installed: true,
|
||||||
|
running: true,
|
||||||
|
version: Some("1.11.0".to_string()),
|
||||||
|
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
|
||||||
|
problems: Vec::new(),
|
||||||
|
actions: vec!["Restart".to_string(), "Stop".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
282
apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs
Normal file
282
apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/adapters/proxy_router.rs"]
|
||||||
|
mod proxy_router;
|
||||||
|
#[path = "../src/adapters/proxifyre.rs"]
|
||||||
|
mod proxifyre;
|
||||||
|
#[path = "../src/adapters/singbox.rs"]
|
||||||
|
mod singbox;
|
||||||
|
|
||||||
|
use models::{
|
||||||
|
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||||
|
ProxyProtocol, Target, TargetKind,
|
||||||
|
};
|
||||||
|
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
||||||
|
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||||
|
use singbox::{
|
||||||
|
SingBoxAdapter, SingBoxCheckResult, SingBoxConfig, SingBoxConfigChecker,
|
||||||
|
SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
cell::RefCell,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generates_local_singbox_config_and_runs_check_when_binary_path_is_supplied() {
|
||||||
|
let adapter = SingBoxAdapter::default();
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = vec![running_singbox_component()];
|
||||||
|
let checker = RecordingChecker::ok("configuration OK");
|
||||||
|
let binary_path = Path::new(r"C:\Tools\VpnProxy\sing-box\sing-box.exe");
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(
|
||||||
|
SingBoxGenerationRequest::new(&targets, &components, Some(binary_path)),
|
||||||
|
&checker,
|
||||||
|
)
|
||||||
|
.expect("running local sing-box should generate config");
|
||||||
|
let config: SingBoxConfig =
|
||||||
|
serde_json::from_str(&generated.contents).expect("generated sing-box json");
|
||||||
|
|
||||||
|
assert_eq!(generated.adapter_id, "singbox");
|
||||||
|
assert_eq!(generated.output_file_name, SINGBOX_OUTPUT_FILE);
|
||||||
|
assert_eq!(generated.local_target_id, "local-singbox");
|
||||||
|
assert_eq!(generated.listen, "127.0.0.1");
|
||||||
|
assert_eq!(generated.listen_port, 1080);
|
||||||
|
assert_eq!(
|
||||||
|
generated.check,
|
||||||
|
Some(SingBoxCheckResult {
|
||||||
|
checked: true,
|
||||||
|
success: true,
|
||||||
|
message: "configuration OK".to_string(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(config.log.level, "info");
|
||||||
|
assert_eq!(config.inbounds.len(), 1);
|
||||||
|
assert_eq!(config.inbounds[0].inbound_type, "mixed");
|
||||||
|
assert_eq!(config.inbounds[0].listen, "127.0.0.1");
|
||||||
|
assert_eq!(config.inbounds[0].listen_port, 1080);
|
||||||
|
assert!(!config.inbounds[0].set_system_proxy);
|
||||||
|
assert_eq!(config.outbounds[0].outbound_type, "direct");
|
||||||
|
assert_eq!(config.route.final_outbound, "direct");
|
||||||
|
let calls = checker.calls.borrow();
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
assert_eq!(calls[0].0.as_path(), binary_path);
|
||||||
|
assert!(calls[0].1.contains(r#""type": "mixed""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_singbox_check_when_binary_path_is_not_supplied() {
|
||||||
|
let adapter = SingBoxAdapter::default();
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = vec![running_singbox_component()];
|
||||||
|
let checker = RecordingChecker::ok("should not run");
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(
|
||||||
|
SingBoxGenerationRequest::new(&targets, &components, None),
|
||||||
|
&checker,
|
||||||
|
)
|
||||||
|
.expect("binary path is optional");
|
||||||
|
|
||||||
|
assert_eq!(generated.check, None);
|
||||||
|
assert!(checker.calls.borrow().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_local_singbox_config_when_required_component_is_missing() {
|
||||||
|
let adapter = SingBoxAdapter::default();
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = Vec::new();
|
||||||
|
let checker = RecordingChecker::ok("should not run");
|
||||||
|
|
||||||
|
let error = adapter
|
||||||
|
.generate_config(
|
||||||
|
SingBoxGenerationRequest::new(&targets, &components, None),
|
||||||
|
&checker,
|
||||||
|
)
|
||||||
|
.expect_err("local sing-box target requires component state");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingRequiredComponent);
|
||||||
|
assert!(checker.calls.borrow().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_local_singbox_config_when_component_is_not_running() {
|
||||||
|
let adapter = SingBoxAdapter::default();
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = vec![stopped_singbox_component()];
|
||||||
|
let checker = RecordingChecker::ok("should not run");
|
||||||
|
|
||||||
|
let error = adapter
|
||||||
|
.generate_config(
|
||||||
|
SingBoxGenerationRequest::new(&targets, &components, None),
|
||||||
|
&checker,
|
||||||
|
)
|
||||||
|
.expect_err("local sing-box target requires running component");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, SingBoxConfigErrorKind::RequiredComponentNotRunning);
|
||||||
|
assert!(checker.calls.borrow().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn propagates_failed_singbox_check_as_structured_error() {
|
||||||
|
let adapter = SingBoxAdapter::default();
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = vec![running_singbox_component()];
|
||||||
|
let checker = RecordingChecker::err("invalid config");
|
||||||
|
|
||||||
|
let error = adapter
|
||||||
|
.generate_config(
|
||||||
|
SingBoxGenerationRequest::new(
|
||||||
|
&targets,
|
||||||
|
&components,
|
||||||
|
Some(Path::new("sing-box.exe")),
|
||||||
|
),
|
||||||
|
&checker,
|
||||||
|
)
|
||||||
|
.expect_err("failed sing-box check should block generated config");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, SingBoxConfigErrorKind::CheckFailed);
|
||||||
|
assert!(error.message.contains("invalid config"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_proxifyre_apply_does_not_require_singbox_component() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let profiles = vec![discord_profile("home-gateway")];
|
||||||
|
let targets = vec![external_socks5_target()];
|
||||||
|
let components = vec![missing_singbox_component()];
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect("external SOCKS5 target should not require local sing-box");
|
||||||
|
let config: ProxiFyreConfig =
|
||||||
|
serde_json::from_str(&generated.contents).expect("generated proxifyre json");
|
||||||
|
|
||||||
|
assert_eq!(config.proxies.len(), 1);
|
||||||
|
assert_eq!(config.proxies[0].socks5_proxy_endpoint, "192.168.50.111:8080");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecordingChecker {
|
||||||
|
calls: RefCell<Vec<(PathBuf, String)>>,
|
||||||
|
result: Result<SingBoxCheckResult, SingBoxConfigError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingChecker {
|
||||||
|
fn ok(message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
calls: RefCell::new(Vec::new()),
|
||||||
|
result: Ok(SingBoxCheckResult {
|
||||||
|
checked: true,
|
||||||
|
success: true,
|
||||||
|
message: message.to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err(message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
calls: RefCell::new(Vec::new()),
|
||||||
|
result: Err(SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::CheckFailed,
|
||||||
|
message,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SingBoxConfigChecker for RecordingChecker {
|
||||||
|
fn check_config(
|
||||||
|
&self,
|
||||||
|
binary_path: &Path,
|
||||||
|
config_json: &str,
|
||||||
|
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||||
|
self.calls
|
||||||
|
.borrow_mut()
|
||||||
|
.push((binary_path.to_path_buf(), config_json.to_string()));
|
||||||
|
self.result.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discord_profile(target_id: &str) -> Profile {
|
||||||
|
Profile {
|
||||||
|
id: "discord".to_string(),
|
||||||
|
name: "Discord".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
target_id: target_id.to_string(),
|
||||||
|
protocols: vec![Protocol::Tcp, Protocol::Udp],
|
||||||
|
items: vec![ProfileItem {
|
||||||
|
item_type: ProfileItemType::Process,
|
||||||
|
value: "Discord".to_string(),
|
||||||
|
recursive: false,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn external_socks5_target() -> Target {
|
||||||
|
Target {
|
||||||
|
id: "home-gateway".to_string(),
|
||||||
|
name: "Home Gateway".to_string(),
|
||||||
|
kind: TargetKind::External,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "192.168.50.111".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
requires_component: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_singbox_target() -> Target {
|
||||||
|
Target {
|
||||||
|
id: "local-singbox".to_string(),
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
kind: TargetKind::Local,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 1080,
|
||||||
|
requires_component: Some(ComponentId::Singbox),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn running_singbox_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
state: ComponentState::Running,
|
||||||
|
installed: true,
|
||||||
|
running: true,
|
||||||
|
version: Some("1.11.0".to_string()),
|
||||||
|
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
|
||||||
|
problems: Vec::new(),
|
||||||
|
actions: vec!["Restart".to_string(), "Stop".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stopped_singbox_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
state: ComponentState::Stopped,
|
||||||
|
installed: true,
|
||||||
|
running: false,
|
||||||
|
version: Some("1.11.0".to_string()),
|
||||||
|
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
|
||||||
|
problems: vec!["Service is stopped".to_string()],
|
||||||
|
actions: vec!["Start".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_singbox_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
state: ComponentState::Missing,
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: vec!["Local sing-box is not installed".to_string()],
|
||||||
|
actions: vec!["Install Local sing-box".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
195
apps/windows-client/src-tauri/tests/storage_tests.rs
Normal file
195
apps/windows-client/src-tauri/tests/storage_tests.rs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
#[path = "../src/activity.rs"]
|
||||||
|
mod activity;
|
||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/storage.rs"]
|
||||||
|
mod storage;
|
||||||
|
|
||||||
|
use models::{
|
||||||
|
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, Profile,
|
||||||
|
ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
|
||||||
|
};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn storage_defaults_to_programdata_root() {
|
||||||
|
let expected = PathBuf::from(r"C:\ProgramData\VpnProxy");
|
||||||
|
|
||||||
|
assert_eq!(default_config_root(), expected);
|
||||||
|
assert_eq!(StoragePaths::default().root, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrips_profiles_targets_components_and_activity() {
|
||||||
|
let root = test_root("roundtrip");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
|
||||||
|
let profiles = vec![sample_profile("discord")];
|
||||||
|
let targets = vec![sample_target("home-gateway")];
|
||||||
|
let components = vec![sample_component()];
|
||||||
|
let activity = vec![sample_activity(
|
||||||
|
"created",
|
||||||
|
"2026-01-01T10:00:00Z",
|
||||||
|
ActivityLevel::Success,
|
||||||
|
)];
|
||||||
|
|
||||||
|
storage.write_profiles(&profiles).expect("write profiles");
|
||||||
|
storage.write_targets(&targets).expect("write targets");
|
||||||
|
storage
|
||||||
|
.write_components(&components)
|
||||||
|
.expect("write components");
|
||||||
|
storage.write_activity(&activity).expect("write activity");
|
||||||
|
|
||||||
|
assert_eq!(storage.read_profiles().expect("read profiles"), profiles);
|
||||||
|
assert_eq!(storage.read_targets().expect("read targets"), targets);
|
||||||
|
assert_eq!(storage.read_components().expect("read components"), components);
|
||||||
|
assert_eq!(storage.read_activity().expect("read activity"), activity);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_json_falls_back_to_empty_collection() {
|
||||||
|
let root = test_root("invalid-json");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
storage.ensure_dirs().expect("create storage dirs");
|
||||||
|
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
storage.read_profiles().expect("invalid profiles fallback"),
|
||||||
|
Vec::<Profile>::new()
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_creates_backup_before_overwriting_source_file() {
|
||||||
|
let root = test_root("backup");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
let first = vec![sample_profile("first")];
|
||||||
|
let second = vec![sample_profile("second")];
|
||||||
|
|
||||||
|
storage.write_profiles(&first).expect("first write");
|
||||||
|
storage.write_profiles(&second).expect("second write");
|
||||||
|
|
||||||
|
let backup = backup_path(&storage.paths().profiles_file);
|
||||||
|
assert!(backup.exists(), "backup file should exist");
|
||||||
|
|
||||||
|
let backup_contents = fs::read_to_string(backup).expect("read backup");
|
||||||
|
let backup_profiles: Vec<Profile> =
|
||||||
|
serde_json::from_str(&backup_contents).expect("backup json");
|
||||||
|
|
||||||
|
assert_eq!(backup_profiles, first);
|
||||||
|
assert_eq!(storage.read_profiles().expect("current profiles"), second);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activity_entries_are_sorted_and_capped() {
|
||||||
|
let root = test_root("activity");
|
||||||
|
let storage = JsonStorage::with_activity_limit(root.clone(), 2);
|
||||||
|
|
||||||
|
storage
|
||||||
|
.append_activity(sample_activity(
|
||||||
|
"old",
|
||||||
|
"2026-01-01T10:00:00Z",
|
||||||
|
ActivityLevel::Info,
|
||||||
|
))
|
||||||
|
.expect("append old");
|
||||||
|
storage
|
||||||
|
.append_activity(sample_activity(
|
||||||
|
"new",
|
||||||
|
"2026-01-03T10:00:00Z",
|
||||||
|
ActivityLevel::Success,
|
||||||
|
))
|
||||||
|
.expect("append new");
|
||||||
|
storage
|
||||||
|
.append_activity(sample_activity(
|
||||||
|
"middle",
|
||||||
|
"2026-01-02T10:00:00Z",
|
||||||
|
ActivityLevel::Warning,
|
||||||
|
))
|
||||||
|
.expect("append middle");
|
||||||
|
|
||||||
|
let entries = storage.read_activity().expect("read capped activity");
|
||||||
|
|
||||||
|
assert_eq!(entries.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.id.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["new", "middle"]
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_root(name: &str) -> PathBuf {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system clock before unix epoch")
|
||||||
|
.as_nanos();
|
||||||
|
|
||||||
|
std::env::temp_dir().join(format!("vpn-proxy-storage-{name}-{timestamp}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup(root: &Path) {
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_profile(id: &str) -> Profile {
|
||||||
|
Profile {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: format!("Profile {id}"),
|
||||||
|
enabled: true,
|
||||||
|
target_id: "home-gateway".to_string(),
|
||||||
|
protocols: vec![Protocol::Tcp, Protocol::Udp],
|
||||||
|
items: vec![ProfileItem {
|
||||||
|
item_type: ProfileItemType::Process,
|
||||||
|
value: "Discord".to_string(),
|
||||||
|
recursive: false,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_target(id: &str) -> Target {
|
||||||
|
Target {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: "Home Gateway".to_string(),
|
||||||
|
kind: TargetKind::External,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "192.168.50.111".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
requires_component: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Proxyfier,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
state: ComponentState::Missing,
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: vec!["ProxiFyre не установлен".to_string()],
|
||||||
|
actions: vec!["Установить ProxiFyre".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_activity(id: &str, at: &str, level: ActivityLevel) -> ActivityEntry {
|
||||||
|
ActivityEntry {
|
||||||
|
id: id.to_string(),
|
||||||
|
at: at.to_string(),
|
||||||
|
level,
|
||||||
|
title: format!("Activity {id}"),
|
||||||
|
message: "Storage test activity".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
79
apps/windows-client/src/api/tauriCommands.ts
Normal file
79
apps/windows-client/src/api/tauriCommands.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type {
|
||||||
|
ActivityEntry,
|
||||||
|
ComponentStatus,
|
||||||
|
Profile,
|
||||||
|
ProfileInput,
|
||||||
|
Target,
|
||||||
|
TargetInput,
|
||||||
|
} from '../domain/types';
|
||||||
|
|
||||||
|
export interface CommandError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Array<{
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusResponse {
|
||||||
|
routeLine: string;
|
||||||
|
activeProfileCount: number;
|
||||||
|
routedAppCount: number;
|
||||||
|
activeTarget?: Target;
|
||||||
|
components: ComponentStatus[];
|
||||||
|
recentActivity: ActivityEntry[];
|
||||||
|
generatedConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelperApplyResult {
|
||||||
|
success: boolean;
|
||||||
|
changed: boolean;
|
||||||
|
action: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyProfilesResponse {
|
||||||
|
success: boolean;
|
||||||
|
changed: boolean;
|
||||||
|
message: string;
|
||||||
|
adapterId: string;
|
||||||
|
generatedConfigPath: string;
|
||||||
|
enabledProfiles: number;
|
||||||
|
routedApps: number;
|
||||||
|
helper: HelperApplyResult;
|
||||||
|
activity: ActivityEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatus(): Promise<StatusResponse> {
|
||||||
|
return invoke<StatusResponse>('get_status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProfiles(): Promise<Profile[]> {
|
||||||
|
return invoke<Profile[]>('get_profiles');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveProfile(input: ProfileInput): Promise<Profile> {
|
||||||
|
return invoke<Profile>('save_profile', { input });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTargets(): Promise<Target[]> {
|
||||||
|
return invoke<Target[]>('get_targets');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTarget(input: TargetInput): Promise<Target> {
|
||||||
|
return invoke<Target>('save_target', { input });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComponents(): Promise<ComponentStatus[]> {
|
||||||
|
return invoke<ComponentStatus[]>('get_components');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyProfiles(): Promise<ApplyProfilesResponse> {
|
||||||
|
return invoke<ApplyProfilesResponse>('apply_profiles');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openConfigLocation(): Promise<string> {
|
||||||
|
return invoke<string>('open_config_location');
|
||||||
|
}
|
||||||
462
apps/windows-client/src/app/App.tsx
Normal file
462
apps/windows-client/src/app/App.tsx
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
applyProfiles,
|
||||||
|
getComponents,
|
||||||
|
getProfiles,
|
||||||
|
getStatus,
|
||||||
|
getTargets,
|
||||||
|
openConfigLocation,
|
||||||
|
saveProfile,
|
||||||
|
saveTarget,
|
||||||
|
type ApplyProfilesResponse,
|
||||||
|
} from '../api/tauriCommands';
|
||||||
|
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, Target } from '../domain/types';
|
||||||
|
|
||||||
|
type DraftItemType = Extract<ProfileItemType, 'process' | 'exe'>;
|
||||||
|
|
||||||
|
interface DraftItem {
|
||||||
|
id: string;
|
||||||
|
type: DraftItemType;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Notice {
|
||||||
|
kind: 'success' | 'error' | 'info';
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAIN_TARGET_ID = 'main-proxy';
|
||||||
|
const MAIN_PROFILE_ID = 'main-profile';
|
||||||
|
|
||||||
|
const fallbackComponents: ComponentStatus[] = [
|
||||||
|
{
|
||||||
|
id: 'proxyfier',
|
||||||
|
name: 'ProxiFyre',
|
||||||
|
state: 'missing',
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
problems: ['ProxiFyre не найден'],
|
||||||
|
actions: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [proxyInput, setProxyInput] = useState('');
|
||||||
|
const [profileId, setProfileId] = useState(MAIN_PROFILE_ID);
|
||||||
|
const [targetId, setTargetId] = useState(MAIN_TARGET_ID);
|
||||||
|
const [items, setItems] = useState<DraftItem[]>([]);
|
||||||
|
const [loadedProfiles, setLoadedProfiles] = useState<Profile[]>([]);
|
||||||
|
const [newItemType, setNewItemType] = useState<DraftItemType>('process');
|
||||||
|
const [newItemValue, setNewItemValue] = useState('');
|
||||||
|
const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
|
||||||
|
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
|
||||||
|
const [notice, setNotice] = useState<Notice | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isApplying, setIsApplying] = useState(false);
|
||||||
|
const [isOpeningConfig, setIsOpeningConfig] = useState(false);
|
||||||
|
|
||||||
|
const proxyfier = useMemo(
|
||||||
|
() => components.find((component) => component.id === 'proxyfier'),
|
||||||
|
[components],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [status, profiles, targets, detectedComponents] = await Promise.all([
|
||||||
|
getStatus(),
|
||||||
|
getProfiles(),
|
||||||
|
getTargets(),
|
||||||
|
getComponents(),
|
||||||
|
]);
|
||||||
|
const activeProfiles = profiles.filter((profile) => profile.enabled);
|
||||||
|
const mainProfile = profiles.find((profile) => profile.id === MAIN_PROFILE_ID);
|
||||||
|
const activeProfile = mainProfile ?? activeProfiles[0];
|
||||||
|
const activeTarget = targetForUi(targets, status.activeTarget, activeProfile);
|
||||||
|
const editableProfiles = mainProfile ? [mainProfile] : activeProfiles;
|
||||||
|
|
||||||
|
if (activeTarget) setProxyInput(formatProxy(activeTarget));
|
||||||
|
setItems(itemsForProfiles(editableProfiles));
|
||||||
|
setLoadedProfiles(profiles);
|
||||||
|
setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID);
|
||||||
|
setTargetId(activeTarget?.id ?? activeProfile?.targetId ?? MAIN_TARGET_ID);
|
||||||
|
|
||||||
|
setComponents(detectedComponents);
|
||||||
|
setGeneratedConfigPath(status.generatedConfigPath);
|
||||||
|
setNotice(null);
|
||||||
|
} catch {
|
||||||
|
setNotice({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Режим предпросмотра',
|
||||||
|
text: 'Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
const value = normalizeItemValue(newItemValue, newItemType);
|
||||||
|
if (!value) {
|
||||||
|
setNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Нечего добавить',
|
||||||
|
text: newItemType === 'process' ? 'Введи имя процесса.' : 'Введи путь к EXE-файлу.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.some((item) => item.type === newItemType && sameValue(item.value, value))) {
|
||||||
|
setNotice({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Уже добавлено',
|
||||||
|
text: value,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setItems((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `${newItemType}-${Date.now()}`,
|
||||||
|
type: newItemType,
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setNewItemValue('');
|
||||||
|
setNotice(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(id: string) {
|
||||||
|
setItems((current) => current.filter((item) => item.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateConfig() {
|
||||||
|
let parsedProxy: ParsedProxy;
|
||||||
|
try {
|
||||||
|
parsedProxy = parseProxy(proxyInput);
|
||||||
|
if (!items.length) {
|
||||||
|
throw new Error('Добавь хотя бы один процесс или EXE-файл.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Проверь данные',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsApplying(true);
|
||||||
|
try {
|
||||||
|
await saveTarget({
|
||||||
|
id: targetId,
|
||||||
|
name: 'Основной прокси',
|
||||||
|
kind: 'external',
|
||||||
|
protocol: parsedProxy.protocol,
|
||||||
|
host: parsedProxy.host,
|
||||||
|
port: parsedProxy.port,
|
||||||
|
});
|
||||||
|
await saveProfile({
|
||||||
|
id: profileId,
|
||||||
|
name: 'Приложения через прокси',
|
||||||
|
enabled: true,
|
||||||
|
targetId,
|
||||||
|
protocols: ['TCP', 'UDP'],
|
||||||
|
items: items.map(profileItemInput),
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
loadedProfiles
|
||||||
|
.filter((profile) => profile.enabled && profile.id !== profileId)
|
||||||
|
.map((profile) => saveProfile(profileInputFromProfile(profile, false))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await applyProfiles();
|
||||||
|
const [status, detectedComponents, profiles] = await Promise.all([
|
||||||
|
getStatus(),
|
||||||
|
getComponents(),
|
||||||
|
getProfiles(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setComponents(detectedComponents);
|
||||||
|
setGeneratedConfigPath(status.generatedConfigPath);
|
||||||
|
setLoadedProfiles(profiles);
|
||||||
|
setNotice(noticeFromApply(result));
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Конфиг не обновлен',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsApplying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConfig() {
|
||||||
|
setIsOpeningConfig(true);
|
||||||
|
try {
|
||||||
|
const openedPath = await openConfigLocation();
|
||||||
|
setNotice({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Конфиг открыт',
|
||||||
|
text: openedPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Не удалось открыть конфиг',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsOpeningConfig(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="simple-shell">
|
||||||
|
<section className="simple-panel">
|
||||||
|
<header className="simple-header">
|
||||||
|
<div>
|
||||||
|
<small>VPN Proxy</small>
|
||||||
|
<h1>Прокси для приложений</h1>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="ghost-button" onClick={refresh} disabled={isLoading}>
|
||||||
|
{isLoading ? 'Ищу...' : 'Обновить'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={`finder-card ${proxyfier?.installed ? 'found' : 'missing'}`}>
|
||||||
|
<span className="status-light" />
|
||||||
|
<div>
|
||||||
|
<strong>{proxyfierTitle(proxyfier)}</strong>
|
||||||
|
<span>{proxyfierDetails(proxyfier)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="simple-field">
|
||||||
|
<span>Прокси</span>
|
||||||
|
<input
|
||||||
|
value={proxyInput}
|
||||||
|
onChange={(event) => setProxyInput(event.target.value)}
|
||||||
|
placeholder="socks5://127.0.0.1:1080"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<section className="apps-section">
|
||||||
|
<div className="section-head">
|
||||||
|
<h2>Приложения</h2>
|
||||||
|
<span>{items.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="add-line">
|
||||||
|
<select
|
||||||
|
value={newItemType}
|
||||||
|
onChange={(event) => setNewItemType(event.target.value as DraftItemType)}
|
||||||
|
>
|
||||||
|
<option value="process">Процесс</option>
|
||||||
|
<option value="exe">EXE-файл</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
value={newItemValue}
|
||||||
|
onChange={(event) => setNewItemValue(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') addItem();
|
||||||
|
}}
|
||||||
|
placeholder={newItemType === 'process' ? 'Discord' : 'C:\\Apps\\app.exe'}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={addItem}>
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="app-list">
|
||||||
|
{items.length ? (
|
||||||
|
items.map((item) => (
|
||||||
|
<div className="app-row" key={item.id}>
|
||||||
|
<div>
|
||||||
|
<strong>{item.value}</strong>
|
||||||
|
<span>{item.type === 'process' ? 'процесс' : 'EXE-файл'}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => removeItem(item.id)} aria-label={`Удалить ${item.value}`}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">Добавь процесс или путь к EXE-файлу.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<div className={`notice-line ${notice.kind}`}>
|
||||||
|
<strong>{notice.title}</strong>
|
||||||
|
<span>{notice.text}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="command-row">
|
||||||
|
<button type="button" className="apply-button" onClick={updateConfig} disabled={isApplying}>
|
||||||
|
{isApplying ? 'Обновляю...' : 'Обновить конфиг'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="open-config-button"
|
||||||
|
onClick={openConfig}
|
||||||
|
disabled={isOpeningConfig}
|
||||||
|
>
|
||||||
|
{isOpeningConfig ? '...' : 'Открыть'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generatedConfigPath ? <p className="config-path">{generatedConfigPath}</p> : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedProxy {
|
||||||
|
protocol: 'socks5';
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProxy(rawValue: string): ParsedProxy {
|
||||||
|
const value = rawValue.trim();
|
||||||
|
if (!value) throw new Error('Введи адрес прокси.');
|
||||||
|
|
||||||
|
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`;
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(withProtocol);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Формат: socks5://host:port или host:port.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = parsed.protocol.replace(':', '').toLowerCase();
|
||||||
|
if (protocol !== 'socks5') {
|
||||||
|
throw new Error('Сейчас поддерживается только SOCKS5.');
|
||||||
|
}
|
||||||
|
if (parsed.username || parsed.password) {
|
||||||
|
throw new Error('Прокси с логином и паролем пока не поддерживаются.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
const port = Number(parsed.port);
|
||||||
|
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
throw new Error('Укажи хост и порт прокси.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { protocol: 'socks5', host, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetForUi(targets: Target[], activeTarget: Target | undefined, profile: Profile | undefined) {
|
||||||
|
if (activeTarget) return activeTarget;
|
||||||
|
if (profile) return targets.find((target) => target.id === profile.targetId);
|
||||||
|
return targets.find((target) => target.id === MAIN_TARGET_ID) ?? targets.find((target) => target.kind === 'external');
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemsForProfiles(profiles: Profile[]): DraftItem[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const items: DraftItem[] = [];
|
||||||
|
|
||||||
|
for (const profile of profiles) {
|
||||||
|
for (const item of profile.items) {
|
||||||
|
if (item.type !== 'process' && item.type !== 'exe') continue;
|
||||||
|
|
||||||
|
const key = `${item.type}:${item.value.trim().toLowerCase()}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
items.push({
|
||||||
|
id: `${item.type}-${items.length}-${item.value}`,
|
||||||
|
type: item.type,
|
||||||
|
value: item.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProxy(target: Target) {
|
||||||
|
return target.protocol === 'socks5'
|
||||||
|
? `${target.host}:${target.port}`
|
||||||
|
: `${target.protocol}://${target.host}:${target.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItemValue(value: string, type: DraftItemType) {
|
||||||
|
const clean = value.trim().replace(/^"|"$/g, '');
|
||||||
|
if (!clean) return '';
|
||||||
|
if (type === 'exe') return clean;
|
||||||
|
|
||||||
|
return clean
|
||||||
|
.split(/[\\/]/)
|
||||||
|
.pop()
|
||||||
|
?.replace(/\.exe$/i, '')
|
||||||
|
.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileItemInput(item: DraftItem): ProfileItemInput {
|
||||||
|
return {
|
||||||
|
type: item.type,
|
||||||
|
value: item.value,
|
||||||
|
recursive: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileInputFromProfile(profile: Profile, enabled: boolean) {
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.name,
|
||||||
|
enabled,
|
||||||
|
targetId: profile.targetId,
|
||||||
|
protocols: profile.protocols,
|
||||||
|
items: profile.items.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
value: item.value,
|
||||||
|
recursive: item.recursive,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyfierTitle(component: ComponentStatus | undefined) {
|
||||||
|
if (!component) return 'ProxiFyre не проверен';
|
||||||
|
if (component.running) return 'ProxiFyre найден и запущен';
|
||||||
|
if (component.installed) return 'ProxiFyre найден';
|
||||||
|
return 'ProxiFyre не найден';
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyfierDetails(component: ComponentStatus | undefined) {
|
||||||
|
if (!component) return 'Нажми «Обновить», чтобы проверить компьютер.';
|
||||||
|
if (component.path) return component.path;
|
||||||
|
return component.problems[0] ?? 'Путь установки не найден.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function noticeFromApply(result: ApplyProfilesResponse): Notice {
|
||||||
|
return {
|
||||||
|
kind: result.success ? 'success' : 'error',
|
||||||
|
title: result.success ? 'Конфиг обновлен' : 'Конфиг создан, но не применен',
|
||||||
|
text: result.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameValue(left: string, right: string) {
|
||||||
|
return left.trim().toLowerCase() === right.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (typeof error === 'string') return error;
|
||||||
|
if (error && typeof error === 'object' && 'message' in error) {
|
||||||
|
return String((error as { message: unknown }).message);
|
||||||
|
}
|
||||||
|
return 'Неизвестная ошибка.';
|
||||||
|
}
|
||||||
77
apps/windows-client/src/domain/types.ts
Normal file
77
apps/windows-client/src/domain/types.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
export type Protocol = 'TCP' | 'UDP';
|
||||||
|
export type ProfileItemType = 'process' | 'folder' | 'exe';
|
||||||
|
export type TargetKind = 'local' | 'external';
|
||||||
|
export type ProxyProtocol = 'socks5' | 'http';
|
||||||
|
export type ComponentId = 'control-app' | 'proxyfier' | 'singbox';
|
||||||
|
export type ComponentState = 'installed' | 'missing' | 'stopped' | 'running' | 'error';
|
||||||
|
export type ActivityLevel = 'info' | 'warning' | 'error' | 'success';
|
||||||
|
|
||||||
|
export interface ProfileItemInput {
|
||||||
|
type: ProfileItemType | string;
|
||||||
|
value: string;
|
||||||
|
recursive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileInput {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
targetId?: string;
|
||||||
|
protocols?: string[];
|
||||||
|
items?: ProfileItemInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileItem {
|
||||||
|
type: ProfileItemType;
|
||||||
|
value: string;
|
||||||
|
recursive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
targetId: string;
|
||||||
|
protocols: Protocol[];
|
||||||
|
items: ProfileItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TargetInput {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
kind?: TargetKind | string;
|
||||||
|
protocol?: ProxyProtocol | string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
requiresComponent?: ComponentId | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Target {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: TargetKind;
|
||||||
|
protocol: ProxyProtocol;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
requiresComponent?: ComponentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentStatus {
|
||||||
|
id: ComponentId;
|
||||||
|
name: string;
|
||||||
|
state: ComponentState;
|
||||||
|
installed: boolean;
|
||||||
|
running: boolean;
|
||||||
|
version?: string;
|
||||||
|
path?: string;
|
||||||
|
problems: string[];
|
||||||
|
actions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityEntry {
|
||||||
|
id: string;
|
||||||
|
at: string;
|
||||||
|
level: ActivityLevel;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
11
apps/windows-client/src/main.tsx
Normal file
11
apps/windows-client/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './app/App';
|
||||||
|
import './styles/app.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
|
||||||
325
apps/windows-client/src/styles/app.css
Normal file
325
apps/windows-client/src/styles/app.css
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
:root {
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
color: #e5e7eb;
|
||||||
|
background: #101216;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #101216;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-shell {
|
||||||
|
display: block;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #101216;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-panel {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #101216;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header,
|
||||||
|
.finder-card,
|
||||||
|
.section-head,
|
||||||
|
.add-line,
|
||||||
|
.app-row,
|
||||||
|
.notice-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header {
|
||||||
|
margin: -18px -18px 16px;
|
||||||
|
border-bottom: 1px solid #2a2f3a;
|
||||||
|
background: #181b22;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header small,
|
||||||
|
.simple-field span,
|
||||||
|
.app-row span,
|
||||||
|
.config-path,
|
||||||
|
.finder-card span {
|
||||||
|
color: #8d99ae;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header h1,
|
||||||
|
.section-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header h1 {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button,
|
||||||
|
.add-line button,
|
||||||
|
.app-row button,
|
||||||
|
.open-config-button {
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px solid #343b49;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #242a35;
|
||||||
|
color: #eef2ff;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button:hover,
|
||||||
|
.add-line button:hover,
|
||||||
|
.app-row button:hover,
|
||||||
|
.open-config-button:hover {
|
||||||
|
background: #2d3543;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card {
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-height: 56px;
|
||||||
|
border: 1px solid #2b3342;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #151923;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card > div,
|
||||||
|
.app-row > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card strong,
|
||||||
|
.finder-card span,
|
||||||
|
.app-row strong,
|
||||||
|
.app-row span {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-light {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card.found .status-light {
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card.missing .status-light {
|
||||||
|
background: #f59e0b;
|
||||||
|
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-field input,
|
||||||
|
.add-line input,
|
||||||
|
.add-line select {
|
||||||
|
min-height: 42px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #343b49;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0d1016;
|
||||||
|
color: #f8fafc;
|
||||||
|
outline: none;
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-field input:focus,
|
||||||
|
.add-line input:focus,
|
||||||
|
.add-line select:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 1px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head span {
|
||||||
|
min-width: 28px;
|
||||||
|
border: 1px solid #343b49;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1b202b;
|
||||||
|
color: #dbeafe;
|
||||||
|
padding: 3px 9px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 130px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-line button {
|
||||||
|
min-width: 104px;
|
||||||
|
border-color: #166534;
|
||||||
|
background: #14532d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-line button:hover {
|
||||||
|
background: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-row,
|
||||||
|
.empty-state,
|
||||||
|
.notice-line {
|
||||||
|
border: 1px solid #2b3342;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #131720;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-row button {
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #8d99ae;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-line {
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-line strong,
|
||||||
|
.notice-line span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-line.success {
|
||||||
|
border-color: rgba(34, 197, 94, 0.42);
|
||||||
|
background: rgba(20, 83, 45, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-line.error {
|
||||||
|
border-color: rgba(239, 68, 68, 0.42);
|
||||||
|
background: rgba(127, 29, 29, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-line.info {
|
||||||
|
border-color: rgba(59, 130, 246, 0.42);
|
||||||
|
background: rgba(30, 58, 138, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 132px;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-button {
|
||||||
|
min-height: 46px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #16a34a;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #22c55e;
|
||||||
|
color: #04130a;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-button:hover {
|
||||||
|
background: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-config-button {
|
||||||
|
min-height: 46px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-path {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.simple-shell {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-panel {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header,
|
||||||
|
.app-row,
|
||||||
|
.notice-line {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-line {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/windows-client/tsconfig.json
Normal file
22
apps/windows-client/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
|
|
||||||
18
apps/windows-client/vite.config.ts
Normal file
18
apps/windows-client/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
host: host || false,
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
watch: {
|
||||||
|
ignored: ['**/src-tauri/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
1342
docs/goals/windows-modular-client/EVIDENCE.md
Normal file
1342
docs/goals/windows-modular-client/EVIDENCE.md
Normal file
File diff suppressed because it is too large
Load Diff
16
docs/goals/windows-modular-client/GOAL.md
Normal file
16
docs/goals/windows-modular-client/GOAL.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Goal: Windows Tauri Proxy Client
|
||||||
|
|
||||||
|
Use Krypton Execution to execute `docs/goals/windows-modular-client/PLAN.md`.
|
||||||
|
|
||||||
|
Core rules:
|
||||||
|
- Treat `PLAN.md` as the source plan.
|
||||||
|
- Preserve intent, ownership, contract, cutover, evidence, and kill criteria.
|
||||||
|
- Build a separate Tauri 2 Windows desktop app under `apps/windows-client`.
|
||||||
|
- Do not implement Windows by extending the current Node gateway/client server.
|
||||||
|
- Keep Control App, Proxyfier Layer, and Local sing-box separately installable and operable.
|
||||||
|
- Make external proxy target + ProxiFyre profile apply the MVP.
|
||||||
|
- Keep Local sing-box optional; it must not be required for external target profiles.
|
||||||
|
- Keep generated ProxiFyre and sing-box configs derived from source models.
|
||||||
|
- Capture acceptance evidence from the target user's perspective and record it in `EVIDENCE.md`.
|
||||||
|
- Say "implemented but unproven" if Windows-only privileged evidence cannot be captured.
|
||||||
|
|
||||||
506
docs/goals/windows-modular-client/PLAN.md
Normal file
506
docs/goals/windows-modular-client/PLAN.md
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
# Windows Tauri Proxy Client Implementation Plan
|
||||||
|
|
||||||
|
**Intent:** Build a separate Windows desktop proxy management app using Tauri 2, React, TypeScript, and Rust. The app manages three independent components: Control App, Proxyfier Layer, and optional Local sing-box.
|
||||||
|
**Current Behavior:** The repo contains a gateway/client Node + React web application and planning documents for a Windows mode inside that app. A newer product/technology brief now targets a standalone Windows desktop utility instead of extending the existing web control panel.
|
||||||
|
**Expected Outcome:** A compact Windows desktop utility lets the user configure app-level proxy routing through external SOCKS5/HTTP targets first, then optionally install and use local sing-box. The app remains useful when sing-box is absent.
|
||||||
|
**Target-Perspective Output:** A Windows user opens the desktop app, sees Overview, Profiles, Targets, Components, and Logs, adds Discord or another process/folder/exe profile, selects an external proxy target, applies changes to the proxyfier layer, and sees component status plus recent activity. Later, installing Local sing-box adds a local target without changing the profile model.
|
||||||
|
**Truth Owner:** Source configuration lives in the Tauri app's Rust domain model and JSON files under `C:\ProgramData\VpnProxy`. Generated ProxiFyre and sing-box configs are derived artifacts. Privileged install/service operations are owned by explicit helper/installer flows, not by React UI state.
|
||||||
|
**Contract Boundary:** React UI calls typed Tauri commands. Tauri Rust backend validates and persists profiles/targets/components. Proxy routing is behind a `ProxyRouterAdapter` boundary, with ProxiFyre as the first adapter. Privileged operations go through explicit helper/install commands returning structured JSON.
|
||||||
|
**Cutover:** Supersede the prior Node `APP_MODE=windows` implementation direction. Keep existing gateway/client code intact. New Windows work lives under a separate Tauri app slice.
|
||||||
|
**Displaced Path:** The old plan to add Windows mode into `src/server`/`src/web` is demoted to historical context. Do not add a third app mode to the current Node server for this product.
|
||||||
|
**Value Density:** The smallest high-value slice is the desktop app MVP with external SOCKS5 target + ProxiFyre profile apply. Local sing-box is optional and comes after the proxyfier MVP is proven.
|
||||||
|
**Evidence Gate:** Evidence must include target-perspective app proof: built Tauri app or dev window screenshot/state, generated proxyfier config artifact, mocked or real helper response, and manual Windows checklist when privileged components are involved.
|
||||||
|
**Acceptance Evidence:** Automated tests pass for Rust/TypeScript domain logic, app build succeeds, the MVP can create a profile and generate/apply ProxiFyre config against an external target, and Windows manual evidence proves independent component behavior.
|
||||||
|
**Evidence Lane:** Record command output, app screenshots/state payloads, generated configs, and manual verification in `docs/goals/windows-modular-client/EVIDENCE.md`.
|
||||||
|
**Kill Criteria:** No Windows implementation inside current Node gateway/client server; no mandatory sing-box dependency; no generated config as source truth; no hidden installation during profile apply; no direct UI parsing of raw PowerShell/stdout.
|
||||||
|
**Architecture Slice:** New standalone Tauri app under `apps/windows-client`, plus docs updates that point from older Windows plans to this plan.
|
||||||
|
**Plan Review Gate:** Requires PRE review before implementation execution.
|
||||||
|
|
||||||
|
## Source Brief
|
||||||
|
|
||||||
|
Product and technology source brief:
|
||||||
|
|
||||||
|
- `docs/windows-client-product-tech-brief.md`
|
||||||
|
|
||||||
|
This plan turns that brief into an execution-ready implementation sequence.
|
||||||
|
|
||||||
|
## Outcome Contract
|
||||||
|
|
||||||
|
Plan title: Windows Tauri Proxy Client
|
||||||
|
|
||||||
|
Intent: Build a native-feeling Windows utility that manages app-level proxy routing while keeping Control App, Proxyfier Layer, and Local sing-box separately installable and operable.
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
- Existing runtime code is a Node HTTP server and Vite/React web UI for gateway and Mac-style client modes.
|
||||||
|
- Earlier Windows docs describe adding Windows mode to that existing app.
|
||||||
|
- The selected direction is now Tauri 2 + React/TypeScript + Rust as a separate Windows desktop app.
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- `apps/windows-client` contains a Tauri 2 app.
|
||||||
|
- The app has Overview, Profiles, Targets, Components, and Logs surfaces.
|
||||||
|
- Profiles store process/folder/exe source items.
|
||||||
|
- Targets store external proxy endpoints and optional local sing-box.
|
||||||
|
- ProxiFyre is the first proxy router adapter.
|
||||||
|
- Local sing-box is optional and never required for external target profiles.
|
||||||
|
|
||||||
|
Target-perspective output:
|
||||||
|
- User can install/run only the Control App.
|
||||||
|
- User can see Proxyfier and Local sing-box as separate components.
|
||||||
|
- User can add an external SOCKS5 target.
|
||||||
|
- User can add a Discord process profile.
|
||||||
|
- User can apply the profile to generated ProxiFyre config.
|
||||||
|
- User sees activity confirming whether apply succeeded or why it was blocked.
|
||||||
|
|
||||||
|
Truth owner:
|
||||||
|
- Rust core domain crate owns normalized models and validation.
|
||||||
|
- JSON source files under `C:\ProgramData\VpnProxy\config` own persisted profiles/targets/component preferences.
|
||||||
|
- `ProxyRouterAdapter` owns conversion from source models to proxy-router generated config.
|
||||||
|
- `SingBoxAdapter` owns generated local sing-box config and service contract.
|
||||||
|
- React UI owns only transient UI state.
|
||||||
|
|
||||||
|
Contract boundary:
|
||||||
|
- UI -> Tauri commands with typed request/response DTOs.
|
||||||
|
- Tauri commands -> Rust core services.
|
||||||
|
- Core services -> adapter traits.
|
||||||
|
- Adapter traits -> helper/install/service commands when privileged operations are needed.
|
||||||
|
- Helper/install commands return structured JSON, never unstructured text for app logic.
|
||||||
|
|
||||||
|
Cutover:
|
||||||
|
- Add superseded notes to old Windows Node-mode docs.
|
||||||
|
- Keep `docs/windows-client-product-tech-brief.md` as product brief.
|
||||||
|
- Make this `PLAN.md` the execution plan.
|
||||||
|
- Do not implement Windows by adding `APP_MODE=windows` to the current Node server.
|
||||||
|
|
||||||
|
Displaced path:
|
||||||
|
- Displace old "Windows mode in current web app" implementation.
|
||||||
|
- Displace "full install vs ProxiFyre-only" as dominant architecture; those become recipes composed from separate components.
|
||||||
|
|
||||||
|
Value density:
|
||||||
|
- MVP must prove app-level routing with external proxy target and ProxiFyre before local sing-box work expands scope.
|
||||||
|
|
||||||
|
Evidence gate:
|
||||||
|
- Tests and build are not enough.
|
||||||
|
- Capture app-visible state and generated config.
|
||||||
|
- Capture Windows manual evidence for service/helper actions when those tasks execute.
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- `cargo test` or equivalent Rust tests for domain/adapters.
|
||||||
|
- frontend typecheck/test/build for React.
|
||||||
|
- Tauri dev/build command result.
|
||||||
|
- Screenshot or state dump showing Windows app surfaces.
|
||||||
|
- Generated ProxiFyre config from a sample profile.
|
||||||
|
- Manual Windows checklist when privileged components are present.
|
||||||
|
|
||||||
|
Non-goals:
|
||||||
|
- No Electron.
|
||||||
|
- No extension of the current Node gateway/client UI for Windows MVP.
|
||||||
|
- No global Windows system proxy changes.
|
||||||
|
- No transparent routing without a proxy router.
|
||||||
|
- No mandatory local sing-box.
|
||||||
|
- No direct coupling of UI to ProxiFyre-specific config shape.
|
||||||
|
|
||||||
|
Risk if wrong:
|
||||||
|
- If built inside the current Node app, the product will inherit gateway/client assumptions and conflict with the selected Tauri direction.
|
||||||
|
- If ProxiFyre is not behind an adapter, licensing or engine changes will force UI/data rewrites.
|
||||||
|
- If privileged work is hidden behind apply, users lose control and failures become hard to diagnose.
|
||||||
|
|
||||||
|
## Architecture Slice
|
||||||
|
|
||||||
|
Files/directories to create:
|
||||||
|
- `apps/windows-client/package.json`
|
||||||
|
- `apps/windows-client/vite.config.ts`
|
||||||
|
- `apps/windows-client/tsconfig.json`
|
||||||
|
- `apps/windows-client/src/main.tsx`
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src/app/routes.tsx`
|
||||||
|
- `apps/windows-client/src/api/tauriCommands.ts`
|
||||||
|
- `apps/windows-client/src/domain/types.ts`
|
||||||
|
- `apps/windows-client/src/features/overview/*`
|
||||||
|
- `apps/windows-client/src/features/profiles/*`
|
||||||
|
- `apps/windows-client/src/features/targets/*`
|
||||||
|
- `apps/windows-client/src/features/components/*`
|
||||||
|
- `apps/windows-client/src/features/logs/*`
|
||||||
|
- `apps/windows-client/src/styles/*`
|
||||||
|
- `apps/windows-client/src-tauri/Cargo.toml`
|
||||||
|
- `apps/windows-client/src-tauri/tauri.conf.json`
|
||||||
|
- `apps/windows-client/src-tauri/capabilities/default.json`
|
||||||
|
- `apps/windows-client/src-tauri/src/main.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/commands.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/models.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/storage.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/activity.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/proxy_router.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/proxifyre.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/singbox.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/helper.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/*`
|
||||||
|
- `apps/windows-client/scripts/install-control-app.ps1`
|
||||||
|
- `apps/windows-client/scripts/install-proxyfier.ps1`
|
||||||
|
- `apps/windows-client/scripts/install-singbox.ps1`
|
||||||
|
|
||||||
|
Files to modify:
|
||||||
|
- `README.md`
|
||||||
|
- `docs/roadmap.md`
|
||||||
|
- `docs/superpowers/specs/2026-05-21-windows-client-design.md`
|
||||||
|
- `docs/superpowers/plans/2026-05-21-windows-client.md`
|
||||||
|
- `docs/goals/windows-modular-client/GOAL.md`
|
||||||
|
- `docs/goals/windows-modular-client/EVIDENCE.md`
|
||||||
|
|
||||||
|
Files to avoid:
|
||||||
|
- `src/server/*` except if a later explicit migration asks for shared code extraction.
|
||||||
|
- `src/web/*` for Windows MVP.
|
||||||
|
- Docker, entrypoint, and compose files.
|
||||||
|
- macOS installer.
|
||||||
|
|
||||||
|
Source of truth:
|
||||||
|
- `C:\ProgramData\VpnProxy\config\profiles.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\config\targets.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\config\components.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\state\activity.json`
|
||||||
|
|
||||||
|
Derived artifacts:
|
||||||
|
- `C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\generated\sing-box-config.json`
|
||||||
|
- ProxiFyre runtime config copied/backed up by helper/apply operation.
|
||||||
|
|
||||||
|
Read path:
|
||||||
|
- React UI calls Tauri commands.
|
||||||
|
- Tauri commands read JSON source via Rust storage service.
|
||||||
|
- Component status combines source preferences, filesystem checks, service checks, and helper responses.
|
||||||
|
|
||||||
|
Write path:
|
||||||
|
- React UI sends typed mutations.
|
||||||
|
- Rust validates with domain models.
|
||||||
|
- Rust writes source JSON atomically with backups.
|
||||||
|
- Apply generates derived config and invokes adapter/helper.
|
||||||
|
|
||||||
|
Integration points:
|
||||||
|
- ProxiFyre adapter emits `app-config.json` compatible output.
|
||||||
|
- Local sing-box adapter emits `sing-box` JSON config and validates via `sing-box check` when binary exists.
|
||||||
|
- Tauri sidecar/helper permissions are declared explicitly.
|
||||||
|
- Installer scripts may be launched or displayed explicitly, never silently during apply.
|
||||||
|
|
||||||
|
Migration/cutover:
|
||||||
|
- Older Windows docs point to this plan and source brief.
|
||||||
|
- Existing Node app remains gateway/client only.
|
||||||
|
- If shared subscription parsing is needed later, extract it intentionally into a shared package rather than importing server internals.
|
||||||
|
|
||||||
|
Acceptance evidence gate:
|
||||||
|
- MVP evidence must show external-target flow works without local sing-box.
|
||||||
|
- Optional sing-box evidence must show the same profile model can switch targets after installing sing-box.
|
||||||
|
|
||||||
|
## Task Board
|
||||||
|
|
||||||
|
### Task 1: Supersede Old Windows Node Plan
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- `docs/windows-client-product-tech-brief.md`
|
||||||
|
- old Windows docs/plans
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `docs/superpowers/specs/2026-05-21-windows-client-design.md`
|
||||||
|
- `docs/superpowers/plans/2026-05-21-windows-client.md`
|
||||||
|
- `docs/roadmap.md`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Runtime source files.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Old Windows documents clearly point to this Tauri plan and no longer read as implementation authority.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- `rg -n "Tauri|superseded|windows-client-product-tech-brief|apps/windows-client" README.md docs`
|
||||||
|
|
||||||
|
Depends on: none
|
||||||
|
|
||||||
|
Parallel safe: yes
|
||||||
|
|
||||||
|
### Task 2: Scaffold Tauri App Shell
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Tauri 2 app structure
|
||||||
|
- Product brief UI surfaces
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/package.json`
|
||||||
|
- `apps/windows-client/vite.config.ts`
|
||||||
|
- `apps/windows-client/tsconfig.json`
|
||||||
|
- `apps/windows-client/index.html`
|
||||||
|
- `apps/windows-client/src/*`
|
||||||
|
- `apps/windows-client/src-tauri/*`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Current root `src/server/*`
|
||||||
|
- Current root `src/web/*`
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Tauri app starts with empty shell and five navigation surfaces.
|
||||||
|
- No business logic yet.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- `cd apps/windows-client && npm install && npm run build`
|
||||||
|
- `cd apps/windows-client/src-tauri && cargo test` if Rust tests exist
|
||||||
|
|
||||||
|
Depends on: Task 1
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 3: Define Domain Models And Validation
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Profile/Target/Component models from brief
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src-tauri/src/models.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/validation.rs`
|
||||||
|
- `apps/windows-client/src/domain/types.ts`
|
||||||
|
- `apps/windows-client/src-tauri/tests/domain_tests.rs`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Adapter/helper code except trait references.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Typed Rust models for `Profile`, `ProfileItem`, `Target`, `ComponentStatus`, `ActivityEntry`.
|
||||||
|
- TypeScript DTOs mirror Rust command responses.
|
||||||
|
- Validation rejects malformed ports/protocols but allows missing local sing-box.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Rust tests showing process/folder/exe normalization and external target validation.
|
||||||
|
|
||||||
|
Depends on: Task 2
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 4: Implement JSON Storage And Activity Log
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Domain models from Task 3
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src-tauri/src/storage.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/activity.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/storage_tests.rs`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- UI screens except command wiring stubs.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Atomic JSON read/write for profiles, targets, components, and activity.
|
||||||
|
- Backups before overwriting source files.
|
||||||
|
- Config root defaults to `C:\ProgramData\VpnProxy`, with test override.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Tests prove roundtrip, invalid JSON fallback behavior, backup creation, activity cap/sort.
|
||||||
|
|
||||||
|
Depends on: Task 3
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 5: Add Proxy Router Adapter Boundary And ProxiFyre Adapter
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Domain models and storage
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/proxy_router.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/proxifyre.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/proxifyre_adapter_tests.rs`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Direct UI coupling to ProxiFyre config fields.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- `ProxyRouterAdapter` trait.
|
||||||
|
- `ProxiFyreAdapter` generates config from enabled profiles and targets.
|
||||||
|
- External target flow does not require sing-box.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Test generates ProxiFyre config for Discord + external SOCKS5 target.
|
||||||
|
- Test blocks local-singbox target only when target requires missing component.
|
||||||
|
|
||||||
|
Depends on: Task 4
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 6: Add Tauri Commands
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Storage and adapter services
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src-tauri/src/commands.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/main.rs`
|
||||||
|
- `apps/windows-client/src/api/tauriCommands.ts`
|
||||||
|
- `apps/windows-client/src-tauri/tests/command_tests.rs`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Full UI implementation beyond command call wrappers.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Commands for status, profiles, targets, components, scan/resolve preview, apply, logs.
|
||||||
|
- Commands return structured responses only.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Command tests or integration tests prove apply generates derived config and records activity using a mock adapter/helper.
|
||||||
|
|
||||||
|
Depends on: Task 5
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 7: Build MVP UI
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Tauri command API
|
||||||
|
- Product brief layout
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src/app/*`
|
||||||
|
- `apps/windows-client/src/features/overview/*`
|
||||||
|
- `apps/windows-client/src/features/profiles/*`
|
||||||
|
- `apps/windows-client/src/features/targets/*`
|
||||||
|
- `apps/windows-client/src/features/components/*`
|
||||||
|
- `apps/windows-client/src/features/logs/*`
|
||||||
|
- `apps/windows-client/src/styles/*`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Rust adapter behavior except fixing DTO mismatches.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Compact utility UI with Overview, Profiles, Targets, Components, Logs.
|
||||||
|
- User can create/edit profile, external target, and trigger apply.
|
||||||
|
- Missing sing-box is shown as valid optional state.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- `npm run build`
|
||||||
|
- Screenshot or browser/app state showing missing sing-box and usable external target flow.
|
||||||
|
|
||||||
|
Depends on: Task 6
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 8: Implement Helper And Explicit Installer Boundary
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Component model
|
||||||
|
- Security model from brief
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src-tauri/src/helper.rs`
|
||||||
|
- `apps/windows-client/src-tauri/capabilities/default.json`
|
||||||
|
- `apps/windows-client/scripts/install-control-app.ps1`
|
||||||
|
- `apps/windows-client/scripts/install-proxyfier.ps1`
|
||||||
|
- `apps/windows-client/scripts/install-singbox.ps1`
|
||||||
|
- `apps/windows-client/src-tauri/tests/helper_tests.rs`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Hidden installer invocation inside profile apply.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Helper command abstraction for status/service/apply.
|
||||||
|
- Installer scripts are explicit and idempotent.
|
||||||
|
- Tauri sidecar/shell permissions are narrow and documented.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Helper tests with mock command runner.
|
||||||
|
- PowerShell parser checks for installer scripts.
|
||||||
|
- Capability file shows limited sidecar permissions.
|
||||||
|
|
||||||
|
Depends on: Task 6
|
||||||
|
|
||||||
|
Parallel safe: partly, after command DTOs are stable
|
||||||
|
|
||||||
|
### Task 9: Add Optional Local Sing-Box Adapter
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- sing-box target model
|
||||||
|
- service/helper boundary
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/singbox.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs`
|
||||||
|
- `apps/windows-client/src/features/components/*`
|
||||||
|
- `apps/windows-client/src/features/targets/*`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Making sing-box mandatory for external targets.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Generate local sing-box config.
|
||||||
|
- Validate via `sing-box check` when binary exists.
|
||||||
|
- Local target appears only when installed/configured or as an explicit install prompt.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Tests show external target apply works without sing-box.
|
||||||
|
- Tests show local-singbox target requires installed/running component.
|
||||||
|
|
||||||
|
Depends on: Tasks 5 and 8
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 10: Package, Verify, And Record Evidence
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Completed MVP implementation
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/*`
|
||||||
|
- `README.md`
|
||||||
|
- `docs/roadmap.md`
|
||||||
|
- `docs/goals/windows-modular-client/EVIDENCE.md`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Unrelated app code.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Build/test commands documented.
|
||||||
|
- README explains separate Control App, Proxyfier, and Local sing-box install flows.
|
||||||
|
- Evidence file captures automated and target-perspective proof.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- `npm run build`
|
||||||
|
- Rust tests
|
||||||
|
- Tauri build/dev proof
|
||||||
|
- generated ProxiFyre config summary
|
||||||
|
- UI screenshot/state
|
||||||
|
- Windows manual checklist, or clearly mark `implemented but unproven` for Windows-only service behavior if not run on a Windows host.
|
||||||
|
|
||||||
|
Depends on: all previous tasks
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
## Manual Windows Verification Checklist
|
||||||
|
|
||||||
|
1. Install/run only Control App.
|
||||||
|
2. Verify Proxyfier and Local sing-box show missing as separate components.
|
||||||
|
3. Add external SOCKS5 target.
|
||||||
|
4. Add Discord process profile.
|
||||||
|
5. Apply profile; verify generated ProxiFyre config and activity entry.
|
||||||
|
6. Install Proxyfier separately; verify status changes.
|
||||||
|
7. Apply profile to real Proxyfier service.
|
||||||
|
8. Install Local sing-box separately.
|
||||||
|
9. Import subscription or config, select outbound, and start Local sing-box.
|
||||||
|
10. Switch existing profile from external target to Local sing-box and apply.
|
||||||
|
11. Stop/restart Proxyfier and Local sing-box separately.
|
||||||
|
12. Copy diagnostics and verify secrets are redacted.
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым |
|
| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым |
|
||||||
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
|
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
|
||||||
| `windows-gaming` | Windows для игр/Discord/Vesktop | native `sing-box.exe` + ProxiFyre | позже приводим в порядок |
|
| `windows-gaming` | Windows для игр/Discord/Vesktop | standalone Tauri 2 app + ProxiFyre adapter + optional native `sing-box.exe` | активное направление: `docs/goals/windows-modular-client/PLAN.md` |
|
||||||
|
|
||||||
## Gateway mode
|
## Gateway mode
|
||||||
|
|
||||||
@@ -84,15 +84,32 @@
|
|||||||
|
|
||||||
## Windows gaming mode
|
## Windows gaming mode
|
||||||
|
|
||||||
Цель: сохранить сценарий для Discord/Vesktop/игр.
|
Цель: отдельное Windows desktop-приложение для Discord/Vesktop/игр, где Control App, Proxyfier Layer и Local sing-box являются независимыми компонентами.
|
||||||
|
|
||||||
|
Current checkpoint:
|
||||||
|
|
||||||
|
- MVP slice exists under `apps/windows-client`.
|
||||||
|
- Frontend build passes with `npm run build`.
|
||||||
|
- Rust/Tauri native verification requires installing Rust/rustup and Visual Studio Build Tools with MSVC/Windows SDK.
|
||||||
|
- Local sing-box is optional; external SOCKS5 targets remain the first verified path.
|
||||||
|
|
||||||
Требования:
|
Требования:
|
||||||
|
|
||||||
- Native `sing-box.exe`.
|
- Standalone Tauri 2 + React/TypeScript + Rust app under `apps/windows-client`.
|
||||||
- Scheduled task или Windows service.
|
- Profiles for process/folder/exe app routing.
|
||||||
- ProxiFyre + WinPacketFilter для приложений, которые не умеют proxy.
|
- External SOCKS5/HTTP targets first; local `sing-box` is optional.
|
||||||
- Управление из PowerShell helper.
|
- Proxyfier adapter boundary with ProxiFyre as the first engine.
|
||||||
- Позже можно сделать Electron/Tauri UI поверх privileged helper.
|
- Explicit installers for Control App, Proxyfier Layer, and Local sing-box.
|
||||||
|
- Privileged helper/install operations return structured JSON.
|
||||||
|
|
||||||
|
Source docs:
|
||||||
|
|
||||||
|
- Product/tech brief: `docs/windows-client-product-tech-brief.md`.
|
||||||
|
- Execution plan: `docs/goals/windows-modular-client/PLAN.md`.
|
||||||
|
|
||||||
|
Superseded:
|
||||||
|
|
||||||
|
- The old Node `APP_MODE=windows` plan in `docs/superpowers/plans/2026-05-21-windows-client.md` is historical context, not the active implementation path.
|
||||||
|
|
||||||
## Рабочий порядок
|
## Рабочий порядок
|
||||||
|
|
||||||
@@ -102,4 +119,4 @@
|
|||||||
4. Реализовать Vite + React UI для subscription -> server select -> apply.
|
4. Реализовать Vite + React UI для subscription -> server select -> apply.
|
||||||
5. Добавить gateway docs/install script.
|
5. Добавить gateway docs/install script.
|
||||||
6. Потом переносить desktop-proxy.
|
6. Потом переносить desktop-proxy.
|
||||||
7. Потом приводить Windows mode к новой архитектуре.
|
7. Потом реализовать standalone Windows Tauri client по `docs/goals/windows-modular-client/PLAN.md`.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Windows Client Implementation Plan
|
# Windows Client Implementation Plan
|
||||||
|
|
||||||
|
> Superseded: do not execute this Node `APP_MODE=windows` plan as the current
|
||||||
|
> Windows implementation path. The active plan is the standalone Tauri 2 desktop
|
||||||
|
> app in `docs/goals/windows-modular-client/PLAN.md`, based on
|
||||||
|
> `docs/windows-client-product-tech-brief.md`.
|
||||||
|
> Content below is retained for historical context and may contradict the active
|
||||||
|
> Tauri plan.
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Restore the Windows proxy workflow as a script-first product with two install modes: full local `sing-box` + ProxiFyre, or ProxiFyre-only routing to an existing proxy, controlled by a clean local web UI.
|
**Goal:** Restore the Windows proxy workflow as a script-first product with two install modes: full local `sing-box` + ProxiFyre, or ProxiFyre-only routing to an existing proxy, controlled by a clean local web UI.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Windows Client Design
|
# Windows Client Design
|
||||||
|
|
||||||
|
> Superseded: this document describes the earlier Node/web-control Windows direction.
|
||||||
|
> The active Windows direction is a standalone Tauri 2 desktop app under
|
||||||
|
> `apps/windows-client`, driven by `docs/windows-client-product-tech-brief.md`
|
||||||
|
> and `docs/goals/windows-modular-client/PLAN.md`.
|
||||||
|
> Content below is retained for historical context and may contradict the active
|
||||||
|
> Tauri plan.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Restore the old Windows workflow in a cleaner product shape: a one-command PowerShell installer can install either a full local `sing-box` + ProxiFyre setup or ProxiFyre-only routing to an existing proxy, then expose a small local web UI for profiles, folders, executable files, status, and logs.
|
Restore the old Windows workflow in a cleaner product shape: a one-command PowerShell installer can install either a full local `sing-box` + ProxiFyre setup or ProxiFyre-only routing to an existing proxy, then expose a small local web UI for profiles, folders, executable files, status, and logs.
|
||||||
|
|||||||
635
docs/windows-client-product-tech-brief.md
Normal file
635
docs/windows-client-product-tech-brief.md
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
# Windows Proxy Client: Product And Technology Brief
|
||||||
|
|
||||||
|
Дата: 2026-07-03
|
||||||
|
|
||||||
|
Цель документа: описать, как должно выглядеть и работать Windows-приложение для управления proxy/VPN-маршрутизацией приложений, и какой стек лучше использовать для реализации.
|
||||||
|
|
||||||
|
Этот документ можно отдать другой модели или команде как исходное ТЗ.
|
||||||
|
|
||||||
|
## Коротко
|
||||||
|
|
||||||
|
Нужно Windows-приложение, которое разделяет систему на три независимые части:
|
||||||
|
|
||||||
|
1. **Control App**: маленькое desktop-приложение для настройки, статуса, профилей, логов и запуска операций.
|
||||||
|
2. **Proxyfier Layer**: отдельный компонент, который заставляет выбранные Windows-приложения ходить через SOCKS5/HTTP proxy, даже если они сами не умеют proxy.
|
||||||
|
3. **Local sing-box**: опциональный локальный VPN/proxy runtime. Его можно установить, не устанавливать, остановить, заменить внешним proxy target.
|
||||||
|
|
||||||
|
Главный принцип: пользователь не обязан ставить все сразу. Если у него уже есть proxy, ему нужны только Control App + Proxyfier. Если нужен локальный VPN-клиент, он отдельно ставит `sing-box`.
|
||||||
|
|
||||||
|
## Как это должно выглядеть
|
||||||
|
|
||||||
|
Приложение должно выглядеть как компактная системная утилита, а не как сайт.
|
||||||
|
|
||||||
|
Главный экран:
|
||||||
|
|
||||||
|
- верхняя строка: общий статус маршрута;
|
||||||
|
- три карточки компонентов: `Control App`, `Proxyfier`, `Local sing-box`;
|
||||||
|
- список активных профилей;
|
||||||
|
- кнопка `Apply changes`;
|
||||||
|
- короткая лента последних событий.
|
||||||
|
|
||||||
|
Пример главного статуса:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Selected apps -> ProxiFyre -> Local sing-box 127.0.0.1:1080 -> VPN
|
||||||
|
```
|
||||||
|
|
||||||
|
или:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Selected apps -> ProxiFyre -> Existing proxy 192.168.50.111:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `sing-box` не установлен, это не ошибка. Карточка должна показывать:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Local sing-box
|
||||||
|
Not installed
|
||||||
|
Install if you want this PC to run its own local VPN proxy.
|
||||||
|
```
|
||||||
|
|
||||||
|
Если Proxyfier не установлен, профили можно редактировать, но apply должен быть заблокирован:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Proxyfier is required to route selected apps.
|
||||||
|
Install Proxyfier
|
||||||
|
```
|
||||||
|
|
||||||
|
## Основные экраны
|
||||||
|
|
||||||
|
### 1. Overview
|
||||||
|
|
||||||
|
Показывает:
|
||||||
|
|
||||||
|
- текущий route line;
|
||||||
|
- статус Control App;
|
||||||
|
- статус Proxyfier;
|
||||||
|
- статус Local sing-box;
|
||||||
|
- активный proxy target;
|
||||||
|
- сколько приложений сейчас включено в routing;
|
||||||
|
- последние 5-10 событий.
|
||||||
|
|
||||||
|
Действия:
|
||||||
|
|
||||||
|
- restart Proxyfier;
|
||||||
|
- restart local sing-box, если установлен;
|
||||||
|
- open logs;
|
||||||
|
- copy diagnostics.
|
||||||
|
|
||||||
|
### 2. Profiles
|
||||||
|
|
||||||
|
Профиль - главный объект настройки.
|
||||||
|
|
||||||
|
Профиль содержит:
|
||||||
|
|
||||||
|
- название;
|
||||||
|
- enabled/disabled;
|
||||||
|
- proxy target;
|
||||||
|
- протоколы: TCP, UDP;
|
||||||
|
- список приложений.
|
||||||
|
|
||||||
|
Типы элементов:
|
||||||
|
|
||||||
|
- `process`: имя процесса, например `Discord`, `Telegram`, `Code`;
|
||||||
|
- `folder`: папка, приложение сканирует `.exe` внутри;
|
||||||
|
- `exe`: конкретный путь к `.exe`.
|
||||||
|
|
||||||
|
UI профиля:
|
||||||
|
|
||||||
|
- слева список профилей;
|
||||||
|
- справа детали выбранного профиля;
|
||||||
|
- поле выбора target;
|
||||||
|
- кнопки добавления: `Process`, `Folder`, `EXE`;
|
||||||
|
- preview resolved apps;
|
||||||
|
- `Save`;
|
||||||
|
- `Apply changes`.
|
||||||
|
|
||||||
|
Важно: пользователь должен видеть понятные исходные элементы, а не только сгенерированный конфиг Proxyfier.
|
||||||
|
|
||||||
|
### 3. Targets
|
||||||
|
|
||||||
|
Proxy target - это куда Proxyfier отправляет трафик выбранных приложений.
|
||||||
|
|
||||||
|
Типы targets:
|
||||||
|
|
||||||
|
- `Local sing-box`: `127.0.0.1:1080`, доступен только если local sing-box установлен и запущен;
|
||||||
|
- `Existing SOCKS5 proxy`: например `127.0.0.1:8080` или `192.168.50.111:8080`;
|
||||||
|
- `Existing HTTP proxy`, если выбранный proxyfier поддерживает HTTP.
|
||||||
|
|
||||||
|
На экране targets:
|
||||||
|
|
||||||
|
- список targets;
|
||||||
|
- проверка соединения;
|
||||||
|
- имя, host, port, protocol;
|
||||||
|
- статус last checked;
|
||||||
|
- кнопка set default.
|
||||||
|
|
||||||
|
### 4. Components
|
||||||
|
|
||||||
|
Отдельный экран или часть Overview.
|
||||||
|
|
||||||
|
Компоненты:
|
||||||
|
|
||||||
|
- Control App;
|
||||||
|
- Proxyfier;
|
||||||
|
- Local sing-box.
|
||||||
|
|
||||||
|
Для каждого:
|
||||||
|
|
||||||
|
- installed / not installed;
|
||||||
|
- running / stopped;
|
||||||
|
- version;
|
||||||
|
- path;
|
||||||
|
- service/task status;
|
||||||
|
- actions.
|
||||||
|
|
||||||
|
Actions должны быть явными:
|
||||||
|
|
||||||
|
- `Install`;
|
||||||
|
- `Repair`;
|
||||||
|
- `Start`;
|
||||||
|
- `Stop`;
|
||||||
|
- `Restart`;
|
||||||
|
- `Open folder`;
|
||||||
|
- `View logs`.
|
||||||
|
|
||||||
|
Нельзя делать скрытую установку `sing-box` при сохранении профиля.
|
||||||
|
|
||||||
|
### 5. Logs / Diagnostics
|
||||||
|
|
||||||
|
Должно быть две зоны:
|
||||||
|
|
||||||
|
- activity: действия пользователя и результат apply;
|
||||||
|
- runtime logs: proxyfier logs, sing-box logs, helper logs.
|
||||||
|
|
||||||
|
Кнопка `Copy diagnostics` должна собирать:
|
||||||
|
|
||||||
|
- версии компонентов;
|
||||||
|
- paths;
|
||||||
|
- running status;
|
||||||
|
- активные profiles;
|
||||||
|
- targets без секретов;
|
||||||
|
- последние ошибки;
|
||||||
|
- путь к сгенерированному proxyfier config.
|
||||||
|
|
||||||
|
## Пользовательские сценарии
|
||||||
|
|
||||||
|
### Сценарий A: у пользователя уже есть proxy
|
||||||
|
|
||||||
|
1. Пользователь устанавливает Control App.
|
||||||
|
2. Открывает приложение.
|
||||||
|
3. Видит, что Proxyfier не установлен, а sing-box отсутствует.
|
||||||
|
4. Нажимает `Install Proxyfier`.
|
||||||
|
5. Добавляет target `192.168.50.111:8080`.
|
||||||
|
6. Создает профиль `Discord`.
|
||||||
|
7. Добавляет process `Discord`.
|
||||||
|
8. Нажимает `Apply changes`.
|
||||||
|
9. Приложение генерирует config для Proxyfier и перезапускает proxyfier service.
|
||||||
|
|
||||||
|
Результат: Discord ходит через внешний proxy. Local sing-box не нужен.
|
||||||
|
|
||||||
|
### Сценарий B: пользователь хочет локальный VPN proxy
|
||||||
|
|
||||||
|
1. Пользователь устанавливает Control App.
|
||||||
|
2. Устанавливает Proxyfier.
|
||||||
|
3. Устанавливает Local sing-box.
|
||||||
|
4. Вводит subscription/VLESS link.
|
||||||
|
5. Выбирает сервер.
|
||||||
|
6. Local sing-box поднимает SOCKS5/HTTP endpoint на `127.0.0.1:1080`.
|
||||||
|
7. Профили используют target `Local sing-box`.
|
||||||
|
|
||||||
|
Результат: выбранные приложения ходят через локальный sing-box.
|
||||||
|
|
||||||
|
### Сценарий C: временно отключить VPN
|
||||||
|
|
||||||
|
1. Пользователь открывает профиль.
|
||||||
|
2. Меняет target с `Local sing-box` на внешний proxy или `Direct/Disabled`.
|
||||||
|
3. Нажимает `Apply changes`.
|
||||||
|
|
||||||
|
Результат: Proxyfier перегенерирован, local sing-box можно остановить отдельно.
|
||||||
|
|
||||||
|
## Рекомендуемый стек
|
||||||
|
|
||||||
|
### Desktop shell: Tauri 2
|
||||||
|
|
||||||
|
Рекомендация: **Tauri 2 + React + TypeScript + Rust backend**.
|
||||||
|
|
||||||
|
Почему:
|
||||||
|
|
||||||
|
- Tauri ориентирован на маленькие desktop-приложения и использует системный web renderer, поэтому приложение легче Electron.
|
||||||
|
- Можно писать UI на обычном web stack: React/TypeScript/Vite.
|
||||||
|
- Backend-часть на Rust хорошо подходит для Windows APIs, файлов, процессов, sidecar binaries и безопасных команд.
|
||||||
|
- Tauri поддерживает sidecar binaries, но требует явно выдать permissions на запуск sidecar, что полезно для security boundary.
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
- React;
|
||||||
|
- TypeScript;
|
||||||
|
- Vite;
|
||||||
|
- TanStack Query для загрузки/кэша status/API;
|
||||||
|
- Zustand или Jotai для локального UI state;
|
||||||
|
- Zod для валидации JSON-моделей;
|
||||||
|
- CSS modules или Tailwind. Для этой утилиты лучше сдержанный Windows-like UI, без тяжелой дизайн-системы.
|
||||||
|
|
||||||
|
Backend внутри Tauri:
|
||||||
|
|
||||||
|
- Rust commands для простых операций;
|
||||||
|
- отдельный `core` crate с доменной логикой;
|
||||||
|
- отдельный `windows-helper` binary для elevated/privileged действий.
|
||||||
|
|
||||||
|
Не рекомендую начинать с Electron, если нет жесткой причины. Electron проще для web-команды, но тяжелее по размеру и памяти. Для маленькой системной утилиты Tauri подходит лучше.
|
||||||
|
|
||||||
|
### Privileged helper
|
||||||
|
|
||||||
|
Нужно отделить обычное приложение от операций администратора.
|
||||||
|
|
||||||
|
Рекомендуемая модель:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tauri UI
|
||||||
|
-> Rust app backend
|
||||||
|
-> unprivileged status/read operations
|
||||||
|
-> explicit elevated helper for install/repair/service operations
|
||||||
|
```
|
||||||
|
|
||||||
|
Privileged helper может быть:
|
||||||
|
|
||||||
|
- Rust CLI, который запускается elevated только для конкретной операции;
|
||||||
|
- Rust Windows service/helper, если нужен постоянный privileged agent;
|
||||||
|
- PowerShell scripts только как thin installer layer, не как основная бизнес-логика.
|
||||||
|
|
||||||
|
Для MVP можно сделать проще:
|
||||||
|
|
||||||
|
- installers запускаются отдельно от имени администратора;
|
||||||
|
- Control App работает обычным пользователем;
|
||||||
|
- service start/stop/restart идет через helper command;
|
||||||
|
- helper возвращает JSON, UI не парсит текст PowerShell.
|
||||||
|
|
||||||
|
Контракт helper:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "proxyfier.apply",
|
||||||
|
"payload": {
|
||||||
|
"configPath": "C:\\Tools\\ProxiFyre\\app-config.json",
|
||||||
|
"config": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"action": "proxyfier.apply",
|
||||||
|
"changed": true,
|
||||||
|
"message": "Proxyfier config applied and service restarted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибки:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"action": "proxyfier.apply",
|
||||||
|
"error": "Proxyfier service is not installed",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service/runtime management
|
||||||
|
|
||||||
|
Для `sing-box` как background runtime:
|
||||||
|
|
||||||
|
- использовать `sing-box check` перед применением config;
|
||||||
|
- хранить config отдельно;
|
||||||
|
- запускать как Windows service или scheduled task;
|
||||||
|
- для service wrapper можно использовать WinSW, если не хочется писать собственный Windows service wrapper.
|
||||||
|
|
||||||
|
Практичный вариант:
|
||||||
|
|
||||||
|
- v1: WinSW wraps `sing-box.exe`;
|
||||||
|
- v2: собственный Rust service/helper, если понадобится полный контроль.
|
||||||
|
|
||||||
|
Control App не должен напрямую владеть процессом `sing-box`. Он должен управлять service/task через helper.
|
||||||
|
|
||||||
|
### Local sing-box
|
||||||
|
|
||||||
|
`sing-box` - опциональный runtime.
|
||||||
|
|
||||||
|
Его роль:
|
||||||
|
|
||||||
|
- принять subscription/VLESS/sing-box config;
|
||||||
|
- поднять локальный mixed SOCKS/HTTP inbound;
|
||||||
|
- слушать только `127.0.0.1`, например `127.0.0.1:1080`;
|
||||||
|
- маршрутизировать трафик через выбранный outbound.
|
||||||
|
|
||||||
|
Config генерируется из source state приложения и проверяется:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
sing-box check -c C:\Tools\VpnProxy\sing-box\config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Local sing-box не должен быть обязательным. Если profile target указывает на внешний proxy, `sing-box` может отсутствовать.
|
||||||
|
|
||||||
|
### Proxyfier layer
|
||||||
|
|
||||||
|
Рекомендуемый стартовый backend: **ProxiFyre**.
|
||||||
|
|
||||||
|
Почему:
|
||||||
|
|
||||||
|
- open-source;
|
||||||
|
- Windows-focused;
|
||||||
|
- маршрутизирует TCP и UDP;
|
||||||
|
- работает per-application;
|
||||||
|
- использует `app-config.json`;
|
||||||
|
- может работать как Windows Service.
|
||||||
|
|
||||||
|
Важное ограничение: ProxiFyre лицензируется как AGPL-3.0. Если продукт должен быть закрытым коммерческим приложением, нужно заранее решить юридический вопрос или сделать adapter layer, чтобы можно было заменить engine на:
|
||||||
|
|
||||||
|
- коммерческий Proxifier;
|
||||||
|
- ProxyBridge;
|
||||||
|
- собственный WinDivert/NDIS/WFP-based engine;
|
||||||
|
- другой per-app proxy router.
|
||||||
|
|
||||||
|
Интерфейс должен называться не `ProxiFyreConfig`, а шире:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProxyRouterAdapter
|
||||||
|
```
|
||||||
|
|
||||||
|
Первый adapter:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProxiFyreAdapter
|
||||||
|
```
|
||||||
|
|
||||||
|
Это позволит поменять engine без переделки UI и профилей.
|
||||||
|
|
||||||
|
### Data storage
|
||||||
|
|
||||||
|
Для MVP лучше использовать простые JSON-файлы с schema validation.
|
||||||
|
|
||||||
|
Причина:
|
||||||
|
|
||||||
|
- настройки легко читать и бэкапить;
|
||||||
|
- можно быстро отлаживать;
|
||||||
|
- config portable;
|
||||||
|
- подходит для profile/target/source state.
|
||||||
|
|
||||||
|
Рекомендуемые файлы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\ProgramData\VpnProxy\config\profiles.json
|
||||||
|
C:\ProgramData\VpnProxy\config\targets.json
|
||||||
|
C:\ProgramData\VpnProxy\config\components.json
|
||||||
|
C:\ProgramData\VpnProxy\state\activity.json
|
||||||
|
C:\ProgramData\VpnProxy\state\last-status.json
|
||||||
|
C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json
|
||||||
|
C:\ProgramData\VpnProxy\generated\sing-box-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Если нужна большая история событий, статистика трафика или сложные миграции, тогда добавить SQLite:
|
||||||
|
|
||||||
|
- `rusqlite` или `sqlx` в Rust;
|
||||||
|
- миграции;
|
||||||
|
- таблицы `activity`, `component_status`, `traffic_events`.
|
||||||
|
|
||||||
|
Но source of truth для профилей можно оставить JSON даже при наличии SQLite.
|
||||||
|
|
||||||
|
### Installer strategy
|
||||||
|
|
||||||
|
Нужны три явных installer entrypoints:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Install Control App
|
||||||
|
Install Proxyfier Layer
|
||||||
|
Install Local sing-box
|
||||||
|
```
|
||||||
|
|
||||||
|
Они могут быть кнопками в UI, но каждая операция должна быть отдельной и понятной.
|
||||||
|
|
||||||
|
CLI/script names:
|
||||||
|
|
||||||
|
```text
|
||||||
|
install-control-app.ps1
|
||||||
|
install-proxyfier.ps1
|
||||||
|
install-singbox.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Или в packaged app:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VpnProxySetup.exe /component control-app
|
||||||
|
VpnProxySetup.exe /component proxyfier
|
||||||
|
VpnProxySetup.exe /component sing-box
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый installer:
|
||||||
|
|
||||||
|
- idempotent;
|
||||||
|
- делает backup перед overwrite;
|
||||||
|
- не удаляет чужие файлы без подтверждения;
|
||||||
|
- проверяет admin rights;
|
||||||
|
- пишет machine-readable install result;
|
||||||
|
- не трогает остальные компоненты без явного выбора.
|
||||||
|
|
||||||
|
### Security model
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- UI работает без admin rights.
|
||||||
|
- Admin elevation только для install/repair/service/config apply, если это реально нужно.
|
||||||
|
- Local API, если будет, слушает только `127.0.0.1`.
|
||||||
|
- Лучше использовать Tauri commands / named pipe, чем открытый HTTP port.
|
||||||
|
- Если нужен loopback HTTP, включить token или origin check.
|
||||||
|
- Секреты subscription URLs не показывать в diagnostics.
|
||||||
|
- Generated configs не редактируются вручную из UI.
|
||||||
|
- Every apply creates backup.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```text
|
||||||
|
+-------------------------------+
|
||||||
|
| Tauri Control App |
|
||||||
|
| React/TypeScript UI |
|
||||||
|
+---------------+---------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+-------------------------------+
|
||||||
|
| Rust App Backend |
|
||||||
|
| profiles, targets, validation |
|
||||||
|
| component status aggregation |
|
||||||
|
+-------+---------------+-------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
+---------------+ +-------------------+
|
||||||
|
| Proxy Router | | Local sing-box |
|
||||||
|
| Adapter | | Adapter |
|
||||||
|
| ProxiFyre v1 | | config + service |
|
||||||
|
+-------+-------+ +---------+---------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
+---------------+ +-------------------+
|
||||||
|
| ProxiFyre | | sing-box.exe |
|
||||||
|
| Windows svc | | Windows svc/task |
|
||||||
|
+---------------+ +-------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Модель данных
|
||||||
|
|
||||||
|
### Profile
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "discord",
|
||||||
|
"name": "Discord",
|
||||||
|
"enabled": true,
|
||||||
|
"targetId": "local-singbox",
|
||||||
|
"protocols": ["TCP", "UDP"],
|
||||||
|
"items": [
|
||||||
|
{ "type": "process", "value": "Discord" },
|
||||||
|
{ "type": "folder", "value": "%LOCALAPPDATA%\\Discord", "recursive": true },
|
||||||
|
{ "type": "exe", "value": "C:\\Games\\Game\\game.exe" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "local-singbox",
|
||||||
|
"name": "Local sing-box",
|
||||||
|
"type": "local",
|
||||||
|
"protocol": "socks5",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 1080,
|
||||||
|
"requiresComponent": "singbox"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
External target:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "home-gateway",
|
||||||
|
"name": "Home gateway",
|
||||||
|
"type": "external",
|
||||||
|
"protocol": "socks5",
|
||||||
|
"host": "192.168.50.111",
|
||||||
|
"port": 8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component status
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "proxyfier",
|
||||||
|
"name": "Proxyfier",
|
||||||
|
"installed": true,
|
||||||
|
"running": true,
|
||||||
|
"version": "2.3.0",
|
||||||
|
"path": "C:\\Tools\\ProxiFyre",
|
||||||
|
"serviceName": "ProxiFyreService",
|
||||||
|
"problems": [],
|
||||||
|
"actions": ["restart", "repair", "openLogs"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apply behavior
|
||||||
|
|
||||||
|
Apply должен делать одно понятное действие:
|
||||||
|
|
||||||
|
1. Прочитать profiles.
|
||||||
|
2. Прочитать targets.
|
||||||
|
3. Проверить, что выбранные targets доступны.
|
||||||
|
4. Проверить, что Proxyfier установлен.
|
||||||
|
5. Разрешить folder/exe в process names.
|
||||||
|
6. Сгенерировать proxyfier config.
|
||||||
|
7. Сделать backup старого config.
|
||||||
|
8. Записать новый config.
|
||||||
|
9. Перезапустить Proxyfier service.
|
||||||
|
10. Записать activity entry.
|
||||||
|
|
||||||
|
Если profile использует `local-singbox`, дополнительно:
|
||||||
|
|
||||||
|
- проверить, что `sing-box` установлен;
|
||||||
|
- проверить, что service running;
|
||||||
|
- проверить, что `127.0.0.1:1080` отвечает.
|
||||||
|
|
||||||
|
Если `local-singbox` не установлен, но profile target внешний, apply должен работать.
|
||||||
|
|
||||||
|
## Что не делать
|
||||||
|
|
||||||
|
- Не делать глобальную смену Windows proxy settings.
|
||||||
|
- Не делать `sing-box` обязательным.
|
||||||
|
- Не смешивать installer и profile apply.
|
||||||
|
- Не хранить generated ProxiFyre config как source of truth.
|
||||||
|
- Не привязывать UI напрямую к ProxiFyre, нужен adapter layer.
|
||||||
|
- Не запускать privileged операции без явного согласия пользователя.
|
||||||
|
- Не делать большой dashboard с лишней статистикой в первой версии.
|
||||||
|
|
||||||
|
## MVP
|
||||||
|
|
||||||
|
Самый правильный первый slice:
|
||||||
|
|
||||||
|
1. Tauri app shell.
|
||||||
|
2. Profiles UI.
|
||||||
|
3. Targets UI.
|
||||||
|
4. Component status UI.
|
||||||
|
5. ProxiFyre adapter.
|
||||||
|
6. External SOCKS5 target.
|
||||||
|
7. Apply profile -> generate ProxiFyre config -> restart service.
|
||||||
|
|
||||||
|
В MVP `sing-box` может быть только карточкой `Not installed / Install`.
|
||||||
|
|
||||||
|
После этого добавить:
|
||||||
|
|
||||||
|
1. Local sing-box installer.
|
||||||
|
2. Subscription import.
|
||||||
|
3. Server selection.
|
||||||
|
4. Generate sing-box config.
|
||||||
|
5. Start/stop/restart local sing-box service.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
Приложение считается успешным, если:
|
||||||
|
|
||||||
|
- можно установить только Control App;
|
||||||
|
- можно установить Proxyfier отдельно;
|
||||||
|
- можно не устанавливать sing-box;
|
||||||
|
- можно добавить внешний SOCKS5 target;
|
||||||
|
- можно создать профиль для Discord;
|
||||||
|
- можно применить профиль;
|
||||||
|
- generated ProxiFyre config не редактируется пользователем вручную;
|
||||||
|
- UI показывает, что local sing-box отсутствует, но это не ломает внешний proxy flow;
|
||||||
|
- после установки sing-box появляется target `Local sing-box`;
|
||||||
|
- пользователь может переключить профиль с внешнего target на local sing-box.
|
||||||
|
|
||||||
|
## Prompt For Another AI
|
||||||
|
|
||||||
|
Build a Windows desktop proxy management app.
|
||||||
|
|
||||||
|
Use Tauri 2 with React, TypeScript, Vite, and a Rust backend. The app must manage three independent components: the Control App, a proxyfier layer, and optional local sing-box. Do not make sing-box mandatory.
|
||||||
|
|
||||||
|
The UI must be a compact Windows utility with these screens: Overview, Profiles, Targets, Components, Logs. Profiles contain process/folder/exe entries and choose a proxy target. Targets can be local sing-box or external SOCKS5/HTTP proxies. Proxyfier is the layer that routes selected apps through the chosen target.
|
||||||
|
|
||||||
|
Start with ProxiFyre as the first proxy router adapter, but design an adapter boundary so it can later be replaced. Store source configuration as JSON with schema validation. Generated ProxiFyre and sing-box configs are derived artifacts, not source truth.
|
||||||
|
|
||||||
|
Privileged operations must be isolated in an explicit helper/installer flow. The main UI should run without admin rights. Install Control App, Install Proxyfier, and Install Local sing-box must be separate operations. Applying a profile must not silently install missing components.
|
||||||
|
|
||||||
|
MVP: external SOCKS5 target + ProxiFyre profile apply. Then add optional local sing-box installation, subscription import, server selection, and local sing-box service control.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Tauri 2: https://v2.tauri.app/
|
||||||
|
- Tauri sidecar permissions: https://v2.tauri.app/develop/sidecar/
|
||||||
|
- sing-box configuration: https://sing-box.sagernet.org/configuration/
|
||||||
|
- ProxiFyre repository: https://github.com/wiresock/proxifyre
|
||||||
|
- WinSW service wrapper: https://github.com/winsw/winsw
|
||||||
|
- Microsoft Windows Service with Worker Service: https://learn.microsoft.com/en-us/dotnet/core/extensions/windows-service
|
||||||
|
|
||||||
Reference in New Issue
Block a user