Clarify active Windows client architecture
This commit is contained in:
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/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user