Compare commits
5 Commits
7316e932f0
...
1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d859dc0a6 | |||
| a2581187da | |||
| 2e80f7b8eb | |||
| f6b722b22a | |||
| 42b85cc8fa |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "proxywarden",
|
"name": "proxywarden",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "proxywarden",
|
"name": "proxywarden",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxywarden",
|
"name": "proxywarden",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Standalone Windows desktop proxy management app for ProxyWarden.",
|
"description": "Standalone Windows desktop proxy management app for ProxyWarden.",
|
||||||
|
|||||||
@@ -386,6 +386,25 @@ function Invoke-NativeCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Clear-ReleaseBundleOutput {
|
||||||
|
if (-not (Test-Path -LiteralPath $BundleRoot)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetRoot = Get-FullPath -Path (Join-Path $RepoRoot "src-tauri\target")
|
||||||
|
$bundleFull = Get-FullPath -Path $BundleRoot
|
||||||
|
if (
|
||||||
|
$bundleFull.Equals($targetRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||||
|
-not (Test-IsSubPath -Parent $targetRoot -Child $bundleFull)
|
||||||
|
) {
|
||||||
|
throw "Refusing to remove bundle directory outside src-tauri target: $bundleFull"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Cleaning stale Tauri bundle output: $bundleFull"
|
||||||
|
Remove-Item -LiteralPath $bundleFull -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-ReleaseBuild {
|
function Invoke-ReleaseBuild {
|
||||||
if ($SkipBuild) {
|
if ($SkipBuild) {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
@@ -402,22 +421,50 @@ function Invoke-ReleaseBuild {
|
|||||||
Write-Host "Skipping Rust tests because -SkipTests was provided."
|
Write-Host "Skipping Rust tests because -SkipTests was provided."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Clear-ReleaseBundleOutput
|
||||||
Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build")
|
Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-ArtifactVersionPattern {
|
||||||
|
param([string]$TargetVersion)
|
||||||
|
|
||||||
|
"(^|[^0-9A-Za-z])$([regex]::Escape($TargetVersion))([^0-9A-Za-z]|$)"
|
||||||
|
}
|
||||||
|
|
||||||
function Copy-ReleaseArtifacts {
|
function Copy-ReleaseArtifacts {
|
||||||
param([string]$ReleaseDir)
|
param(
|
||||||
|
[string]$ReleaseDir,
|
||||||
|
[string]$TargetVersion
|
||||||
|
)
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath $BundleRoot)) {
|
if (-not (Test-Path -LiteralPath $BundleRoot)) {
|
||||||
throw "Tauri bundle output was not found: $BundleRoot"
|
throw "Tauri bundle output was not found: $BundleRoot"
|
||||||
}
|
}
|
||||||
|
|
||||||
$artifactDir = Join-Path $ReleaseDir "artifacts"
|
$artifactDir = Join-Path $ReleaseDir "artifacts"
|
||||||
$files = Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
|
$allFiles = @(Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
|
||||||
Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") }
|
Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") } |
|
||||||
|
Sort-Object FullName)
|
||||||
|
|
||||||
|
if ($allFiles.Count -eq 0) {
|
||||||
|
throw "No release artifacts were found under $BundleRoot."
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionPattern = Get-ArtifactVersionPattern -TargetVersion $TargetVersion
|
||||||
|
$files = @($allFiles | Where-Object { $_.Name -match $versionPattern })
|
||||||
|
$ignoredFiles = @($allFiles | Where-Object { $_.Name -notmatch $versionPattern })
|
||||||
|
|
||||||
if ($files.Count -eq 0) {
|
if ($files.Count -eq 0) {
|
||||||
throw "No release artifacts were found under $BundleRoot."
|
$found = ($allFiles | ForEach-Object { Get-RelativePath -BasePath $BundleRoot -Path $_.FullName }) -join ", "
|
||||||
|
throw "No release artifacts for version $TargetVersion were found under $BundleRoot. Found artifacts: $found"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ignoredFiles.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Ignoring bundle artifacts that do not match version ${TargetVersion}:"
|
||||||
|
foreach ($ignored in $ignoredFiles) {
|
||||||
|
Write-Host (" - " + (Get-RelativePath -BasePath $BundleRoot -Path $ignored.FullName))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$copied = @()
|
$copied = @()
|
||||||
@@ -587,7 +634,7 @@ try {
|
|||||||
Invoke-ReleaseBuild
|
Invoke-ReleaseBuild
|
||||||
|
|
||||||
$releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion
|
$releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion
|
||||||
$artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir)
|
$artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir -TargetVersion $targetVersion)
|
||||||
Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null
|
Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null
|
||||||
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts
|
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts
|
||||||
|
|
||||||
|
|||||||
5
src-tauri/Cargo.lock
generated
5
src-tauri/Cargo.lock
generated
@@ -2314,9 +2314,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proxywarden"
|
name = "proxywarden"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"percent-encoding",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2324,6 +2325,8 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"url",
|
"url",
|
||||||
|
"uuid",
|
||||||
|
"winreg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "proxywarden"
|
name = "proxywarden"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
description = "Standalone Windows desktop proxy management app for ProxyWarden."
|
description = "Standalone Windows desktop proxy management app for ProxyWarden."
|
||||||
authors = ["ProxyWarden"]
|
authors = ["ProxyWarden"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -19,4 +19,9 @@ serde_json = "1"
|
|||||||
tauri-plugin-dialog = "2.7.1"
|
tauri-plugin-dialog = "2.7.1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "socks"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "socks"] }
|
||||||
|
percent-encoding = "2"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
winreg = "0.55"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
#[cfg(not(test))]
|
|
||||||
use crate::adapters::proxy_router::{
|
use crate::adapters::proxy_router::{
|
||||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||||
ProxyRouterRequest,
|
ProxyRouterRequest,
|
||||||
@@ -7,11 +6,6 @@ use crate::models::{
|
|||||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
|
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
|
||||||
ProxyProtocol, Target,
|
ProxyProtocol, Target,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
|
||||||
use crate::proxy_router::{
|
|
||||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
|
||||||
ProxyRouterRequest,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
|
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
#[cfg(not(test))]
|
|
||||||
use crate::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
|
use crate::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
|
||||||
#[cfg(not(test))]
|
|
||||||
use crate::adapters::proxy_router::{
|
use crate::adapters::proxy_router::{
|
||||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||||
ProxyRouterRequest,
|
ProxyRouterRequest,
|
||||||
};
|
};
|
||||||
#[cfg(not(test))]
|
|
||||||
use crate::adapters::singbox::{
|
use crate::adapters::singbox::{
|
||||||
SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker,
|
SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker,
|
||||||
SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest,
|
SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest,
|
||||||
@@ -21,18 +18,6 @@ use crate::models::{
|
|||||||
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
|
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
|
||||||
};
|
};
|
||||||
use crate::process::command_no_window;
|
use crate::process::command_no_window;
|
||||||
#[cfg(test)]
|
|
||||||
use crate::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
|
|
||||||
#[cfg(test)]
|
|
||||||
use crate::proxy_router::{
|
|
||||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
|
||||||
ProxyRouterRequest,
|
|
||||||
};
|
|
||||||
#[cfg(test)]
|
|
||||||
use crate::singbox::{
|
|
||||||
SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker,
|
|
||||||
SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest,
|
|
||||||
};
|
|
||||||
use crate::singbox_service::{
|
use crate::singbox_service::{
|
||||||
build_singbox_setup_status, ensure_safe_singbox_install_dir,
|
build_singbox_setup_status, ensure_safe_singbox_install_dir,
|
||||||
parse_service_command_output as parse_singbox_service_command_output, service_control_script,
|
parse_service_command_output as parse_singbox_service_command_output, service_control_script,
|
||||||
@@ -225,6 +210,8 @@ pub struct LocalSingBoxStatusResponse {
|
|||||||
pub component: ComponentStatusDto,
|
pub component: ComponentStatusDto,
|
||||||
pub generated_config_path: String,
|
pub generated_config_path: String,
|
||||||
pub lan_listen_host: Option<String>,
|
pub lan_listen_host: Option<String>,
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub subscription_identity: SubscriptionRequestIdentityDto,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -258,6 +245,21 @@ pub struct SubscriptionServerDto {
|
|||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SubscriptionRequestIdentityDto {
|
||||||
|
pub headers: Vec<SubscriptionRequestHeaderDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SubscriptionRequestHeaderDto {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SaveSingBoxSubscriptionInputDto {
|
pub struct SaveSingBoxSubscriptionInputDto {
|
||||||
@@ -491,6 +493,7 @@ pub trait SubscriptionFetcher {
|
|||||||
fn fetch_subscription(
|
fn fetch_subscription(
|
||||||
&self,
|
&self,
|
||||||
url: &str,
|
url: &str,
|
||||||
|
identity: &subscription::SubscriptionFetchIdentity,
|
||||||
) -> Result<SubscriptionCache, subscription::SubscriptionError>;
|
) -> Result<SubscriptionCache, subscription::SubscriptionError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,11 +503,27 @@ impl SubscriptionFetcher for SystemSubscriptionFetcher {
|
|||||||
fn fetch_subscription(
|
fn fetch_subscription(
|
||||||
&self,
|
&self,
|
||||||
url: &str,
|
url: &str,
|
||||||
|
identity: &subscription::SubscriptionFetchIdentity,
|
||||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||||
subscription::fetch_subscription(url)
|
subscription::fetch_subscription_with_identity(url, identity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
fn subscription_request_identity_for_display() -> SubscriptionRequestIdentityDto {
|
||||||
|
let identity = subscription::SubscriptionFetchIdentity::default();
|
||||||
|
let headers = identity
|
||||||
|
.request_headers_without_device_hwid()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, value)| SubscriptionRequestHeaderDto {
|
||||||
|
name: name.to_string(),
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
SubscriptionRequestIdentityDto { headers }
|
||||||
|
}
|
||||||
|
|
||||||
pub trait Clock {
|
pub trait Clock {
|
||||||
fn now(&self) -> String;
|
fn now(&self) -> String;
|
||||||
}
|
}
|
||||||
@@ -1325,10 +1344,29 @@ pub fn apply_profiles_with_services(
|
|||||||
adapter: &impl ProxyRouterAdapter,
|
adapter: &impl ProxyRouterAdapter,
|
||||||
helper: &impl ProxyApplyHelper,
|
helper: &impl ProxyApplyHelper,
|
||||||
clock: &impl Clock,
|
clock: &impl Clock,
|
||||||
|
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||||
|
apply_profiles_with_services_and_detection(
|
||||||
|
storage,
|
||||||
|
adapter,
|
||||||
|
helper,
|
||||||
|
clock,
|
||||||
|
detect_proxyfier_install(),
|
||||||
|
detect_singbox_install(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_profiles_with_services_and_detection(
|
||||||
|
storage: &JsonStorage,
|
||||||
|
adapter: &impl ProxyRouterAdapter,
|
||||||
|
helper: &impl ProxyApplyHelper,
|
||||||
|
clock: &impl Clock,
|
||||||
|
detected_proxyfier: Option<DetectedProxyfier>,
|
||||||
|
detected_singbox: Option<DetectedSingBox>,
|
||||||
) -> Result<ApplyProfilesResponse, CommandError> {
|
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||||
let targets = storage.read_targets().map_err(storage_error)?;
|
let targets = storage.read_targets().map_err(storage_error)?;
|
||||||
let components = components_or_defaults(storage)?;
|
let components =
|
||||||
|
components_or_defaults_with_detection(storage, detected_proxyfier, detected_singbox)?;
|
||||||
let generated =
|
let generated =
|
||||||
match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) {
|
match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) {
|
||||||
Ok(generated) => generated,
|
Ok(generated) => generated,
|
||||||
@@ -1398,6 +1436,8 @@ fn read_singbox_status_with_detection(
|
|||||||
.display()
|
.display()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
lan_listen_host: local_lan_ipv4(),
|
lan_listen_host: local_lan_ipv4(),
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
subscription_identity: subscription_request_identity_for_display(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1411,6 +1451,7 @@ pub fn save_singbox_subscription_to_storage(
|
|||||||
|
|
||||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||||
config.subscription_url = Some(subscription_url);
|
config.subscription_url = Some(subscription_url);
|
||||||
|
ensure_device_hwid(&mut config);
|
||||||
config.updated_at = Some(clock.now());
|
config.updated_at = Some(clock.now());
|
||||||
storage
|
storage
|
||||||
.write_local_singbox_config(&config)
|
.write_local_singbox_config(&config)
|
||||||
@@ -1438,8 +1479,18 @@ pub fn fetch_singbox_subscription_with_fetcher(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let device_hwid_created = ensure_device_hwid(&mut config);
|
||||||
|
if device_hwid_created {
|
||||||
|
config.updated_at = Some(clock.now());
|
||||||
|
storage
|
||||||
|
.write_local_singbox_config(&config)
|
||||||
|
.map_err(storage_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let identity =
|
||||||
|
subscription::SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref());
|
||||||
let cache = fetcher
|
let cache = fetcher
|
||||||
.fetch_subscription(&subscription_url)
|
.fetch_subscription(&subscription_url, &identity)
|
||||||
.map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?;
|
.map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?;
|
||||||
let selected_tag = config
|
let selected_tag = config
|
||||||
.selected_server_tag
|
.selected_server_tag
|
||||||
@@ -1689,6 +1740,19 @@ fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_device_hwid(config: &mut LocalSingBoxConfig) -> bool {
|
||||||
|
if config
|
||||||
|
.device_hwid
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
config.device_hwid = Some(uuid::Uuid::new_v4().hyphenated().to_string().to_uppercase());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse {
|
fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse {
|
||||||
ping_endpoint(&server.tag, &server.server, server.server_port)
|
ping_endpoint(&server.tag, &server.server, server.server_port)
|
||||||
}
|
}
|
||||||
@@ -2415,7 +2479,7 @@ try {{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn singbox_installer_runner_script(
|
pub fn singbox_installer_runner_script(
|
||||||
installer_path: &Path,
|
installer_path: &Path,
|
||||||
result_path: &Path,
|
result_path: &Path,
|
||||||
installer_args: &[String],
|
installer_args: &[String],
|
||||||
@@ -2534,11 +2598,23 @@ fn elevated_singbox_service_failed_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn components_or_defaults(storage: &JsonStorage) -> Result<Vec<ComponentStatus>, CommandError> {
|
fn components_or_defaults(storage: &JsonStorage) -> Result<Vec<ComponentStatus>, CommandError> {
|
||||||
|
components_or_defaults_with_detection(
|
||||||
|
storage,
|
||||||
|
detect_proxyfier_install(),
|
||||||
|
detect_singbox_install(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn components_or_defaults_with_detection(
|
||||||
|
storage: &JsonStorage,
|
||||||
|
detected_proxyfier: Option<DetectedProxyfier>,
|
||||||
|
detected_singbox: Option<DetectedSingBox>,
|
||||||
|
) -> Result<Vec<ComponentStatus>, CommandError> {
|
||||||
let components = storage.read_components().map_err(storage_error)?;
|
let components = storage.read_components().map_err(storage_error)?;
|
||||||
Ok(resolve_component_statuses(
|
Ok(resolve_component_statuses(
|
||||||
components,
|
components,
|
||||||
detect_proxyfier_install(),
|
detected_proxyfier,
|
||||||
detect_singbox_install(),
|
detected_singbox,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3417,7 +3493,7 @@ try {{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String {
|
pub fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String {
|
||||||
let mut script = String::new();
|
let mut script = String::new();
|
||||||
script.push_str("$ErrorActionPreference = 'Stop'\n");
|
script.push_str("$ErrorActionPreference = 'Stop'\n");
|
||||||
script.push_str(&format!(
|
script.push_str(&format!(
|
||||||
@@ -3504,7 +3580,7 @@ fn powershell_output_message(output: &Output, fallback: &str) -> String {
|
|||||||
fallback.to_string()
|
fallback.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
pub fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||||
let mut script = String::new();
|
let mut script = String::new();
|
||||||
script.push_str(&format!(
|
script.push_str(&format!(
|
||||||
"$targetDir = '{}'\n",
|
"$targetDir = '{}'\n",
|
||||||
|
|||||||
@@ -1,5 +1,59 @@
|
|||||||
|
pub mod activity;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod component_detection;
|
||||||
|
pub mod helper;
|
||||||
|
pub mod models;
|
||||||
|
pub mod process;
|
||||||
|
pub mod singbox_service;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod subscription;
|
||||||
|
pub mod validation;
|
||||||
|
|
||||||
|
pub mod adapters {
|
||||||
|
pub mod proxifyre;
|
||||||
|
pub mod proxy_router;
|
||||||
|
pub mod singbox;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.manage(commands::CommandState::default())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::get_status,
|
||||||
|
commands::get_admin_status,
|
||||||
|
commands::restart_as_admin,
|
||||||
|
commands::get_startup_snapshot,
|
||||||
|
commands::get_profiles,
|
||||||
|
commands::get_saved_state,
|
||||||
|
commands::save_profile,
|
||||||
|
commands::get_targets,
|
||||||
|
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::ping_proxy_target,
|
||||||
|
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::start_singbox_service,
|
||||||
|
commands::stop_singbox_service,
|
||||||
|
commands::install_singbox,
|
||||||
|
commands::uninstall_singbox
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("не удалось запустить клиент ProxyWarden");
|
.expect("не удалось запустить клиент ProxyWarden");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,5 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod activity;
|
|
||||||
mod commands;
|
|
||||||
mod component_detection;
|
|
||||||
mod models;
|
|
||||||
mod process;
|
|
||||||
mod singbox_service;
|
|
||||||
mod storage;
|
|
||||||
mod subscription;
|
|
||||||
mod validation;
|
|
||||||
|
|
||||||
mod adapters {
|
|
||||||
pub mod proxifyre;
|
|
||||||
pub mod proxy_router;
|
|
||||||
pub mod singbox;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) mod proxifyre {
|
|
||||||
pub use crate::adapters::proxifyre::*;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) mod proxy_router {
|
|
||||||
pub use crate::adapters::proxy_router::*;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) mod singbox {
|
|
||||||
pub use crate::adapters::singbox::*;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
proxywarden_lib::run();
|
||||||
.plugin(tauri_plugin_dialog::init())
|
|
||||||
.manage(commands::CommandState::default())
|
|
||||||
.invoke_handler(tauri::generate_handler![
|
|
||||||
commands::get_status,
|
|
||||||
commands::get_admin_status,
|
|
||||||
commands::restart_as_admin,
|
|
||||||
commands::get_startup_snapshot,
|
|
||||||
commands::get_profiles,
|
|
||||||
commands::get_saved_state,
|
|
||||||
commands::save_profile,
|
|
||||||
commands::get_targets,
|
|
||||||
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::ping_proxy_target,
|
|
||||||
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::start_singbox_service,
|
|
||||||
commands::stop_singbox_service,
|
|
||||||
commands::install_singbox,
|
|
||||||
commands::uninstall_singbox
|
|
||||||
])
|
|
||||||
.run(tauri::generate_context!())
|
|
||||||
.expect("не удалось запустить клиент ProxyWarden");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
use percent_encoding::percent_decode_str;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
|
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_LISTEN_PORT: u16 = 1080;
|
||||||
@@ -138,6 +140,8 @@ pub struct LocalSingBoxConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub subscription_url: Option<String>,
|
pub subscription_url: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub device_hwid: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub selected_server_tag: Option<String>,
|
pub selected_server_tag: Option<String>,
|
||||||
#[serde(default = "default_local_singbox_listen_host")]
|
#[serde(default = "default_local_singbox_listen_host")]
|
||||||
pub listen_host: String,
|
pub listen_host: String,
|
||||||
@@ -157,12 +161,19 @@ impl LocalSingBoxConfig {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.map(redact_subscription_url)
|
.map(redact_subscription_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn normalize_percent_encoded_tags(&mut self) {
|
||||||
|
if let Some(selected_server_tag) = self.selected_server_tag.as_mut() {
|
||||||
|
*selected_server_tag = decode_percent_encoded_utf8(selected_server_tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LocalSingBoxConfig {
|
impl Default for LocalSingBoxConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
subscription_url: None,
|
subscription_url: None,
|
||||||
|
device_hwid: None,
|
||||||
selected_server_tag: None,
|
selected_server_tag: None,
|
||||||
listen_host: default_local_singbox_listen_host(),
|
listen_host: default_local_singbox_listen_host(),
|
||||||
listen_port: default_local_singbox_listen_port(),
|
listen_port: default_local_singbox_listen_port(),
|
||||||
@@ -183,6 +194,36 @@ pub struct SubscriptionCache {
|
|||||||
pub fetched_at: String,
|
pub fetched_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SubscriptionCache {
|
||||||
|
pub fn normalize_percent_encoded_tags(&mut self) {
|
||||||
|
for server in &mut self.servers {
|
||||||
|
server.tag = decode_percent_encoded_utf8(&server.tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(outbounds) = self
|
||||||
|
.config
|
||||||
|
.get_mut("outbounds")
|
||||||
|
.and_then(Value::as_array_mut)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for outbound in outbounds {
|
||||||
|
let Some(decoded_tag) = outbound
|
||||||
|
.get("tag")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(decode_percent_encoded_utf8)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(object) = outbound.as_object_mut() {
|
||||||
|
object.insert("tag".to_string(), Value::String(decoded_tag));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct SubscriptionServer {
|
pub struct SubscriptionServer {
|
||||||
pub tag: String,
|
pub tag: String,
|
||||||
@@ -271,3 +312,10 @@ pub fn redact_subscription_url(raw_url: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn decode_percent_encoded_utf8(value: &str) -> String {
|
||||||
|
percent_decode_str(value)
|
||||||
|
.decode_utf8()
|
||||||
|
.map(|decoded| decoded.into_owned())
|
||||||
|
.unwrap_or_else(|_| value.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ impl JsonStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
|
pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
|
||||||
self.read_json_or_default(&self.paths.local_singbox_file)
|
let mut config: LocalSingBoxConfig =
|
||||||
|
self.read_json_or_default(&self.paths.local_singbox_file)?;
|
||||||
|
config.normalize_percent_encoded_tags();
|
||||||
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_local_singbox_config(&self, config: &LocalSingBoxConfig) -> io::Result<()> {
|
pub fn write_local_singbox_config(&self, config: &LocalSingBoxConfig) -> io::Result<()> {
|
||||||
@@ -104,7 +107,12 @@ impl JsonStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_singbox_subscription_cache(&self) -> io::Result<Option<SubscriptionCache>> {
|
pub fn read_singbox_subscription_cache(&self) -> io::Result<Option<SubscriptionCache>> {
|
||||||
self.read_optional_json(&self.paths.singbox_subscription_cache_file)
|
let mut cache = self
|
||||||
|
.read_optional_json::<SubscriptionCache>(&self.paths.singbox_subscription_cache_file)?;
|
||||||
|
if let Some(cache) = cache.as_mut() {
|
||||||
|
cache.normalize_percent_encoded_tags();
|
||||||
|
}
|
||||||
|
Ok(cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_singbox_subscription_cache(&self, cache: &SubscriptionCache) -> io::Result<()> {
|
pub fn write_singbox_subscription_cache(&self, cache: &SubscriptionCache) -> io::Result<()> {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use crate::models::{SubscriptionCache, SubscriptionServer};
|
use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer};
|
||||||
use base64::{engine::general_purpose, Engine};
|
use base64::{engine::general_purpose, Engine};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||||
|
const DEFAULT_APP_NAME: &str = "ProxyWarden";
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SubscriptionError {
|
pub struct SubscriptionError {
|
||||||
@@ -33,6 +34,67 @@ pub struct ParsedSubscription {
|
|||||||
pub servers: Vec<SubscriptionServer>,
|
pub servers: Vec<SubscriptionServer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SubscriptionFetchIdentity {
|
||||||
|
pub device_hwid: Option<String>,
|
||||||
|
pub app_name: String,
|
||||||
|
pub user_agent: String,
|
||||||
|
pub device_os: String,
|
||||||
|
pub device_os_version: Option<String>,
|
||||||
|
pub device_model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubscriptionFetchIdentity {
|
||||||
|
pub fn with_device_hwid(device_hwid: Option<&str>) -> Self {
|
||||||
|
Self {
|
||||||
|
device_hwid: device_hwid
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string),
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_headers_without_device_hwid(&self) -> Vec<(&'static str, String)> {
|
||||||
|
let mut headers = vec![
|
||||||
|
("User-Agent", self.user_agent.clone()),
|
||||||
|
("X-App-Name", self.app_name.clone()),
|
||||||
|
("X-Device-OS", self.device_os.clone()),
|
||||||
|
("X-Device-Model", self.device_model.clone()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(device_os_version) = self
|
||||||
|
.device_os_version
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
let header_value = sanitize_header_value(device_os_version);
|
||||||
|
if !header_value.is_empty() {
|
||||||
|
headers.push(("X-Device-OS-Version", header_value.clone()));
|
||||||
|
headers.push(("X-Ver-OS", header_value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SubscriptionFetchIdentity {
|
||||||
|
fn default() -> Self {
|
||||||
|
let device_os = std::env::consts::OS.to_string();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
device_hwid: None,
|
||||||
|
app_name: DEFAULT_APP_NAME.to_string(),
|
||||||
|
user_agent: format!("{DEFAULT_APP_NAME}/{device_os}"),
|
||||||
|
device_os,
|
||||||
|
device_os_version: detect_device_os_version(),
|
||||||
|
device_model: DEFAULT_APP_NAME.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_subscription_body(body: &str) -> Result<ParsedSubscription, SubscriptionError> {
|
pub fn parse_subscription_body(body: &str) -> Result<ParsedSubscription, SubscriptionError> {
|
||||||
let config = match serde_json::from_str::<Value>(body) {
|
let config = match serde_json::from_str::<Value>(body) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
@@ -66,6 +128,13 @@ pub fn parse_user_info(header_value: Option<&str>) -> Map<String, Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionError> {
|
pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionError> {
|
||||||
|
fetch_subscription_with_identity(url, &SubscriptionFetchIdentity::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_subscription_with_identity(
|
||||||
|
url: &str,
|
||||||
|
identity: &SubscriptionFetchIdentity,
|
||||||
|
) -> Result<SubscriptionCache, SubscriptionError> {
|
||||||
let parsed_url =
|
let parsed_url =
|
||||||
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
|
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
|
||||||
if !matches!(parsed_url.scheme(), "http" | "https") {
|
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||||
@@ -74,11 +143,22 @@ pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionEr
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = reqwest::blocking::Client::new()
|
let mut request = reqwest::blocking::Client::new().get(parsed_url);
|
||||||
.get(parsed_url)
|
|
||||||
.header("user-agent", "singbox")
|
for (name, value) in identity.request_headers_without_device_hwid() {
|
||||||
.header("x-device-os", std::env::consts::OS)
|
request = request.header(name, value);
|
||||||
.header("x-device-model", "proxywarden")
|
}
|
||||||
|
|
||||||
|
if let Some(device_hwid) = identity
|
||||||
|
.device_hwid
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
request = request.header("x-hwid", device_hwid);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request
|
||||||
.send()
|
.send()
|
||||||
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
|
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
|
||||||
|
|
||||||
@@ -137,7 +217,10 @@ fn parse_vless_url(raw_url: &str) -> Result<Value, SubscriptionError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?;
|
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?;
|
||||||
let tag = parsed.fragment().unwrap_or("vless-out").to_string();
|
let tag = parsed
|
||||||
|
.fragment()
|
||||||
|
.map(decode_percent_encoded_utf8)
|
||||||
|
.unwrap_or_else(|| "vless-out".to_string());
|
||||||
let uuid = parsed.username().trim().to_string();
|
let uuid = parsed.username().trim().to_string();
|
||||||
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
|
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
|
||||||
let server_port = parsed.port_or_known_default().unwrap_or(443);
|
let server_port = parsed.port_or_known_default().unwrap_or(443);
|
||||||
@@ -260,6 +343,86 @@ fn query_value(url: &Url, key: &str) -> Option<String> {
|
|||||||
.map(|(_, value)| value.into_owned())
|
.map(|(_, value)| value.into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_header_value(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| ch.is_ascii_graphic() || *ch == ' ')
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_device_os_version() -> Option<String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
windows_device_os_version()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn windows_device_os_version() -> Option<String> {
|
||||||
|
use winreg::{enums::HKEY_LOCAL_MACHINE, RegKey};
|
||||||
|
|
||||||
|
let current_version = RegKey::predef(HKEY_LOCAL_MACHINE)
|
||||||
|
.open_subkey("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion")
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let product_name = current_version
|
||||||
|
.get_value::<String, _>("ProductName")
|
||||||
|
.ok()
|
||||||
|
.map(|value| normalize_windows_product_name(&value, ¤t_version))
|
||||||
|
.filter(|value| !value.trim().is_empty());
|
||||||
|
let display_version = current_version
|
||||||
|
.get_value::<String, _>("DisplayVersion")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| current_version.get_value::<String, _>("ReleaseId").ok())
|
||||||
|
.filter(|value| !value.trim().is_empty());
|
||||||
|
let build = current_version
|
||||||
|
.get_value::<String, _>("CurrentBuildNumber")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| current_version.get_value::<String, _>("CurrentBuild").ok())
|
||||||
|
.filter(|value| !value.trim().is_empty());
|
||||||
|
let ubr = current_version.get_value::<u32, _>("UBR").ok();
|
||||||
|
let build = match (build, ubr) {
|
||||||
|
(Some(build), Some(ubr)) => Some(format!("{build}.{ubr}")),
|
||||||
|
(build, _) => build,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if let Some(product_name) = product_name {
|
||||||
|
parts.push(product_name);
|
||||||
|
}
|
||||||
|
if let Some(display_version) = display_version {
|
||||||
|
parts.push(display_version);
|
||||||
|
}
|
||||||
|
if let Some(build) = build {
|
||||||
|
parts.push(format!("build {build}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = parts.join(" | ");
|
||||||
|
(!version.is_empty()).then_some(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn normalize_windows_product_name(value: &str, current_version: &winreg::RegKey) -> String {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
let build_number = current_version
|
||||||
|
.get_value::<String, _>("CurrentBuildNumber")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| current_version.get_value::<String, _>("CurrentBuild").ok())
|
||||||
|
.and_then(|value| value.parse::<u32>().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if build_number >= 22000 && trimmed.starts_with("Windows 10") {
|
||||||
|
return trimmed.replacen("Windows 10", "Windows 11", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn now_timestamp() -> String {
|
fn now_timestamp() -> String {
|
||||||
let seconds = SystemTime::now()
|
let seconds = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ProxyWarden",
|
"productName": "ProxyWarden",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"identifier": "ru.dokops.proxywarden.windows",
|
"identifier": "ru.dokops.proxywarden.windows",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -1,42 +1,19 @@
|
|||||||
#[path = "../src/activity.rs"]
|
use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter;
|
||||||
mod activity;
|
use proxywarden_lib::commands::{
|
||||||
#[path = "../src/commands.rs"]
|
self, apply_profiles_with_services, apply_profiles_with_services_and_detection, build_status,
|
||||||
mod commands;
|
read_saved_state_with_proxifyre_config, resolve_component_statuses, resolve_preview,
|
||||||
#[path = "../src/component_detection.rs"]
|
save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper,
|
||||||
mod component_detection;
|
HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper,
|
||||||
#[path = "../src/models.rs"]
|
TargetInputDto,
|
||||||
mod models;
|
|
||||||
#[path = "../src/process.rs"]
|
|
||||||
mod process;
|
|
||||||
#[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::{
|
|
||||||
apply_profiles_with_services, build_status, read_saved_state_with_proxifyre_config,
|
|
||||||
resolve_component_statuses, resolve_preview, save_profile_to_storage, save_target_to_storage,
|
|
||||||
Clock, CommandError, DetectedProxyApplyHelper, HelperApplyRequest, HelperApplyResult,
|
|
||||||
ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper, TargetInputDto,
|
|
||||||
};
|
};
|
||||||
use component_detection::{
|
use proxywarden_lib::component_detection::{
|
||||||
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
|
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
|
||||||
};
|
};
|
||||||
use models::{
|
use proxywarden_lib::models::{
|
||||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
self, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType,
|
||||||
ProxyProtocol, Target, TargetKind,
|
Protocol, ProxyProtocol, Target, TargetKind,
|
||||||
};
|
};
|
||||||
use proxifyre::ProxiFyreAdapter;
|
use proxywarden_lib::storage::JsonStorage;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
@@ -44,7 +21,6 @@ use std::path::{Path, PathBuf};
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::process::Command as ProcessCommand;
|
use std::process::Command as ProcessCommand;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use storage::JsonStorage;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn save_commands_normalize_and_persist_profile_and_target() {
|
fn save_commands_normalize_and_persist_profile_and_target() {
|
||||||
@@ -354,11 +330,13 @@ fn apply_blocks_local_singbox_target_when_component_is_missing() {
|
|||||||
.expect("write targets");
|
.expect("write targets");
|
||||||
write_json(&storage.paths().components_file, &[singbox_missing()]);
|
write_json(&storage.paths().components_file, &[singbox_missing()]);
|
||||||
|
|
||||||
let error = apply_profiles_with_services(
|
let error = apply_profiles_with_services_and_detection(
|
||||||
&storage,
|
&storage,
|
||||||
&ProxiFyreAdapter::default(),
|
&ProxiFyreAdapter::default(),
|
||||||
&MockApplyHelper,
|
&MockApplyHelper,
|
||||||
&FixedClock,
|
&FixedClock,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect_err("missing sing-box should block local target apply");
|
.expect_err("missing sing-box should block local target apply");
|
||||||
let activity = storage.read_activity().expect("read blocked activity");
|
let activity = storage.read_activity().expect("read blocked activity");
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
#[path = "../src/component_detection.rs"]
|
use proxywarden_lib::component_detection::{
|
||||||
mod component_detection;
|
|
||||||
#[path = "../src/models.rs"]
|
|
||||||
mod models;
|
|
||||||
#[path = "../src/process.rs"]
|
|
||||||
mod process;
|
|
||||||
|
|
||||||
use component_detection::{
|
|
||||||
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
|
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
|
||||||
proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost,
|
proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost,
|
||||||
ProxyfierEngine, RegistryInstallEntry,
|
ProxyfierEngine, RegistryInstallEntry,
|
||||||
};
|
};
|
||||||
use models::ComponentState;
|
use proxywarden_lib::models::ComponentState;
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
#[path = "../src/models.rs"]
|
use proxywarden_lib::models::{
|
||||||
mod models;
|
|
||||||
#[path = "../src/validation.rs"]
|
|
||||||
mod validation;
|
|
||||||
|
|
||||||
use models::{
|
|
||||||
ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||||||
TargetInput, TargetKind,
|
TargetInput, TargetKind,
|
||||||
};
|
};
|
||||||
use validation::{normalize_profile, normalize_target};
|
use proxywarden_lib::validation::{normalize_profile, normalize_target};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalizes_profile_source_items() {
|
fn normalizes_profile_source_items() {
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
#[path = "../src/helper.rs"]
|
use proxywarden_lib::helper::{
|
||||||
mod helper;
|
|
||||||
#[path = "../src/models.rs"]
|
|
||||||
mod models;
|
|
||||||
|
|
||||||
use helper::{
|
|
||||||
helper_action_requires_elevation, install_request, parse_helper_response,
|
helper_action_requires_elevation, install_request, parse_helper_response,
|
||||||
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
|
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
|
||||||
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
|
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
|
||||||
};
|
};
|
||||||
use models::ComponentId;
|
use proxywarden_lib::models::ComponentId;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
#[path = "../src/models.rs"]
|
use proxywarden_lib::adapters::proxifyre::{
|
||||||
mod models;
|
ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE,
|
||||||
#[path = "../src/adapters/proxifyre.rs"]
|
};
|
||||||
mod proxifyre;
|
use proxywarden_lib::adapters::proxy_router::{
|
||||||
#[path = "../src/adapters/proxy_router.rs"]
|
ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest,
|
||||||
mod proxy_router;
|
};
|
||||||
|
use proxywarden_lib::models::{
|
||||||
use models::{
|
|
||||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||||
ProxyProtocol, Target, TargetKind,
|
ProxyProtocol, Target, TargetKind,
|
||||||
};
|
};
|
||||||
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE};
|
|
||||||
use proxy_router::{ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generates_proxifyre_config_for_discord_external_socks5_target() {
|
fn generates_proxifyre_config_for_discord_external_socks5_target() {
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
#[path = "../src/models.rs"]
|
use proxywarden_lib::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
||||||
mod models;
|
use proxywarden_lib::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||||
#[path = "../src/process.rs"]
|
use proxywarden_lib::adapters::singbox::{
|
||||||
mod process;
|
|
||||||
#[path = "../src/adapters/proxifyre.rs"]
|
|
||||||
mod proxifyre;
|
|
||||||
#[path = "../src/adapters/proxy_router.rs"]
|
|
||||||
mod proxy_router;
|
|
||||||
#[path = "../src/adapters/singbox.rs"]
|
|
||||||
mod singbox;
|
|
||||||
|
|
||||||
use models::{
|
|
||||||
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, SingBoxConfigChecker, SingBoxConfigError,
|
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||||
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
|
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
|
||||||
SINGBOX_OUTPUT_FILE,
|
SINGBOX_OUTPUT_FILE,
|
||||||
};
|
};
|
||||||
|
use proxywarden_lib::models::{
|
||||||
|
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
|
||||||
|
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
|
||||||
|
TargetKind,
|
||||||
|
};
|
||||||
use std::{
|
use std::{
|
||||||
cell::RefCell,
|
cell::RefCell,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
@@ -217,6 +206,7 @@ impl SingBoxConfigChecker for RecordingChecker {
|
|||||||
fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
|
fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
|
||||||
LocalSingBoxConfig {
|
LocalSingBoxConfig {
|
||||||
subscription_url: Some("https://sub.example.test/list".to_string()),
|
subscription_url: Some("https://sub.example.test/list".to_string()),
|
||||||
|
device_hwid: None,
|
||||||
selected_server_tag: Some(selected_server_tag.to_string()),
|
selected_server_tag: Some(selected_server_tag.to_string()),
|
||||||
listen_host: "127.0.0.1".to_string(),
|
listen_host: "127.0.0.1".to_string(),
|
||||||
listen_port: 1080,
|
listen_port: 1080,
|
||||||
|
|||||||
@@ -1,44 +1,22 @@
|
|||||||
#[path = "../src/activity.rs"]
|
use proxywarden_lib::adapters::singbox::{
|
||||||
mod activity;
|
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||||
#[path = "../src/commands.rs"]
|
|
||||||
mod commands;
|
|
||||||
#[path = "../src/component_detection.rs"]
|
|
||||||
mod component_detection;
|
|
||||||
#[path = "../src/models.rs"]
|
|
||||||
mod models;
|
|
||||||
#[path = "../src/process.rs"]
|
|
||||||
mod process;
|
|
||||||
#[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::{
|
use proxywarden_lib::commands::{
|
||||||
|
fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage,
|
||||||
|
generate_singbox_config_with_services, read_singbox_status,
|
||||||
|
save_singbox_subscription_to_storage, select_singbox_server_in_storage, Clock,
|
||||||
|
SaveSingBoxSubscriptionInputDto, SelectSingBoxServerInputDto, SubscriptionFetcher,
|
||||||
|
};
|
||||||
|
use proxywarden_lib::models::{
|
||||||
ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache,
|
ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache,
|
||||||
SubscriptionServer, TargetKind,
|
SubscriptionServer, TargetKind,
|
||||||
};
|
};
|
||||||
|
use proxywarden_lib::storage::JsonStorage;
|
||||||
|
use proxywarden_lib::subscription;
|
||||||
use serde_json::{json, Map};
|
use serde_json::{json, Map};
|
||||||
use singbox::{SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError};
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use storage::JsonStorage;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn saves_subscription_url_without_exposing_secret_query() {
|
fn saves_subscription_url_without_exposing_secret_query() {
|
||||||
@@ -61,6 +39,7 @@ fn saves_subscription_url_without_exposing_secret_query() {
|
|||||||
config.subscription_url,
|
config.subscription_url,
|
||||||
Some("https://sub.example.test/path?token=secret".to_string())
|
Some("https://sub.example.test/path?token=secret".to_string())
|
||||||
);
|
);
|
||||||
|
assert_valid_generated_hwid(config.device_hwid.as_deref());
|
||||||
assert!(status.config.has_subscription);
|
assert!(status.config.has_subscription);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
status.config.subscription_display_url,
|
status.config.subscription_display_url,
|
||||||
@@ -70,6 +49,39 @@ fn saves_subscription_url_without_exposing_secret_query() {
|
|||||||
cleanup(&root);
|
cleanup(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
#[test]
|
||||||
|
fn singbox_status_exposes_dev_subscription_headers_without_hwid() {
|
||||||
|
let root = test_root("dev-subscription-identity");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
|
||||||
|
let status = read_singbox_status(&storage).expect("read sing-box status");
|
||||||
|
let headers = status
|
||||||
|
.subscription_identity
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.map(|header| (header.name.clone(), header.value.clone()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let expected_headers = subscription::SubscriptionFetchIdentity::default()
|
||||||
|
.request_headers_without_device_hwid()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, value)| (name.to_string(), value))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(headers, expected_headers);
|
||||||
|
assert!(headers
|
||||||
|
.iter()
|
||||||
|
.all(|(name, _)| !name.eq_ignore_ascii_case("x-hwid")));
|
||||||
|
let serialized = serde_json::to_value(&status).expect("serialize status");
|
||||||
|
assert!(serialized.get("subscriptionIdentity").is_some());
|
||||||
|
assert!(!serialized
|
||||||
|
.to_string()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.contains("x-hwid"));
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_non_http_subscription_url() {
|
fn rejects_non_http_subscription_url() {
|
||||||
let root = test_root("invalid-subscription");
|
let root = test_root("invalid-subscription");
|
||||||
@@ -127,6 +139,64 @@ fn fetches_subscription_cache_and_selects_first_server() {
|
|||||||
cleanup(&root);
|
cleanup(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetches_subscription_with_saved_device_hwid() {
|
||||||
|
let root = test_root("fetch-subscription-hwid");
|
||||||
|
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 and generated HWID");
|
||||||
|
let expected_hwid = storage
|
||||||
|
.read_local_singbox_config()
|
||||||
|
.expect("read local sing-box config")
|
||||||
|
.device_hwid
|
||||||
|
.expect("generated HWID");
|
||||||
|
|
||||||
|
fetch_singbox_subscription_with_fetcher(
|
||||||
|
&storage,
|
||||||
|
&HwidAssertingFetcher {
|
||||||
|
cache: sample_cache(),
|
||||||
|
expected_hwid,
|
||||||
|
},
|
||||||
|
&FixedClock,
|
||||||
|
)
|
||||||
|
.expect("fetch subscription through mock");
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_generates_device_hwid_for_existing_subscription_without_one() {
|
||||||
|
let root = test_root("fetch-generates-hwid");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
storage
|
||||||
|
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||||
|
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
|
||||||
|
device_hwid: None,
|
||||||
|
..LocalSingBoxConfig::default()
|
||||||
|
})
|
||||||
|
.expect("write local sing-box config");
|
||||||
|
|
||||||
|
fetch_singbox_subscription_with_fetcher(
|
||||||
|
&storage,
|
||||||
|
&GeneratedHwidAssertingFetcher(sample_cache()),
|
||||||
|
&FixedClock,
|
||||||
|
)
|
||||||
|
.expect("fetch subscription through mock");
|
||||||
|
let config = storage
|
||||||
|
.read_local_singbox_config()
|
||||||
|
.expect("read local sing-box config");
|
||||||
|
|
||||||
|
assert_valid_generated_hwid(config.device_hwid.as_deref());
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selects_server_from_cached_subscription() {
|
fn selects_server_from_cached_subscription() {
|
||||||
let root = test_root("select-server");
|
let root = test_root("select-server");
|
||||||
@@ -193,6 +263,7 @@ fn generate_writes_config_and_local_singbox_target() {
|
|||||||
storage
|
storage
|
||||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||||
subscription_url: Some("https://sub.example.test/path".to_string()),
|
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||||
|
device_hwid: Some("C34C14C9-94BC-4918-B053-A249BC117A91".to_string()),
|
||||||
selected_server_tag: Some("nl-1".to_string()),
|
selected_server_tag: Some("nl-1".to_string()),
|
||||||
..LocalSingBoxConfig::default()
|
..LocalSingBoxConfig::default()
|
||||||
})
|
})
|
||||||
@@ -256,9 +327,11 @@ fn generate_requires_cached_subscription() {
|
|||||||
fn forget_subscription_clears_url_selection_and_cache() {
|
fn forget_subscription_clears_url_selection_and_cache() {
|
||||||
let root = test_root("forget-subscription");
|
let root = test_root("forget-subscription");
|
||||||
let storage = JsonStorage::new(root.clone());
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
let existing_hwid = "C34C14C9-94BC-4918-B053-A249BC117A91".to_string();
|
||||||
storage
|
storage
|
||||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||||
subscription_url: Some("https://sub.example.test/path".to_string()),
|
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||||
|
device_hwid: Some(existing_hwid.clone()),
|
||||||
selected_server_tag: Some("nl-1".to_string()),
|
selected_server_tag: Some("nl-1".to_string()),
|
||||||
..LocalSingBoxConfig::default()
|
..LocalSingBoxConfig::default()
|
||||||
})
|
})
|
||||||
@@ -278,6 +351,7 @@ fn forget_subscription_clears_url_selection_and_cache() {
|
|||||||
|
|
||||||
assert!(!status.config.has_subscription);
|
assert!(!status.config.has_subscription);
|
||||||
assert_eq!(config.subscription_url, None);
|
assert_eq!(config.subscription_url, None);
|
||||||
|
assert_eq!(config.device_hwid, Some(existing_hwid));
|
||||||
assert_eq!(config.selected_server_tag, None);
|
assert_eq!(config.selected_server_tag, None);
|
||||||
assert_eq!(cache, None);
|
assert_eq!(cache, None);
|
||||||
|
|
||||||
@@ -290,12 +364,59 @@ impl SubscriptionFetcher for MockFetcher {
|
|||||||
fn fetch_subscription(
|
fn fetch_subscription(
|
||||||
&self,
|
&self,
|
||||||
url: &str,
|
url: &str,
|
||||||
|
_identity: &subscription::SubscriptionFetchIdentity,
|
||||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||||
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||||
Ok(self.0.clone())
|
Ok(self.0.clone())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct HwidAssertingFetcher {
|
||||||
|
cache: SubscriptionCache,
|
||||||
|
expected_hwid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubscriptionFetcher for HwidAssertingFetcher {
|
||||||
|
fn fetch_subscription(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
identity: &subscription::SubscriptionFetchIdentity,
|
||||||
|
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||||
|
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||||
|
assert_eq!(
|
||||||
|
identity.device_hwid.as_deref(),
|
||||||
|
Some(self.expected_hwid.as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(identity.app_name, "ProxyWarden");
|
||||||
|
assert!(identity.user_agent.starts_with("ProxyWarden/"));
|
||||||
|
assert_eq!(identity.device_os, std::env::consts::OS);
|
||||||
|
assert_eq!(identity.device_model, "ProxyWarden");
|
||||||
|
Ok(self.cache.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GeneratedHwidAssertingFetcher(SubscriptionCache);
|
||||||
|
|
||||||
|
impl SubscriptionFetcher for GeneratedHwidAssertingFetcher {
|
||||||
|
fn fetch_subscription(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
identity: &subscription::SubscriptionFetchIdentity,
|
||||||
|
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||||
|
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||||
|
assert_valid_generated_hwid(identity.device_hwid.as_deref());
|
||||||
|
assert_eq!(identity.app_name, "ProxyWarden");
|
||||||
|
assert!(identity.user_agent.starts_with("ProxyWarden/"));
|
||||||
|
Ok(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_valid_generated_hwid(value: Option<&str>) {
|
||||||
|
let value = value.expect("generated HWID");
|
||||||
|
uuid::Uuid::parse_str(value).expect("HWID should be a UUID");
|
||||||
|
assert_eq!(value, value.to_ascii_uppercase());
|
||||||
|
}
|
||||||
|
|
||||||
struct MockChecker;
|
struct MockChecker;
|
||||||
|
|
||||||
impl SingBoxConfigChecker for MockChecker {
|
impl SingBoxConfigChecker for MockChecker {
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
#[path = "../src/component_detection.rs"]
|
use proxywarden_lib::component_detection::DetectedSingBox;
|
||||||
mod component_detection;
|
use proxywarden_lib::singbox_service::{
|
||||||
#[path = "../src/models.rs"]
|
|
||||||
mod models;
|
|
||||||
#[path = "../src/process.rs"]
|
|
||||||
mod process;
|
|
||||||
#[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,
|
build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output,
|
||||||
service_control_script, SingBoxServiceAction,
|
service_control_script, SingBoxServiceAction,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
#[path = "../src/activity.rs"]
|
use proxywarden_lib::models::{
|
||||||
mod activity;
|
|
||||||
#[path = "../src/models.rs"]
|
|
||||||
mod models;
|
|
||||||
#[path = "../src/storage.rs"]
|
|
||||||
mod storage;
|
|
||||||
|
|
||||||
use models::{
|
|
||||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||||||
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
|
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
|
||||||
SubscriptionServer, Target, TargetKind,
|
SubscriptionServer, Target, TargetKind,
|
||||||
};
|
};
|
||||||
|
use proxywarden_lib::storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn storage_defaults_to_programdata_root() {
|
fn storage_defaults_to_programdata_root() {
|
||||||
@@ -59,6 +52,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
|||||||
let storage = JsonStorage::new(root.clone());
|
let storage = JsonStorage::new(root.clone());
|
||||||
let config = LocalSingBoxConfig {
|
let config = LocalSingBoxConfig {
|
||||||
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
|
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
|
||||||
|
device_hwid: Some("hwid-abcdef1234".to_string()),
|
||||||
selected_server_tag: Some("nl-1".to_string()),
|
selected_server_tag: Some("nl-1".to_string()),
|
||||||
listen_host: "127.0.0.1".to_string(),
|
listen_host: "127.0.0.1".to_string(),
|
||||||
listen_port: 1080,
|
listen_port: 1080,
|
||||||
@@ -91,7 +85,6 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
|||||||
config.subscription_display_url(),
|
config.subscription_display_url(),
|
||||||
Some("https://sub.example.test/...".to_string())
|
Some("https://sub.example.test/...".to_string())
|
||||||
);
|
);
|
||||||
|
|
||||||
cleanup(&root);
|
cleanup(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +97,7 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
|
|||||||
.expect("read default local sing-box config");
|
.expect("read default local sing-box config");
|
||||||
|
|
||||||
assert_eq!(config.subscription_url, None);
|
assert_eq!(config.subscription_url, None);
|
||||||
|
assert_eq!(config.device_hwid, None);
|
||||||
assert_eq!(config.selected_server_tag, None);
|
assert_eq!(config.selected_server_tag, None);
|
||||||
assert_eq!(config.listen_host, "127.0.0.1");
|
assert_eq!(config.listen_host, "127.0.0.1");
|
||||||
assert_eq!(config.listen_port, 1080);
|
assert_eq!(config.listen_port, 1080);
|
||||||
@@ -112,6 +106,58 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
|
|||||||
cleanup(&root);
|
cleanup(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_percent_encoded_singbox_tags_as_utf8() {
|
||||||
|
let root = test_root("local-singbox-percent-tags");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
let encoded_tag =
|
||||||
|
"%D0%A3%D0%BC%D0%BD%D1%8B%D0%B9%20%F0%9F%87%B3%F0%9F%87%B1-%3E%F0%9F%87%B7%F0%9F%87%BA";
|
||||||
|
let decoded_tag = "Умный 🇳🇱->🇷🇺";
|
||||||
|
|
||||||
|
storage
|
||||||
|
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||||
|
selected_server_tag: Some(encoded_tag.to_string()),
|
||||||
|
..LocalSingBoxConfig::default()
|
||||||
|
})
|
||||||
|
.expect("write local sing-box config");
|
||||||
|
storage
|
||||||
|
.write_singbox_subscription_cache(&SubscriptionCache {
|
||||||
|
config: serde_json::json!({
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "vless",
|
||||||
|
"tag": encoded_tag,
|
||||||
|
"server": "nl.example.test",
|
||||||
|
"server_port": 443
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
servers: vec![SubscriptionServer {
|
||||||
|
tag: encoded_tag.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(),
|
||||||
|
})
|
||||||
|
.expect("write subscription cache");
|
||||||
|
|
||||||
|
let config = storage
|
||||||
|
.read_local_singbox_config()
|
||||||
|
.expect("read local sing-box config");
|
||||||
|
let cache = storage
|
||||||
|
.read_singbox_subscription_cache()
|
||||||
|
.expect("read subscription cache")
|
||||||
|
.expect("subscription cache");
|
||||||
|
|
||||||
|
assert_eq!(config.selected_server_tag, Some(decoded_tag.to_string()));
|
||||||
|
assert_eq!(cache.servers[0].tag, decoded_tag);
|
||||||
|
assert_eq!(cache.config["outbounds"][0]["tag"], decoded_tag);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_subscription_cache_falls_back_to_none() {
|
fn invalid_subscription_cache_falls_back_to_none() {
|
||||||
let root = test_root("invalid-subscription-cache");
|
let root = test_root("invalid-subscription-cache");
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
#[path = "../src/models.rs"]
|
|
||||||
mod models;
|
|
||||||
#[path = "../src/subscription.rs"]
|
|
||||||
mod subscription;
|
|
||||||
|
|
||||||
use base64::{engine::general_purpose, Engine};
|
use base64::{engine::general_purpose, Engine};
|
||||||
use models::redact_subscription_url;
|
use proxywarden_lib::models::redact_subscription_url;
|
||||||
use subscription::{parse_subscription_body, parse_user_info};
|
use proxywarden_lib::subscription::{
|
||||||
|
self, parse_subscription_body, parse_user_info, SubscriptionFetchIdentity,
|
||||||
|
};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_singbox_json_config_servers() {
|
fn parses_singbox_json_config_servers() {
|
||||||
@@ -41,6 +41,19 @@ fn parses_base64_vless_link_list() {
|
|||||||
assert_eq!(outbound["packet_encoding"], "xudp");
|
assert_eq!(outbound["packet_encoding"], "xudp");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_percent_encoded_vless_fragment_tag() {
|
||||||
|
let link = sample_vless_link(
|
||||||
|
"%D0%A3%D0%BC%D0%BD%D1%8B%D0%B9%20%F0%9F%87%B3%F0%9F%87%B1-%3E%F0%9F%87%B7%F0%9F%87%BA",
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed = parse_subscription_body(&link).expect("vless link should parse");
|
||||||
|
let outbound = &parsed.config["outbounds"][0];
|
||||||
|
|
||||||
|
assert_eq!(parsed.servers[0].tag, "Умный 🇳🇱->🇷🇺");
|
||||||
|
assert_eq!(outbound["tag"], "Умный 🇳🇱->🇷🇺");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_body_without_supported_outbounds() {
|
fn rejects_body_without_supported_outbounds() {
|
||||||
let error = parse_subscription_body(r#"{"outbounds":[{"type":"direct","tag":"direct"}]}"#)
|
let error = parse_subscription_body(r#"{"outbounds":[{"type":"direct","tag":"direct"}]}"#)
|
||||||
@@ -78,6 +91,58 @@ fn rejects_invalid_or_non_http_subscription_url_before_network() {
|
|||||||
assert!(unsupported.message.contains("http or https"));
|
assert!(unsupported.message.contains("http or https"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener");
|
||||||
|
let url = format!("http://{}/subscription", listener.local_addr().unwrap());
|
||||||
|
let request_thread = std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("accept test request");
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||||
|
.expect("set read timeout");
|
||||||
|
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut buffer = [0_u8; 512];
|
||||||
|
loop {
|
||||||
|
let bytes_read = stream.read(&mut buffer).expect("read request");
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
request.extend_from_slice(&buffer[..bytes_read]);
|
||||||
|
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = r#"{"outbounds":[{"type":"vless","tag":"nl-1","server":"nl.example.test","server_port":443}]}"#;
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
stream
|
||||||
|
.write_all(response.as_bytes())
|
||||||
|
.expect("write response");
|
||||||
|
|
||||||
|
String::from_utf8_lossy(&request).to_ascii_lowercase()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut identity = SubscriptionFetchIdentity::with_device_hwid(Some("hwid-abc123"));
|
||||||
|
identity.device_os_version = Some("Windows 11 Pro | 25H2 | build 26200.8655".to_string());
|
||||||
|
let cache = subscription::fetch_subscription_with_identity(&url, &identity)
|
||||||
|
.expect("fetch subscription through local test server");
|
||||||
|
let request = request_thread.join().expect("request thread");
|
||||||
|
|
||||||
|
assert_eq!(cache.servers[0].tag, "nl-1");
|
||||||
|
assert!(request.contains("x-hwid: hwid-abc123"));
|
||||||
|
assert!(request.contains("user-agent: proxywarden/"));
|
||||||
|
assert!(request.contains("x-app-name: proxywarden"));
|
||||||
|
assert!(request.contains("x-device-os:"));
|
||||||
|
assert!(request.contains("x-device-os-version: windows 11 pro | 25h2 | build 26200.8655"));
|
||||||
|
assert!(request.contains("x-ver-os: windows 11 pro | 25h2 | build 26200.8655"));
|
||||||
|
assert!(request.contains("x-device-model: proxywarden"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn redacts_subscription_url_for_display() {
|
fn redacts_subscription_url_for_display() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ export interface LocalSingBoxStatusResponse {
|
|||||||
component: ComponentStatus;
|
component: ComponentStatus;
|
||||||
generatedConfigPath: string;
|
generatedConfigPath: string;
|
||||||
lanListenHost?: string;
|
lanListenHost?: string;
|
||||||
|
subscriptionIdentity?: SubscriptionRequestIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionRequestIdentity {
|
||||||
|
headers: SubscriptionRequestHeader[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionRequestHeader {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PingServerResponse {
|
export interface PingServerResponse {
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ const MAIN_PROFILE_ID = 'main-profile';
|
|||||||
const LOCAL_SINGBOX_TARGET_ID = 'local-singbox';
|
const LOCAL_SINGBOX_TARGET_ID = 'local-singbox';
|
||||||
const LOG_VISIBLE_MS = 6500;
|
const LOG_VISIBLE_MS = 6500;
|
||||||
const PANEL_ORDER: PanelId[] = ['proxifyre', 'summary', 'proxy'];
|
const PANEL_ORDER: PanelId[] = ['proxifyre', 'summary', 'proxy'];
|
||||||
|
const SHOW_DEV_SUBSCRIPTION_IDENTITY = import.meta.env.DEV;
|
||||||
const proxyWardenToggleOnImage = new URL('../assets/proxywarden-toggle-on.png', import.meta.url).href;
|
const proxyWardenToggleOnImage = new URL('../assets/proxywarden-toggle-on.png', import.meta.url).href;
|
||||||
const proxyWardenToggleOffImage = new URL('../assets/proxywarden-toggle-off.png', import.meta.url).href;
|
const proxyWardenToggleOffImage = new URL('../assets/proxywarden-toggle-off.png', import.meta.url).href;
|
||||||
|
|
||||||
@@ -1381,7 +1382,13 @@ export function App() {
|
|||||||
inlineActions={(
|
inlineActions={(
|
||||||
<DetailsPopover
|
<DetailsPopover
|
||||||
className="setup-summary"
|
className="setup-summary"
|
||||||
details={singBoxDetailLines(singbox, singBoxStatus, singBoxSetupStatus, selectedServerTag)}
|
details={singBoxDetailLines(
|
||||||
|
singbox,
|
||||||
|
singBoxStatus,
|
||||||
|
singBoxSetupStatus,
|
||||||
|
selectedServerTag,
|
||||||
|
SHOW_DEV_SUBSCRIPTION_IDENTITY,
|
||||||
|
)}
|
||||||
popoverLabel="Состав Local sing-box"
|
popoverLabel="Состав Local sing-box"
|
||||||
aria-label={`Подробности Local sing-box: ${setupSummary}`}
|
aria-label={`Подробности Local sing-box: ${setupSummary}`}
|
||||||
>
|
>
|
||||||
@@ -1413,6 +1420,7 @@ export function App() {
|
|||||||
loading={singBoxAction === 'fetch'}
|
loading={singBoxAction === 'fetch'}
|
||||||
loadingLabel="Загружаю"
|
loadingLabel="Загружаю"
|
||||||
variant="neutral"
|
variant="neutral"
|
||||||
|
disabled={Boolean(singBoxAction)}
|
||||||
>
|
>
|
||||||
{subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'}
|
{subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2551,6 +2559,7 @@ function singBoxDetailLines(
|
|||||||
status: LocalSingBoxStatusResponse | null,
|
status: LocalSingBoxStatusResponse | null,
|
||||||
setupStatus: SingBoxSetupStatus | null,
|
setupStatus: SingBoxSetupStatus | null,
|
||||||
selectedServerTag: string | undefined,
|
selectedServerTag: string | undefined,
|
||||||
|
showDevSubscriptionIdentity = false,
|
||||||
) {
|
) {
|
||||||
const setupDetails = setupStatus
|
const setupDetails = setupStatus
|
||||||
? setupStatus.items
|
? setupStatus.items
|
||||||
@@ -2558,7 +2567,7 @@ function singBoxDetailLines(
|
|||||||
.join('; ')
|
.join('; ')
|
||||||
: 'состав не проверен';
|
: 'состав не проверен';
|
||||||
|
|
||||||
return [
|
const details = [
|
||||||
`Локально: ${localSingBoxAddress(status)}`,
|
`Локально: ${localSingBoxAddress(status)}`,
|
||||||
`LAN: ${lanSingBoxAddress(status) ?? 'недоступен'}`,
|
`LAN: ${lanSingBoxAddress(status) ?? 'недоступен'}`,
|
||||||
`Сервер: ${selectedServerTag ? displayServerTag(selectedServerTag) : 'не выбран'}`,
|
`Сервер: ${selectedServerTag ? displayServerTag(selectedServerTag) : 'не выбран'}`,
|
||||||
@@ -2567,6 +2576,30 @@ function singBoxDetailLines(
|
|||||||
`Подписка: ${status?.config.subscriptionDisplayUrl ?? (status?.config.hasSubscription ? 'сохранена' : 'не загружена')}`,
|
`Подписка: ${status?.config.subscriptionDisplayUrl ?? (status?.config.hasSubscription ? 'сохранена' : 'не загружена')}`,
|
||||||
`Состав: ${setupDetails}`,
|
`Состав: ${setupDetails}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (showDevSubscriptionIdentity) {
|
||||||
|
details.push(...subscriptionIdentityDetailLines(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscriptionIdentityDetailLines(status: LocalSingBoxStatusResponse | null) {
|
||||||
|
const headers = status?.subscriptionIdentity?.headers
|
||||||
|
?.filter((header) => header.name.trim().toLowerCase() !== 'x-hwid') ?? [];
|
||||||
|
|
||||||
|
if (!headers.length) return ['Dev headers подписки: недоступны'];
|
||||||
|
|
||||||
|
const details = [
|
||||||
|
'Dev headers подписки (без HWID):',
|
||||||
|
...headers.map((header) => `${header.name}: ${header.value || 'пусто'}`),
|
||||||
|
];
|
||||||
|
const sendsLegacyOsVersion = headers.some((header) => header.name.trim().toLowerCase() === 'x-ver-os');
|
||||||
|
if (!sendsLegacyOsVersion) {
|
||||||
|
details.push('X-Ver-OS: не отправится, версия ОС не определена');
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
function componentDetails(component: ComponentStatus | undefined, checking: boolean) {
|
function componentDetails(component: ComponentStatus | undefined, checking: boolean) {
|
||||||
|
|||||||
@@ -1896,7 +1896,7 @@ button.summary-card:hover {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-line .icon-command {
|
.subscription-line .ui-icon-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 42px;
|
width: 42px;
|
||||||
@@ -3153,11 +3153,11 @@ button.summary-card:hover {
|
|||||||
grid-column: 2 / 4;
|
grid-column: 2 / 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-line button:not(.icon-command) {
|
.subscription-line button:not(.ui-icon-button) {
|
||||||
grid-column: 1 / 3;
|
grid-column: 1 / 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-line .icon-command {
|
.subscription-line .ui-icon-button {
|
||||||
grid-column: 3;
|
grid-column: 3;
|
||||||
width: 42px;
|
width: 42px;
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user