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\targets.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
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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(
|
||||
[string]$InstallRoot = "C:\Program Files\VpnProxy\sing-box",
|
||||
[string]$BinaryPath = "",
|
||||
[string]$ServiceName = "VpnProxySingBox",
|
||||
[string]$ConfigSource = "C:\ProgramData\VpnProxy\generated\sing-box-config.json",
|
||||
[switch]$PlanOnly,
|
||||
[switch]$Force
|
||||
[switch]$Force,
|
||||
[switch]$Uninstall
|
||||
)
|
||||
|
||||
$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 {
|
||||
param(
|
||||
[bool]$Success,
|
||||
@@ -23,7 +29,7 @@ function New-Result {
|
||||
changed = $Changed
|
||||
message = $Message
|
||||
details = $Details
|
||||
} | ConvertTo-Json -Depth 6
|
||||
} | ConvertTo-Json -Depth 8
|
||||
}
|
||||
|
||||
function Test-IsAdministrator {
|
||||
@@ -32,6 +38,48 @@ function Test-IsAdministrator {
|
||||
$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 {
|
||||
param([string]$Path)
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
@@ -42,16 +90,92 @@ function Backup-File {
|
||||
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 {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$installRootFull = [System.IO.Path]::GetFullPath($InstallRoot)
|
||||
$details = @{
|
||||
installRoot = $InstallRoot
|
||||
binaryPath = $BinaryPath
|
||||
installRoot = $installRootFull
|
||||
serviceName = $ServiceName
|
||||
configSource = $ConfigSource
|
||||
singboxReleaseApi = $SingBoxReleaseApi
|
||||
winswReleaseApi = $WinSwReleaseApi
|
||||
planOnly = [bool]$PlanOnly
|
||||
uninstall = [bool]$Uninstall
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -60,36 +184,86 @@ try {
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($BinaryPath) -or -not (Test-Path -LiteralPath $BinaryPath)) {
|
||||
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "BinaryPath is required and must point to sing-box.exe." -Details $details
|
||||
if ($Uninstall) {
|
||||
if (-not (Test-SafeInstallRoot -Path $installRootFull)) {
|
||||
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
|
||||
if (-not (Test-Path -LiteralPath $InstallRoot)) {
|
||||
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $installRootFull -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
|
||||
|
||||
$winswRelease = Invoke-RestMethod -Uri $WinSwReleaseApi -Headers @{ "User-Agent" = "vpn-proxy-windows-client" }
|
||||
$winswAsset = Select-Asset $winswRelease.assets "WinSW-$winswArch\.exe$" "WinSW"
|
||||
Invoke-Download $winswAsset.browser_download_url (Join-Path $installRootFull $WrapperFile)
|
||||
$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
|
||||
}
|
||||
|
||||
$configPath = Join-Path $InstallRoot "config.json"
|
||||
$backupPath = Backup-File -Path $configPath
|
||||
if ($backupPath) {
|
||||
$details.backupPath = $backupPath
|
||||
& ".\$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
|
||||
}
|
||||
}
|
||||
|
||||
$markerPath = Join-Path $InstallRoot "install-singbox.marker.json"
|
||||
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
|
||||
@{
|
||||
component = "singbox"
|
||||
binaryPath = $BinaryPath
|
||||
serviceName = $ServiceName
|
||||
installedAt = (Get-Date).ToString("o")
|
||||
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
|
||||
$changed = $true
|
||||
}
|
||||
|
||||
$details.markerPath = $markerPath
|
||||
New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box install boundary completed." -Details $details
|
||||
New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box service is installed and started." -Details $details
|
||||
} catch {
|
||||
New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message
|
||||
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"
|
||||
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]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -390,6 +407,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -814,6 +840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -994,8 +1021,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1017,8 +1046,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1268,6 +1300,22 @@ dependencies = [
|
||||
"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]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1668,6 +1716,12 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2267,6 +2321,62 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
@@ -2288,6 +2398,32 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -2363,6 +2499,46 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@@ -2421,6 +2597,20 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -2436,12 +2626,53 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2632,6 +2863,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
@@ -2702,7 +2945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -2830,6 +3073,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -2982,7 +3231,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -3342,6 +3591,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -3632,6 +3891,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -3697,11 +3962,14 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
name = "vpn-proxy-windows-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3836,6 +4104,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.5"
|
||||
@@ -3892,6 +4170,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -4122,6 +4409,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -4508,6 +4804,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
|
||||
@@ -17,4 +17,6 @@ tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "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::{
|
||||
ComponentId, ComponentState, ComponentStatus, ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::Path,
|
||||
@@ -12,25 +11,29 @@ use std::{
|
||||
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
|
||||
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
|
||||
pub const DEFAULT_MIXED_INBOUND_TAG: &str = "vpn-proxy-mixed-in";
|
||||
pub const DEFAULT_VPN_OUTBOUND_TAG: &str = "vpn";
|
||||
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)]
|
||||
pub struct SingBoxAdapter {
|
||||
log_level: String,
|
||||
inbound_tag: String,
|
||||
outbound_tag: String,
|
||||
vpn_outbound_tag: String,
|
||||
}
|
||||
|
||||
impl SingBoxAdapter {
|
||||
pub fn new(
|
||||
log_level: impl Into<String>,
|
||||
inbound_tag: impl Into<String>,
|
||||
outbound_tag: impl Into<String>,
|
||||
vpn_outbound_tag: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
log_level: log_level.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
|
||||
C: SingBoxConfigChecker,
|
||||
{
|
||||
let target = find_local_singbox_target(request.targets)?;
|
||||
ensure_local_singbox_target(target, request.components)?;
|
||||
|
||||
let config = SingBoxConfig {
|
||||
log: SingBoxLog {
|
||||
disabled: false,
|
||||
level: self.log_level.clone(),
|
||||
timestamp: true,
|
||||
let selected_server_tag = request
|
||||
.config
|
||||
.selected_server_tag
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
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 {
|
||||
inbound_type: "mixed".to_string(),
|
||||
tag: self.inbound_tag.clone(),
|
||||
listen: target.host.clone(),
|
||||
listen_port: target.port,
|
||||
users: Vec::new(),
|
||||
set_system_proxy: false,
|
||||
}],
|
||||
outbounds: vec![SingBoxOutbound {
|
||||
outbound_type: "direct".to_string(),
|
||||
tag: self.outbound_tag.clone(),
|
||||
}],
|
||||
route: SingBoxRoute {
|
||||
final_outbound: self.outbound_tag.clone(),
|
||||
},
|
||||
};
|
||||
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "mixed",
|
||||
"tag": self.inbound_tag,
|
||||
"listen": request.config.listen_host,
|
||||
"listen_port": request.config.listen_port,
|
||||
"users": [],
|
||||
"set_system_proxy": false
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
vpn_outbound,
|
||||
{ "type": "direct", "tag": DEFAULT_DIRECT_OUTBOUND_TAG },
|
||||
{ "type": "block", "tag": DEFAULT_BLOCK_OUTBOUND_TAG }
|
||||
],
|
||||
"route": {
|
||||
"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(
|
||||
SingBoxConfigErrorKind::Serialization,
|
||||
format!("Не удалось сериализовать конфиг sing-box: {error}"),
|
||||
@@ -82,9 +105,9 @@ impl SingBoxAdapter {
|
||||
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
|
||||
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
|
||||
contents,
|
||||
local_target_id: target.id.clone(),
|
||||
listen: target.host.clone(),
|
||||
listen_port: target.port,
|
||||
selected_server_tag: selected_server_tag.to_string(),
|
||||
listen: request.config.listen_host.clone(),
|
||||
listen_port: request.config.listen_port,
|
||||
check,
|
||||
})
|
||||
}
|
||||
@@ -92,30 +115,26 @@ impl SingBoxAdapter {
|
||||
|
||||
impl Default for SingBoxAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new(
|
||||
"info",
|
||||
DEFAULT_MIXED_INBOUND_TAG,
|
||||
DEFAULT_DIRECT_OUTBOUND_TAG,
|
||||
)
|
||||
Self::new("info", DEFAULT_MIXED_INBOUND_TAG, DEFAULT_VPN_OUTBOUND_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SingBoxGenerationRequest<'a> {
|
||||
pub targets: &'a [Target],
|
||||
pub components: &'a [ComponentStatus],
|
||||
pub config: &'a LocalSingBoxConfig,
|
||||
pub subscription_cache: &'a SubscriptionCache,
|
||||
pub binary_path: Option<&'a Path>,
|
||||
}
|
||||
|
||||
impl<'a> SingBoxGenerationRequest<'a> {
|
||||
pub fn new(
|
||||
targets: &'a [Target],
|
||||
components: &'a [ComponentStatus],
|
||||
config: &'a LocalSingBoxConfig,
|
||||
subscription_cache: &'a SubscriptionCache,
|
||||
binary_path: Option<&'a Path>,
|
||||
) -> Self {
|
||||
Self {
|
||||
targets,
|
||||
components,
|
||||
config,
|
||||
subscription_cache,
|
||||
binary_path,
|
||||
}
|
||||
}
|
||||
@@ -126,7 +145,7 @@ pub struct SingBoxGeneratedConfig {
|
||||
pub adapter_id: String,
|
||||
pub output_file_name: String,
|
||||
pub contents: String,
|
||||
pub local_target_id: String,
|
||||
pub selected_server_tag: String,
|
||||
pub listen: String,
|
||||
pub listen_port: u16,
|
||||
pub check: Option<SingBoxCheckResult>,
|
||||
@@ -156,10 +175,9 @@ impl SingBoxConfigError {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SingBoxConfigErrorKind {
|
||||
MissingLocalTarget,
|
||||
MissingRequiredComponent,
|
||||
RequiredComponentNotRunning,
|
||||
UnsupportedTarget,
|
||||
MissingSelectedServer,
|
||||
MissingSelectedOutbound,
|
||||
UnsupportedSelectedOutbound,
|
||||
Serialization,
|
||||
CheckFailed,
|
||||
}
|
||||
@@ -237,114 +255,67 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SingBoxConfig {
|
||||
pub log: SingBoxLog,
|
||||
pub inbounds: Vec<SingBoxInbound>,
|
||||
pub outbounds: Vec<SingBoxOutbound>,
|
||||
pub route: SingBoxRoute,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SingBoxLog {
|
||||
pub disabled: bool,
|
||||
pub level: String,
|
||||
pub timestamp: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SingBoxInbound {
|
||||
#[serde(rename = "type")]
|
||||
pub inbound_type: String,
|
||||
pub tag: String,
|
||||
pub listen: String,
|
||||
#[serde(rename = "listen_port")]
|
||||
pub listen_port: u16,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub users: Vec<SingBoxUser>,
|
||||
#[serde(rename = "set_system_proxy")]
|
||||
pub set_system_proxy: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SingBoxUser {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SingBoxOutbound {
|
||||
#[serde(rename = "type")]
|
||||
pub outbound_type: String,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SingBoxRoute {
|
||||
#[serde(rename = "final")]
|
||||
pub final_outbound: String,
|
||||
}
|
||||
|
||||
fn find_local_singbox_target(targets: &[Target]) -> Result<&Target, SingBoxConfigError> {
|
||||
targets
|
||||
fn selected_outbound(
|
||||
subscription_config: &Value,
|
||||
selected_server_tag: &str,
|
||||
vpn_outbound_tag: &str,
|
||||
) -> Result<Value, SingBoxConfigError> {
|
||||
let outbounds = subscription_config
|
||||
.get("outbounds")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||
"В cache подписки нет outbounds",
|
||||
)
|
||||
})?;
|
||||
let outbound = outbounds
|
||||
.iter()
|
||||
.find(|target| {
|
||||
target.kind == TargetKind::Local
|
||||
&& target.requires_component.as_ref() == Some(&ComponentId::Singbox)
|
||||
.find(|outbound| {
|
||||
outbound
|
||||
.get("tag")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|tag| tag.trim() == selected_server_tag)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingLocalTarget,
|
||||
"Локальная цель, требующая sing-box, не настроена",
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||
format!("Outbound не найден: {selected_server_tag}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
})?;
|
||||
let outbound_type = outbound
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
fn ensure_local_singbox_target(
|
||||
target: &Target,
|
||||
components: &[ComponentStatus],
|
||||
) -> Result<(), SingBoxConfigError> {
|
||||
if target.kind != TargetKind::Local
|
||||
|| target.protocol != ProxyProtocol::Socks5
|
||||
|| target.requires_component.as_ref() != Some(&ComponentId::Singbox)
|
||||
{
|
||||
if !SUPPORTED_PROXY_TYPES.contains(&outbound_type) {
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedTarget,
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!(
|
||||
"Цель '{}' должна быть локальной SOCKS5-целью, требующей sing-box",
|
||||
target.id
|
||||
"Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let Some(status) = components
|
||||
.iter()
|
||||
.find(|component| component.id == ComponentId::Singbox)
|
||||
else {
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingRequiredComponent,
|
||||
format!(
|
||||
"Локальная цель '{}' требует состояние компонента sing-box",
|
||||
target.id
|
||||
),
|
||||
));
|
||||
};
|
||||
|
||||
if !component_is_running(status) {
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::RequiredComponentNotRunning,
|
||||
format!(
|
||||
"Локальная цель '{}' требует установленный и запущенный sing-box",
|
||||
target.id
|
||||
),
|
||||
));
|
||||
let mut outbound = outbound.clone();
|
||||
let object = outbound.as_object_mut().ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"),
|
||||
)
|
||||
})?;
|
||||
object.insert(
|
||||
"tag".to_string(),
|
||||
Value::String(vpn_outbound_tag.to_string()),
|
||||
);
|
||||
if outbound_type == "vless" && !object.contains_key("packet_encoding") {
|
||||
object.insert(
|
||||
"packet_encoding".to_string(),
|
||||
Value::String("xudp".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn component_is_running(status: &ComponentStatus) -> bool {
|
||||
status.installed && status.running && status.state == ComponentState::Running
|
||||
Ok(outbound)
|
||||
}
|
||||
|
||||
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 std::{
|
||||
env,
|
||||
@@ -22,6 +25,17 @@ pub struct DetectedProxyfier {
|
||||
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)]
|
||||
pub struct RegistryInstallEntry {
|
||||
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 {
|
||||
let state = if proxyfier.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)]
|
||||
struct ProxyfierCandidate {
|
||||
engine: ProxyfierEngine,
|
||||
@@ -272,6 +361,68 @@ fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) ->
|
||||
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 {
|
||||
match engine {
|
||||
ProxyfierEngine::ProxiFyre => "ProxiFyre.exe",
|
||||
|
||||
@@ -4,12 +4,15 @@ mod activity;
|
||||
mod commands;
|
||||
mod component_detection;
|
||||
mod models;
|
||||
mod singbox_service;
|
||||
mod storage;
|
||||
mod subscription;
|
||||
mod validation;
|
||||
|
||||
mod adapters {
|
||||
pub mod proxifyre;
|
||||
pub mod proxy_router;
|
||||
pub mod singbox;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -22,6 +25,11 @@ pub(crate) mod proxy_router {
|
||||
pub use crate::adapters::proxy_router::*;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod singbox {
|
||||
pub use crate::adapters::singbox::*;
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -35,14 +43,27 @@ fn main() {
|
||||
commands::save_target,
|
||||
commands::get_components,
|
||||
commands::get_proxifyre_setup_status,
|
||||
commands::get_singbox_status,
|
||||
commands::get_singbox_setup_status,
|
||||
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::get_logs,
|
||||
commands::open_config_location,
|
||||
commands::start_proxifyre_service,
|
||||
commands::stop_proxifyre_service,
|
||||
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!())
|
||||
.expect("не удалось запустить клиент VPN Proxy для Windows");
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum Protocol {
|
||||
@@ -128,6 +133,65 @@ pub struct ComponentStatus {
|
||||
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)]
|
||||
pub struct ActivityEntry {
|
||||
pub id: String,
|
||||
@@ -165,3 +229,45 @@ fn default_target_kind() -> String {
|
||||
fn default_proxy_protocol() -> 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::models::{ActivityEntry, ComponentStatus, Profile, Target};
|
||||
use crate::models::{
|
||||
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target,
|
||||
};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{self, ErrorKind};
|
||||
@@ -18,6 +20,8 @@ pub struct StoragePaths {
|
||||
pub profiles_file: PathBuf,
|
||||
pub targets_file: PathBuf,
|
||||
pub components_file: PathBuf,
|
||||
pub local_singbox_file: PathBuf,
|
||||
pub singbox_subscription_cache_file: PathBuf,
|
||||
pub activity_file: PathBuf,
|
||||
}
|
||||
|
||||
@@ -33,6 +37,8 @@ impl StoragePaths {
|
||||
profiles_file: config_dir.join("profiles.json"),
|
||||
targets_file: config_dir.join("targets.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"),
|
||||
config_dir,
|
||||
state_dir,
|
||||
@@ -100,6 +106,30 @@ impl JsonStorage {
|
||||
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>> {
|
||||
let entries = self.read_json_or_default(&self.paths.activity_file)?;
|
||||
Ok(cap_activity(entries, self.activity_limit))
|
||||
@@ -139,6 +169,17 @@ impl JsonStorage {
|
||||
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
|
||||
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 {
|
||||
|
||||
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;
|
||||
#[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;
|
||||
|
||||
@@ -155,6 +161,25 @@ fn proxifyre_install_script_parses_as_powershell() {
|
||||
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]
|
||||
fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
|
||||
let root = test_root("apply");
|
||||
@@ -241,6 +266,7 @@ fn component_status_merges_detected_existing_proxifyre() {
|
||||
running: true,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let proxyfier = components
|
||||
.iter()
|
||||
|
||||
@@ -4,7 +4,8 @@ mod component_detection;
|
||||
mod models;
|
||||
|
||||
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,
|
||||
};
|
||||
use models::ComponentState;
|
||||
@@ -74,6 +75,66 @@ fn missing_proxyfier_returns_install_action_status() {
|
||||
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)]
|
||||
struct MockHost {
|
||||
env: HashMap<String, String>,
|
||||
|
||||
@@ -8,14 +8,16 @@ mod proxy_router;
|
||||
mod singbox;
|
||||
|
||||
use models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target, TargetKind,
|
||||
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
|
||||
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
|
||||
TargetKind,
|
||||
};
|
||||
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
||||
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||
use singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfig, SingBoxConfigChecker, SingBoxConfigError,
|
||||
SingBoxConfigErrorKind, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
|
||||
SINGBOX_OUTPUT_FILE,
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
@@ -23,25 +25,25 @@ use std::{
|
||||
};
|
||||
|
||||
#[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 targets = vec![local_singbox_target()];
|
||||
let components = vec![running_singbox_component()];
|
||||
let config = local_singbox_config("nl-1");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("configuration OK");
|
||||
let binary_path = Path::new(r"C:\Tools\VpnProxy\sing-box\sing-box.exe");
|
||||
|
||||
let generated = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&targets, &components, Some(binary_path)),
|
||||
SingBoxGenerationRequest::new(&config, &cache, Some(binary_path)),
|
||||
&checker,
|
||||
)
|
||||
.expect("running local sing-box should generate config");
|
||||
let config: SingBoxConfig =
|
||||
.expect("selected outbound should generate config");
|
||||
let generated_config: serde_json::Value =
|
||||
serde_json::from_str(&generated.contents).expect("generated sing-box json");
|
||||
|
||||
assert_eq!(generated.adapter_id, "singbox");
|
||||
assert_eq!(generated.output_file_name, SINGBOX_OUTPUT_FILE);
|
||||
assert_eq!(generated.local_target_id, "local-singbox");
|
||||
assert_eq!(generated.selected_server_tag, "nl-1");
|
||||
assert_eq!(generated.listen, "127.0.0.1");
|
||||
assert_eq!(generated.listen_port, 1080);
|
||||
assert_eq!(
|
||||
@@ -52,30 +54,39 @@ fn generates_local_singbox_config_and_runs_check_when_binary_path_is_supplied()
|
||||
message: "configuration OK".to_string(),
|
||||
})
|
||||
);
|
||||
assert_eq!(config.log.level, "info");
|
||||
assert_eq!(config.inbounds.len(), 1);
|
||||
assert_eq!(config.inbounds[0].inbound_type, "mixed");
|
||||
assert_eq!(config.inbounds[0].listen, "127.0.0.1");
|
||||
assert_eq!(config.inbounds[0].listen_port, 1080);
|
||||
assert!(!config.inbounds[0].set_system_proxy);
|
||||
assert_eq!(config.outbounds[0].outbound_type, "direct");
|
||||
assert_eq!(config.route.final_outbound, "direct");
|
||||
assert_eq!(generated_config["log"]["level"], "info");
|
||||
assert_eq!(generated_config["inbounds"][0]["type"], "mixed");
|
||||
assert_eq!(generated_config["inbounds"][0]["listen"], "127.0.0.1");
|
||||
assert_eq!(generated_config["inbounds"][0]["listen_port"], 1080);
|
||||
assert_eq!(generated_config["inbounds"][0]["set_system_proxy"], false);
|
||||
assert_eq!(generated_config["outbounds"][0]["type"], "vless");
|
||||
assert_eq!(
|
||||
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();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0.as_path(), binary_path);
|
||||
assert!(calls[0].1.contains(r#""type": "mixed""#));
|
||||
assert!(calls[0].1.contains(r#""tag": "vpn""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_singbox_check_when_binary_path_is_not_supplied() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let targets = vec![local_singbox_target()];
|
||||
let components = vec![running_singbox_component()];
|
||||
let config = local_singbox_config("nl-1");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
let generated = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&targets, &components, None),
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&checker,
|
||||
)
|
||||
.expect("binary path is optional");
|
||||
@@ -85,54 +96,53 @@ fn skips_singbox_check_when_binary_path_is_not_supplied() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_local_singbox_config_when_required_component_is_missing() {
|
||||
fn blocks_config_when_server_is_not_selected() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let targets = vec![local_singbox_target()];
|
||||
let components = Vec::new();
|
||||
let mut config = local_singbox_config("nl-1");
|
||||
config.selected_server_tag = None;
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
let error = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&targets, &components, None),
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_local_singbox_config_when_component_is_not_running() {
|
||||
fn blocks_config_when_selected_outbound_is_missing() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let targets = vec![local_singbox_target()];
|
||||
let components = vec![stopped_singbox_component()];
|
||||
let config = local_singbox_config("missing-server");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
let error = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&targets, &components, None),
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&checker,
|
||||
)
|
||||
.expect_err("local sing-box target requires running component");
|
||||
.expect_err("missing outbound should block config");
|
||||
|
||||
assert_eq!(
|
||||
error.kind,
|
||||
SingBoxConfigErrorKind::RequiredComponentNotRunning
|
||||
);
|
||||
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound);
|
||||
assert!(error.message.contains("missing-server"));
|
||||
assert!(checker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagates_failed_singbox_check_as_structured_error() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let targets = vec![local_singbox_target()];
|
||||
let components = vec![running_singbox_component()];
|
||||
let config = local_singbox_config("nl-1");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::err("invalid config");
|
||||
|
||||
let error = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&targets, &components, Some(Path::new("sing-box.exe"))),
|
||||
SingBoxGenerationRequest::new(&config, &cache, Some(Path::new("sing-box.exe"))),
|
||||
&checker,
|
||||
)
|
||||
.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 {
|
||||
Profile {
|
||||
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 {
|
||||
ComponentStatus {
|
||||
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;
|
||||
|
||||
use models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, Profile,
|
||||
ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
|
||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||||
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
|
||||
SubscriptionServer, Target, TargetKind,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -54,6 +55,86 @@ fn roundtrips_profiles_targets_components_and_activity() {
|
||||
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]
|
||||
fn invalid_json_falls_back_to_empty_collection() {
|
||||
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 {
|
||||
ActivityEntry {
|
||||
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 {
|
||||
ActivityEntry,
|
||||
ComponentStatus,
|
||||
LocalSingBoxConfig,
|
||||
Profile,
|
||||
ProfileInput,
|
||||
SubscriptionCache,
|
||||
SubscriptionServer,
|
||||
Target,
|
||||
TargetInput,
|
||||
} from '../domain/types';
|
||||
@@ -47,6 +50,47 @@ export interface ProxiFyreSetupStatus {
|
||||
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 {
|
||||
success: boolean;
|
||||
changed: boolean;
|
||||
@@ -98,6 +142,52 @@ export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
|
||||
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> {
|
||||
return invoke<ApplyProfilesResponse>('apply_profiles');
|
||||
}
|
||||
@@ -121,3 +211,19 @@ export function installProxiFyre(): Promise<ComponentStatus> {
|
||||
export function uninstallProxiFyre(): Promise<ComponentStatus> {
|
||||
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 { 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 {
|
||||
applyProfiles,
|
||||
fetchSingBoxSubscription,
|
||||
forgetSingBoxSubscription,
|
||||
generateSingBoxConfig,
|
||||
getComponents,
|
||||
getProxiFyreSetupStatus,
|
||||
getSavedState,
|
||||
getSingBoxSetupStatus,
|
||||
getSingBoxStatus,
|
||||
installProxiFyre,
|
||||
installSingBox,
|
||||
openConfigLocation,
|
||||
pingAllSingBoxServers,
|
||||
saveProfile,
|
||||
saveSingBoxSubscription,
|
||||
saveTarget,
|
||||
selectSingBoxServer,
|
||||
startProxiFyreService,
|
||||
startSingBoxService,
|
||||
stopProxiFyreService,
|
||||
stopSingBoxService,
|
||||
uninstallProxiFyre,
|
||||
uninstallSingBox,
|
||||
type ApplyProfilesResponse,
|
||||
type LocalSingBoxStatusResponse,
|
||||
type PingServerResponse,
|
||||
type ProxiFyreSetupStatus,
|
||||
type SingBoxSetupStatus,
|
||||
} 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 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;
|
||||
|
||||
interface DraftItem {
|
||||
@@ -41,6 +58,7 @@ interface LogEntry extends Notice {
|
||||
|
||||
const MAIN_TARGET_ID = 'main-proxy';
|
||||
const MAIN_PROFILE_ID = 'main-profile';
|
||||
const LOCAL_SINGBOX_TARGET_ID = 'local-singbox';
|
||||
const LOG_VISIBLE_MS = 6500;
|
||||
|
||||
const fallbackComponents: ComponentStatus[] = [
|
||||
@@ -53,20 +71,37 @@ const fallbackComponents: ComponentStatus[] = [
|
||||
problems: ['ProxiFyre не найден'],
|
||||
actions: [],
|
||||
},
|
||||
{
|
||||
id: 'singbox',
|
||||
name: 'Локальный sing-box',
|
||||
state: 'missing',
|
||||
installed: false,
|
||||
running: false,
|
||||
problems: [],
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
|
||||
export function App() {
|
||||
const [proxyInput, setProxyInput] = useState('');
|
||||
const [routeMode, setRouteMode] = useState<RouteMode>('external');
|
||||
const [profileId, setProfileId] = useState(MAIN_PROFILE_ID);
|
||||
const [targetId, setTargetId] = useState(MAIN_TARGET_ID);
|
||||
const [items, setItems] = useState<DraftItem[]>([]);
|
||||
const [hasUnappliedChanges, setHasUnappliedChanges] = useState(false);
|
||||
const [loadedProfiles, setLoadedProfiles] = useState<Profile[]>([]);
|
||||
const [isProcessInputOpen, setIsProcessInputOpen] = useState(false);
|
||||
const [processInput, setProcessInput] = useState('');
|
||||
const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null);
|
||||
const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
|
||||
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 [isSingBoxSetupOpen, setIsSingBoxSetupOpen] = useState(false);
|
||||
const [isSingBoxInfoOpen, setIsSingBoxInfoOpen] = useState(false);
|
||||
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
|
||||
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||
const [activeLogId, setActiveLogId] = useState<string | null>(null);
|
||||
@@ -76,7 +111,9 @@ export function App() {
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [isOpeningConfig, setIsOpeningConfig] = useState(false);
|
||||
const [serviceAction, setServiceAction] = useState<ProxiFyreAction | null>(null);
|
||||
const [singBoxAction, setSingBoxAction] = useState<SingBoxAction | null>(null);
|
||||
const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false);
|
||||
const [isSingBoxMenuOpen, setIsSingBoxMenuOpen] = useState(false);
|
||||
const [serviceVisualState, setServiceVisualState] = useState<ServiceVisualState>(null);
|
||||
const serviceVisualTimerRef = useRef<number | null>(null);
|
||||
|
||||
@@ -84,6 +121,10 @@ export function App() {
|
||||
() => components.find((component) => component.id === 'proxyfier'),
|
||||
[components],
|
||||
);
|
||||
const singbox = useMemo(
|
||||
() => singBoxStatus?.component ?? components.find((component) => component.id === 'singbox'),
|
||||
[components, singBoxStatus],
|
||||
);
|
||||
const activeLog = useMemo(
|
||||
() => logEntries.find((entry) => entry.id === activeLogId) ?? null,
|
||||
[activeLogId, logEntries],
|
||||
@@ -91,6 +132,9 @@ export function App() {
|
||||
const finderStateClass = isDetectingComponents ? 'checking' : proxyfier?.installed ? 'found' : 'missing';
|
||||
const finderVisualClass =
|
||||
serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : '';
|
||||
const isSingBoxInstalled = Boolean(singbox?.installed);
|
||||
const singBoxStateClass = isDetectingComponents ? 'checking' : singbox?.installed ? 'found' : 'missing';
|
||||
const singBoxVisualClass = singBoxAction ? 'working' : '';
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
@@ -135,16 +179,20 @@ export function App() {
|
||||
async function refreshComponents() {
|
||||
setIsDetectingComponents(true);
|
||||
try {
|
||||
const [detectedComponents, detectedSetupStatus] = await Promise.all([
|
||||
const [detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([
|
||||
getComponents(),
|
||||
getProxiFyreSetupStatus(),
|
||||
getSingBoxStatus(),
|
||||
getSingBoxSetupStatus(),
|
||||
]);
|
||||
setComponents(detectedComponents);
|
||||
setSetupStatus(detectedSetupStatus);
|
||||
setSingBoxStatus(detectedSingBoxStatus);
|
||||
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
|
||||
} catch (error) {
|
||||
showNotice({
|
||||
kind: 'error',
|
||||
title: 'ProxiFyre не проверен',
|
||||
title: 'Компоненты не проверены',
|
||||
text: errorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
@@ -157,14 +205,21 @@ export function App() {
|
||||
const mainProfile = profiles.find((profile) => profile.id === MAIN_PROFILE_ID);
|
||||
const activeProfile = mainProfile ?? activeProfiles[0];
|
||||
const activeTarget = targetForUi(targets, activeProfile);
|
||||
const externalTarget = targetForExternalProxy(targets);
|
||||
const editableProfiles = mainProfile ? [mainProfile] : activeProfiles;
|
||||
|
||||
if (activeTarget) setProxyInput(formatProxy(activeTarget));
|
||||
if (externalTarget) setProxyInput(formatProxy(externalTarget));
|
||||
setItems(itemsForProfiles(editableProfiles));
|
||||
setLoadedProfiles(profiles);
|
||||
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);
|
||||
setHasUnappliedChanges(false);
|
||||
}
|
||||
|
||||
function addItem(type: DraftItemType, rawValue: string) {
|
||||
@@ -195,6 +250,7 @@ export function App() {
|
||||
value,
|
||||
},
|
||||
]);
|
||||
setHasUnappliedChanges(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -207,6 +263,19 @@ export function App() {
|
||||
|
||||
function removeItem(id: string) {
|
||||
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'>) {
|
||||
@@ -228,10 +297,16 @@ export function App() {
|
||||
}
|
||||
|
||||
async function updateConfig() {
|
||||
let parsedProxy: ParsedProxy;
|
||||
let parsedProxy: ParsedProxy | null = null;
|
||||
try {
|
||||
parsedProxy = parseProxy(proxyInput);
|
||||
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) {
|
||||
showNotice({
|
||||
kind: 'error',
|
||||
@@ -243,6 +318,9 @@ export function App() {
|
||||
|
||||
setIsApplying(true);
|
||||
try {
|
||||
let singBoxGeneratedPath = '';
|
||||
if (routeMode === 'external') {
|
||||
if (!parsedProxy) throw new Error('Прокси не разобран.');
|
||||
await saveTarget({
|
||||
id: targetId,
|
||||
name: 'Основной прокси',
|
||||
@@ -251,11 +329,17 @@ export function App() {
|
||||
host: parsedProxy.host,
|
||||
port: parsedProxy.port,
|
||||
});
|
||||
} else {
|
||||
const singBoxResult = await generateSingBoxConfig();
|
||||
singBoxGeneratedPath = singBoxResult.generatedConfigPath;
|
||||
await ensureSingBoxRunningForApply();
|
||||
}
|
||||
|
||||
await saveProfile({
|
||||
id: profileId,
|
||||
name: 'Приложения через прокси',
|
||||
enabled: true,
|
||||
targetId,
|
||||
targetId: routeMode === 'local-singbox' ? LOCAL_SINGBOX_TARGET_ID : targetId,
|
||||
protocols: ['TCP', 'UDP'],
|
||||
items: items.map(profileItemInput),
|
||||
});
|
||||
@@ -266,16 +350,21 @@ export function App() {
|
||||
);
|
||||
|
||||
const result = await applyProfiles();
|
||||
const [saved, detectedComponents, detectedSetupStatus] = await Promise.all([
|
||||
const [saved, detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([
|
||||
getSavedState(),
|
||||
getComponents(),
|
||||
getProxiFyreSetupStatus(),
|
||||
getSingBoxStatus(),
|
||||
getSingBoxSetupStatus(),
|
||||
]);
|
||||
|
||||
applySavedState(saved.profiles, saved.targets, result.generatedConfigPath);
|
||||
setComponents(detectedComponents);
|
||||
setSetupStatus(detectedSetupStatus);
|
||||
showNotice(noticeFromApply(result));
|
||||
setSingBoxStatus(detectedSingBoxStatus);
|
||||
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
|
||||
setHasUnappliedChanges(false);
|
||||
showNotice(routeMode === 'local-singbox' ? noticeFromLocalApply(result, singBoxGeneratedPath) : noticeFromApply(result));
|
||||
} catch (error) {
|
||||
showNotice({
|
||||
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() {
|
||||
setIsOpeningConfig(true);
|
||||
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() {
|
||||
if (serviceVisualTimerRef.current !== null) {
|
||||
window.clearTimeout(serviceVisualTimerRef.current);
|
||||
@@ -541,15 +864,245 @@ export function App() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label className="simple-field">
|
||||
<span>Прокси</span>
|
||||
<section className="route-panel" aria-label="Маршрут приложений">
|
||||
<div className="route-switch">
|
||||
<button
|
||||
type="button"
|
||||
className={routeMode === 'external' ? 'active' : ''}
|
||||
onClick={() => changeRouteMode('external')}
|
||||
aria-pressed={routeMode === 'external'}
|
||||
>
|
||||
Внешний прокси
|
||||
</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) => setProxyInput(event.target.value)}
|
||||
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">
|
||||
<div className="section-head">
|
||||
@@ -653,9 +1206,24 @@ export function App() {
|
||||
</div>
|
||||
</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">
|
||||
<button type="button" className="apply-button" onClick={updateConfig} disabled={isApplying}>
|
||||
{isApplying ? 'Обновляю...' : 'Обновить конфиг'}
|
||||
{isApplying
|
||||
? singBoxAction === 'start'
|
||||
? 'Запускаю sing-box...'
|
||||
: singBoxAction === 'stop'
|
||||
? 'Перезапускаю sing-box...'
|
||||
: 'Применяю...'
|
||||
: hasUnappliedChanges
|
||||
? 'Применить в ProxiFyre'
|
||||
: 'Обновить конфиг'}
|
||||
</button>
|
||||
<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');
|
||||
}
|
||||
|
||||
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[] {
|
||||
const seen = new Set<string>();
|
||||
const items: DraftItem[] = [];
|
||||
@@ -873,6 +1446,40 @@ function proxyfierDetails(component: ComponentStatus | undefined, checking: bool
|
||||
return component.problems[0] ?? 'Путь установки не найден.';
|
||||
}
|
||||
|
||||
function singBoxTitle(component: ComponentStatus | undefined, checking: boolean) {
|
||||
if (checking) return 'Проверяю Local sing-box';
|
||||
if (!component) return 'Local sing-box не проверен';
|
||||
if (component.running) return 'Local sing-box найден и запущен';
|
||||
if (component.installed) return 'Local sing-box найден';
|
||||
return 'Local sing-box не установлен';
|
||||
}
|
||||
|
||||
function singBoxDetails(
|
||||
component: ComponentStatus | undefined,
|
||||
status: LocalSingBoxStatusResponse | null,
|
||||
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 {
|
||||
return {
|
||||
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) {
|
||||
const index = components.findIndex((current) => current.id === component.id);
|
||||
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) {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === 'string') return error;
|
||||
|
||||
@@ -68,6 +68,30 @@ export interface ComponentStatus {
|
||||
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 {
|
||||
id: string;
|
||||
at: string;
|
||||
|
||||
@@ -18,6 +18,7 @@ body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #101216;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -54,11 +55,13 @@ button:disabled {
|
||||
}
|
||||
|
||||
.simple-header,
|
||||
.finder-card,
|
||||
.section-head,
|
||||
.add-toolbar,
|
||||
.process-add-line,
|
||||
.app-row {
|
||||
.app-row,
|
||||
.route-switch,
|
||||
.singbox-info-actions,
|
||||
.server-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -103,7 +106,11 @@ button:disabled {
|
||||
.app-row button,
|
||||
.open-config-button,
|
||||
.service-button,
|
||||
.service-menu-button {
|
||||
.service-menu-button,
|
||||
.subscription-line button,
|
||||
.route-switch button,
|
||||
.singbox-info-actions button,
|
||||
.server-row {
|
||||
min-height: 36px;
|
||||
border: 1px solid #343b49;
|
||||
border-radius: 4px;
|
||||
@@ -119,7 +126,11 @@ button:disabled {
|
||||
.app-row button:hover,
|
||||
.open-config-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;
|
||||
}
|
||||
|
||||
@@ -127,7 +138,9 @@ button:disabled {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
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;
|
||||
min-height: 56px;
|
||||
overflow: visible;
|
||||
@@ -137,13 +150,19 @@ button:disabled {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.finder-border-glow {
|
||||
.singbox-card {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.finder-card .finder-border-glow {
|
||||
position: absolute;
|
||||
display: none;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
contain: layout paint;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
@@ -155,6 +174,7 @@ button:disabled {
|
||||
|
||||
.finder-card.checking .finder-border-glow,
|
||||
.finder-card.working .finder-border-glow {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -163,6 +183,7 @@ button:disabled {
|
||||
}
|
||||
|
||||
.finder-card.settling .finder-border-glow {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transition-duration: 0.7s;
|
||||
}
|
||||
@@ -217,14 +238,27 @@ button:disabled {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.finder-card strong,
|
||||
.finder-card span,
|
||||
.finder-text > strong,
|
||||
.finder-text > span,
|
||||
.app-row-main strong,
|
||||
.app-row-main > div > span {
|
||||
display: block;
|
||||
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 {
|
||||
flex: 0 0 auto;
|
||||
width: 11px;
|
||||
@@ -398,14 +432,29 @@ button:disabled {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.route-proxy-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.simple-field input,
|
||||
.process-add-line input {
|
||||
.process-add-line input,
|
||||
.subscription-line input {
|
||||
min-height: 42px;
|
||||
width: 100%;
|
||||
border: 1px solid #343b49;
|
||||
@@ -417,11 +466,227 @@ button:disabled {
|
||||
}
|
||||
|
||||
.simple-field input:focus,
|
||||
.process-add-line input:focus {
|
||||
.process-add-line input:focus,
|
||||
.subscription-line input:focus {
|
||||
border-color: #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 {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -863,6 +1128,10 @@ button:disabled {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.simple-header {
|
||||
margin: -14px -14px 16px;
|
||||
}
|
||||
|
||||
.add-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -876,6 +1145,23 @@ button:disabled {
|
||||
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 {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
@@ -892,6 +1178,18 @@ button:disabled {
|
||||
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 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -908,6 +1206,11 @@ button:disabled {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.log-current strong,
|
||||
.log-current span {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.log-history-row {
|
||||
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