Refactor proxy routing and session management
This commit is contained in:
@@ -21,6 +21,8 @@ Source configuration is owned by Rust domain models and JSON files under:
|
|||||||
C:\ProgramData\VpnProxy\config\profiles.json
|
C:\ProgramData\VpnProxy\config\profiles.json
|
||||||
C:\ProgramData\VpnProxy\config\targets.json
|
C:\ProgramData\VpnProxy\config\targets.json
|
||||||
C:\ProgramData\VpnProxy\config\components.json
|
C:\ProgramData\VpnProxy\config\components.json
|
||||||
|
C:\ProgramData\VpnProxy\config\local-singbox.json
|
||||||
|
C:\ProgramData\VpnProxy\state\singbox-subscription-cache.json
|
||||||
C:\ProgramData\VpnProxy\state\activity.json
|
C:\ProgramData\VpnProxy\state\activity.json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -78,6 +80,25 @@ Installers must be launched intentionally by the user or by a future narrow
|
|||||||
helper permission. Profile apply must not silently install Control App,
|
helper permission. Profile apply must not silently install Control App,
|
||||||
Proxyfier, or Local sing-box.
|
Proxyfier, or Local sing-box.
|
||||||
|
|
||||||
|
Local sing-box install creates this optional runtime:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\Program Files\VpnProxy\sing-box\sing-box.exe
|
||||||
|
C:\Program Files\VpnProxy\sing-box\VpnProxySingBox.exe
|
||||||
|
Windows service: VpnProxySingBox
|
||||||
|
```
|
||||||
|
|
||||||
|
The install flow downloads `sing-box` from `SagerNet/sing-box` releases and the
|
||||||
|
WinSW service wrapper from `winsw/winsw` releases, then writes a service config
|
||||||
|
that points at `C:\ProgramData\VpnProxy\generated\sing-box-config.json`. It
|
||||||
|
requires UAC confirmation. Uninstall is scoped to the configured
|
||||||
|
`VpnProxy\sing-box` install root.
|
||||||
|
|
||||||
|
The external proxy route remains independent from Local sing-box. Choosing
|
||||||
|
Local sing-box in the UI generates `sing-box-config.json`, ensures the
|
||||||
|
`local-singbox` SOCKS5 target at `127.0.0.1:1080`, and then applies ProxiFyre
|
||||||
|
to that local target.
|
||||||
|
|
||||||
## Existing Proxyfier Detection
|
## Existing Proxyfier Detection
|
||||||
|
|
||||||
The app detects an already installed Proxyfier layer before showing component
|
The app detects an already installed Proxyfier layer before showing component
|
||||||
@@ -113,4 +134,5 @@ different profile format.
|
|||||||
7. Install and start Local sing-box only when using a local target.
|
7. Install and start Local sing-box only when using a local target.
|
||||||
|
|
||||||
Task evidence is recorded in
|
Task evidence is recorded in
|
||||||
`docs/goals/windows-modular-client/EVIDENCE.md`.
|
`docs/goals/windows-modular-client/EVIDENCE.md` and
|
||||||
|
`docs/goals/windows-local-singbox/EVIDENCE.md`.
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
param(
|
param(
|
||||||
[string]$InstallRoot = "C:\Program Files\VpnProxy\sing-box",
|
[string]$InstallRoot = "C:\Program Files\VpnProxy\sing-box",
|
||||||
[string]$BinaryPath = "",
|
|
||||||
[string]$ServiceName = "VpnProxySingBox",
|
[string]$ServiceName = "VpnProxySingBox",
|
||||||
|
[string]$ConfigSource = "C:\ProgramData\VpnProxy\generated\sing-box-config.json",
|
||||||
[switch]$PlanOnly,
|
[switch]$PlanOnly,
|
||||||
[switch]$Force
|
[switch]$Force,
|
||||||
|
[switch]$Uninstall
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$SingBoxReleaseApi = "https://api.github.com/repos/SagerNet/sing-box/releases/latest"
|
||||||
|
$WinSwReleaseApi = "https://api.github.com/repos/winsw/winsw/releases/latest"
|
||||||
|
$WrapperFile = "$ServiceName.exe"
|
||||||
|
$ConfigFile = "config.json"
|
||||||
|
|
||||||
function New-Result {
|
function New-Result {
|
||||||
param(
|
param(
|
||||||
[bool]$Success,
|
[bool]$Success,
|
||||||
@@ -23,7 +29,7 @@ function New-Result {
|
|||||||
changed = $Changed
|
changed = $Changed
|
||||||
message = $Message
|
message = $Message
|
||||||
details = $Details
|
details = $Details
|
||||||
} | ConvertTo-Json -Depth 6
|
} | ConvertTo-Json -Depth 8
|
||||||
}
|
}
|
||||||
|
|
||||||
function Test-IsAdministrator {
|
function Test-IsAdministrator {
|
||||||
@@ -32,6 +38,48 @@ function Test-IsAdministrator {
|
|||||||
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-NativeArchitecture {
|
||||||
|
$processor = Get-CimInstance Win32_Processor | Select-Object -First 1
|
||||||
|
if ($null -ne $processor -and $processor.Architecture -eq 12) { return "arm64" }
|
||||||
|
if ([Environment]::Is64BitOperatingSystem) { return "amd64" }
|
||||||
|
return "386"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-WinSwArchitecture {
|
||||||
|
param([string]$Arch)
|
||||||
|
if ($Arch -eq "arm64") { return "arm64" }
|
||||||
|
if ($Arch -eq "386") { return "x86" }
|
||||||
|
return "x64"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Download {
|
||||||
|
param([string]$Uri, [string]$Path)
|
||||||
|
Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $Path -Headers @{ "User-Agent" = "vpn-proxy-windows-client" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select-Asset {
|
||||||
|
param(
|
||||||
|
[object[]]$Assets,
|
||||||
|
[string]$Pattern,
|
||||||
|
[string]$Label
|
||||||
|
)
|
||||||
|
|
||||||
|
$asset = $Assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1
|
||||||
|
if ($null -eq $asset) {
|
||||||
|
throw "Не найден release asset для $Label по шаблону $Pattern."
|
||||||
|
}
|
||||||
|
return $asset
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-SafeInstallRoot {
|
||||||
|
param([string]$Path)
|
||||||
|
$full = [System.IO.Path]::GetFullPath($Path).TrimEnd("\")
|
||||||
|
$leaf = Split-Path -Leaf $full
|
||||||
|
$parent = Split-Path -Parent $full
|
||||||
|
if ($leaf -ne "sing-box") { return $false }
|
||||||
|
return $parent -match "\\VpnProxy$|\\vpn-proxy$"
|
||||||
|
}
|
||||||
|
|
||||||
function Backup-File {
|
function Backup-File {
|
||||||
param([string]$Path)
|
param([string]$Path)
|
||||||
if (Test-Path -LiteralPath $Path) {
|
if (Test-Path -LiteralPath $Path) {
|
||||||
@@ -42,16 +90,92 @@ function Backup-File {
|
|||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Write-Utf8NoBomFile {
|
||||||
|
param(
|
||||||
|
[string]$Path,
|
||||||
|
[string]$Value
|
||||||
|
)
|
||||||
|
|
||||||
|
$encoding = New-Object System.Text.UTF8Encoding $false
|
||||||
|
[System.IO.File]::WriteAllText($Path, $Value, $encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-WinSwConfig {
|
||||||
|
param(
|
||||||
|
[string]$Root,
|
||||||
|
[string]$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
$xmlPath = Join-Path $Root "$Name.xml"
|
||||||
|
$logDir = Join-Path $Root "logs"
|
||||||
|
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
||||||
|
$xml = @"
|
||||||
|
<service>
|
||||||
|
<id>$Name</id>
|
||||||
|
<name>VPN Proxy Local sing-box</name>
|
||||||
|
<description>Local sing-box runtime managed by VPN Proxy Windows client.</description>
|
||||||
|
<executable>%BASE%\sing-box.exe</executable>
|
||||||
|
<arguments>run -c "%BASE%\config.json"</arguments>
|
||||||
|
<logpath>%BASE%\logs</logpath>
|
||||||
|
<log mode="roll-by-size">
|
||||||
|
<sizeThreshold>10485760</sizeThreshold>
|
||||||
|
<keepFiles>4</keepFiles>
|
||||||
|
</log>
|
||||||
|
<onfailure action="restart" delay="5 sec"/>
|
||||||
|
</service>
|
||||||
|
"@
|
||||||
|
Write-Utf8NoBomFile -Path $xmlPath -Value $xml
|
||||||
|
return $xmlPath
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-And-Uninstall-Service {
|
||||||
|
param(
|
||||||
|
[string]$Root,
|
||||||
|
[string]$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
$wrapper = Join-Path $Root "$Name.exe"
|
||||||
|
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $service -and $service.Status -ne "Stopped") {
|
||||||
|
Stop-Service -Name $Name -Force -ErrorAction SilentlyContinue
|
||||||
|
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $service) {
|
||||||
|
try { $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(15)) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $wrapper) {
|
||||||
|
Push-Location $Root
|
||||||
|
try { & $wrapper uninstall | Out-Null } finally { Pop-Location }
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $service) {
|
||||||
|
sc.exe delete $Name | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$installRootFull = [System.IO.Path]::GetFullPath($InstallRoot)
|
||||||
$details = @{
|
$details = @{
|
||||||
installRoot = $InstallRoot
|
installRoot = $installRootFull
|
||||||
binaryPath = $BinaryPath
|
|
||||||
serviceName = $ServiceName
|
serviceName = $ServiceName
|
||||||
|
configSource = $ConfigSource
|
||||||
|
singboxReleaseApi = $SingBoxReleaseApi
|
||||||
|
winswReleaseApi = $WinSwReleaseApi
|
||||||
planOnly = [bool]$PlanOnly
|
planOnly = [bool]$PlanOnly
|
||||||
|
uninstall = [bool]$Uninstall
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($PlanOnly) {
|
if ($PlanOnly) {
|
||||||
New-Result -Success $true -Action "install-singbox" -Changed $false -Message "Local sing-box install plan is ready." -Details $details
|
$details.items = @(
|
||||||
|
@{ id = "sing-box-binary"; name = "sing-box.exe"; source = $SingBoxReleaseApi; target = (Join-Path $installRootFull "sing-box.exe") },
|
||||||
|
@{ id = "winsw-wrapper"; name = $WrapperFile; source = $WinSwReleaseApi; target = (Join-Path $installRootFull $WrapperFile) },
|
||||||
|
@{ id = "windows-service"; name = $ServiceName; target = "Windows Service" },
|
||||||
|
@{ id = "config"; name = $ConfigFile; source = $ConfigSource; target = (Join-Path $installRootFull $ConfigFile) }
|
||||||
|
)
|
||||||
|
New-Result -Success $true -Action "install-singbox.plan" -Changed $false -Message "Local sing-box install plan is ready." -Details $details
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,36 +184,86 @@ try {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($BinaryPath) -or -not (Test-Path -LiteralPath $BinaryPath)) {
|
if ($Uninstall) {
|
||||||
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "BinaryPath is required and must point to sing-box.exe." -Details $details
|
if (-not (Test-SafeInstallRoot -Path $installRootFull)) {
|
||||||
exit 2
|
New-Result -Success $false -Action "uninstall-singbox" -Changed $false -Message "Unsafe InstallRoot for recursive uninstall." -Details $details
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName
|
||||||
|
if (Test-Path -LiteralPath $installRootFull) {
|
||||||
|
Remove-Item -LiteralPath $installRootFull -Recurse -Force
|
||||||
|
}
|
||||||
|
New-Result -Success $true -Action "uninstall-singbox" -Changed $true -Message "Local sing-box service and install folder were removed." -Details $details
|
||||||
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
$changed = $false
|
$changed = $false
|
||||||
if (-not (Test-Path -LiteralPath $InstallRoot)) {
|
New-Item -ItemType Directory -Path $installRootFull -Force | Out-Null
|
||||||
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("vpn-proxy-singbox-" + [guid]::NewGuid().ToString("N"))
|
||||||
|
$extractDir = Join-Path $workDir "extract"
|
||||||
|
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
|
||||||
|
|
||||||
|
try {
|
||||||
|
$arch = Get-NativeArchitecture
|
||||||
|
$winswArch = Get-WinSwArchitecture -Arch $arch
|
||||||
|
$details.architecture = $arch
|
||||||
|
$details.winswArchitecture = $winswArch
|
||||||
|
|
||||||
|
$singboxRelease = Invoke-RestMethod -Uri $SingBoxReleaseApi -Headers @{ "User-Agent" = "vpn-proxy-windows-client" }
|
||||||
|
$singboxAsset = Select-Asset $singboxRelease.assets "windows-$arch\.zip$" "sing-box"
|
||||||
|
$singboxZip = Join-Path $workDir $singboxAsset.name
|
||||||
|
Invoke-Download $singboxAsset.browser_download_url $singboxZip
|
||||||
|
Expand-Archive -LiteralPath $singboxZip -DestinationPath $extractDir -Force
|
||||||
|
$singboxExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter "sing-box.exe" | Select-Object -First 1
|
||||||
|
if ($null -eq $singboxExe) { throw "В архиве sing-box не найден sing-box.exe." }
|
||||||
|
Copy-Item -LiteralPath $singboxExe.FullName -Destination (Join-Path $installRootFull "sing-box.exe") -Force
|
||||||
$changed = $true
|
$changed = $true
|
||||||
}
|
|
||||||
|
|
||||||
$configPath = Join-Path $InstallRoot "config.json"
|
$winswRelease = Invoke-RestMethod -Uri $WinSwReleaseApi -Headers @{ "User-Agent" = "vpn-proxy-windows-client" }
|
||||||
$backupPath = Backup-File -Path $configPath
|
$winswAsset = Select-Asset $winswRelease.assets "WinSW-$winswArch\.exe$" "WinSW"
|
||||||
if ($backupPath) {
|
Invoke-Download $winswAsset.browser_download_url (Join-Path $installRootFull $WrapperFile)
|
||||||
$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
|
$changed = $true
|
||||||
|
|
||||||
|
$configTarget = Join-Path $installRootFull $ConfigFile
|
||||||
|
$backupPath = Backup-File -Path $configTarget
|
||||||
|
if ($backupPath) { $details.backupPath = $backupPath }
|
||||||
|
if (Test-Path -LiteralPath $ConfigSource) {
|
||||||
|
Copy-Item -LiteralPath $ConfigSource -Destination $configTarget -Force
|
||||||
|
} elseif (-not (Test-Path -LiteralPath $configTarget)) {
|
||||||
|
Write-Utf8NoBomFile -Path $configTarget -Value '{"log":{"level":"info","timestamp":true},"inbounds":[],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"direct"}}'
|
||||||
|
}
|
||||||
|
|
||||||
|
$xmlPath = Write-WinSwConfig -Root $installRootFull -Name $ServiceName
|
||||||
|
$details.configPath = $configTarget
|
||||||
|
$details.wrapperConfigPath = $xmlPath
|
||||||
|
|
||||||
|
if ($Force) {
|
||||||
|
Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location $installRootFull
|
||||||
|
try {
|
||||||
|
$service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $service) {
|
||||||
|
& ".\$WrapperFile" install
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "WinSW install завершился с кодом $LASTEXITCODE." }
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
& ".\$WrapperFile" start
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Start-Service -Name $ServiceName -ErrorAction Stop
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (Test-Path -LiteralPath $workDir) {
|
||||||
|
Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$details.markerPath = $markerPath
|
New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box service is installed and started." -Details $details
|
||||||
New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box install boundary completed." -Details $details
|
|
||||||
} catch {
|
} catch {
|
||||||
New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message
|
New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
306
apps/windows-client/src-tauri/Cargo.lock
generated
306
apps/windows-client/src-tauri/Cargo.lock
generated
@@ -309,6 +309,23 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -390,6 +407,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -814,6 +840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -994,8 +1021,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1017,8 +1046,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1268,6 +1300,22 @@ dependencies = [
|
|||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1668,6 +1716,12 @@ version = "0.4.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -2267,6 +2321,62 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"lru-slab",
|
||||||
|
"rand",
|
||||||
|
"rand_pcg",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.46"
|
version = "1.0.46"
|
||||||
@@ -2288,6 +2398,32 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2363,6 +2499,46 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
@@ -2421,6 +2597,20 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -2436,12 +2626,53 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.41"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||||
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -2632,6 +2863,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.21.0"
|
version = "3.21.0"
|
||||||
@@ -2702,7 +2945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2830,6 +3073,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -2982,7 +3231,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -3342,6 +3591,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -3632,6 +3891,12 @@ version = "1.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -3697,11 +3962,14 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
name = "vpn-proxy-windows-client"
|
name = "vpn-proxy-windows-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3836,6 +4104,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -3892,6 +4170,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@@ -4122,6 +4409,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -4508,6 +4804,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
|
|||||||
@@ -17,4 +17,6 @@ tauri = { version = "2", features = [] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri-plugin-dialog = "2.7.1"
|
tauri-plugin-dialog = "2.7.1"
|
||||||
|
base64 = "0.22"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||||
|
url = "2"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use crate::models::{
|
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
|
||||||
ComponentId, ComponentState, ComponentStatus, ProxyProtocol, Target, TargetKind,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
use std::{
|
use std::{
|
||||||
env, fs,
|
env, fs,
|
||||||
path::Path,
|
path::Path,
|
||||||
@@ -12,25 +11,29 @@ use std::{
|
|||||||
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
|
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
|
||||||
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
|
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
|
||||||
pub const DEFAULT_MIXED_INBOUND_TAG: &str = "vpn-proxy-mixed-in";
|
pub const DEFAULT_MIXED_INBOUND_TAG: &str = "vpn-proxy-mixed-in";
|
||||||
|
pub const DEFAULT_VPN_OUTBOUND_TAG: &str = "vpn";
|
||||||
pub const DEFAULT_DIRECT_OUTBOUND_TAG: &str = "direct";
|
pub const DEFAULT_DIRECT_OUTBOUND_TAG: &str = "direct";
|
||||||
|
pub const DEFAULT_BLOCK_OUTBOUND_TAG: &str = "block";
|
||||||
|
|
||||||
|
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SingBoxAdapter {
|
pub struct SingBoxAdapter {
|
||||||
log_level: String,
|
log_level: String,
|
||||||
inbound_tag: String,
|
inbound_tag: String,
|
||||||
outbound_tag: String,
|
vpn_outbound_tag: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SingBoxAdapter {
|
impl SingBoxAdapter {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
log_level: impl Into<String>,
|
log_level: impl Into<String>,
|
||||||
inbound_tag: impl Into<String>,
|
inbound_tag: impl Into<String>,
|
||||||
outbound_tag: impl Into<String>,
|
vpn_outbound_tag: impl Into<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
log_level: log_level.into(),
|
log_level: log_level.into(),
|
||||||
inbound_tag: inbound_tag.into(),
|
inbound_tag: inbound_tag.into(),
|
||||||
outbound_tag: outbound_tag.into(),
|
vpn_outbound_tag: vpn_outbound_tag.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,32 +45,52 @@ impl SingBoxAdapter {
|
|||||||
where
|
where
|
||||||
C: SingBoxConfigChecker,
|
C: SingBoxConfigChecker,
|
||||||
{
|
{
|
||||||
let target = find_local_singbox_target(request.targets)?;
|
let selected_server_tag = request
|
||||||
ensure_local_singbox_target(target, request.components)?;
|
.config
|
||||||
|
.selected_server_tag
|
||||||
let config = SingBoxConfig {
|
.as_deref()
|
||||||
log: SingBoxLog {
|
.map(str::trim)
|
||||||
disabled: false,
|
.filter(|value| !value.is_empty())
|
||||||
level: self.log_level.clone(),
|
.ok_or_else(|| {
|
||||||
timestamp: true,
|
SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::MissingSelectedServer,
|
||||||
|
"Сервер Local sing-box не выбран",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let vpn_outbound = selected_outbound(
|
||||||
|
&request.subscription_cache.config,
|
||||||
|
selected_server_tag,
|
||||||
|
&self.vpn_outbound_tag,
|
||||||
|
)?;
|
||||||
|
let generated_config = json!({
|
||||||
|
"log": {
|
||||||
|
"disabled": false,
|
||||||
|
"level": self.log_level,
|
||||||
|
"timestamp": true
|
||||||
},
|
},
|
||||||
inbounds: vec![SingBoxInbound {
|
"inbounds": [
|
||||||
inbound_type: "mixed".to_string(),
|
{
|
||||||
tag: self.inbound_tag.clone(),
|
"type": "mixed",
|
||||||
listen: target.host.clone(),
|
"tag": self.inbound_tag,
|
||||||
listen_port: target.port,
|
"listen": request.config.listen_host,
|
||||||
users: Vec::new(),
|
"listen_port": request.config.listen_port,
|
||||||
set_system_proxy: false,
|
"users": [],
|
||||||
}],
|
"set_system_proxy": false
|
||||||
outbounds: vec![SingBoxOutbound {
|
}
|
||||||
outbound_type: "direct".to_string(),
|
],
|
||||||
tag: self.outbound_tag.clone(),
|
"outbounds": [
|
||||||
}],
|
vpn_outbound,
|
||||||
route: SingBoxRoute {
|
{ "type": "direct", "tag": DEFAULT_DIRECT_OUTBOUND_TAG },
|
||||||
final_outbound: self.outbound_tag.clone(),
|
{ "type": "block", "tag": DEFAULT_BLOCK_OUTBOUND_TAG }
|
||||||
},
|
],
|
||||||
};
|
"route": {
|
||||||
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
|
"rules": [
|
||||||
|
{ "ip_is_private": true, "outbound": DEFAULT_DIRECT_OUTBOUND_TAG }
|
||||||
|
],
|
||||||
|
"final": self.vpn_outbound_tag
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let contents = serde_json::to_string_pretty(&generated_config).map_err(|error| {
|
||||||
SingBoxConfigError::new(
|
SingBoxConfigError::new(
|
||||||
SingBoxConfigErrorKind::Serialization,
|
SingBoxConfigErrorKind::Serialization,
|
||||||
format!("Не удалось сериализовать конфиг sing-box: {error}"),
|
format!("Не удалось сериализовать конфиг sing-box: {error}"),
|
||||||
@@ -82,9 +105,9 @@ impl SingBoxAdapter {
|
|||||||
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
|
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
|
||||||
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
|
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
|
||||||
contents,
|
contents,
|
||||||
local_target_id: target.id.clone(),
|
selected_server_tag: selected_server_tag.to_string(),
|
||||||
listen: target.host.clone(),
|
listen: request.config.listen_host.clone(),
|
||||||
listen_port: target.port,
|
listen_port: request.config.listen_port,
|
||||||
check,
|
check,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -92,30 +115,26 @@ impl SingBoxAdapter {
|
|||||||
|
|
||||||
impl Default for SingBoxAdapter {
|
impl Default for SingBoxAdapter {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new(
|
Self::new("info", DEFAULT_MIXED_INBOUND_TAG, DEFAULT_VPN_OUTBOUND_TAG)
|
||||||
"info",
|
|
||||||
DEFAULT_MIXED_INBOUND_TAG,
|
|
||||||
DEFAULT_DIRECT_OUTBOUND_TAG,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct SingBoxGenerationRequest<'a> {
|
pub struct SingBoxGenerationRequest<'a> {
|
||||||
pub targets: &'a [Target],
|
pub config: &'a LocalSingBoxConfig,
|
||||||
pub components: &'a [ComponentStatus],
|
pub subscription_cache: &'a SubscriptionCache,
|
||||||
pub binary_path: Option<&'a Path>,
|
pub binary_path: Option<&'a Path>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> SingBoxGenerationRequest<'a> {
|
impl<'a> SingBoxGenerationRequest<'a> {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
targets: &'a [Target],
|
config: &'a LocalSingBoxConfig,
|
||||||
components: &'a [ComponentStatus],
|
subscription_cache: &'a SubscriptionCache,
|
||||||
binary_path: Option<&'a Path>,
|
binary_path: Option<&'a Path>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
targets,
|
config,
|
||||||
components,
|
subscription_cache,
|
||||||
binary_path,
|
binary_path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +145,7 @@ pub struct SingBoxGeneratedConfig {
|
|||||||
pub adapter_id: String,
|
pub adapter_id: String,
|
||||||
pub output_file_name: String,
|
pub output_file_name: String,
|
||||||
pub contents: String,
|
pub contents: String,
|
||||||
pub local_target_id: String,
|
pub selected_server_tag: String,
|
||||||
pub listen: String,
|
pub listen: String,
|
||||||
pub listen_port: u16,
|
pub listen_port: u16,
|
||||||
pub check: Option<SingBoxCheckResult>,
|
pub check: Option<SingBoxCheckResult>,
|
||||||
@@ -156,10 +175,9 @@ impl SingBoxConfigError {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum SingBoxConfigErrorKind {
|
pub enum SingBoxConfigErrorKind {
|
||||||
MissingLocalTarget,
|
MissingSelectedServer,
|
||||||
MissingRequiredComponent,
|
MissingSelectedOutbound,
|
||||||
RequiredComponentNotRunning,
|
UnsupportedSelectedOutbound,
|
||||||
UnsupportedTarget,
|
|
||||||
Serialization,
|
Serialization,
|
||||||
CheckFailed,
|
CheckFailed,
|
||||||
}
|
}
|
||||||
@@ -237,114 +255,67 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
fn selected_outbound(
|
||||||
pub struct SingBoxConfig {
|
subscription_config: &Value,
|
||||||
pub log: SingBoxLog,
|
selected_server_tag: &str,
|
||||||
pub inbounds: Vec<SingBoxInbound>,
|
vpn_outbound_tag: &str,
|
||||||
pub outbounds: Vec<SingBoxOutbound>,
|
) -> Result<Value, SingBoxConfigError> {
|
||||||
pub route: SingBoxRoute,
|
let outbounds = subscription_config
|
||||||
}
|
.get("outbounds")
|
||||||
|
.and_then(Value::as_array)
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
.ok_or_else(|| {
|
||||||
pub struct SingBoxLog {
|
SingBoxConfigError::new(
|
||||||
pub disabled: bool,
|
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||||
pub level: String,
|
"В cache подписки нет outbounds",
|
||||||
pub timestamp: bool,
|
)
|
||||||
}
|
})?;
|
||||||
|
let outbound = outbounds
|
||||||
#[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()
|
.iter()
|
||||||
.find(|target| {
|
.find(|outbound| {
|
||||||
target.kind == TargetKind::Local
|
outbound
|
||||||
&& target.requires_component.as_ref() == Some(&ComponentId::Singbox)
|
.get("tag")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|tag| tag.trim() == selected_server_tag)
|
||||||
})
|
})
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
SingBoxConfigError::new(
|
SingBoxConfigError::new(
|
||||||
SingBoxConfigErrorKind::MissingLocalTarget,
|
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||||
"Локальная цель, требующая sing-box, не настроена",
|
format!("Outbound не найден: {selected_server_tag}"),
|
||||||
)
|
)
|
||||||
})
|
})?;
|
||||||
}
|
let outbound_type = outbound
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
fn ensure_local_singbox_target(
|
if !SUPPORTED_PROXY_TYPES.contains(&outbound_type) {
|
||||||
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(
|
return Err(SingBoxConfigError::new(
|
||||||
SingBoxConfigErrorKind::UnsupportedTarget,
|
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||||
format!(
|
format!(
|
||||||
"Цель '{}' должна быть локальной SOCKS5-целью, требующей sing-box",
|
"Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'"
|
||||||
target.id
|
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(status) = components
|
let mut outbound = outbound.clone();
|
||||||
.iter()
|
let object = outbound.as_object_mut().ok_or_else(|| {
|
||||||
.find(|component| component.id == ComponentId::Singbox)
|
SingBoxConfigError::new(
|
||||||
else {
|
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||||
return Err(SingBoxConfigError::new(
|
format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"),
|
||||||
SingBoxConfigErrorKind::MissingRequiredComponent,
|
)
|
||||||
format!(
|
})?;
|
||||||
"Локальная цель '{}' требует состояние компонента sing-box",
|
object.insert(
|
||||||
target.id
|
"tag".to_string(),
|
||||||
),
|
Value::String(vpn_outbound_tag.to_string()),
|
||||||
));
|
);
|
||||||
};
|
if outbound_type == "vless" && !object.contains_key("packet_encoding") {
|
||||||
|
object.insert(
|
||||||
if !component_is_running(status) {
|
"packet_encoding".to_string(),
|
||||||
return Err(SingBoxConfigError::new(
|
Value::String("xudp".to_string()),
|
||||||
SingBoxConfigErrorKind::RequiredComponentNotRunning,
|
);
|
||||||
format!(
|
|
||||||
"Локальная цель '{}' требует установленный и запущенный sing-box",
|
|
||||||
target.id
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(outbound)
|
||||||
}
|
|
||||||
|
|
||||||
fn component_is_running(status: &ComponentStatus) -> bool {
|
|
||||||
status.installed && status.running && status.state == ComponentState::Running
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now_millis() -> u128 {
|
fn now_millis() -> u128 {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
|||||||
use crate::models::{ComponentId, ComponentState, ComponentStatus};
|
use crate::models::{
|
||||||
|
ComponentId, ComponentState, ComponentStatus, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT,
|
||||||
|
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME,
|
||||||
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
@@ -22,6 +25,17 @@ pub struct DetectedProxyfier {
|
|||||||
pub service_name: Option<String>,
|
pub service_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DetectedSingBox {
|
||||||
|
pub install_dir: PathBuf,
|
||||||
|
pub executable_path: PathBuf,
|
||||||
|
pub wrapper_path: PathBuf,
|
||||||
|
pub binary_exists: bool,
|
||||||
|
pub wrapper_exists: bool,
|
||||||
|
pub running: bool,
|
||||||
|
pub service_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct RegistryInstallEntry {
|
pub struct RegistryInstallEntry {
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
@@ -101,6 +115,29 @@ pub fn proxyfier_component_from_detection(detected: Option<&DetectedProxyfier>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn detect_singbox_install() -> Option<DetectedSingBox> {
|
||||||
|
detect_singbox_install_with_host(&SystemProxyfierDetectionHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_singbox_install_with_host(
|
||||||
|
host: &impl ProxyfierDetectionHost,
|
||||||
|
) -> Option<DetectedSingBox> {
|
||||||
|
let running = host.process_running("sing-box.exe")
|
||||||
|
|| host.service_running(DEFAULT_LOCAL_SINGBOX_SERVICE_NAME);
|
||||||
|
|
||||||
|
singbox_candidates(host)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|install_dir| detected_singbox_from_dir(host, install_dir, running))
|
||||||
|
.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn singbox_component_from_detection(detected: Option<&DetectedSingBox>) -> ComponentStatus {
|
||||||
|
match detected {
|
||||||
|
Some(singbox) => detected_singbox_component(singbox),
|
||||||
|
None => missing_singbox_component(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatus {
|
fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatus {
|
||||||
let state = if proxyfier.running {
|
let state = if proxyfier.running {
|
||||||
ComponentState::Running
|
ComponentState::Running
|
||||||
@@ -154,6 +191,58 @@ fn missing_proxyfier_component() -> ComponentStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn detected_singbox_component(singbox: &DetectedSingBox) -> ComponentStatus {
|
||||||
|
let state = if singbox.running {
|
||||||
|
ComponentState::Running
|
||||||
|
} else {
|
||||||
|
ComponentState::Stopped
|
||||||
|
};
|
||||||
|
let actions = if singbox.running {
|
||||||
|
vec![
|
||||||
|
"Сгенерировать конфиг".to_string(),
|
||||||
|
"Остановить".to_string(),
|
||||||
|
"Открыть папку".to_string(),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
"Сгенерировать конфиг".to_string(),
|
||||||
|
"Запустить".to_string(),
|
||||||
|
"Открыть папку".to_string(),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let problems = if singbox.running {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec!["Служба Local sing-box остановлена".to_string()]
|
||||||
|
};
|
||||||
|
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
state,
|
||||||
|
installed: true,
|
||||||
|
running: singbox.running,
|
||||||
|
version: Some("sing-box найден".to_string()),
|
||||||
|
path: Some(singbox.executable_path.display().to_string()),
|
||||||
|
problems,
|
||||||
|
actions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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::new(),
|
||||||
|
actions: vec!["Установить Local sing-box".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct ProxyfierCandidate {
|
struct ProxyfierCandidate {
|
||||||
engine: ProxyfierEngine,
|
engine: ProxyfierEngine,
|
||||||
@@ -272,6 +361,68 @@ fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) ->
|
|||||||
dirs
|
dirs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn singbox_candidates(host: &impl ProxyfierDetectionHost) -> Vec<PathBuf> {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
|
||||||
|
if let Some(path) = host.env_var("VPN_PROXY_SINGBOX_ROOT") {
|
||||||
|
push_path_candidate(&mut candidates, PathBuf::from(path));
|
||||||
|
}
|
||||||
|
push_path_candidate(
|
||||||
|
&mut candidates,
|
||||||
|
PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT),
|
||||||
|
);
|
||||||
|
push_path_candidate(
|
||||||
|
&mut candidates,
|
||||||
|
PathBuf::from(r"C:\Tools\VpnProxy\sing-box"),
|
||||||
|
);
|
||||||
|
for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
|
||||||
|
if let Some(root) = host.env_var(env_name) {
|
||||||
|
push_path_candidate(
|
||||||
|
&mut candidates,
|
||||||
|
PathBuf::from(&root).join("VpnProxy").join("sing-box"),
|
||||||
|
);
|
||||||
|
push_path_candidate(&mut candidates, PathBuf::from(root).join("sing-box"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_path_candidate(candidates: &mut Vec<PathBuf>, candidate: PathBuf) {
|
||||||
|
if !candidates
|
||||||
|
.iter()
|
||||||
|
.any(|existing| same_path(existing, &candidate))
|
||||||
|
{
|
||||||
|
candidates.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detected_singbox_from_dir(
|
||||||
|
host: &impl ProxyfierDetectionHost,
|
||||||
|
install_dir: PathBuf,
|
||||||
|
running: bool,
|
||||||
|
) -> Option<DetectedSingBox> {
|
||||||
|
let executable_path = install_dir.join("sing-box.exe");
|
||||||
|
let wrapper_path = install_dir.join("VpnProxySingBox.exe");
|
||||||
|
let binary_exists = host.path_exists(&executable_path);
|
||||||
|
let wrapper_exists = host.path_exists(&wrapper_path);
|
||||||
|
let exists = host.path_exists(&install_dir) || binary_exists || wrapper_exists;
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(DetectedSingBox {
|
||||||
|
install_dir,
|
||||||
|
executable_path,
|
||||||
|
wrapper_path,
|
||||||
|
binary_exists,
|
||||||
|
wrapper_exists,
|
||||||
|
running,
|
||||||
|
service_name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn executable_name(engine: &ProxyfierEngine) -> &'static str {
|
fn executable_name(engine: &ProxyfierEngine) -> &'static str {
|
||||||
match engine {
|
match engine {
|
||||||
ProxyfierEngine::ProxiFyre => "ProxiFyre.exe",
|
ProxyfierEngine::ProxiFyre => "ProxiFyre.exe",
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ mod activity;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod component_detection;
|
mod component_detection;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod singbox_service;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
mod subscription;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
mod adapters {
|
mod adapters {
|
||||||
pub mod proxifyre;
|
pub mod proxifyre;
|
||||||
pub mod proxy_router;
|
pub mod proxy_router;
|
||||||
|
pub mod singbox;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -22,6 +25,11 @@ pub(crate) mod proxy_router {
|
|||||||
pub use crate::adapters::proxy_router::*;
|
pub use crate::adapters::proxy_router::*;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod singbox {
|
||||||
|
pub use crate::adapters::singbox::*;
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
@@ -35,14 +43,27 @@ fn main() {
|
|||||||
commands::save_target,
|
commands::save_target,
|
||||||
commands::get_components,
|
commands::get_components,
|
||||||
commands::get_proxifyre_setup_status,
|
commands::get_proxifyre_setup_status,
|
||||||
|
commands::get_singbox_status,
|
||||||
|
commands::get_singbox_setup_status,
|
||||||
commands::resolve_profile_preview,
|
commands::resolve_profile_preview,
|
||||||
|
commands::save_singbox_subscription,
|
||||||
|
commands::fetch_singbox_subscription,
|
||||||
|
commands::forget_singbox_subscription,
|
||||||
|
commands::select_singbox_server,
|
||||||
|
commands::ping_singbox_server,
|
||||||
|
commands::ping_all_singbox_servers,
|
||||||
|
commands::generate_singbox_config,
|
||||||
commands::apply_profiles,
|
commands::apply_profiles,
|
||||||
commands::get_logs,
|
commands::get_logs,
|
||||||
commands::open_config_location,
|
commands::open_config_location,
|
||||||
commands::start_proxifyre_service,
|
commands::start_proxifyre_service,
|
||||||
commands::stop_proxifyre_service,
|
commands::stop_proxifyre_service,
|
||||||
commands::install_proxifyre,
|
commands::install_proxifyre,
|
||||||
commands::uninstall_proxifyre
|
commands::uninstall_proxifyre,
|
||||||
|
commands::start_singbox_service,
|
||||||
|
commands::stop_singbox_service,
|
||||||
|
commands::install_singbox,
|
||||||
|
commands::uninstall_singbox
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("не удалось запустить клиент VPN Proxy для Windows");
|
.expect("не удалось запустить клиент VPN Proxy для Windows");
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
|
||||||
|
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
|
||||||
|
pub const DEFAULT_LOCAL_SINGBOX_SERVICE_NAME: &str = "VpnProxySingBox";
|
||||||
|
pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str = r"C:\Program Files\VpnProxy\sing-box";
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
pub enum Protocol {
|
pub enum Protocol {
|
||||||
@@ -128,6 +133,65 @@ pub struct ComponentStatus {
|
|||||||
pub actions: Vec<String>,
|
pub actions: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct LocalSingBoxConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub subscription_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub selected_server_tag: Option<String>,
|
||||||
|
#[serde(default = "default_local_singbox_listen_host")]
|
||||||
|
pub listen_host: String,
|
||||||
|
#[serde(default = "default_local_singbox_listen_port")]
|
||||||
|
pub listen_port: u16,
|
||||||
|
#[serde(default = "default_local_singbox_service_name")]
|
||||||
|
pub service_name: String,
|
||||||
|
#[serde(default = "default_local_singbox_install_root")]
|
||||||
|
pub install_root: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalSingBoxConfig {
|
||||||
|
pub fn subscription_display_url(&self) -> Option<String> {
|
||||||
|
self.subscription_url
|
||||||
|
.as_deref()
|
||||||
|
.map(redact_subscription_url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LocalSingBoxConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
subscription_url: None,
|
||||||
|
selected_server_tag: None,
|
||||||
|
listen_host: default_local_singbox_listen_host(),
|
||||||
|
listen_port: default_local_singbox_listen_port(),
|
||||||
|
service_name: default_local_singbox_service_name(),
|
||||||
|
install_root: default_local_singbox_install_root(),
|
||||||
|
updated_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SubscriptionCache {
|
||||||
|
pub config: serde_json::Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub servers: Vec<SubscriptionServer>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_info: serde_json::Map<String, serde_json::Value>,
|
||||||
|
pub fetched_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SubscriptionServer {
|
||||||
|
pub tag: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub server_type: String,
|
||||||
|
pub server: String,
|
||||||
|
pub server_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ActivityEntry {
|
pub struct ActivityEntry {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -165,3 +229,45 @@ fn default_target_kind() -> String {
|
|||||||
fn default_proxy_protocol() -> String {
|
fn default_proxy_protocol() -> String {
|
||||||
"socks5".to_string()
|
"socks5".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_local_singbox_listen_host() -> String {
|
||||||
|
DEFAULT_LOCAL_SINGBOX_LISTEN_HOST.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_local_singbox_listen_port() -> u16 {
|
||||||
|
DEFAULT_LOCAL_SINGBOX_LISTEN_PORT
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_local_singbox_service_name() -> String {
|
||||||
|
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_local_singbox_install_root() -> String {
|
||||||
|
DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redact_subscription_url(raw_url: &str) -> String {
|
||||||
|
let trimmed = raw_url.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
match trimmed.split_once("://") {
|
||||||
|
Some((scheme, rest)) => {
|
||||||
|
let host = rest
|
||||||
|
.split(['/', '?', '#'])
|
||||||
|
.next()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("subscription");
|
||||||
|
format!("{scheme}://{host}/...")
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let visible = trimmed.chars().take(18).collect::<String>();
|
||||||
|
if trimmed.chars().count() <= 18 {
|
||||||
|
"***".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{visible}...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
273
apps/windows-client/src-tauri/src/singbox_service.rs
Normal file
273
apps/windows-client/src-tauri/src/singbox_service.rs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
use crate::component_detection::DetectedSingBox;
|
||||||
|
use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub const SINGBOX_RELEASE_API_URL: &str =
|
||||||
|
"https://api.github.com/repos/SagerNet/sing-box/releases/latest";
|
||||||
|
pub const WINSW_RELEASE_API_URL: &str = "https://api.github.com/repos/winsw/winsw/releases/latest";
|
||||||
|
pub const WINSW_WRAPPER_FILE: &str = "VpnProxySingBox.exe";
|
||||||
|
pub const SINGBOX_BINARY_FILE: &str = "sing-box.exe";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SingBoxServiceAction {
|
||||||
|
Start,
|
||||||
|
Stop,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SingBoxServiceAction {
|
||||||
|
pub fn action_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SingBoxServiceAction::Start => "start",
|
||||||
|
SingBoxServiceAction::Stop => "stop",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SingBoxServiceAction::Start => "запустить",
|
||||||
|
SingBoxServiceAction::Stop => "остановить",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SingBoxSetupStatus {
|
||||||
|
pub ready: bool,
|
||||||
|
pub missing_count: usize,
|
||||||
|
pub items: Vec<SingBoxSetupItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SingBoxSetupItem {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub installed: bool,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub details: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ServiceCommandOutput {
|
||||||
|
pub success: bool,
|
||||||
|
pub code: String,
|
||||||
|
pub service_name: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub process_id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBoxSetupStatus {
|
||||||
|
let install_root = detected
|
||||||
|
.map(|singbox| singbox.install_dir.display().to_string())
|
||||||
|
.unwrap_or_else(|| DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string());
|
||||||
|
let binary_item = match detected {
|
||||||
|
Some(singbox) if singbox.binary_exists => SingBoxSetupItem {
|
||||||
|
id: "sing-box-binary".to_string(),
|
||||||
|
name: "sing-box".to_string(),
|
||||||
|
installed: true,
|
||||||
|
version: Some("binary найден".to_string()),
|
||||||
|
details: singbox.executable_path.display().to_string(),
|
||||||
|
},
|
||||||
|
_ => SingBoxSetupItem {
|
||||||
|
id: "sing-box-binary".to_string(),
|
||||||
|
name: "sing-box".to_string(),
|
||||||
|
installed: false,
|
||||||
|
version: None,
|
||||||
|
details: format!(
|
||||||
|
"Будет скачан из GitHub releases SagerNet/sing-box и установлен в {install_root}."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let wrapper_item = match detected {
|
||||||
|
Some(singbox) if singbox.wrapper_exists => SingBoxSetupItem {
|
||||||
|
id: "winsw-wrapper".to_string(),
|
||||||
|
name: "WinSW service wrapper".to_string(),
|
||||||
|
installed: true,
|
||||||
|
version: Some("wrapper найден".to_string()),
|
||||||
|
details: singbox.wrapper_path.display().to_string(),
|
||||||
|
},
|
||||||
|
_ => SingBoxSetupItem {
|
||||||
|
id: "winsw-wrapper".to_string(),
|
||||||
|
name: "WinSW service wrapper".to_string(),
|
||||||
|
installed: false,
|
||||||
|
version: None,
|
||||||
|
details: format!(
|
||||||
|
"Будет скачан из GitHub releases winsw/winsw как {WINSW_WRAPPER_FILE}."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let service_item = match detected {
|
||||||
|
Some(singbox) if singbox.running => SingBoxSetupItem {
|
||||||
|
id: "windows-service".to_string(),
|
||||||
|
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||||
|
installed: true,
|
||||||
|
version: Some("служба запущена".to_string()),
|
||||||
|
details: format!("Служба {}", singbox.service_name),
|
||||||
|
},
|
||||||
|
Some(singbox) => SingBoxSetupItem {
|
||||||
|
id: "windows-service".to_string(),
|
||||||
|
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||||
|
installed: true,
|
||||||
|
version: Some("служба остановлена".to_string()),
|
||||||
|
details: format!("Служба {}", singbox.service_name),
|
||||||
|
},
|
||||||
|
None => SingBoxSetupItem {
|
||||||
|
id: "windows-service".to_string(),
|
||||||
|
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||||
|
installed: false,
|
||||||
|
version: None,
|
||||||
|
details: "Будет создана Windows-служба Local sing-box.".to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let items = vec![binary_item, wrapper_item, service_item];
|
||||||
|
let missing_count = items.iter().filter(|item| !item.installed).count();
|
||||||
|
|
||||||
|
SingBoxSetupStatus {
|
||||||
|
ready: missing_count == 0,
|
||||||
|
missing_count,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_service_command_output(stdout: &[u8]) -> Option<ServiceCommandOutput> {
|
||||||
|
let stdout = String::from_utf8_lossy(stdout);
|
||||||
|
let payload = stdout
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|line| line.starts_with('{') && line.ends_with('}'))?;
|
||||||
|
|
||||||
|
serde_json::from_str(payload).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_safe_singbox_install_dir(path: &Path) -> Result<(), String> {
|
||||||
|
let normalized = path
|
||||||
|
.display()
|
||||||
|
.to_string()
|
||||||
|
.replace('/', "\\")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
|
if file_name == "sing-box"
|
||||||
|
&& (normalized.contains("\\vpnproxy\\") || normalized.contains("\\vpn-proxy\\"))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"Отказываюсь рекурсивно удалять Local sing-box с небезопасным путем: {}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn service_control_script(
|
||||||
|
action: SingBoxServiceAction,
|
||||||
|
service_name: &str,
|
||||||
|
config_source: Option<&Path>,
|
||||||
|
config_target: Option<&Path>,
|
||||||
|
) -> String {
|
||||||
|
let action_name = action.action_name();
|
||||||
|
let escaped_service_name = escape_powershell_single(service_name);
|
||||||
|
let escaped_config_source = config_source
|
||||||
|
.map(|path| escape_powershell_single(&path.display().to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let escaped_config_target = config_target
|
||||||
|
.map(|path| escape_powershell_single(&path.display().to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$serviceName = '{escaped_service_name}'
|
||||||
|
$action = '{action_name}'
|
||||||
|
$configSource = '{escaped_config_source}'
|
||||||
|
$configTarget = '{escaped_config_target}'
|
||||||
|
|
||||||
|
function Get-ServiceProcessId([string]$name) {{
|
||||||
|
$escapedName = $name.Replace("'", "''")
|
||||||
|
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $record) {{ return 0 }}
|
||||||
|
return [int]$record.ProcessId
|
||||||
|
}}
|
||||||
|
|
||||||
|
function Get-ServiceStatus([string]$name) {{
|
||||||
|
$current = Get-Service -Name $name -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $current) {{ return $null }}
|
||||||
|
return $current.Status.ToString()
|
||||||
|
}}
|
||||||
|
|
||||||
|
function Write-ServiceResult([bool]$success, [string]$code, [string]$status, [int]$processId) {{
|
||||||
|
[PSCustomObject]@{{
|
||||||
|
success = $success
|
||||||
|
code = $code
|
||||||
|
serviceName = $serviceName
|
||||||
|
status = $status
|
||||||
|
processId = $processId
|
||||||
|
}} | ConvertTo-Json -Compress
|
||||||
|
exit 0
|
||||||
|
}}
|
||||||
|
|
||||||
|
function Sync-ServiceConfig {{
|
||||||
|
if ($action -ne 'start' -or [string]::IsNullOrWhiteSpace($configSource)) {{ return }}
|
||||||
|
if (-not (Test-Path -LiteralPath $configSource)) {{
|
||||||
|
Write-ServiceResult $false 'config_source_missing' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||||
|
}}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($configTarget)) {{ return }}
|
||||||
|
|
||||||
|
try {{
|
||||||
|
Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop
|
||||||
|
}} catch {{
|
||||||
|
Write-ServiceResult $false 'config_sync_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $service) {{
|
||||||
|
Write-ServiceResult $false 'service_not_found' $null 0
|
||||||
|
}}
|
||||||
|
|
||||||
|
if ($action -eq 'start') {{
|
||||||
|
Sync-ServiceConfig
|
||||||
|
|
||||||
|
if ($service.Status -eq 'Running') {{
|
||||||
|
Write-ServiceResult $true 'already_running' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||||
|
}}
|
||||||
|
|
||||||
|
try {{
|
||||||
|
Start-Service -Name $serviceName -ErrorAction Stop
|
||||||
|
$service = Get-Service -Name $serviceName -ErrorAction Stop
|
||||||
|
$service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15))
|
||||||
|
}} catch {{
|
||||||
|
Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||||
|
}}
|
||||||
|
|
||||||
|
Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||||
|
}}
|
||||||
|
|
||||||
|
if ($service.Status -eq 'Stopped') {{
|
||||||
|
Write-ServiceResult $true 'already_stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||||
|
}}
|
||||||
|
|
||||||
|
try {{
|
||||||
|
Stop-Service -Name $serviceName -Force -ErrorAction Stop
|
||||||
|
$service = Get-Service -Name $serviceName -ErrorAction Stop
|
||||||
|
$service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15))
|
||||||
|
}} catch {{
|
||||||
|
Write-ServiceResult $false 'stop_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||||
|
}}
|
||||||
|
|
||||||
|
Write-ServiceResult ($service.Status -eq 'Stopped') 'stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_powershell_single(value: &str) -> String {
|
||||||
|
value.replace('\'', "''")
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
|
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
|
||||||
use crate::models::{ActivityEntry, ComponentStatus, Profile, Target};
|
use crate::models::{
|
||||||
|
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target,
|
||||||
|
};
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{self, ErrorKind};
|
use std::io::{self, ErrorKind};
|
||||||
@@ -18,6 +20,8 @@ pub struct StoragePaths {
|
|||||||
pub profiles_file: PathBuf,
|
pub profiles_file: PathBuf,
|
||||||
pub targets_file: PathBuf,
|
pub targets_file: PathBuf,
|
||||||
pub components_file: PathBuf,
|
pub components_file: PathBuf,
|
||||||
|
pub local_singbox_file: PathBuf,
|
||||||
|
pub singbox_subscription_cache_file: PathBuf,
|
||||||
pub activity_file: PathBuf,
|
pub activity_file: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +37,8 @@ impl StoragePaths {
|
|||||||
profiles_file: config_dir.join("profiles.json"),
|
profiles_file: config_dir.join("profiles.json"),
|
||||||
targets_file: config_dir.join("targets.json"),
|
targets_file: config_dir.join("targets.json"),
|
||||||
components_file: config_dir.join("components.json"),
|
components_file: config_dir.join("components.json"),
|
||||||
|
local_singbox_file: config_dir.join("local-singbox.json"),
|
||||||
|
singbox_subscription_cache_file: state_dir.join("singbox-subscription-cache.json"),
|
||||||
activity_file: state_dir.join("activity.json"),
|
activity_file: state_dir.join("activity.json"),
|
||||||
config_dir,
|
config_dir,
|
||||||
state_dir,
|
state_dir,
|
||||||
@@ -100,6 +106,30 @@ impl JsonStorage {
|
|||||||
self.write_json(&self.paths.components_file, components)
|
self.write_json(&self.paths.components_file, components)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
|
||||||
|
self.read_json_or_default(&self.paths.local_singbox_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_local_singbox_config(&self, config: &LocalSingBoxConfig) -> io::Result<()> {
|
||||||
|
self.write_json(&self.paths.local_singbox_file, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_singbox_subscription_cache(&self) -> io::Result<Option<SubscriptionCache>> {
|
||||||
|
self.read_optional_json(&self.paths.singbox_subscription_cache_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_singbox_subscription_cache(&self, cache: &SubscriptionCache) -> io::Result<()> {
|
||||||
|
self.write_json(&self.paths.singbox_subscription_cache_file, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_singbox_subscription_cache(&self) -> io::Result<()> {
|
||||||
|
match fs::remove_file(&self.paths.singbox_subscription_cache_file) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn read_activity(&self) -> io::Result<Vec<ActivityEntry>> {
|
pub fn read_activity(&self) -> io::Result<Vec<ActivityEntry>> {
|
||||||
let entries = self.read_json_or_default(&self.paths.activity_file)?;
|
let entries = self.read_json_or_default(&self.paths.activity_file)?;
|
||||||
Ok(cap_activity(entries, self.activity_limit))
|
Ok(cap_activity(entries, self.activity_limit))
|
||||||
@@ -139,6 +169,17 @@ impl JsonStorage {
|
|||||||
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
|
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
|
||||||
write_atomic(path, &contents)
|
write_atomic(path, &contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_optional_json<T>(&self, path: &Path) -> io::Result<Option<T>>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(contents) => Ok(serde_json::from_str(&contents).ok()),
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for JsonStorage {
|
impl Default for JsonStorage {
|
||||||
|
|||||||
269
apps/windows-client/src-tauri/src/subscription.rs
Normal file
269
apps/windows-client/src-tauri/src/subscription.rs
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
use crate::models::{SubscriptionCache, SubscriptionServer};
|
||||||
|
use base64::{engine::general_purpose, Engine};
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SubscriptionError {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubscriptionError {
|
||||||
|
fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SubscriptionError {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter.write_str(&self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SubscriptionError {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ParsedSubscription {
|
||||||
|
pub config: Value,
|
||||||
|
pub servers: Vec<SubscriptionServer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_subscription_body(body: &str) -> Result<ParsedSubscription, SubscriptionError> {
|
||||||
|
let config = match serde_json::from_str::<Value>(body) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => parse_link_subscription(body)?,
|
||||||
|
};
|
||||||
|
let servers = servers_from_config(&config)?;
|
||||||
|
|
||||||
|
Ok(ParsedSubscription { config, servers })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_user_info(header_value: Option<&str>) -> Map<String, Value> {
|
||||||
|
let mut result = Map::new();
|
||||||
|
let Some(header_value) = header_value else {
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
for part in header_value.split(';') {
|
||||||
|
let Some((key, value)) = part.trim().split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(parsed) = value.trim().parse::<i64>() {
|
||||||
|
result.insert(key.to_string(), Value::Number(parsed.into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionError> {
|
||||||
|
let parsed_url =
|
||||||
|
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
|
||||||
|
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||||
|
return Err(SubscriptionError::new(
|
||||||
|
"Subscription URL must use http or https",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = reqwest::blocking::Client::new()
|
||||||
|
.get(parsed_url)
|
||||||
|
.header("user-agent", "singbox")
|
||||||
|
.header("x-device-os", std::env::consts::OS)
|
||||||
|
.header("x-device-model", "vpn-proxy-windows-client")
|
||||||
|
.send()
|
||||||
|
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(SubscriptionError::new(format!(
|
||||||
|
"Subscription request failed: HTTP {}",
|
||||||
|
status.as_u16()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_info = parse_user_info(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get("subscription-userinfo")
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
);
|
||||||
|
let body = response.text().map_err(|error| {
|
||||||
|
SubscriptionError::new(format!("Subscription body read failed: {error}"))
|
||||||
|
})?;
|
||||||
|
let parsed = parse_subscription_body(&body)?;
|
||||||
|
|
||||||
|
Ok(SubscriptionCache {
|
||||||
|
config: parsed.config,
|
||||||
|
servers: parsed.servers,
|
||||||
|
user_info,
|
||||||
|
fetched_at: now_timestamp(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
|
||||||
|
let decoded = maybe_decode_base64(body);
|
||||||
|
let links = decoded
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| line.starts_with("vless://"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if links.is_empty() {
|
||||||
|
return Err(SubscriptionError::new(
|
||||||
|
"Subscription does not contain JSON config or VLESS links",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let outbounds = links
|
||||||
|
.into_iter()
|
||||||
|
.map(parse_vless_url)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(json!({ "outbounds": outbounds }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_vless_url(raw_url: &str) -> Result<Value, SubscriptionError> {
|
||||||
|
if !raw_url.starts_with("vless://") {
|
||||||
|
return Err(SubscriptionError::new("VLESS URL must start with vless://"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?;
|
||||||
|
let tag = parsed.fragment().unwrap_or("vless-out").to_string();
|
||||||
|
let uuid = parsed.username().trim().to_string();
|
||||||
|
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
|
||||||
|
let server_port = parsed.port_or_known_default().unwrap_or(443);
|
||||||
|
let public_key = query_value(&parsed, "pbk").unwrap_or_default();
|
||||||
|
let short_id = query_value(&parsed, "sid").unwrap_or_default();
|
||||||
|
let server_name = query_value(&parsed, "sni").unwrap_or_else(|| server.clone());
|
||||||
|
let fingerprint = query_value(&parsed, "fp").unwrap_or_else(|| "chrome".to_string());
|
||||||
|
let flow = query_value(&parsed, "flow").unwrap_or_default();
|
||||||
|
|
||||||
|
if uuid.is_empty() || server.is_empty() {
|
||||||
|
return Err(SubscriptionError::new(
|
||||||
|
"VLESS URL misses uuid, host or port",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if public_key.is_empty() || short_id.is_empty() {
|
||||||
|
return Err(SubscriptionError::new(
|
||||||
|
"VLESS REALITY parameters pbk and sid are required",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"type": "vless",
|
||||||
|
"tag": tag,
|
||||||
|
"server": server,
|
||||||
|
"server_port": server_port,
|
||||||
|
"uuid": uuid,
|
||||||
|
"flow": flow,
|
||||||
|
"tls": {
|
||||||
|
"enabled": true,
|
||||||
|
"server_name": server_name,
|
||||||
|
"utls": {
|
||||||
|
"enabled": true,
|
||||||
|
"fingerprint": fingerprint
|
||||||
|
},
|
||||||
|
"reality": {
|
||||||
|
"enabled": true,
|
||||||
|
"public_key": public_key,
|
||||||
|
"short_id": short_id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packet_encoding": "xudp"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn servers_from_config(config: &Value) -> Result<Vec<SubscriptionServer>, SubscriptionError> {
|
||||||
|
let servers = config
|
||||||
|
.get("outbounds")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(server_from_outbound)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if servers.is_empty() {
|
||||||
|
return Err(SubscriptionError::new(
|
||||||
|
"No supported proxy outbounds found in subscription",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
|
||||||
|
let server_type = outbound.get("type")?.as_str()?.to_string();
|
||||||
|
if !SUPPORTED_PROXY_TYPES.contains(&server_type.as_str()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server = outbound
|
||||||
|
.get("server")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let server_port = outbound
|
||||||
|
.get("server_port")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.and_then(|value| u16::try_from(value).ok())
|
||||||
|
.unwrap_or(443);
|
||||||
|
let tag = outbound
|
||||||
|
.get("tag")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| format!("{server_type}-{server}"));
|
||||||
|
|
||||||
|
Some(SubscriptionServer {
|
||||||
|
tag,
|
||||||
|
server_type,
|
||||||
|
server,
|
||||||
|
server_port,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_decode_base64(content: &str) -> String {
|
||||||
|
let compact = content.split_whitespace().collect::<String>();
|
||||||
|
if compact.is_empty()
|
||||||
|
|| !compact
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '-' | '_'))
|
||||||
|
{
|
||||||
|
return content.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
for engine in [general_purpose::STANDARD, general_purpose::URL_SAFE] {
|
||||||
|
if let Ok(decoded) = engine.decode(compact.as_bytes()) {
|
||||||
|
if let Ok(decoded) = String::from_utf8(decoded) {
|
||||||
|
if decoded.contains("vless://") || decoded.contains('{') {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_value(url: &Url, key: &str) -> Option<String> {
|
||||||
|
url.query_pairs()
|
||||||
|
.find(|(name, _)| name == key)
|
||||||
|
.map(|(_, value)| value.into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_timestamp() -> String {
|
||||||
|
let seconds = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
format!("unix:{seconds}")
|
||||||
|
}
|
||||||
@@ -10,8 +10,14 @@ mod models;
|
|||||||
mod proxifyre;
|
mod proxifyre;
|
||||||
#[path = "../src/adapters/proxy_router.rs"]
|
#[path = "../src/adapters/proxy_router.rs"]
|
||||||
mod proxy_router;
|
mod proxy_router;
|
||||||
|
#[path = "../src/adapters/singbox.rs"]
|
||||||
|
mod singbox;
|
||||||
|
#[path = "../src/singbox_service.rs"]
|
||||||
|
mod singbox_service;
|
||||||
#[path = "../src/storage.rs"]
|
#[path = "../src/storage.rs"]
|
||||||
mod storage;
|
mod storage;
|
||||||
|
#[path = "../src/subscription.rs"]
|
||||||
|
mod subscription;
|
||||||
#[path = "../src/validation.rs"]
|
#[path = "../src/validation.rs"]
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
@@ -155,6 +161,25 @@ fn proxifyre_install_script_parses_as_powershell() {
|
|||||||
cleanup(&root);
|
cleanup(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn singbox_runner_preserves_installer_args_with_spaces() {
|
||||||
|
let script = commands::singbox_installer_runner_script(
|
||||||
|
Path::new(r"C:\ProgramData\VpnProxy\state\install-singbox.ps1"),
|
||||||
|
Path::new(r"C:\ProgramData\VpnProxy\state\install.log"),
|
||||||
|
&[
|
||||||
|
"-InstallRoot".to_string(),
|
||||||
|
r"C:\Program Files\VpnProxy\sing-box".to_string(),
|
||||||
|
"-ServiceName".to_string(),
|
||||||
|
"VpnProxySingBox".to_string(),
|
||||||
|
"-Uninstall".to_string(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(script.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\VpnProxy\\sing-box'"));
|
||||||
|
assert!(script.contains("& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs"));
|
||||||
|
assert!(!script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
|
fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
|
||||||
let root = test_root("apply");
|
let root = test_root("apply");
|
||||||
@@ -241,6 +266,7 @@ fn component_status_merges_detected_existing_proxifyre() {
|
|||||||
running: true,
|
running: true,
|
||||||
service_name: Some("ProxiFyreService".to_string()),
|
service_name: Some("ProxiFyreService".to_string()),
|
||||||
}),
|
}),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let proxyfier = components
|
let proxyfier = components
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ mod component_detection;
|
|||||||
mod models;
|
mod models;
|
||||||
|
|
||||||
use component_detection::{
|
use component_detection::{
|
||||||
detect_proxyfier_install_with_host, proxyfier_component_from_detection, ProxyfierDetectionHost,
|
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
|
||||||
|
proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost,
|
||||||
ProxyfierEngine, RegistryInstallEntry,
|
ProxyfierEngine, RegistryInstallEntry,
|
||||||
};
|
};
|
||||||
use models::ComponentState;
|
use models::ComponentState;
|
||||||
@@ -74,6 +75,66 @@ fn missing_proxyfier_returns_install_action_status() {
|
|||||||
assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
|
assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_running_local_singbox_from_default_install_root_and_service() {
|
||||||
|
let host = MockHost::new()
|
||||||
|
.with_path(r"C:\Program Files\VpnProxy\sing-box\sing-box.exe")
|
||||||
|
.with_service("VpnProxySingBox");
|
||||||
|
|
||||||
|
let detected =
|
||||||
|
detect_singbox_install_with_host(&host).expect("existing sing-box should be detected");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
detected.executable_path,
|
||||||
|
PathBuf::from(r"C:\Program Files\VpnProxy\sing-box\sing-box.exe")
|
||||||
|
);
|
||||||
|
assert_eq!(detected.service_name, "VpnProxySingBox");
|
||||||
|
assert!(detected.running);
|
||||||
|
|
||||||
|
let component = singbox_component_from_detection(Some(&detected));
|
||||||
|
assert_eq!(component.state, ComponentState::Running);
|
||||||
|
assert!(component.installed);
|
||||||
|
assert!(component.running);
|
||||||
|
assert_eq!(
|
||||||
|
component.path,
|
||||||
|
Some(r"C:\Program Files\VpnProxy\sing-box\sing-box.exe".to_string())
|
||||||
|
);
|
||||||
|
assert!(component.problems.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_stopped_local_singbox_from_env_override() {
|
||||||
|
let host = MockHost::new()
|
||||||
|
.with_env("VPN_PROXY_SINGBOX_ROOT", r"D:\Portable\sing-box")
|
||||||
|
.with_path(r"D:\Portable\sing-box\sing-box.exe");
|
||||||
|
|
||||||
|
let detected = detect_singbox_install_with_host(&host).expect("env override should be checked");
|
||||||
|
let component = singbox_component_from_detection(Some(&detected));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
detected.executable_path,
|
||||||
|
PathBuf::from(r"D:\Portable\sing-box\sing-box.exe")
|
||||||
|
);
|
||||||
|
assert_eq!(component.state, ComponentState::Stopped);
|
||||||
|
assert!(component.installed);
|
||||||
|
assert!(!component.running);
|
||||||
|
assert!(component
|
||||||
|
.problems
|
||||||
|
.iter()
|
||||||
|
.any(|problem| problem.contains("остановлена")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_local_singbox_returns_optional_install_action_status() {
|
||||||
|
let component = singbox_component_from_detection(None);
|
||||||
|
|
||||||
|
assert_eq!(component.state, ComponentState::Missing);
|
||||||
|
assert!(!component.installed);
|
||||||
|
assert!(!component.running);
|
||||||
|
assert_eq!(component.actions, vec!["Установить Local sing-box"]);
|
||||||
|
assert!(component.problems.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct MockHost {
|
struct MockHost {
|
||||||
env: HashMap<String, String>,
|
env: HashMap<String, String>,
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ mod proxy_router;
|
|||||||
mod singbox;
|
mod singbox;
|
||||||
|
|
||||||
use models::{
|
use models::{
|
||||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
|
||||||
ProxyProtocol, Target, TargetKind,
|
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
|
||||||
|
TargetKind,
|
||||||
};
|
};
|
||||||
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
||||||
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||||
use singbox::{
|
use singbox::{
|
||||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfig, SingBoxConfigChecker, SingBoxConfigError,
|
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||||
SingBoxConfigErrorKind, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
|
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
|
||||||
|
SINGBOX_OUTPUT_FILE,
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
cell::RefCell,
|
cell::RefCell,
|
||||||
@@ -23,25 +25,25 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generates_local_singbox_config_and_runs_check_when_binary_path_is_supplied() {
|
fn generates_selected_outbound_config_and_runs_check_when_binary_path_is_supplied() {
|
||||||
let adapter = SingBoxAdapter::default();
|
let adapter = SingBoxAdapter::default();
|
||||||
let targets = vec![local_singbox_target()];
|
let config = local_singbox_config("nl-1");
|
||||||
let components = vec![running_singbox_component()];
|
let cache = subscription_cache();
|
||||||
let checker = RecordingChecker::ok("configuration OK");
|
let checker = RecordingChecker::ok("configuration OK");
|
||||||
let binary_path = Path::new(r"C:\Tools\VpnProxy\sing-box\sing-box.exe");
|
let binary_path = Path::new(r"C:\Tools\VpnProxy\sing-box\sing-box.exe");
|
||||||
|
|
||||||
let generated = adapter
|
let generated = adapter
|
||||||
.generate_config(
|
.generate_config(
|
||||||
SingBoxGenerationRequest::new(&targets, &components, Some(binary_path)),
|
SingBoxGenerationRequest::new(&config, &cache, Some(binary_path)),
|
||||||
&checker,
|
&checker,
|
||||||
)
|
)
|
||||||
.expect("running local sing-box should generate config");
|
.expect("selected outbound should generate config");
|
||||||
let config: SingBoxConfig =
|
let generated_config: serde_json::Value =
|
||||||
serde_json::from_str(&generated.contents).expect("generated sing-box json");
|
serde_json::from_str(&generated.contents).expect("generated sing-box json");
|
||||||
|
|
||||||
assert_eq!(generated.adapter_id, "singbox");
|
assert_eq!(generated.adapter_id, "singbox");
|
||||||
assert_eq!(generated.output_file_name, SINGBOX_OUTPUT_FILE);
|
assert_eq!(generated.output_file_name, SINGBOX_OUTPUT_FILE);
|
||||||
assert_eq!(generated.local_target_id, "local-singbox");
|
assert_eq!(generated.selected_server_tag, "nl-1");
|
||||||
assert_eq!(generated.listen, "127.0.0.1");
|
assert_eq!(generated.listen, "127.0.0.1");
|
||||||
assert_eq!(generated.listen_port, 1080);
|
assert_eq!(generated.listen_port, 1080);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -52,30 +54,39 @@ fn generates_local_singbox_config_and_runs_check_when_binary_path_is_supplied()
|
|||||||
message: "configuration OK".to_string(),
|
message: "configuration OK".to_string(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(config.log.level, "info");
|
assert_eq!(generated_config["log"]["level"], "info");
|
||||||
assert_eq!(config.inbounds.len(), 1);
|
assert_eq!(generated_config["inbounds"][0]["type"], "mixed");
|
||||||
assert_eq!(config.inbounds[0].inbound_type, "mixed");
|
assert_eq!(generated_config["inbounds"][0]["listen"], "127.0.0.1");
|
||||||
assert_eq!(config.inbounds[0].listen, "127.0.0.1");
|
assert_eq!(generated_config["inbounds"][0]["listen_port"], 1080);
|
||||||
assert_eq!(config.inbounds[0].listen_port, 1080);
|
assert_eq!(generated_config["inbounds"][0]["set_system_proxy"], false);
|
||||||
assert!(!config.inbounds[0].set_system_proxy);
|
assert_eq!(generated_config["outbounds"][0]["type"], "vless");
|
||||||
assert_eq!(config.outbounds[0].outbound_type, "direct");
|
assert_eq!(
|
||||||
assert_eq!(config.route.final_outbound, "direct");
|
generated_config["outbounds"][0]["tag"],
|
||||||
|
DEFAULT_VPN_OUTBOUND_TAG
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
generated_config["outbounds"][0]["server"],
|
||||||
|
"nl.example.test"
|
||||||
|
);
|
||||||
|
assert_eq!(generated_config["outbounds"][0]["packet_encoding"], "xudp");
|
||||||
|
assert_eq!(generated_config["route"]["final"], DEFAULT_VPN_OUTBOUND_TAG);
|
||||||
let calls = checker.calls.borrow();
|
let calls = checker.calls.borrow();
|
||||||
assert_eq!(calls.len(), 1);
|
assert_eq!(calls.len(), 1);
|
||||||
assert_eq!(calls[0].0.as_path(), binary_path);
|
assert_eq!(calls[0].0.as_path(), binary_path);
|
||||||
assert!(calls[0].1.contains(r#""type": "mixed""#));
|
assert!(calls[0].1.contains(r#""type": "mixed""#));
|
||||||
|
assert!(calls[0].1.contains(r#""tag": "vpn""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skips_singbox_check_when_binary_path_is_not_supplied() {
|
fn skips_singbox_check_when_binary_path_is_not_supplied() {
|
||||||
let adapter = SingBoxAdapter::default();
|
let adapter = SingBoxAdapter::default();
|
||||||
let targets = vec![local_singbox_target()];
|
let config = local_singbox_config("nl-1");
|
||||||
let components = vec![running_singbox_component()];
|
let cache = subscription_cache();
|
||||||
let checker = RecordingChecker::ok("should not run");
|
let checker = RecordingChecker::ok("should not run");
|
||||||
|
|
||||||
let generated = adapter
|
let generated = adapter
|
||||||
.generate_config(
|
.generate_config(
|
||||||
SingBoxGenerationRequest::new(&targets, &components, None),
|
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||||
&checker,
|
&checker,
|
||||||
)
|
)
|
||||||
.expect("binary path is optional");
|
.expect("binary path is optional");
|
||||||
@@ -85,54 +96,53 @@ fn skips_singbox_check_when_binary_path_is_not_supplied() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_local_singbox_config_when_required_component_is_missing() {
|
fn blocks_config_when_server_is_not_selected() {
|
||||||
let adapter = SingBoxAdapter::default();
|
let adapter = SingBoxAdapter::default();
|
||||||
let targets = vec![local_singbox_target()];
|
let mut config = local_singbox_config("nl-1");
|
||||||
let components = Vec::new();
|
config.selected_server_tag = None;
|
||||||
|
let cache = subscription_cache();
|
||||||
let checker = RecordingChecker::ok("should not run");
|
let checker = RecordingChecker::ok("should not run");
|
||||||
|
|
||||||
let error = adapter
|
let error = adapter
|
||||||
.generate_config(
|
.generate_config(
|
||||||
SingBoxGenerationRequest::new(&targets, &components, None),
|
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||||
&checker,
|
&checker,
|
||||||
)
|
)
|
||||||
.expect_err("local sing-box target requires component state");
|
.expect_err("missing selected server should block config");
|
||||||
|
|
||||||
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingRequiredComponent);
|
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedServer);
|
||||||
assert!(checker.calls.borrow().is_empty());
|
assert!(checker.calls.borrow().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_local_singbox_config_when_component_is_not_running() {
|
fn blocks_config_when_selected_outbound_is_missing() {
|
||||||
let adapter = SingBoxAdapter::default();
|
let adapter = SingBoxAdapter::default();
|
||||||
let targets = vec![local_singbox_target()];
|
let config = local_singbox_config("missing-server");
|
||||||
let components = vec![stopped_singbox_component()];
|
let cache = subscription_cache();
|
||||||
let checker = RecordingChecker::ok("should not run");
|
let checker = RecordingChecker::ok("should not run");
|
||||||
|
|
||||||
let error = adapter
|
let error = adapter
|
||||||
.generate_config(
|
.generate_config(
|
||||||
SingBoxGenerationRequest::new(&targets, &components, None),
|
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||||
&checker,
|
&checker,
|
||||||
)
|
)
|
||||||
.expect_err("local sing-box target requires running component");
|
.expect_err("missing outbound should block config");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound);
|
||||||
error.kind,
|
assert!(error.message.contains("missing-server"));
|
||||||
SingBoxConfigErrorKind::RequiredComponentNotRunning
|
|
||||||
);
|
|
||||||
assert!(checker.calls.borrow().is_empty());
|
assert!(checker.calls.borrow().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn propagates_failed_singbox_check_as_structured_error() {
|
fn propagates_failed_singbox_check_as_structured_error() {
|
||||||
let adapter = SingBoxAdapter::default();
|
let adapter = SingBoxAdapter::default();
|
||||||
let targets = vec![local_singbox_target()];
|
let config = local_singbox_config("nl-1");
|
||||||
let components = vec![running_singbox_component()];
|
let cache = subscription_cache();
|
||||||
let checker = RecordingChecker::err("invalid config");
|
let checker = RecordingChecker::err("invalid config");
|
||||||
|
|
||||||
let error = adapter
|
let error = adapter
|
||||||
.generate_config(
|
.generate_config(
|
||||||
SingBoxGenerationRequest::new(&targets, &components, Some(Path::new("sing-box.exe"))),
|
SingBoxGenerationRequest::new(&config, &cache, Some(Path::new("sing-box.exe"))),
|
||||||
&checker,
|
&checker,
|
||||||
)
|
)
|
||||||
.expect_err("failed sing-box check should block generated config");
|
.expect_err("failed sing-box check should block generated config");
|
||||||
@@ -202,6 +212,46 @@ impl SingBoxConfigChecker for RecordingChecker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
|
||||||
|
LocalSingBoxConfig {
|
||||||
|
subscription_url: Some("https://sub.example.test/list".to_string()),
|
||||||
|
selected_server_tag: Some(selected_server_tag.to_string()),
|
||||||
|
listen_host: "127.0.0.1".to_string(),
|
||||||
|
listen_port: 1080,
|
||||||
|
service_name: "VpnProxySingBox".to_string(),
|
||||||
|
install_root: r"C:\Program Files\VpnProxy\sing-box".to_string(),
|
||||||
|
updated_at: Some("2026-07-07T10:00:00Z".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscription_cache() -> SubscriptionCache {
|
||||||
|
SubscriptionCache {
|
||||||
|
config: serde_json::json!({
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "vless",
|
||||||
|
"tag": "nl-1",
|
||||||
|
"server": "nl.example.test",
|
||||||
|
"server_port": 443,
|
||||||
|
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "direct",
|
||||||
|
"tag": "direct"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
servers: vec![SubscriptionServer {
|
||||||
|
tag: "nl-1".to_string(),
|
||||||
|
server_type: "vless".to_string(),
|
||||||
|
server: "nl.example.test".to_string(),
|
||||||
|
server_port: 443,
|
||||||
|
}],
|
||||||
|
user_info: serde_json::Map::new(),
|
||||||
|
fetched_at: "2026-07-07T10:00:00Z".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn discord_profile(target_id: &str) -> Profile {
|
fn discord_profile(target_id: &str) -> Profile {
|
||||||
Profile {
|
Profile {
|
||||||
id: "discord".to_string(),
|
id: "discord".to_string(),
|
||||||
@@ -229,46 +279,6 @@ fn external_socks5_target() -> Target {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
fn missing_singbox_component() -> ComponentStatus {
|
||||||
ComponentStatus {
|
ComponentStatus {
|
||||||
id: ComponentId::Singbox,
|
id: ComponentId::Singbox,
|
||||||
|
|||||||
397
apps/windows-client/src-tauri/tests/singbox_command_tests.rs
Normal file
397
apps/windows-client/src-tauri/tests/singbox_command_tests.rs
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
#[path = "../src/activity.rs"]
|
||||||
|
mod activity;
|
||||||
|
#[path = "../src/commands.rs"]
|
||||||
|
mod commands;
|
||||||
|
#[path = "../src/component_detection.rs"]
|
||||||
|
mod component_detection;
|
||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/adapters/proxifyre.rs"]
|
||||||
|
mod proxifyre;
|
||||||
|
#[path = "../src/adapters/proxy_router.rs"]
|
||||||
|
mod proxy_router;
|
||||||
|
#[path = "../src/adapters/singbox.rs"]
|
||||||
|
mod singbox;
|
||||||
|
#[path = "../src/singbox_service.rs"]
|
||||||
|
mod singbox_service;
|
||||||
|
#[path = "../src/storage.rs"]
|
||||||
|
mod storage;
|
||||||
|
#[path = "../src/subscription.rs"]
|
||||||
|
mod subscription;
|
||||||
|
#[path = "../src/validation.rs"]
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
use commands::{
|
||||||
|
fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage,
|
||||||
|
generate_singbox_config_with_services, save_singbox_subscription_to_storage,
|
||||||
|
select_singbox_server_in_storage, Clock, SaveSingBoxSubscriptionInputDto,
|
||||||
|
SelectSingBoxServerInputDto, SubscriptionFetcher,
|
||||||
|
};
|
||||||
|
use models::{
|
||||||
|
ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache,
|
||||||
|
SubscriptionServer, TargetKind,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Map};
|
||||||
|
use singbox::{SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use storage::JsonStorage;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saves_subscription_url_without_exposing_secret_query() {
|
||||||
|
let root = test_root("save-subscription");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
|
||||||
|
let status = save_singbox_subscription_to_storage(
|
||||||
|
&storage,
|
||||||
|
SaveSingBoxSubscriptionInputDto {
|
||||||
|
subscription_url: " https://sub.example.test/path?token=secret ".to_string(),
|
||||||
|
},
|
||||||
|
&FixedClock,
|
||||||
|
)
|
||||||
|
.expect("subscription URL should be saved");
|
||||||
|
let config = storage
|
||||||
|
.read_local_singbox_config()
|
||||||
|
.expect("read local sing-box config");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config.subscription_url,
|
||||||
|
Some("https://sub.example.test/path?token=secret".to_string())
|
||||||
|
);
|
||||||
|
assert!(status.config.has_subscription);
|
||||||
|
assert_eq!(
|
||||||
|
status.config.subscription_display_url,
|
||||||
|
Some("https://sub.example.test/...".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_http_subscription_url() {
|
||||||
|
let root = test_root("invalid-subscription");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
|
||||||
|
let error = save_singbox_subscription_to_storage(
|
||||||
|
&storage,
|
||||||
|
SaveSingBoxSubscriptionInputDto {
|
||||||
|
subscription_url: "file:///C:/sub.txt".to_string(),
|
||||||
|
},
|
||||||
|
&FixedClock,
|
||||||
|
)
|
||||||
|
.expect_err("non-http subscription URL should fail");
|
||||||
|
|
||||||
|
assert_eq!(error.code, "singbox_subscription_url_invalid");
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetches_subscription_cache_and_selects_first_server() {
|
||||||
|
let root = test_root("fetch-subscription");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
save_singbox_subscription_to_storage(
|
||||||
|
&storage,
|
||||||
|
SaveSingBoxSubscriptionInputDto {
|
||||||
|
subscription_url: "https://sub.example.test/path?token=secret".to_string(),
|
||||||
|
},
|
||||||
|
&FixedClock,
|
||||||
|
)
|
||||||
|
.expect("save subscription URL");
|
||||||
|
|
||||||
|
let status = fetch_singbox_subscription_with_fetcher(
|
||||||
|
&storage,
|
||||||
|
&MockFetcher(sample_cache()),
|
||||||
|
&FixedClock,
|
||||||
|
)
|
||||||
|
.expect("fetch subscription through mock");
|
||||||
|
let config = storage
|
||||||
|
.read_local_singbox_config()
|
||||||
|
.expect("read local sing-box config");
|
||||||
|
let cache = storage
|
||||||
|
.read_singbox_subscription_cache()
|
||||||
|
.expect("read cache")
|
||||||
|
.expect("cache should exist");
|
||||||
|
let activity = storage.read_activity().expect("read activity");
|
||||||
|
|
||||||
|
assert_eq!(status.config.selected_server_tag, Some("nl-1".to_string()));
|
||||||
|
assert_eq!(status.cache.expect("status cache").servers.len(), 2);
|
||||||
|
assert_eq!(config.selected_server_tag, Some("nl-1".to_string()));
|
||||||
|
assert_eq!(cache.servers.len(), 2);
|
||||||
|
assert_eq!(activity[0].level, ActivityLevel::Success);
|
||||||
|
assert_eq!(activity[0].title, "Подписка Local sing-box обновлена");
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selects_server_from_cached_subscription() {
|
||||||
|
let root = test_root("select-server");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
storage
|
||||||
|
.write_singbox_subscription_cache(&sample_cache())
|
||||||
|
.expect("write cache");
|
||||||
|
|
||||||
|
let status = select_singbox_server_in_storage(
|
||||||
|
&storage,
|
||||||
|
SelectSingBoxServerInputDto {
|
||||||
|
tag: "de-1".to_string(),
|
||||||
|
server: None,
|
||||||
|
server_port: None,
|
||||||
|
},
|
||||||
|
&FixedClock,
|
||||||
|
)
|
||||||
|
.expect("server should be selected");
|
||||||
|
let config = storage
|
||||||
|
.read_local_singbox_config()
|
||||||
|
.expect("read local sing-box config");
|
||||||
|
|
||||||
|
assert_eq!(status.config.selected_server_tag, Some("de-1".to_string()));
|
||||||
|
assert_eq!(config.selected_server_tag, Some("de-1".to_string()));
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selects_server_by_endpoint_when_display_tag_is_sanitized() {
|
||||||
|
let root = test_root("select-server-sanitized-tag");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
storage
|
||||||
|
.write_singbox_subscription_cache(&sample_cache_with_flag_tag())
|
||||||
|
.expect("write cache");
|
||||||
|
|
||||||
|
let status = select_singbox_server_in_storage(
|
||||||
|
&storage,
|
||||||
|
SelectSingBoxServerInputDto {
|
||||||
|
tag: "Умный".to_string(),
|
||||||
|
server: Some("media.example.test".to_string()),
|
||||||
|
server_port: Some(443),
|
||||||
|
},
|
||||||
|
&FixedClock,
|
||||||
|
)
|
||||||
|
.expect("server should be selected by endpoint fallback");
|
||||||
|
let config = storage
|
||||||
|
.read_local_singbox_config()
|
||||||
|
.expect("read local sing-box config");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
status.config.selected_server_tag,
|
||||||
|
Some("Умный 🇳🇱->🇷🇺".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(config.selected_server_tag, Some("Умный 🇳🇱->🇷🇺".to_string()));
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_writes_config_and_local_singbox_target() {
|
||||||
|
let root = test_root("generate-config");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
storage
|
||||||
|
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||||
|
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||||
|
selected_server_tag: Some("nl-1".to_string()),
|
||||||
|
..LocalSingBoxConfig::default()
|
||||||
|
})
|
||||||
|
.expect("write local sing-box config");
|
||||||
|
storage
|
||||||
|
.write_singbox_subscription_cache(&sample_cache())
|
||||||
|
.expect("write cache");
|
||||||
|
|
||||||
|
let response = generate_singbox_config_with_services(
|
||||||
|
&storage,
|
||||||
|
&SingBoxAdapter::default(),
|
||||||
|
&MockChecker,
|
||||||
|
&FixedClock,
|
||||||
|
Some(Path::new("sing-box.exe")),
|
||||||
|
)
|
||||||
|
.expect("generate sing-box config");
|
||||||
|
let generated = fs::read_to_string(&response.generated_config_path).expect("read generated");
|
||||||
|
let targets = storage.read_targets().expect("read targets");
|
||||||
|
let target = targets
|
||||||
|
.iter()
|
||||||
|
.find(|target| target.id == "local-singbox")
|
||||||
|
.expect("local sing-box target");
|
||||||
|
let activity = storage.read_activity().expect("read activity");
|
||||||
|
|
||||||
|
assert!(response.success);
|
||||||
|
assert_eq!(response.adapter_id, "singbox");
|
||||||
|
assert_eq!(response.selected_server_tag, "nl-1");
|
||||||
|
assert!(response.check.expect("check result").success);
|
||||||
|
assert!(generated.contains("\"type\": \"mixed\""));
|
||||||
|
assert!(generated.contains("\"tag\": \"vpn\""));
|
||||||
|
assert_eq!(target.kind, TargetKind::Local);
|
||||||
|
assert_eq!(target.protocol, ProxyProtocol::Socks5);
|
||||||
|
assert_eq!(target.host, "127.0.0.1");
|
||||||
|
assert_eq!(target.port, 1080);
|
||||||
|
assert_eq!(target.requires_component, Some(ComponentId::Singbox));
|
||||||
|
assert_eq!(activity[0].title, "Конфиг Local sing-box создан");
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_requires_cached_subscription() {
|
||||||
|
let root = test_root("generate-missing-cache");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
|
||||||
|
let error = generate_singbox_config_with_services(
|
||||||
|
&storage,
|
||||||
|
&SingBoxAdapter::default(),
|
||||||
|
&MockChecker,
|
||||||
|
&FixedClock,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect_err("missing cache should block generation");
|
||||||
|
|
||||||
|
assert_eq!(error.code, "singbox_subscription_cache_missing");
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forget_subscription_clears_url_selection_and_cache() {
|
||||||
|
let root = test_root("forget-subscription");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
storage
|
||||||
|
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||||
|
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||||
|
selected_server_tag: Some("nl-1".to_string()),
|
||||||
|
..LocalSingBoxConfig::default()
|
||||||
|
})
|
||||||
|
.expect("write local sing-box config");
|
||||||
|
storage
|
||||||
|
.write_singbox_subscription_cache(&sample_cache())
|
||||||
|
.expect("write cache");
|
||||||
|
|
||||||
|
let status =
|
||||||
|
forget_singbox_subscription_in_storage(&storage, &FixedClock).expect("forget subscription");
|
||||||
|
let config = storage
|
||||||
|
.read_local_singbox_config()
|
||||||
|
.expect("read local sing-box config");
|
||||||
|
let cache = storage
|
||||||
|
.read_singbox_subscription_cache()
|
||||||
|
.expect("read cache");
|
||||||
|
|
||||||
|
assert!(!status.config.has_subscription);
|
||||||
|
assert_eq!(config.subscription_url, None);
|
||||||
|
assert_eq!(config.selected_server_tag, None);
|
||||||
|
assert_eq!(cache, None);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockFetcher(SubscriptionCache);
|
||||||
|
|
||||||
|
impl SubscriptionFetcher for MockFetcher {
|
||||||
|
fn fetch_subscription(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||||
|
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||||
|
Ok(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockChecker;
|
||||||
|
|
||||||
|
impl SingBoxConfigChecker for MockChecker {
|
||||||
|
fn check_config(
|
||||||
|
&self,
|
||||||
|
binary_path: &Path,
|
||||||
|
config_json: &str,
|
||||||
|
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||||
|
assert_eq!(binary_path, Path::new("sing-box.exe"));
|
||||||
|
assert!(config_json.contains("\"final\": \"vpn\""));
|
||||||
|
Ok(SingBoxCheckResult {
|
||||||
|
checked: true,
|
||||||
|
success: true,
|
||||||
|
message: "mock check passed".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixedClock;
|
||||||
|
|
||||||
|
impl Clock for FixedClock {
|
||||||
|
fn now(&self) -> String {
|
||||||
|
"2026-07-07T00:00:00Z".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_cache() -> SubscriptionCache {
|
||||||
|
SubscriptionCache {
|
||||||
|
config: json!({
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "vless",
|
||||||
|
"tag": "nl-1",
|
||||||
|
"server": "nl.example.test",
|
||||||
|
"server_port": 443,
|
||||||
|
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "trojan",
|
||||||
|
"tag": "de-1",
|
||||||
|
"server": "de.example.test",
|
||||||
|
"server_port": 443,
|
||||||
|
"password": "secret"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
servers: vec![
|
||||||
|
SubscriptionServer {
|
||||||
|
tag: "nl-1".to_string(),
|
||||||
|
server_type: "vless".to_string(),
|
||||||
|
server: "nl.example.test".to_string(),
|
||||||
|
server_port: 443,
|
||||||
|
},
|
||||||
|
SubscriptionServer {
|
||||||
|
tag: "de-1".to_string(),
|
||||||
|
server_type: "trojan".to_string(),
|
||||||
|
server: "de.example.test".to_string(),
|
||||||
|
server_port: 443,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
user_info: Map::new(),
|
||||||
|
fetched_at: "2026-07-07T00:00:00Z".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_cache_with_flag_tag() -> SubscriptionCache {
|
||||||
|
SubscriptionCache {
|
||||||
|
config: json!({
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "vless",
|
||||||
|
"tag": "Умный 🇳🇱->🇷🇺",
|
||||||
|
"server": "media.example.test",
|
||||||
|
"server_port": 443,
|
||||||
|
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
servers: vec![SubscriptionServer {
|
||||||
|
tag: "Умный 🇳🇱->🇷🇺".to_string(),
|
||||||
|
server_type: "vless".to_string(),
|
||||||
|
server: "media.example.test".to_string(),
|
||||||
|
server_port: 443,
|
||||||
|
}],
|
||||||
|
user_info: Map::new(),
|
||||||
|
fetched_at: "2026-07-07T00:00:00Z".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-singbox-commands-{name}-{timestamp}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup(root: &Path) {
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
128
apps/windows-client/src-tauri/tests/singbox_service_tests.rs
Normal file
128
apps/windows-client/src-tauri/tests/singbox_service_tests.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
#[path = "../src/component_detection.rs"]
|
||||||
|
mod component_detection;
|
||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/singbox_service.rs"]
|
||||||
|
mod singbox_service;
|
||||||
|
|
||||||
|
use component_detection::DetectedSingBox;
|
||||||
|
use singbox_service::{
|
||||||
|
build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output,
|
||||||
|
service_control_script, SingBoxServiceAction,
|
||||||
|
};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::process::Command as ProcessCommand;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setup_status_reports_missing_items_when_singbox_is_absent() {
|
||||||
|
let status = build_singbox_setup_status(None);
|
||||||
|
|
||||||
|
assert!(!status.ready);
|
||||||
|
assert_eq!(status.missing_count, 3);
|
||||||
|
assert_eq!(status.items[0].id, "sing-box-binary");
|
||||||
|
assert!(status.items[0].details.contains("SagerNet/sing-box"));
|
||||||
|
assert_eq!(status.items[1].id, "winsw-wrapper");
|
||||||
|
assert!(status.items[1].details.contains("winsw/winsw"));
|
||||||
|
assert_eq!(status.items[2].id, "windows-service");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setup_status_reports_ready_when_binary_wrapper_and_service_exist() {
|
||||||
|
let detected = detected_singbox(true, true, true);
|
||||||
|
let status = build_singbox_setup_status(Some(&detected));
|
||||||
|
|
||||||
|
assert!(status.ready);
|
||||||
|
assert_eq!(status.missing_count, 0);
|
||||||
|
assert!(status.items.iter().all(|item| item.installed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_last_json_service_command_output_line() {
|
||||||
|
let output = br#"
|
||||||
|
noise
|
||||||
|
{"success":true,"code":"started","serviceName":"VpnProxySingBox","status":"Running","processId":42}
|
||||||
|
"#;
|
||||||
|
let parsed = parse_service_command_output(output).expect("service json should parse");
|
||||||
|
|
||||||
|
assert!(parsed.success);
|
||||||
|
assert_eq!(parsed.code, "started");
|
||||||
|
assert_eq!(parsed.service_name, Some("VpnProxySingBox".to_string()));
|
||||||
|
assert_eq!(parsed.status, Some("Running".to_string()));
|
||||||
|
assert_eq!(parsed.process_id, Some(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn safe_install_dir_allows_only_vpnproxy_singbox_folder() {
|
||||||
|
assert!(
|
||||||
|
ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\VpnProxy\sing-box")).is_ok()
|
||||||
|
);
|
||||||
|
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Windows")).is_err());
|
||||||
|
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_control_script_targets_named_service_and_action() {
|
||||||
|
let script = service_control_script(SingBoxServiceAction::Start, "VpnProxySingBox", None, None);
|
||||||
|
|
||||||
|
assert!(script.contains("$serviceName = 'VpnProxySingBox'"));
|
||||||
|
assert!(script.contains("$action = 'start'"));
|
||||||
|
assert!(script.contains("ConvertTo-Json -Compress"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_control_script_syncs_generated_config_before_start() {
|
||||||
|
let source = Path::new(r"C:\ProgramData\VpnProxy\generated\sing-box-config.json");
|
||||||
|
let target = Path::new(r"C:\Program Files\VpnProxy\sing-box\config.json");
|
||||||
|
let script = service_control_script(
|
||||||
|
SingBoxServiceAction::Start,
|
||||||
|
"VpnProxySingBox",
|
||||||
|
Some(source),
|
||||||
|
Some(target),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(script.contains(
|
||||||
|
"$configSource = 'C:\\ProgramData\\VpnProxy\\generated\\sing-box-config.json'"
|
||||||
|
));
|
||||||
|
assert!(script.contains(
|
||||||
|
"$configTarget = 'C:\\Program Files\\VpnProxy\\sing-box\\config.json'"
|
||||||
|
));
|
||||||
|
assert!(script.contains("Copy-Item -LiteralPath $configSource"));
|
||||||
|
assert!(script.contains("'config_sync_failed'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn install_singbox_script_parses_as_powershell() {
|
||||||
|
let script_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("scripts")
|
||||||
|
.join("install-singbox.ps1");
|
||||||
|
let escaped_path = script_path.display().to_string().replace('\'', "''");
|
||||||
|
let parser = format!(
|
||||||
|
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
|
||||||
|
);
|
||||||
|
let output = ProcessCommand::new("powershell")
|
||||||
|
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
|
||||||
|
.output()
|
||||||
|
.expect("powershell parser should run");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"install-singbox.ps1 should parse\nstdout:\n{}\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> DetectedSingBox {
|
||||||
|
DetectedSingBox {
|
||||||
|
install_dir: PathBuf::from(r"C:\Program Files\VpnProxy\sing-box"),
|
||||||
|
executable_path: PathBuf::from(r"C:\Program Files\VpnProxy\sing-box\sing-box.exe"),
|
||||||
|
wrapper_path: PathBuf::from(r"C:\Program Files\VpnProxy\sing-box\VpnProxySingBox.exe"),
|
||||||
|
binary_exists,
|
||||||
|
wrapper_exists,
|
||||||
|
running,
|
||||||
|
service_name: "VpnProxySingBox".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@ mod models;
|
|||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
use models::{
|
use models::{
|
||||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, Profile,
|
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||||||
ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
|
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
|
||||||
|
SubscriptionServer, Target, TargetKind,
|
||||||
};
|
};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -54,6 +55,86 @@ fn roundtrips_profiles_targets_components_and_activity() {
|
|||||||
cleanup(&root);
|
cleanup(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||||
|
let root = test_root("local-singbox");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
let config = LocalSingBoxConfig {
|
||||||
|
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
|
||||||
|
selected_server_tag: Some("nl-1".to_string()),
|
||||||
|
listen_host: "127.0.0.1".to_string(),
|
||||||
|
listen_port: 1080,
|
||||||
|
service_name: "VpnProxySingBox".to_string(),
|
||||||
|
install_root: r"C:\Program Files\VpnProxy\sing-box".to_string(),
|
||||||
|
updated_at: Some("2026-07-07T10:00:00Z".to_string()),
|
||||||
|
};
|
||||||
|
let cache = sample_subscription_cache();
|
||||||
|
|
||||||
|
storage
|
||||||
|
.write_local_singbox_config(&config)
|
||||||
|
.expect("write local sing-box config");
|
||||||
|
storage
|
||||||
|
.write_singbox_subscription_cache(&cache)
|
||||||
|
.expect("write subscription cache");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
storage
|
||||||
|
.read_local_singbox_config()
|
||||||
|
.expect("read local sing-box config"),
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
storage
|
||||||
|
.read_singbox_subscription_cache()
|
||||||
|
.expect("read subscription cache"),
|
||||||
|
Some(cache)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.subscription_display_url(),
|
||||||
|
Some("https://sub.example.test/...".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_local_singbox_config_defaults_to_optional_empty_state() {
|
||||||
|
let root = test_root("local-singbox-default");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
let config = storage
|
||||||
|
.read_local_singbox_config()
|
||||||
|
.expect("read default local sing-box config");
|
||||||
|
|
||||||
|
assert_eq!(config.subscription_url, None);
|
||||||
|
assert_eq!(config.selected_server_tag, None);
|
||||||
|
assert_eq!(config.listen_host, "127.0.0.1");
|
||||||
|
assert_eq!(config.listen_port, 1080);
|
||||||
|
assert_eq!(config.service_name, "VpnProxySingBox");
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_subscription_cache_falls_back_to_none() {
|
||||||
|
let root = test_root("invalid-subscription-cache");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
storage.ensure_dirs().expect("create storage dirs");
|
||||||
|
fs::write(
|
||||||
|
&storage.paths().singbox_subscription_cache_file,
|
||||||
|
"{not valid json",
|
||||||
|
)
|
||||||
|
.expect("write invalid cache");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
storage
|
||||||
|
.read_singbox_subscription_cache()
|
||||||
|
.expect("invalid cache fallback"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_json_falls_back_to_empty_collection() {
|
fn invalid_json_falls_back_to_empty_collection() {
|
||||||
let root = test_root("invalid-json");
|
let root = test_root("invalid-json");
|
||||||
@@ -187,6 +268,29 @@ fn sample_component() -> ComponentStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_subscription_cache() -> SubscriptionCache {
|
||||||
|
SubscriptionCache {
|
||||||
|
config: serde_json::json!({
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "vless",
|
||||||
|
"tag": "nl-1",
|
||||||
|
"server": "nl.example.test",
|
||||||
|
"server_port": 443
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
servers: vec![SubscriptionServer {
|
||||||
|
tag: "nl-1".to_string(),
|
||||||
|
server_type: "vless".to_string(),
|
||||||
|
server: "nl.example.test".to_string(),
|
||||||
|
server_port: 443,
|
||||||
|
}],
|
||||||
|
user_info: serde_json::Map::new(),
|
||||||
|
fetched_at: "2026-07-07T10:00:00Z".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn sample_activity(id: &str, at: &str, level: ActivityLevel) -> ActivityEntry {
|
fn sample_activity(id: &str, at: &str, level: ActivityLevel) -> ActivityEntry {
|
||||||
ActivityEntry {
|
ActivityEntry {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
|
|||||||
99
apps/windows-client/src-tauri/tests/subscription_tests.rs
Normal file
99
apps/windows-client/src-tauri/tests/subscription_tests.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/subscription.rs"]
|
||||||
|
mod subscription;
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose, Engine};
|
||||||
|
use models::redact_subscription_url;
|
||||||
|
use subscription::{parse_subscription_body, parse_user_info};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_singbox_json_config_servers() {
|
||||||
|
let parsed = parse_subscription_body(
|
||||||
|
r#"{
|
||||||
|
"outbounds": [
|
||||||
|
{ "type": "direct", "tag": "direct" },
|
||||||
|
{ "type": "vless", "tag": "nl-1", "server": "nl.example.test", "server_port": 443 },
|
||||||
|
{ "type": "trojan", "tag": "de-1", "server": "de.example.test", "server_port": 8443 }
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("json subscription should parse");
|
||||||
|
|
||||||
|
assert_eq!(parsed.servers.len(), 2);
|
||||||
|
assert_eq!(parsed.servers[0].tag, "nl-1");
|
||||||
|
assert_eq!(parsed.servers[0].server_type, "vless");
|
||||||
|
assert_eq!(parsed.servers[1].server_port, 8443);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_base64_vless_link_list() {
|
||||||
|
let link = sample_vless_link("nl-1");
|
||||||
|
let encoded = general_purpose::STANDARD.encode(format!("{link}\n"));
|
||||||
|
|
||||||
|
let parsed = parse_subscription_body(&encoded).expect("base64 vless list should parse");
|
||||||
|
let outbound = &parsed.config["outbounds"][0];
|
||||||
|
|
||||||
|
assert_eq!(parsed.servers.len(), 1);
|
||||||
|
assert_eq!(parsed.servers[0].tag, "nl-1");
|
||||||
|
assert_eq!(outbound["type"], "vless");
|
||||||
|
assert_eq!(outbound["server"], "nl.example.test");
|
||||||
|
assert_eq!(outbound["packet_encoding"], "xudp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_body_without_supported_outbounds() {
|
||||||
|
let error = parse_subscription_body(r#"{"outbounds":[{"type":"direct","tag":"direct"}]}"#)
|
||||||
|
.expect_err("unsupported subscription should fail");
|
||||||
|
|
||||||
|
assert!(error.message.contains("No supported proxy outbounds found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_vless_without_reality_parameters() {
|
||||||
|
let error = parse_subscription_body("vless://uuid@nl.example.test:443#nl-1")
|
||||||
|
.expect_err("missing reality params should fail");
|
||||||
|
|
||||||
|
assert!(error.message.contains("pbk and sid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_subscription_user_info_header() {
|
||||||
|
let user_info = parse_user_info(Some("upload=10; download=20; total=30; expire=bad"));
|
||||||
|
|
||||||
|
assert_eq!(user_info["upload"], 10);
|
||||||
|
assert_eq!(user_info["download"], 20);
|
||||||
|
assert_eq!(user_info["total"], 30);
|
||||||
|
assert!(!user_info.contains_key("expire"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_or_non_http_subscription_url_before_network() {
|
||||||
|
let invalid = subscription::fetch_subscription("not a url")
|
||||||
|
.expect_err("invalid url should fail before request");
|
||||||
|
let unsupported = subscription::fetch_subscription("file:///C:/subscription.txt")
|
||||||
|
.expect_err("non-http url should fail before request");
|
||||||
|
|
||||||
|
assert!(invalid.message.contains("Invalid subscription URL"));
|
||||||
|
assert!(unsupported.message.contains("http or https"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redacts_subscription_url_for_display() {
|
||||||
|
assert_eq!(
|
||||||
|
redact_subscription_url("https://sub.example.test/path?token=secret"),
|
||||||
|
"https://sub.example.test/..."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
redact_subscription_url("vless://uuid@example.test"),
|
||||||
|
"vless://uuid@example.test/..."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_vless_link(tag: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"vless://{}@nl.example.test:443?security=reality&sni=example.test&fp=chrome&pbk=public-key&sid=short-id&flow=xtls-rprx-vision#{}",
|
||||||
|
"11111111-1111-1111-1111-111111111111",
|
||||||
|
tag
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import type {
|
import type {
|
||||||
ActivityEntry,
|
ActivityEntry,
|
||||||
ComponentStatus,
|
ComponentStatus,
|
||||||
|
LocalSingBoxConfig,
|
||||||
Profile,
|
Profile,
|
||||||
ProfileInput,
|
ProfileInput,
|
||||||
|
SubscriptionCache,
|
||||||
|
SubscriptionServer,
|
||||||
Target,
|
Target,
|
||||||
TargetInput,
|
TargetInput,
|
||||||
} from '../domain/types';
|
} from '../domain/types';
|
||||||
@@ -47,6 +50,47 @@ export interface ProxiFyreSetupStatus {
|
|||||||
items: ProxiFyreSetupItem[];
|
items: ProxiFyreSetupItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SingBoxSetupItem = ProxiFyreSetupItem;
|
||||||
|
|
||||||
|
export interface SingBoxSetupStatus {
|
||||||
|
ready: boolean;
|
||||||
|
missingCount: number;
|
||||||
|
items: SingBoxSetupItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalSingBoxStatusResponse {
|
||||||
|
config: LocalSingBoxConfig;
|
||||||
|
cache?: SubscriptionCache;
|
||||||
|
component: ComponentStatus;
|
||||||
|
generatedConfigPath: string;
|
||||||
|
lanListenHost?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PingServerResponse {
|
||||||
|
tag: string;
|
||||||
|
server: string;
|
||||||
|
serverPort: number;
|
||||||
|
ok: boolean;
|
||||||
|
latency?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateSingBoxConfigResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
adapterId: string;
|
||||||
|
generatedConfigPath: string;
|
||||||
|
selectedServerTag: string;
|
||||||
|
listenHost: string;
|
||||||
|
listenPort: number;
|
||||||
|
check?: {
|
||||||
|
checked: boolean;
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
activity: ActivityEntry;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HelperApplyResult {
|
export interface HelperApplyResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
@@ -98,6 +142,52 @@ export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
|
|||||||
return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status');
|
return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> {
|
||||||
|
return invoke<LocalSingBoxStatusResponse>('get_singbox_status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSingBoxSetupStatus(): Promise<SingBoxSetupStatus> {
|
||||||
|
return invoke<SingBoxSetupStatus>('get_singbox_setup_status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSingBoxSubscription(subscriptionUrl: string): Promise<LocalSingBoxStatusResponse> {
|
||||||
|
return invoke<LocalSingBoxStatusResponse>('save_singbox_subscription', {
|
||||||
|
input: { subscriptionUrl },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
|
||||||
|
return invoke<LocalSingBoxStatusResponse>('fetch_singbox_subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgetSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
|
||||||
|
return invoke<LocalSingBoxStatusResponse>('forget_singbox_subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectSingBoxServer(server: SubscriptionServer): Promise<LocalSingBoxStatusResponse> {
|
||||||
|
return invoke<LocalSingBoxStatusResponse>('select_singbox_server', {
|
||||||
|
input: {
|
||||||
|
tag: server.tag,
|
||||||
|
server: server.server,
|
||||||
|
serverPort: server.serverPort,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pingSingBoxServer(tag: string): Promise<PingServerResponse> {
|
||||||
|
return invoke<PingServerResponse>('ping_singbox_server', {
|
||||||
|
input: { tag },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pingAllSingBoxServers(): Promise<PingServerResponse[]> {
|
||||||
|
return invoke<PingServerResponse[]>('ping_all_singbox_servers');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSingBoxConfig(): Promise<GenerateSingBoxConfigResponse> {
|
||||||
|
return invoke<GenerateSingBoxConfigResponse>('generate_singbox_config');
|
||||||
|
}
|
||||||
|
|
||||||
export function applyProfiles(): Promise<ApplyProfilesResponse> {
|
export function applyProfiles(): Promise<ApplyProfilesResponse> {
|
||||||
return invoke<ApplyProfilesResponse>('apply_profiles');
|
return invoke<ApplyProfilesResponse>('apply_profiles');
|
||||||
}
|
}
|
||||||
@@ -121,3 +211,19 @@ export function installProxiFyre(): Promise<ComponentStatus> {
|
|||||||
export function uninstallProxiFyre(): Promise<ComponentStatus> {
|
export function uninstallProxiFyre(): Promise<ComponentStatus> {
|
||||||
return invoke<ComponentStatus>('uninstall_proxifyre');
|
return invoke<ComponentStatus>('uninstall_proxifyre');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startSingBoxService(): Promise<ComponentStatus> {
|
||||||
|
return invoke<ComponentStatus>('start_singbox_service');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSingBoxService(): Promise<ComponentStatus> {
|
||||||
|
return invoke<ComponentStatus>('stop_singbox_service');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installSingBox(): Promise<ComponentStatus> {
|
||||||
|
return invoke<ComponentStatus>('install_singbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uninstallSingBox(): Promise<ComponentStatus> {
|
||||||
|
return invoke<ComponentStatus>('uninstall_singbox');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,25 +1,42 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { Cpu, FileCode2, FolderOpen, MoreHorizontal } from 'lucide-react';
|
import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, MoreHorizontal, Trash2, Wand2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
applyProfiles,
|
applyProfiles,
|
||||||
|
fetchSingBoxSubscription,
|
||||||
|
forgetSingBoxSubscription,
|
||||||
|
generateSingBoxConfig,
|
||||||
getComponents,
|
getComponents,
|
||||||
getProxiFyreSetupStatus,
|
getProxiFyreSetupStatus,
|
||||||
getSavedState,
|
getSavedState,
|
||||||
|
getSingBoxSetupStatus,
|
||||||
|
getSingBoxStatus,
|
||||||
installProxiFyre,
|
installProxiFyre,
|
||||||
|
installSingBox,
|
||||||
openConfigLocation,
|
openConfigLocation,
|
||||||
|
pingAllSingBoxServers,
|
||||||
saveProfile,
|
saveProfile,
|
||||||
|
saveSingBoxSubscription,
|
||||||
saveTarget,
|
saveTarget,
|
||||||
|
selectSingBoxServer,
|
||||||
startProxiFyreService,
|
startProxiFyreService,
|
||||||
|
startSingBoxService,
|
||||||
stopProxiFyreService,
|
stopProxiFyreService,
|
||||||
|
stopSingBoxService,
|
||||||
uninstallProxiFyre,
|
uninstallProxiFyre,
|
||||||
|
uninstallSingBox,
|
||||||
type ApplyProfilesResponse,
|
type ApplyProfilesResponse,
|
||||||
|
type LocalSingBoxStatusResponse,
|
||||||
|
type PingServerResponse,
|
||||||
type ProxiFyreSetupStatus,
|
type ProxiFyreSetupStatus,
|
||||||
|
type SingBoxSetupStatus,
|
||||||
} from '../api/tauriCommands';
|
} from '../api/tauriCommands';
|
||||||
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, Target } from '../domain/types';
|
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
|
||||||
|
|
||||||
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
|
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
|
||||||
type ProxiFyreAction = 'start' | 'stop' | 'install' | 'uninstall';
|
type ProxiFyreAction = 'start' | 'stop' | 'install' | 'uninstall';
|
||||||
|
type SingBoxAction = 'start' | 'stop' | 'install' | 'uninstall' | 'fetch' | 'forget' | 'generate' | 'ping';
|
||||||
|
type RouteMode = 'external' | 'local-singbox';
|
||||||
type ServiceVisualState = 'active' | 'settling' | null;
|
type ServiceVisualState = 'active' | 'settling' | null;
|
||||||
|
|
||||||
interface DraftItem {
|
interface DraftItem {
|
||||||
@@ -41,6 +58,7 @@ interface LogEntry extends Notice {
|
|||||||
|
|
||||||
const MAIN_TARGET_ID = 'main-proxy';
|
const MAIN_TARGET_ID = 'main-proxy';
|
||||||
const MAIN_PROFILE_ID = 'main-profile';
|
const MAIN_PROFILE_ID = 'main-profile';
|
||||||
|
const LOCAL_SINGBOX_TARGET_ID = 'local-singbox';
|
||||||
const LOG_VISIBLE_MS = 6500;
|
const LOG_VISIBLE_MS = 6500;
|
||||||
|
|
||||||
const fallbackComponents: ComponentStatus[] = [
|
const fallbackComponents: ComponentStatus[] = [
|
||||||
@@ -53,20 +71,37 @@ const fallbackComponents: ComponentStatus[] = [
|
|||||||
problems: ['ProxiFyre не найден'],
|
problems: ['ProxiFyre не найден'],
|
||||||
actions: [],
|
actions: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'singbox',
|
||||||
|
name: 'Локальный sing-box',
|
||||||
|
state: 'missing',
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
problems: [],
|
||||||
|
actions: [],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [proxyInput, setProxyInput] = useState('');
|
const [proxyInput, setProxyInput] = useState('');
|
||||||
|
const [routeMode, setRouteMode] = useState<RouteMode>('external');
|
||||||
const [profileId, setProfileId] = useState(MAIN_PROFILE_ID);
|
const [profileId, setProfileId] = useState(MAIN_PROFILE_ID);
|
||||||
const [targetId, setTargetId] = useState(MAIN_TARGET_ID);
|
const [targetId, setTargetId] = useState(MAIN_TARGET_ID);
|
||||||
const [items, setItems] = useState<DraftItem[]>([]);
|
const [items, setItems] = useState<DraftItem[]>([]);
|
||||||
|
const [hasUnappliedChanges, setHasUnappliedChanges] = useState(false);
|
||||||
const [loadedProfiles, setLoadedProfiles] = useState<Profile[]>([]);
|
const [loadedProfiles, setLoadedProfiles] = useState<Profile[]>([]);
|
||||||
const [isProcessInputOpen, setIsProcessInputOpen] = useState(false);
|
const [isProcessInputOpen, setIsProcessInputOpen] = useState(false);
|
||||||
const [processInput, setProcessInput] = useState('');
|
const [processInput, setProcessInput] = useState('');
|
||||||
const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null);
|
const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null);
|
||||||
const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
|
const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
|
||||||
const [setupStatus, setSetupStatus] = useState<ProxiFyreSetupStatus | null>(null);
|
const [setupStatus, setSetupStatus] = useState<ProxiFyreSetupStatus | null>(null);
|
||||||
|
const [singBoxStatus, setSingBoxStatus] = useState<LocalSingBoxStatusResponse | null>(null);
|
||||||
|
const [singBoxSetupStatus, setSingBoxSetupStatus] = useState<SingBoxSetupStatus | null>(null);
|
||||||
|
const [subscriptionInput, setSubscriptionInput] = useState('');
|
||||||
|
const [serverPings, setServerPings] = useState<Record<string, PingServerResponse>>({});
|
||||||
const [isSetupOpen, setIsSetupOpen] = useState(false);
|
const [isSetupOpen, setIsSetupOpen] = useState(false);
|
||||||
|
const [isSingBoxSetupOpen, setIsSingBoxSetupOpen] = useState(false);
|
||||||
|
const [isSingBoxInfoOpen, setIsSingBoxInfoOpen] = useState(false);
|
||||||
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
|
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
|
||||||
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||||
const [activeLogId, setActiveLogId] = useState<string | null>(null);
|
const [activeLogId, setActiveLogId] = useState<string | null>(null);
|
||||||
@@ -76,7 +111,9 @@ export function App() {
|
|||||||
const [isApplying, setIsApplying] = useState(false);
|
const [isApplying, setIsApplying] = useState(false);
|
||||||
const [isOpeningConfig, setIsOpeningConfig] = useState(false);
|
const [isOpeningConfig, setIsOpeningConfig] = useState(false);
|
||||||
const [serviceAction, setServiceAction] = useState<ProxiFyreAction | null>(null);
|
const [serviceAction, setServiceAction] = useState<ProxiFyreAction | null>(null);
|
||||||
|
const [singBoxAction, setSingBoxAction] = useState<SingBoxAction | null>(null);
|
||||||
const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false);
|
const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false);
|
||||||
|
const [isSingBoxMenuOpen, setIsSingBoxMenuOpen] = useState(false);
|
||||||
const [serviceVisualState, setServiceVisualState] = useState<ServiceVisualState>(null);
|
const [serviceVisualState, setServiceVisualState] = useState<ServiceVisualState>(null);
|
||||||
const serviceVisualTimerRef = useRef<number | null>(null);
|
const serviceVisualTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -84,6 +121,10 @@ export function App() {
|
|||||||
() => components.find((component) => component.id === 'proxyfier'),
|
() => components.find((component) => component.id === 'proxyfier'),
|
||||||
[components],
|
[components],
|
||||||
);
|
);
|
||||||
|
const singbox = useMemo(
|
||||||
|
() => singBoxStatus?.component ?? components.find((component) => component.id === 'singbox'),
|
||||||
|
[components, singBoxStatus],
|
||||||
|
);
|
||||||
const activeLog = useMemo(
|
const activeLog = useMemo(
|
||||||
() => logEntries.find((entry) => entry.id === activeLogId) ?? null,
|
() => logEntries.find((entry) => entry.id === activeLogId) ?? null,
|
||||||
[activeLogId, logEntries],
|
[activeLogId, logEntries],
|
||||||
@@ -91,6 +132,9 @@ export function App() {
|
|||||||
const finderStateClass = isDetectingComponents ? 'checking' : proxyfier?.installed ? 'found' : 'missing';
|
const finderStateClass = isDetectingComponents ? 'checking' : proxyfier?.installed ? 'found' : 'missing';
|
||||||
const finderVisualClass =
|
const finderVisualClass =
|
||||||
serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : '';
|
serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : '';
|
||||||
|
const isSingBoxInstalled = Boolean(singbox?.installed);
|
||||||
|
const singBoxStateClass = isDetectingComponents ? 'checking' : singbox?.installed ? 'found' : 'missing';
|
||||||
|
const singBoxVisualClass = singBoxAction ? 'working' : '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -135,16 +179,20 @@ export function App() {
|
|||||||
async function refreshComponents() {
|
async function refreshComponents() {
|
||||||
setIsDetectingComponents(true);
|
setIsDetectingComponents(true);
|
||||||
try {
|
try {
|
||||||
const [detectedComponents, detectedSetupStatus] = await Promise.all([
|
const [detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([
|
||||||
getComponents(),
|
getComponents(),
|
||||||
getProxiFyreSetupStatus(),
|
getProxiFyreSetupStatus(),
|
||||||
|
getSingBoxStatus(),
|
||||||
|
getSingBoxSetupStatus(),
|
||||||
]);
|
]);
|
||||||
setComponents(detectedComponents);
|
setComponents(detectedComponents);
|
||||||
setSetupStatus(detectedSetupStatus);
|
setSetupStatus(detectedSetupStatus);
|
||||||
|
setSingBoxStatus(detectedSingBoxStatus);
|
||||||
|
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNotice({
|
showNotice({
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
title: 'ProxiFyre не проверен',
|
title: 'Компоненты не проверены',
|
||||||
text: errorMessage(error),
|
text: errorMessage(error),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -157,14 +205,21 @@ export function App() {
|
|||||||
const mainProfile = profiles.find((profile) => profile.id === MAIN_PROFILE_ID);
|
const mainProfile = profiles.find((profile) => profile.id === MAIN_PROFILE_ID);
|
||||||
const activeProfile = mainProfile ?? activeProfiles[0];
|
const activeProfile = mainProfile ?? activeProfiles[0];
|
||||||
const activeTarget = targetForUi(targets, activeProfile);
|
const activeTarget = targetForUi(targets, activeProfile);
|
||||||
|
const externalTarget = targetForExternalProxy(targets);
|
||||||
const editableProfiles = mainProfile ? [mainProfile] : activeProfiles;
|
const editableProfiles = mainProfile ? [mainProfile] : activeProfiles;
|
||||||
|
|
||||||
if (activeTarget) setProxyInput(formatProxy(activeTarget));
|
if (externalTarget) setProxyInput(formatProxy(externalTarget));
|
||||||
setItems(itemsForProfiles(editableProfiles));
|
setItems(itemsForProfiles(editableProfiles));
|
||||||
setLoadedProfiles(profiles);
|
setLoadedProfiles(profiles);
|
||||||
setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID);
|
setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID);
|
||||||
setTargetId(activeTarget?.id ?? activeProfile?.targetId ?? MAIN_TARGET_ID);
|
setTargetId(externalTarget?.id ?? MAIN_TARGET_ID);
|
||||||
|
setRouteMode(
|
||||||
|
activeTarget?.id === LOCAL_SINGBOX_TARGET_ID || activeProfile?.targetId === LOCAL_SINGBOX_TARGET_ID
|
||||||
|
? 'local-singbox'
|
||||||
|
: 'external',
|
||||||
|
);
|
||||||
setGeneratedConfigPath(generatedPath);
|
setGeneratedConfigPath(generatedPath);
|
||||||
|
setHasUnappliedChanges(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addItem(type: DraftItemType, rawValue: string) {
|
function addItem(type: DraftItemType, rawValue: string) {
|
||||||
@@ -195,6 +250,7 @@ export function App() {
|
|||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
setHasUnappliedChanges(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +263,19 @@ export function App() {
|
|||||||
|
|
||||||
function removeItem(id: string) {
|
function removeItem(id: string) {
|
||||||
setItems((current) => current.filter((item) => item.id !== id));
|
setItems((current) => current.filter((item) => item.id !== id));
|
||||||
|
setHasUnappliedChanges(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeRouteMode(nextMode: RouteMode) {
|
||||||
|
setRouteMode((current) => {
|
||||||
|
if (current !== nextMode) setHasUnappliedChanges(true);
|
||||||
|
return nextMode;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeProxyInput(nextValue: string) {
|
||||||
|
setProxyInput(nextValue);
|
||||||
|
setHasUnappliedChanges(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickAndAddItem(type: Extract<DraftItemType, 'exe' | 'folder'>) {
|
async function pickAndAddItem(type: Extract<DraftItemType, 'exe' | 'folder'>) {
|
||||||
@@ -228,10 +297,16 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateConfig() {
|
async function updateConfig() {
|
||||||
let parsedProxy: ParsedProxy;
|
let parsedProxy: ParsedProxy | null = null;
|
||||||
try {
|
try {
|
||||||
parsedProxy = parseProxy(proxyInput);
|
|
||||||
if (!items.length) throw new Error('Добавь хотя бы один процесс, EXE-файл или папку.');
|
if (!items.length) throw new Error('Добавь хотя бы один процесс, EXE-файл или папку.');
|
||||||
|
if (routeMode === 'external') {
|
||||||
|
parsedProxy = parseProxy(proxyInput);
|
||||||
|
} else if (!isSingBoxInstalled) {
|
||||||
|
throw new Error('Сначала установи Local sing-box.');
|
||||||
|
} else if (!singBoxStatus?.config.selectedServerTag) {
|
||||||
|
throw new Error('Выбери сервер Local sing-box.');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNotice({
|
showNotice({
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
@@ -243,19 +318,28 @@ export function App() {
|
|||||||
|
|
||||||
setIsApplying(true);
|
setIsApplying(true);
|
||||||
try {
|
try {
|
||||||
await saveTarget({
|
let singBoxGeneratedPath = '';
|
||||||
id: targetId,
|
if (routeMode === 'external') {
|
||||||
name: 'Основной прокси',
|
if (!parsedProxy) throw new Error('Прокси не разобран.');
|
||||||
kind: 'external',
|
await saveTarget({
|
||||||
protocol: parsedProxy.protocol,
|
id: targetId,
|
||||||
host: parsedProxy.host,
|
name: 'Основной прокси',
|
||||||
port: parsedProxy.port,
|
kind: 'external',
|
||||||
});
|
protocol: parsedProxy.protocol,
|
||||||
|
host: parsedProxy.host,
|
||||||
|
port: parsedProxy.port,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const singBoxResult = await generateSingBoxConfig();
|
||||||
|
singBoxGeneratedPath = singBoxResult.generatedConfigPath;
|
||||||
|
await ensureSingBoxRunningForApply();
|
||||||
|
}
|
||||||
|
|
||||||
await saveProfile({
|
await saveProfile({
|
||||||
id: profileId,
|
id: profileId,
|
||||||
name: 'Приложения через прокси',
|
name: 'Приложения через прокси',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
targetId,
|
targetId: routeMode === 'local-singbox' ? LOCAL_SINGBOX_TARGET_ID : targetId,
|
||||||
protocols: ['TCP', 'UDP'],
|
protocols: ['TCP', 'UDP'],
|
||||||
items: items.map(profileItemInput),
|
items: items.map(profileItemInput),
|
||||||
});
|
});
|
||||||
@@ -266,16 +350,21 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await applyProfiles();
|
const result = await applyProfiles();
|
||||||
const [saved, detectedComponents, detectedSetupStatus] = await Promise.all([
|
const [saved, detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([
|
||||||
getSavedState(),
|
getSavedState(),
|
||||||
getComponents(),
|
getComponents(),
|
||||||
getProxiFyreSetupStatus(),
|
getProxiFyreSetupStatus(),
|
||||||
|
getSingBoxStatus(),
|
||||||
|
getSingBoxSetupStatus(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
applySavedState(saved.profiles, saved.targets, result.generatedConfigPath);
|
applySavedState(saved.profiles, saved.targets, result.generatedConfigPath);
|
||||||
setComponents(detectedComponents);
|
setComponents(detectedComponents);
|
||||||
setSetupStatus(detectedSetupStatus);
|
setSetupStatus(detectedSetupStatus);
|
||||||
showNotice(noticeFromApply(result));
|
setSingBoxStatus(detectedSingBoxStatus);
|
||||||
|
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
|
||||||
|
setHasUnappliedChanges(false);
|
||||||
|
showNotice(routeMode === 'local-singbox' ? noticeFromLocalApply(result, singBoxGeneratedPath) : noticeFromApply(result));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNotice({
|
showNotice({
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
@@ -287,6 +376,30 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureSingBoxRunningForApply() {
|
||||||
|
if (routeMode !== 'local-singbox' || !singbox?.installed) return;
|
||||||
|
|
||||||
|
setIsSingBoxMenuOpen(false);
|
||||||
|
try {
|
||||||
|
await nextFrame();
|
||||||
|
if (singbox.running) {
|
||||||
|
setSingBoxAction('stop');
|
||||||
|
const stopped = await stopSingBoxService();
|
||||||
|
setComponents((current) => upsertComponent(current, stopped));
|
||||||
|
}
|
||||||
|
|
||||||
|
setSingBoxAction('start');
|
||||||
|
const component = await startSingBoxService();
|
||||||
|
setComponents((current) => upsertComponent(current, component));
|
||||||
|
const status = await refreshSingBoxState();
|
||||||
|
if (!status.component.running) {
|
||||||
|
throw new Error('Local sing-box установлен, но служба не запустилась.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSingBoxAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openConfig() {
|
async function openConfig() {
|
||||||
setIsOpeningConfig(true);
|
setIsOpeningConfig(true);
|
||||||
try {
|
try {
|
||||||
@@ -395,6 +508,216 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshSingBoxState() {
|
||||||
|
const [detectedSingBoxStatus, detectedSingBoxSetupStatus, detectedComponents] = await Promise.all([
|
||||||
|
getSingBoxStatus(),
|
||||||
|
getSingBoxSetupStatus(),
|
||||||
|
getComponents(),
|
||||||
|
]);
|
||||||
|
setSingBoxStatus(detectedSingBoxStatus);
|
||||||
|
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
|
||||||
|
setComponents(detectedComponents);
|
||||||
|
return detectedSingBoxStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSingBoxServiceRunning(shouldRun: boolean) {
|
||||||
|
const action: SingBoxAction = shouldRun ? 'start' : 'stop';
|
||||||
|
setSingBoxAction(action);
|
||||||
|
setIsSingBoxMenuOpen(false);
|
||||||
|
try {
|
||||||
|
await nextFrame();
|
||||||
|
const component = shouldRun ? await startSingBoxService() : await stopSingBoxService();
|
||||||
|
setComponents((current) => upsertComponent(current, component));
|
||||||
|
await refreshSingBoxState();
|
||||||
|
showNotice({
|
||||||
|
kind: 'success',
|
||||||
|
title: shouldRun ? 'sing-box запущен' : 'sing-box остановлен',
|
||||||
|
text: componentDetails(component, false),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: shouldRun ? 'sing-box не запущен' : 'sing-box не остановлен',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSingBoxAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installSingBoxPackage() {
|
||||||
|
setSingBoxAction('install');
|
||||||
|
setIsSingBoxMenuOpen(false);
|
||||||
|
try {
|
||||||
|
await nextFrame();
|
||||||
|
const component = await installSingBox();
|
||||||
|
setComponents((current) => upsertComponent(current, component));
|
||||||
|
await refreshSingBoxState();
|
||||||
|
showNotice({
|
||||||
|
kind: 'success',
|
||||||
|
title: 'Local sing-box установлен',
|
||||||
|
text: componentDetails(component, false),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Local sing-box не установлен',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSingBoxAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uninstallSingBoxPackage() {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'Удалить Local sing-box с компьютера? Будет удалена служба и папка установки sing-box.',
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setSingBoxAction('uninstall');
|
||||||
|
setIsSingBoxMenuOpen(false);
|
||||||
|
try {
|
||||||
|
await nextFrame();
|
||||||
|
const component = await uninstallSingBox();
|
||||||
|
setComponents((current) => upsertComponent(current, component));
|
||||||
|
await refreshSingBoxState();
|
||||||
|
showNotice({
|
||||||
|
kind: 'success',
|
||||||
|
title: 'Local sing-box удален',
|
||||||
|
text: 'Служба и папка установки Local sing-box удалены.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Local sing-box не удален',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSingBoxAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncSingBoxSubscription() {
|
||||||
|
const subscriptionUrl = subscriptionInput.trim();
|
||||||
|
if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Ссылка не указана',
|
||||||
|
text: 'Вставь ссылку подписки Local sing-box.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSingBoxAction('fetch');
|
||||||
|
try {
|
||||||
|
if (subscriptionUrl) {
|
||||||
|
await saveSingBoxSubscription(subscriptionUrl);
|
||||||
|
}
|
||||||
|
const status = await fetchSingBoxSubscription();
|
||||||
|
setSingBoxStatus(status);
|
||||||
|
setComponents((current) => upsertComponent(current, status.component));
|
||||||
|
setSubscriptionInput('');
|
||||||
|
setServerPings({});
|
||||||
|
setHasUnappliedChanges(true);
|
||||||
|
showNotice({
|
||||||
|
kind: 'success',
|
||||||
|
title: 'Подписка обновлена',
|
||||||
|
text: `Серверов: ${status.cache?.servers.length ?? 0}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Подписка не обновлена',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSingBoxAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forgetSingBoxSubscriptionData() {
|
||||||
|
setSingBoxAction('forget');
|
||||||
|
setIsSingBoxMenuOpen(false);
|
||||||
|
try {
|
||||||
|
const status = await forgetSingBoxSubscription();
|
||||||
|
setSingBoxStatus(status);
|
||||||
|
setComponents((current) => upsertComponent(current, status.component));
|
||||||
|
setServerPings({});
|
||||||
|
setHasUnappliedChanges(true);
|
||||||
|
showNotice({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Подписка очищена',
|
||||||
|
text: 'Ссылка, cache и выбранный сервер Local sing-box удалены.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Подписка не очищена',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSingBoxAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseSingBoxServer(server: SubscriptionServer) {
|
||||||
|
try {
|
||||||
|
const status = await selectSingBoxServer(server);
|
||||||
|
setSingBoxStatus(status);
|
||||||
|
setComponents((current) => upsertComponent(current, status.component));
|
||||||
|
setHasUnappliedChanges(true);
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Сервер не выбран',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pingSingBoxServers() {
|
||||||
|
setSingBoxAction('ping');
|
||||||
|
try {
|
||||||
|
const results = await pingAllSingBoxServers();
|
||||||
|
setServerPings(Object.fromEntries(results.map((result) => [result.tag, result])));
|
||||||
|
showNotice({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Ping завершен',
|
||||||
|
text: pingSummary(results),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Ping не выполнен',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSingBoxAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSingBoxNow() {
|
||||||
|
setSingBoxAction('generate');
|
||||||
|
try {
|
||||||
|
const result = await generateSingBoxConfig();
|
||||||
|
await refreshSingBoxState();
|
||||||
|
showNotice({
|
||||||
|
kind: 'success',
|
||||||
|
title: 'Конфиг sing-box создан',
|
||||||
|
text: result.generatedConfigPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Конфиг sing-box не создан',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSingBoxAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startServiceVisual() {
|
function startServiceVisual() {
|
||||||
if (serviceVisualTimerRef.current !== null) {
|
if (serviceVisualTimerRef.current !== null) {
|
||||||
window.clearTimeout(serviceVisualTimerRef.current);
|
window.clearTimeout(serviceVisualTimerRef.current);
|
||||||
@@ -541,15 +864,245 @@ export function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="simple-field">
|
<section className="route-panel" aria-label="Маршрут приложений">
|
||||||
<span>Прокси</span>
|
<div className="route-switch">
|
||||||
<input
|
<button
|
||||||
value={proxyInput}
|
type="button"
|
||||||
onChange={(event) => setProxyInput(event.target.value)}
|
className={routeMode === 'external' ? 'active' : ''}
|
||||||
placeholder="socks5://127.0.0.1:1080"
|
onClick={() => changeRouteMode('external')}
|
||||||
spellCheck={false}
|
aria-pressed={routeMode === 'external'}
|
||||||
/>
|
>
|
||||||
</label>
|
Внешний прокси
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={routeMode === 'local-singbox' ? 'active' : ''}
|
||||||
|
onClick={() => changeRouteMode('local-singbox')}
|
||||||
|
aria-pressed={routeMode === 'local-singbox'}
|
||||||
|
>
|
||||||
|
Local sing-box
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{routeMode === 'external' ? (
|
||||||
|
<label className="simple-field route-proxy-field">
|
||||||
|
<span className="sr-only">Внешний прокси</span>
|
||||||
|
<input
|
||||||
|
value={proxyInput}
|
||||||
|
onChange={(event) => changeProxyInput(event.target.value)}
|
||||||
|
placeholder="socks5://127.0.0.1:1080"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{routeMode === 'local-singbox' ? (
|
||||||
|
<div className={`finder-card singbox-card ${singBoxStateClass} ${singBoxVisualClass}`.trim()}>
|
||||||
|
<span className="finder-border-glow" aria-hidden="true">
|
||||||
|
<span className="finder-border-glow-segment top" />
|
||||||
|
<span className="finder-border-glow-segment right" />
|
||||||
|
<span className="finder-border-glow-segment bottom" />
|
||||||
|
<span className="finder-border-glow-segment left" />
|
||||||
|
</span>
|
||||||
|
<span className="status-light" />
|
||||||
|
<div className="finder-text">
|
||||||
|
<strong>{singBoxTitle(singbox, isDetectingComponents)}</strong>
|
||||||
|
<span>{singBoxDetails(singbox, singBoxStatus, isDetectingComponents)}</span>
|
||||||
|
<div className="singbox-inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="setup-toggle"
|
||||||
|
onClick={() => setIsSingBoxSetupOpen((current) => !current)}
|
||||||
|
disabled={isDetectingComponents && !singBoxSetupStatus}
|
||||||
|
aria-expanded={isSingBoxSetupOpen}
|
||||||
|
>
|
||||||
|
{singbox?.installed ? 'Состав Local sing-box' : 'Что будет установлено'}
|
||||||
|
{singBoxSetupStatus ? (
|
||||||
|
<span>{singBoxSetupStatus.ready ? 'все есть' : `не хватает: ${singBoxSetupStatus.missingCount}`}</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="info-toggle"
|
||||||
|
onClick={() => setIsSingBoxInfoOpen((current) => !current)}
|
||||||
|
aria-label="Подробности Local sing-box"
|
||||||
|
aria-expanded={isSingBoxInfoOpen}
|
||||||
|
title="Подробности"
|
||||||
|
>
|
||||||
|
<Info size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="service-actions" aria-label="Управление службой Local sing-box">
|
||||||
|
{singbox?.installed ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`service-button ${singbox.running ? 'stop' : ''}`.trim()}
|
||||||
|
onClick={() => void setSingBoxServiceRunning(!singbox.running)}
|
||||||
|
disabled={isDetectingComponents || Boolean(singBoxAction)}
|
||||||
|
>
|
||||||
|
{singBoxAction === 'start' || singBoxAction === 'stop'
|
||||||
|
? '...'
|
||||||
|
: singbox.running
|
||||||
|
? 'Остановить'
|
||||||
|
: 'Запустить'}
|
||||||
|
</button>
|
||||||
|
<div className="service-menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="service-menu-button"
|
||||||
|
onClick={() => setIsSingBoxMenuOpen((current) => !current)}
|
||||||
|
disabled={isDetectingComponents || Boolean(singBoxAction)}
|
||||||
|
aria-label="Дополнительные действия Local sing-box"
|
||||||
|
aria-expanded={isSingBoxMenuOpen}
|
||||||
|
title="Еще"
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={20} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
{isSingBoxMenuOpen ? (
|
||||||
|
<div className="service-menu-popover">
|
||||||
|
<button type="button" onClick={() => void uninstallSingBoxPackage()}>
|
||||||
|
{singBoxAction === 'uninstall' ? 'Удаляю...' : 'Удалить Local sing-box'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="service-button install"
|
||||||
|
onClick={() => void installSingBoxPackage()}
|
||||||
|
disabled={isDetectingComponents || Boolean(singBoxAction)}
|
||||||
|
>
|
||||||
|
{singBoxAction === 'install' ? '...' : 'Установить'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSingBoxInfoOpen ? (
|
||||||
|
<div className="singbox-info-popover">
|
||||||
|
<div className="singbox-info-grid">
|
||||||
|
<span>Локально</span>
|
||||||
|
<strong>{localSingBoxAddress(singBoxStatus)}</strong>
|
||||||
|
<span>LAN</span>
|
||||||
|
<strong>{lanSingBoxAddress(singBoxStatus) ?? 'недоступен'}</strong>
|
||||||
|
<span>Сервер</span>
|
||||||
|
<strong>
|
||||||
|
{singBoxStatus?.config.selectedServerTag
|
||||||
|
? displayServerTag(singBoxStatus.config.selectedServerTag)
|
||||||
|
: 'не выбран'}
|
||||||
|
</strong>
|
||||||
|
<span>Файл</span>
|
||||||
|
<strong>{singbox?.path ?? 'не найден'}</strong>
|
||||||
|
<span>Конфиг</span>
|
||||||
|
<strong>{singBoxStatus?.generatedConfigPath ?? 'не создан'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="singbox-info-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void pingSingBoxServers()}
|
||||||
|
disabled={Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length}
|
||||||
|
>
|
||||||
|
<Gauge size={15} strokeWidth={1.9} />
|
||||||
|
Ping
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void generateSingBoxNow()}
|
||||||
|
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.selectedServerTag}
|
||||||
|
>
|
||||||
|
<Wand2 size={15} strokeWidth={1.9} />
|
||||||
|
Конфиг
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isSingBoxSetupOpen ? (
|
||||||
|
<div className="setup-details">
|
||||||
|
{singBoxSetupStatus ? (
|
||||||
|
singBoxSetupStatus.items.map((item) => (
|
||||||
|
<div className={`setup-item ${item.installed ? 'installed' : 'missing'}`} key={item.id}>
|
||||||
|
<span className="setup-state-dot" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<span>{setupItemDetails(item.installed, item.version, item.details)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="setup-item">
|
||||||
|
<span className="setup-state-dot" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>Проверяю состав</strong>
|
||||||
|
<span>Ищу sing-box, WinSW wrapper и службу.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isSingBoxInstalled ? (
|
||||||
|
<div className="singbox-workspace">
|
||||||
|
<div className="subscription-line">
|
||||||
|
<span className="subscription-icon" aria-hidden="true">
|
||||||
|
<Link2 size={18} strokeWidth={1.9} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={subscriptionInput}
|
||||||
|
onChange={(event) => setSubscriptionInput(event.target.value)}
|
||||||
|
placeholder={singBoxStatus?.config.subscriptionDisplayUrl ?? 'https://example.com/sub'}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void syncSingBoxSubscription()}
|
||||||
|
disabled={singBoxAction === 'fetch'}
|
||||||
|
>
|
||||||
|
{singBoxAction === 'fetch' ? '...' : subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-command"
|
||||||
|
onClick={() => void forgetSingBoxSubscriptionData()}
|
||||||
|
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription}
|
||||||
|
aria-label="Очистить подписку Local sing-box"
|
||||||
|
title="Очистить"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} strokeWidth={1.9} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{singBoxStatus?.cache?.servers.length ? (
|
||||||
|
<div className="server-list">
|
||||||
|
{singBoxStatus.cache.servers.map((server) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`server-row ${server.tag === singBoxStatus.config.selectedServerTag ? 'selected' : ''}`.trim()}
|
||||||
|
key={server.tag}
|
||||||
|
onClick={() => void chooseSingBoxServer(server)}
|
||||||
|
title={serverTooltip(server, serverPings[server.tag])}
|
||||||
|
>
|
||||||
|
<span className="server-select-dot" aria-hidden="true" />
|
||||||
|
<strong>{displayServerTag(server.tag)}</strong>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">Подписка Local sing-box еще не загружена.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="singbox-install-note">
|
||||||
|
<strong>Local sing-box не установлен</strong>
|
||||||
|
<span>Установи компонент, чтобы подключить подписку, выбрать сервер и включить локальный маршрут.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="apps-section">
|
<section className="apps-section">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
@@ -653,9 +1206,24 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{hasUnappliedChanges ? (
|
||||||
|
<div className="apply-state pending" role="status">
|
||||||
|
<strong>Изменения еще не применены в ProxiFyre</strong>
|
||||||
|
<span>{applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="command-row">
|
<div className="command-row">
|
||||||
<button type="button" className="apply-button" onClick={updateConfig} disabled={isApplying}>
|
<button type="button" className="apply-button" onClick={updateConfig} disabled={isApplying}>
|
||||||
{isApplying ? 'Обновляю...' : 'Обновить конфиг'}
|
{isApplying
|
||||||
|
? singBoxAction === 'start'
|
||||||
|
? 'Запускаю sing-box...'
|
||||||
|
: singBoxAction === 'stop'
|
||||||
|
? 'Перезапускаю sing-box...'
|
||||||
|
: 'Применяю...'
|
||||||
|
: hasUnappliedChanges
|
||||||
|
? 'Применить в ProxiFyre'
|
||||||
|
: 'Обновить конфиг'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -745,6 +1313,11 @@ function targetForUi(targets: Target[], profile: Profile | undefined) {
|
|||||||
return targets.find((target) => target.id === MAIN_TARGET_ID) ?? targets.find((target) => target.kind === 'external');
|
return targets.find((target) => target.id === MAIN_TARGET_ID) ?? targets.find((target) => target.kind === 'external');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function targetForExternalProxy(targets: Target[]) {
|
||||||
|
return targets.find((target) => target.id === MAIN_TARGET_ID)
|
||||||
|
?? targets.find((target) => target.kind === 'external' && target.id !== LOCAL_SINGBOX_TARGET_ID);
|
||||||
|
}
|
||||||
|
|
||||||
function itemsForProfiles(profiles: Profile[]): DraftItem[] {
|
function itemsForProfiles(profiles: Profile[]): DraftItem[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const items: DraftItem[] = [];
|
const items: DraftItem[] = [];
|
||||||
@@ -873,6 +1446,40 @@ function proxyfierDetails(component: ComponentStatus | undefined, checking: bool
|
|||||||
return component.problems[0] ?? 'Путь установки не найден.';
|
return component.problems[0] ?? 'Путь установки не найден.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function singBoxTitle(component: ComponentStatus | undefined, checking: boolean) {
|
||||||
|
if (checking) return 'Проверяю Local sing-box';
|
||||||
|
if (!component) return 'Local sing-box не проверен';
|
||||||
|
if (component.running) return 'Local sing-box найден и запущен';
|
||||||
|
if (component.installed) return 'Local sing-box найден';
|
||||||
|
return 'Local sing-box не установлен';
|
||||||
|
}
|
||||||
|
|
||||||
|
function singBoxDetails(
|
||||||
|
component: ComponentStatus | undefined,
|
||||||
|
status: LocalSingBoxStatusResponse | null,
|
||||||
|
checking: boolean,
|
||||||
|
) {
|
||||||
|
if (checking) return 'Ищу sing-box, wrapper и службу.';
|
||||||
|
if (component?.running && status) {
|
||||||
|
const lanAddress = lanSingBoxAddress(status);
|
||||||
|
return lanAddress
|
||||||
|
? `Доступен локально: ${localSingBoxAddress(status)} · LAN: ${lanAddress}`
|
||||||
|
: `Доступен локально: ${localSingBoxAddress(status)}`;
|
||||||
|
}
|
||||||
|
if (component?.installed) return 'Служба остановлена. Запусти Local sing-box перед применением маршрута.';
|
||||||
|
if (status?.config.hasSubscription) {
|
||||||
|
return status.config.subscriptionDisplayUrl ?? 'Подписка сохранена.';
|
||||||
|
}
|
||||||
|
return component?.problems[0] ?? 'Установи компонент, чтобы подключить подписку и выбрать сервер.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function componentDetails(component: ComponentStatus | undefined, checking: boolean) {
|
||||||
|
if (checking) return 'Проверяю состояние службы.';
|
||||||
|
if (!component) return 'Компонент не проверен.';
|
||||||
|
if (component.path) return component.path;
|
||||||
|
return component.problems[0] ?? 'Путь установки не найден.';
|
||||||
|
}
|
||||||
|
|
||||||
function noticeFromApply(result: ApplyProfilesResponse): Notice {
|
function noticeFromApply(result: ApplyProfilesResponse): Notice {
|
||||||
return {
|
return {
|
||||||
kind: result.success ? 'success' : 'error',
|
kind: result.success ? 'success' : 'error',
|
||||||
@@ -881,6 +1488,24 @@ function noticeFromApply(result: ApplyProfilesResponse): Notice {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function noticeFromLocalApply(result: ApplyProfilesResponse, singBoxGeneratedPath: string): Notice {
|
||||||
|
return {
|
||||||
|
kind: result.success ? 'success' : 'error',
|
||||||
|
title: result.success ? 'Local sing-box применен' : 'Конфиг создан, но не применен',
|
||||||
|
text: singBoxGeneratedPath ? `${result.message} sing-box: ${singBoxGeneratedPath}` : result.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStateText(routeMode: RouteMode, isSingBoxInstalled: boolean, isSingBoxRunning: boolean) {
|
||||||
|
if (routeMode === 'local-singbox' && isSingBoxInstalled && !isSingBoxRunning) {
|
||||||
|
return 'Local sing-box сейчас остановлен. При применении клиент сначала запустит службу, затем обновит ProxiFyre.';
|
||||||
|
}
|
||||||
|
if (routeMode === 'local-singbox' && isSingBoxInstalled && isSingBoxRunning) {
|
||||||
|
return 'При применении клиент обновит конфиг, перезапустит Local sing-box и затем обновит ProxiFyre.';
|
||||||
|
}
|
||||||
|
return 'Нажми «Применить в ProxiFyre». Если служба уже запущена и маршрут не обновился, перезапусти ProxiFyre.';
|
||||||
|
}
|
||||||
|
|
||||||
function upsertComponent(components: ComponentStatus[], component: ComponentStatus) {
|
function upsertComponent(components: ComponentStatus[], component: ComponentStatus) {
|
||||||
const index = components.findIndex((current) => current.id === component.id);
|
const index = components.findIndex((current) => current.id === component.id);
|
||||||
if (index === -1) return [...components, component];
|
if (index === -1) return [...components, component];
|
||||||
@@ -904,6 +1529,61 @@ function formatLogTime(timestamp: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serverLabel(server: SubscriptionServer) {
|
||||||
|
return `${server.type} · ${server.server}:${server.serverPort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverTooltip(server: SubscriptionServer, ping: PingServerResponse | undefined) {
|
||||||
|
const details = serverLabel(server);
|
||||||
|
if (!ping) return details;
|
||||||
|
return ping.ok ? `${details} · ping ${ping.latency ?? 0} ms` : `${details} · ping fail`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayServerTag(tag: string) {
|
||||||
|
const withoutFlags = tag
|
||||||
|
.replace(/[\u{1f1e6}-\u{1f1ff}]/gu, '')
|
||||||
|
.replace(/\s*->\s*/g, ' -> ')
|
||||||
|
.replace(/\s*->\s*$/g, '')
|
||||||
|
.replace(/^\s*->\s*/g, '')
|
||||||
|
.replace(/\s{2,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return withoutFlags || tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pingSummary(results: PingServerResponse[]) {
|
||||||
|
if (!results.length) return 'Серверов для проверки нет.';
|
||||||
|
const ok = results.filter((result) => result.ok);
|
||||||
|
if (!ok.length) return `Не ответил ни один сервер из ${results.length}.`;
|
||||||
|
const best = ok.reduce((current, result) => {
|
||||||
|
if ((result.latency ?? Number.MAX_SAFE_INTEGER) < (current.latency ?? Number.MAX_SAFE_INTEGER)) return result;
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
return `Ответили ${ok.length}/${results.length}; быстрее ${best.tag}: ${best.latency ?? 0} ms.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localSingBoxAddress(status: LocalSingBoxStatusResponse | null) {
|
||||||
|
if (!status) return 'не загружен';
|
||||||
|
const host = status.config.listenHost.trim();
|
||||||
|
const displayHost = host === '0.0.0.0' || host === '::' || !host ? '127.0.0.1' : host;
|
||||||
|
return formatHostPort(displayHost, status.config.listenPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lanSingBoxAddress(status: LocalSingBoxStatusResponse | null) {
|
||||||
|
if (!status) return null;
|
||||||
|
const host = status.config.listenHost.trim().toLowerCase();
|
||||||
|
if (host === '127.0.0.1' || host === 'localhost' || host === '::1') return null;
|
||||||
|
const lanHost = host === '0.0.0.0' || host === '::' || !host
|
||||||
|
? status.lanListenHost
|
||||||
|
: status.config.listenHost;
|
||||||
|
if (!lanHost) return null;
|
||||||
|
return formatHostPort(lanHost, status.config.listenPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHostPort(host: string, port: number) {
|
||||||
|
return host.includes(':') && !host.startsWith('[') ? `[${host}]:${port}` : `${host}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown) {
|
function errorMessage(error: unknown) {
|
||||||
if (error instanceof Error) return error.message;
|
if (error instanceof Error) return error.message;
|
||||||
if (typeof error === 'string') return error;
|
if (typeof error === 'string') return error;
|
||||||
|
|||||||
@@ -68,6 +68,30 @@ export interface ComponentStatus {
|
|||||||
actions: string[];
|
actions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalSingBoxConfig {
|
||||||
|
subscriptionDisplayUrl?: string;
|
||||||
|
hasSubscription: boolean;
|
||||||
|
selectedServerTag?: string;
|
||||||
|
listenHost: string;
|
||||||
|
listenPort: number;
|
||||||
|
serviceName: string;
|
||||||
|
installRoot: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionServer {
|
||||||
|
tag: string;
|
||||||
|
type: string;
|
||||||
|
server: string;
|
||||||
|
serverPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionCache {
|
||||||
|
servers: SubscriptionServer[];
|
||||||
|
userInfo: Record<string, number | string | boolean | null>;
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActivityEntry {
|
export interface ActivityEntry {
|
||||||
id: string;
|
id: string;
|
||||||
at: string;
|
at: string;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #101216;
|
background: #101216;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -54,11 +55,13 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.simple-header,
|
.simple-header,
|
||||||
.finder-card,
|
|
||||||
.section-head,
|
.section-head,
|
||||||
.add-toolbar,
|
.add-toolbar,
|
||||||
.process-add-line,
|
.process-add-line,
|
||||||
.app-row {
|
.app-row,
|
||||||
|
.route-switch,
|
||||||
|
.singbox-info-actions,
|
||||||
|
.server-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -103,7 +106,11 @@ button:disabled {
|
|||||||
.app-row button,
|
.app-row button,
|
||||||
.open-config-button,
|
.open-config-button,
|
||||||
.service-button,
|
.service-button,
|
||||||
.service-menu-button {
|
.service-menu-button,
|
||||||
|
.subscription-line button,
|
||||||
|
.route-switch button,
|
||||||
|
.singbox-info-actions button,
|
||||||
|
.server-row {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
border: 1px solid #343b49;
|
border: 1px solid #343b49;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -119,7 +126,11 @@ button:disabled {
|
|||||||
.app-row button:hover,
|
.app-row button:hover,
|
||||||
.open-config-button:hover,
|
.open-config-button:hover,
|
||||||
.service-button:hover,
|
.service-button:hover,
|
||||||
.service-menu-button:hover {
|
.service-menu-button:hover,
|
||||||
|
.subscription-line button:hover,
|
||||||
|
.route-switch button:hover,
|
||||||
|
.singbox-info-actions button:hover,
|
||||||
|
.server-row:hover {
|
||||||
background: #2d3543;
|
background: #2d3543;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +138,9 @@ button:disabled {
|
|||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(220px, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -137,13 +150,19 @@ button:disabled {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finder-border-glow {
|
.singbox-card {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card .finder-border-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
display: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
contain: layout paint;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.22s ease;
|
transition: opacity 0.22s ease;
|
||||||
}
|
}
|
||||||
@@ -155,6 +174,7 @@ button:disabled {
|
|||||||
|
|
||||||
.finder-card.checking .finder-border-glow,
|
.finder-card.checking .finder-border-glow,
|
||||||
.finder-card.working .finder-border-glow {
|
.finder-card.working .finder-border-glow {
|
||||||
|
display: block;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +183,7 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.finder-card.settling .finder-border-glow {
|
.finder-card.settling .finder-border-glow {
|
||||||
|
display: block;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition-duration: 0.7s;
|
transition-duration: 0.7s;
|
||||||
}
|
}
|
||||||
@@ -217,14 +238,27 @@ button:disabled {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finder-card strong,
|
.finder-text > strong,
|
||||||
.finder-card span,
|
.finder-text > span,
|
||||||
.app-row-main strong,
|
.app-row-main strong,
|
||||||
.app-row-main > div > span {
|
.app-row-main > div > span {
|
||||||
display: block;
|
display: block;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finder-text > strong {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-text > span {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card .subscription-icon,
|
||||||
|
.finder-card .server-icon {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
.status-light {
|
.status-light {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 11px;
|
width: 11px;
|
||||||
@@ -398,14 +432,29 @@ button:disabled {
|
|||||||
background: rgba(127, 29, 29, 0.42);
|
background: rgba(127, 29, 29, 0.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border: 1px solid #2b3342;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #151923;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.simple-field {
|
.simple-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
margin: 14px 0;
|
margin: 14px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-proxy-field {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.simple-field input,
|
.simple-field input,
|
||||||
.process-add-line input {
|
.process-add-line input,
|
||||||
|
.subscription-line input {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #343b49;
|
border: 1px solid #343b49;
|
||||||
@@ -417,11 +466,227 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.simple-field input:focus,
|
.simple-field input:focus,
|
||||||
.process-add-line input:focus {
|
.process-add-line input:focus,
|
||||||
|
.subscription-line input:focus {
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
box-shadow: 0 0 0 1px #3b82f6;
|
box-shadow: 0 0 0 1px #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.singbox-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid #2b3342;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singbox-inline-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-toggle {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid #263040;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #111720;
|
||||||
|
color: #bfdbfe;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-toggle:hover {
|
||||||
|
background: #1d2735;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-toggle svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singbox-info-popover {
|
||||||
|
position: relative;
|
||||||
|
z-index: 8;
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #343b49;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #111720;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singbox-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 86px minmax(0, 1fr);
|
||||||
|
gap: 7px 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singbox-info-grid span {
|
||||||
|
color: #8d99ae;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singbox-info-grid strong {
|
||||||
|
min-width: 0;
|
||||||
|
color: #dbeafe;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singbox-info-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singbox-info-actions button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 32px;
|
||||||
|
color: #dbeafe;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singbox-info-actions button svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-switch {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-switch button {
|
||||||
|
min-height: 38px;
|
||||||
|
width: 100%;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-switch button.active {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr) 112px 42px;
|
||||||
|
gap: 7px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-icon,
|
||||||
|
.server-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid #2b3342;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0d1016;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-line button {
|
||||||
|
min-height: 42px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-line .icon-command {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 42px;
|
||||||
|
padding: 0;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-row {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 7px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 38px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-row.selected {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #17213a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-select-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 1px solid #64748b;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #0d1016;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-row.selected .server-select-dot {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
background: #60a5fa;
|
||||||
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-row strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #dbeafe;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singbox-install-note {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
gap: 3px;
|
||||||
|
border-top: 1px solid #2b3342;
|
||||||
|
color: #9aa8bd;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singbox-install-note strong {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-state {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 14px;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.48);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(120, 53, 15, 0.2);
|
||||||
|
color: #fcd34d;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-state span {
|
||||||
|
color: #d6b875;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.apps-section {
|
.apps-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -863,6 +1128,10 @@ button:disabled {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.simple-header {
|
||||||
|
margin: -14px -14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.add-toolbar {
|
.add-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -876,6 +1145,23 @@ button:disabled {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subscription-line {
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr) 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-line input {
|
||||||
|
grid-column: 2 / 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-line button:not(.icon-command) {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-line .icon-command {
|
||||||
|
grid-column: 3;
|
||||||
|
width: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
.finder-card {
|
.finder-card {
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
@@ -892,6 +1178,18 @@ button:disabled {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.singbox-info-popover {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singbox-info-grid {
|
||||||
|
grid-template-columns: 70px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.command-row {
|
.command-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -908,6 +1206,11 @@ button:disabled {
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-current strong,
|
||||||
|
.log-current span {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.log-history-row {
|
.log-history-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
61
docs/goals/windows-local-singbox/EVIDENCE.md
Normal file
61
docs/goals/windows-local-singbox/EVIDENCE.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Windows Local Sing-Box Evidence
|
||||||
|
|
||||||
|
## Acceptance Evidence
|
||||||
|
|
||||||
|
Record the real artifact that proves the outcome from the Windows-client user's perspective.
|
||||||
|
|
||||||
|
- Windows client now has two independent component blocks:
|
||||||
|
- `ProxiFyre` block remains the existing required app-routing layer.
|
||||||
|
- `Local sing-box` block is optional, visually matches the ProxiFyre finder card, has setup details, install/start/stop/uninstall actions, subscription input, route switch, server list, ping, and config generation actions.
|
||||||
|
- Route choice is explicit:
|
||||||
|
- `Внешний прокси` keeps the existing external SOCKS5 flow.
|
||||||
|
- `Local sing-box` generates `sing-box-config.json`, ensures target `local-singbox` at `127.0.0.1:1080`, and applies ProxiFyre to that local target.
|
||||||
|
- Generated config proof is covered by `singbox_command_tests::generate_writes_config_and_local_singbox_target`:
|
||||||
|
- generated config contains a local mixed inbound;
|
||||||
|
- selected outbound is retagged to stable `vpn`;
|
||||||
|
- persisted target is `local-singbox`, `kind=local`, `protocol=socks5`, `host=127.0.0.1`, `port=1080`, `requiresComponent=singbox`.
|
||||||
|
- Browser visual proof:
|
||||||
|
- Vite dev server: `http://127.0.0.1:5173/`
|
||||||
|
- Playwright/system Chrome desktop screenshot showed ProxiFyre and Local sing-box blocks, route switch, subscription input, ping/config actions, and apply/open controls.
|
||||||
|
- Mobile screenshot at `390px` width showed the same controls stacked without overlap.
|
||||||
|
- Final mobile layout metric: `docScrollWidth=390`, `viewportWidth=390`, `overflow=[]`.
|
||||||
|
- Privileged Windows service lane:
|
||||||
|
- Implemented install/start/stop/uninstall paths and PowerShell parser checks.
|
||||||
|
- Real elevated UAC install/start was not executed in this session, so this lane is `implemented but unproven` until manual Windows validation runs.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Record focused checks that passed, including command and relevant output.
|
||||||
|
|
||||||
|
- `cd apps/windows-client && npm run build`
|
||||||
|
- Passed: `tsc && vite build`.
|
||||||
|
- `cd apps/windows-client/src-tauri && cargo test --test storage_tests --test subscription_tests --test singbox_adapter_tests --test component_detection_tests --test singbox_service_tests --test command_tests --test singbox_command_tests`
|
||||||
|
- Passed:
|
||||||
|
- `command_tests`: 8 passed
|
||||||
|
- `component_detection_tests`: 7 passed
|
||||||
|
- `singbox_adapter_tests`: 6 passed
|
||||||
|
- `singbox_command_tests`: 7 passed
|
||||||
|
- `singbox_service_tests`: 6 passed
|
||||||
|
- `storage_tests`: 8 passed
|
||||||
|
- `subscription_tests`: 7 passed
|
||||||
|
- Total focused Local sing-box backend path: 49 passed.
|
||||||
|
- `cd apps/windows-client/src-tauri && cargo test --test proxifyre_adapter_tests`
|
||||||
|
- Passed: 6 passed.
|
||||||
|
- Proves existing external SOCKS5 route still generates and `local-singbox` route is blocked when the required component is missing but works when it is running.
|
||||||
|
- `cargo fmt`
|
||||||
|
- Passed.
|
||||||
|
- `apps/windows-client/scripts/install-singbox.ps1` parser check:
|
||||||
|
- Covered by `singbox_service_tests::install_singbox_script_parses_as_powershell` on Windows.
|
||||||
|
- Playwright/system Chrome visual check:
|
||||||
|
- Desktop viewport `1280x1200`: main controls rendered.
|
||||||
|
- Mobile viewport `390x900`: no horizontal overflow after CSS fix.
|
||||||
|
|
||||||
|
## Review Notes
|
||||||
|
|
||||||
|
Record PRE reviewer, maintainer, or verifier findings that changed the result.
|
||||||
|
|
||||||
|
- PRE self-review: aligned after tightening the service-wrapper contract to WinSW and requiring `start_singbox_service` to regenerate/check config before service start.
|
||||||
|
- POST plan review: implementation stayed inside `apps/windows-client` and did not reuse root Node subscription parsing. External proxy remains default and does not require Local sing-box.
|
||||||
|
- Correctness review: covered storage defaults, redacted subscription URL, HTTP/HTTPS validation, JSON/base64/VLESS subscription parsing, selected-outbound config generation, missing selected server errors, component detection, service command output parsing, Tauri command orchestration, route target generation, TypeScript build, and responsive UI.
|
||||||
|
- Maintainability review: Rust owns subscription/cache/config/service state; React owns transient UI only. Tauri commands are typed and do not expose raw PowerShell/stdout to the UI.
|
||||||
|
- Residual risk: real UAC install/start/stop/uninstall for `VpnProxySingBox` must be validated manually on a Windows machine with admin confirmation and network access to GitHub releases.
|
||||||
13
docs/goals/windows-local-singbox/GOAL.md
Normal file
13
docs/goals/windows-local-singbox/GOAL.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Goal: Windows Local Sing-Box
|
||||||
|
|
||||||
|
Use Krypton Execution to execute `docs/goals/windows-local-singbox/PLAN.md`.
|
||||||
|
|
||||||
|
Core rules:
|
||||||
|
- Treat PLAN.md as the source plan.
|
||||||
|
- Preserve intent, ownership, contract, cutover, evidence, and kill criteria.
|
||||||
|
- Implement the feature as the existing optional Local sing-box component, not as a new unrelated component id.
|
||||||
|
- Keep external proxy flow working without Local sing-box.
|
||||||
|
- Do not add hidden installation during profile apply.
|
||||||
|
- Do not import or call the root Node server from the Windows client.
|
||||||
|
- Capture acceptance evidence from the target perspective and record it in EVIDENCE.md.
|
||||||
|
- Say "implemented but unproven" if elevated Windows service evidence cannot be captured.
|
||||||
285
docs/goals/windows-local-singbox/PLAN.md
Normal file
285
docs/goals/windows-local-singbox/PLAN.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# Windows Local Sing-Box Implementation Plan
|
||||||
|
|
||||||
|
**Intent:** Add an optional Windows-client Local sing-box block that mirrors the ProxiFyre block, installs and controls a real sing-box service, imports a subscription/link, lets the user pick and ping servers, and routes selected apps through the chosen local outbound.
|
||||||
|
**Current Behavior:** The Windows client supports an existing external SOCKS5 proxy and a ProxiFyre component block with setup details, install/start/stop/uninstall actions, animation, and generated config apply. `singbox` exists as an optional component in the model, but `install-singbox.ps1` is only a marker boundary, `main.rs` does not register sing-box commands, the frontend has no sing-box block, and subscription parsing lives only in the Node server.
|
||||||
|
**Expected Outcome:** A Windows user can keep using the external proxy path unchanged, or explicitly install optional Local sing-box, paste a subscription/VLESS link, fetch servers, ping them, choose one, generate a checked sing-box config, start/stop the sing-box service, and apply ProxiFyre routing to `local-singbox`.
|
||||||
|
**Target-Perspective Output:** In the Windows client, the user sees a second optional block styled and animated like ProxiFyre. It shows what will be installed, asks for clear confirmation before privileged work, displays subscription/server state, exposes server ping and selection, and makes it obvious whether selected apps route to an existing proxy or to Local sing-box.
|
||||||
|
**Truth Owner:** Rust/Tauri backend owns sing-box source state, subscription parsing/cache, generated sing-box config, component detection, and service operations. React owns only transient UI state. Generated configs and service files are derived artifacts.
|
||||||
|
**Contract Boundary:** React calls typed Tauri commands. Rust validates and persists source JSON under `C:\ProgramData\VpnProxy`, generates configs through the sing-box adapter, and invokes explicit elevated PowerShell only for install/uninstall/service actions. UI never parses raw PowerShell/stdout.
|
||||||
|
**Cutover:** Replace the current marker-only sing-box installer path with a real optional Local sing-box component while preserving `ComponentId::Singbox` and `local-singbox` target semantics. Keep external proxy as the default usable route when sing-box is absent.
|
||||||
|
**Displaced Path:** Displace `apps/windows-client/scripts/install-singbox.ps1` marker behavior and the current direct-only `adapters/singbox.rs` output. Do not create a parallel Node/server subscription path for the Windows client.
|
||||||
|
**Value Density:** The smallest high-value slice is a controlled Local sing-box block that can fetch a subscription, select an outbound, generate checked local sing-box config, and expose service controls without making sing-box mandatory.
|
||||||
|
**Evidence Gate:** Evidence must include target-perspective UI proof plus generated config proof, not just tests. Privileged Windows service behavior must be manually verified or explicitly marked `implemented but unproven`.
|
||||||
|
**Acceptance Evidence:** Rust tests for parser/config/service-command boundaries; frontend build; app state or screenshot showing ProxiFyre and Local sing-box blocks; generated `sing-box-config.json` containing the selected outbound and local mixed inbound; generated ProxiFyre config pointing to `127.0.0.1:1080`; Windows service checklist when elevated actions are run.
|
||||||
|
**Evidence Lane:** Record commands, selected app state, generated config summaries, and manual Windows results in `docs/goals/windows-local-singbox/EVIDENCE.md`.
|
||||||
|
**Kill Criteria:** No mandatory sing-box for external proxy flow; no hidden install during profile apply; no duplicate subscription source of truth in React or Node server; no raw PowerShell text as app logic; no permanent marker-only sing-box install path.
|
||||||
|
**Architecture Slice:** Extend `apps/windows-client` only: Rust domain/storage/commands/adapters/detection/scripts plus React UI/types/CSS. Avoid root `src/server` and root `src/web` except as read-only reference.
|
||||||
|
**Plan Review Gate:** Requires PRE review before execution.
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
The user-facing feature name is `Local sing-box` / `sing-box`. Do not use alternate feature names in UI, commands, docs, or files.
|
||||||
|
|
||||||
|
## Architecture Slice
|
||||||
|
|
||||||
|
Files to create:
|
||||||
|
- `apps/windows-client/src-tauri/src/subscription.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/singbox_service.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/subscription_tests.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/singbox_service_tests.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/singbox_command_tests.rs`
|
||||||
|
|
||||||
|
Files to modify:
|
||||||
|
- `apps/windows-client/src-tauri/Cargo.toml`
|
||||||
|
- `apps/windows-client/src-tauri/src/main.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/models.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/storage.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/commands.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/component_detection.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/singbox.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/command_tests.rs`
|
||||||
|
- `apps/windows-client/src/domain/types.ts`
|
||||||
|
- `apps/windows-client/src/api/tauriCommands.ts`
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src/styles/app.css`
|
||||||
|
- `apps/windows-client/scripts/install-singbox.ps1`
|
||||||
|
- `apps/windows-client/README.md`
|
||||||
|
- `docs/goals/windows-local-singbox/EVIDENCE.md`
|
||||||
|
|
||||||
|
Files to avoid:
|
||||||
|
- `src/server/*` except as read-only reference for subscription semantics.
|
||||||
|
- `src/web/*`
|
||||||
|
- Docker, compose, gateway, and macOS installer files.
|
||||||
|
- Existing ProxiFyre behavior except where it must consume the `local-singbox` target.
|
||||||
|
|
||||||
|
Source of truth:
|
||||||
|
- `C:\ProgramData\VpnProxy\config\profiles.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\config\targets.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\config\local-singbox.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\state\singbox-subscription-cache.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\state\activity.json`
|
||||||
|
|
||||||
|
Derived artifacts:
|
||||||
|
- `C:\ProgramData\VpnProxy\generated\sing-box-config.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json`
|
||||||
|
- Installed `sing-box.exe` and WinSW wrapper under `C:\Program Files\VpnProxy\sing-box`
|
||||||
|
- Windows service `VpnProxySingBox`
|
||||||
|
|
||||||
|
Read path:
|
||||||
|
- React calls `get_components`, `get_singbox_status`, `get_singbox_setup_status`, and subscription/server commands.
|
||||||
|
- Rust reads source Local sing-box config and cache, detects installed `sing-box`/service/wrapper, and returns redacted DTOs.
|
||||||
|
|
||||||
|
Write path:
|
||||||
|
- React sends typed mutations for subscription URL, selected server, local endpoint, and service actions.
|
||||||
|
- Rust validates input, writes source JSON atomically, fetches/cache subscription data, generates `sing-box-config.json`, and runs `sing-box check` before service start/restart.
|
||||||
|
|
||||||
|
Contract boundary:
|
||||||
|
- `subscription.rs` owns fetch/parse/link normalization.
|
||||||
|
- `adapters/singbox.rs` owns conversion from selected outbound to local runtime config.
|
||||||
|
- `singbox_service.rs` owns install/service script generation and structured command parsing.
|
||||||
|
- `commands.rs` owns Tauri DTOs and activity entries.
|
||||||
|
- `App.tsx` owns layout/state orchestration only.
|
||||||
|
|
||||||
|
Integration points:
|
||||||
|
- ProxiFyre route remains `selected apps -> ProxiFyre -> target`.
|
||||||
|
- Local sing-box route uses target `local-singbox`, `kind=local`, `protocol=socks5`, `host=127.0.0.1`, `port=1080`, `requiresComponent=singbox`.
|
||||||
|
- Server ping uses TCP connect to the selected outbound host/port, like the existing server ping semantics.
|
||||||
|
- Install flow downloads/installs `sing-box` plus a WinSW Windows service wrapper, then writes machine-readable result JSON.
|
||||||
|
|
||||||
|
Migration/cutover:
|
||||||
|
- On first sing-box save/start, ensure `local-singbox` target exists or is updated from Local sing-box listen settings.
|
||||||
|
- Do not switch the user's profile target automatically unless the user chooses Local sing-box.
|
||||||
|
- Existing external proxy input remains the default path and must still build/apply without sing-box.
|
||||||
|
|
||||||
|
Displaced path:
|
||||||
|
- `install-singbox.ps1` must stop being a marker-only script.
|
||||||
|
- The old direct-only sing-box config in `adapters/singbox.rs` must be replaced by selected-subscription-outbound config generation.
|
||||||
|
- No Windows-client code should import or call the root Node server for subscription parsing.
|
||||||
|
|
||||||
|
Acceptance evidence gate:
|
||||||
|
- Automated evidence proves parser/config/ping/service-command boundaries.
|
||||||
|
- App-visible evidence proves the optional block, setup details, server selection, and route mode.
|
||||||
|
- Generated config evidence proves selected server is used by sing-box and ProxiFyre points to the local endpoint.
|
||||||
|
|
||||||
|
## Task Board
|
||||||
|
|
||||||
|
### Task 1: Add Local Sing-Box Domain And Storage Contract
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src-tauri/src/models.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/storage.rs`
|
||||||
|
- `apps/windows-client/src/domain/types.ts`
|
||||||
|
- `apps/windows-client/src-tauri/tests/storage_tests.rs`
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- `LocalSingBoxConfig` source model with subscription URL, redacted display URL, selected server tag, listen host/port, service name, install root, and timestamps.
|
||||||
|
- `SubscriptionCache` model with parsed config, server summaries, user info, and fetched timestamp.
|
||||||
|
- Storage paths for `config/local-singbox.json` and `state/singbox-subscription-cache.json`.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `cd apps/windows-client/src-tauri && cargo test storage`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Tests prove atomic roundtrip, default optional empty state, redacted URL DTO, and invalid cache fallback.
|
||||||
|
|
||||||
|
Parallel safe: no.
|
||||||
|
|
||||||
|
### Task 2: Port Subscription Parsing And Fetching To Rust
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src-tauri/Cargo.toml`
|
||||||
|
- `apps/windows-client/src-tauri/src/subscription.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/subscription_tests.rs`
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- Parser supports sing-box JSON configs, base64 subscription bodies, and VLESS REALITY links with the same supported outbound types as `src/server/subscription.js`.
|
||||||
|
- Fetch command support is implemented in Rust with HTTP/HTTPS only and structured errors.
|
||||||
|
- Subscription URL is never echoed unredacted in diagnostics or normal status DTOs.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `cd apps/windows-client/src-tauri && cargo test subscription`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Tests cover JSON config, base64 VLESS list, invalid URL, unsupported outbound, and redaction.
|
||||||
|
|
||||||
|
Parallel safe: yes after Task 1 model names are stable.
|
||||||
|
|
||||||
|
### Task 3: Replace Sing-Box Adapter With Selected-Outbound Config Generation
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/singbox.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs`
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- Adapter builds local mixed inbound on the configured listen host/port.
|
||||||
|
- Adapter selects the cached outbound by tag, clones it, ensures a stable outbound tag, and adds `direct`/`block`.
|
||||||
|
- Adapter runs `sing-box check` when a binary path is available.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `cd apps/windows-client/src-tauri && cargo test singbox_adapter`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Tests show selected VLESS outbound appears in generated config, missing selected tag blocks generation, and external-target ProxiFyre flow remains independent from Local sing-box.
|
||||||
|
|
||||||
|
Parallel safe: yes after Task 1.
|
||||||
|
|
||||||
|
### Task 4: Implement Sing-Box Detection, Setup Status, And Service Scripts
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src-tauri/src/component_detection.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/singbox_service.rs`
|
||||||
|
- `apps/windows-client/scripts/install-singbox.ps1`
|
||||||
|
- `apps/windows-client/src-tauri/tests/component_detection_tests.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/singbox_service_tests.rs`
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- Detection finds installed `sing-box.exe`, service `VpnProxySingBox`, service running state, version/path, and problems.
|
||||||
|
- Setup status lists exactly what will be installed: sing-box binary, WinSW service wrapper, service name, install root, generated config/log paths.
|
||||||
|
- Install script performs a real idempotent install/repair boundary using WinSW and returns structured JSON; uninstall is safe-scoped to the configured install root.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `cd apps/windows-client/src-tauri && cargo test component_detection singbox_service`
|
||||||
|
- Windows parser check for `install-singbox.ps1` when running on Windows.
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Tests prove missing/installed/running component merge and PowerShell result parsing. Manual service install/start evidence is recorded later.
|
||||||
|
|
||||||
|
Parallel safe: yes, but integrate with Task 6 before UI.
|
||||||
|
|
||||||
|
### Task 5: Add Tauri Sing-Box Commands
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src-tauri/src/commands.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/main.rs`
|
||||||
|
- `apps/windows-client/src/api/tauriCommands.ts`
|
||||||
|
- `apps/windows-client/src-tauri/tests/command_tests.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/singbox_command_tests.rs`
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- Commands: `get_singbox_status`, `get_singbox_setup_status`, `save_singbox_subscription`, `fetch_singbox_subscription`, `forget_singbox_subscription`, `select_singbox_server`, `ping_singbox_server`, `ping_all_singbox_servers`, `generate_singbox_config`, `start_singbox_service`, `stop_singbox_service`, `install_singbox`, `uninstall_singbox`.
|
||||||
|
- Commands update activity with structured entries.
|
||||||
|
- `generate_singbox_config` ensures/updates the `local-singbox` target but does not change profile targets without user choice.
|
||||||
|
- `start_singbox_service` regenerates and checks config from current source state before starting the service.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `cd apps/windows-client/src-tauri && cargo test command singbox_command`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Tests prove fetch/cache/select/generate flow and ProxiFyre apply blocks only when a profile actually targets missing/stopped Local sing-box.
|
||||||
|
|
||||||
|
Parallel safe: no; depends on Tasks 1-4.
|
||||||
|
|
||||||
|
### Task 6: Build The Optional Local Sing-Box UI Block
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src/domain/types.ts`
|
||||||
|
- `apps/windows-client/src/api/tauriCommands.ts`
|
||||||
|
- `apps/windows-client/src/styles/app.css`
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- A second block appears beside/under ProxiFyre, visually matching `finder-card`, setup details, action menu, status light, and border animation behavior.
|
||||||
|
- UI shows Local sing-box as optional, not an error, when missing.
|
||||||
|
- User can paste subscription/link, fetch servers, select server, ping one/all, generate config, install/start/stop/uninstall Local sing-box, and choose whether the main profile uses existing proxy or Local sing-box.
|
||||||
|
- Loading/error/success states are visible and controlled by user action.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `cd apps/windows-client && npm run build`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Screenshot or app state shows ProxiFyre block and Local sing-box block, expanded setup details, fetched server list with ping state, and route target toggle.
|
||||||
|
|
||||||
|
Parallel safe: no; depends on Task 5 DTOs.
|
||||||
|
|
||||||
|
### Task 7: Integrate Route Choice And ProxiFyre Apply
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src-tauri/src/commands.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/proxifyre.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/command_tests.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/proxifyre_adapter_tests.rs`
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- Main profile can target either external proxy or `local-singbox`.
|
||||||
|
- Switching to Local sing-box writes `targetId=local-singbox`; switching back writes external target.
|
||||||
|
- ProxiFyre generated config points to `127.0.0.1:1080` when Local sing-box is selected and still points to the entered external proxy otherwise.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `cd apps/windows-client/src-tauri && cargo test proxifyre_adapter command`
|
||||||
|
- `cd apps/windows-client && npm run build`
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- Generated ProxiFyre config samples for both external proxy and Local sing-box routes are recorded.
|
||||||
|
|
||||||
|
Parallel safe: no.
|
||||||
|
|
||||||
|
### Task 8: Documentation, Evidence, And Windows Manual Gate
|
||||||
|
|
||||||
|
Allowed files:
|
||||||
|
- `apps/windows-client/README.md`
|
||||||
|
- `docs/goals/windows-local-singbox/EVIDENCE.md`
|
||||||
|
- `docs/goals/windows-modular-client/EVIDENCE.md`
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- README explains separate ProxiFyre and Local sing-box install flows, data paths, service name, and rollback.
|
||||||
|
- Evidence file records tests/builds/UI proof/generated configs/manual Windows service checks.
|
||||||
|
- If elevated service install cannot be run in the current environment, record `implemented but unproven` for that lane with exact remaining manual steps.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `git status --short`
|
||||||
|
- Evidence commands listed in `EVIDENCE.md`.
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- A target-perspective checklist proves the external proxy path still works and Local sing-box path works or is explicitly unproven only at the privileged Windows service lane.
|
||||||
|
|
||||||
|
Parallel safe: no.
|
||||||
|
|
||||||
|
## PRE Review Prompt
|
||||||
|
|
||||||
|
Use `C:\Users\PC\.agents\skills\krypton-planning\plan-reviewer-prompt.md` with:
|
||||||
|
|
||||||
|
- Plan file: `docs/goals/windows-local-singbox/PLAN.md`
|
||||||
|
- Original request: add an optional Windows-client Local sing-box block like ProxiFyre, with install/service management, connection link/subscription import, server selection, ping, animations, and user-controlled visibility of what will be installed.
|
||||||
|
- Unsafe paths or layers: root `src/server`, root `src/web`, hidden installer calls inside apply, raw PowerShell parsing in React, duplicate subscription state.
|
||||||
Reference in New Issue
Block a user