Compare commits
10 Commits
6439dbfeaa
...
1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d859dc0a6 | |||
| a2581187da | |||
| 2e80f7b8eb | |||
| f6b722b22a | |||
| 42b85cc8fa | |||
| 7316e932f0 | |||
| 7bb437f28b | |||
| 32ee151a31 | |||
| 838dea0e03 | |||
| ae13070eba |
@@ -49,6 +49,7 @@ ProxyWarden - standalone Windows desktop client в корне репозитор
|
||||
- В UI держать стиль компактной Windows-утилиты, а не landing/dashboard. Использовать existing `Button`, `Tabs`, `ServiceControlRow`, `StatusPill`, `Field`, `ActionMenu`, `LogDock`.
|
||||
- Всплывающие подсказки при наведении делать быстрыми, кастомными и читаемыми: темная compact-плашка с мягкой рамкой/тенью, появление ~120ms, без нативного browser `title` как основного UI. Для иконок расширять общий `IconButton`/tooltip-паттерн, а не дублировать JSX/CSS локально.
|
||||
- Apply actions должны быть disabled с объяснением, когда нет приложений, ProxiFyre отсутствует, proxy input неверный или local route не готов.
|
||||
- Не оставлять dev-серверы (`npm run dev`, `npm run tauri -- dev`, preview-серверы) запущенными после проверки. Если сервер был поднят агентом, остановить его перед финальным ответом.
|
||||
|
||||
## Проверка
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "proxywarden",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxywarden",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxywarden",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Standalone Windows desktop proxy management app for ProxyWarden.",
|
||||
|
||||
@@ -353,13 +353,12 @@ function New-ReleaseDirectory {
|
||||
|
||||
$releaseDir = Join-Path $root "proxywarden-v$TargetVersion"
|
||||
|
||||
if ((Test-Path -LiteralPath $releaseDir) -and $Force) {
|
||||
if (Test-Path -LiteralPath $releaseDir) {
|
||||
if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) {
|
||||
throw "Refusing to remove release directory outside OutputRoot: $releaseDir"
|
||||
}
|
||||
Write-Host "Replacing existing release directory: $releaseDir"
|
||||
Remove-Item -LiteralPath $releaseDir -Recurse -Force
|
||||
} elseif (Test-Path -LiteralPath $releaseDir) {
|
||||
throw "Release directory already exists: $releaseDir. Use -Force to replace it."
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path (Join-Path $releaseDir "artifacts") -Force | Out-Null
|
||||
@@ -387,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 {
|
||||
if ($SkipBuild) {
|
||||
Write-Host ""
|
||||
@@ -403,26 +421,50 @@ function Invoke-ReleaseBuild {
|
||||
Write-Host "Skipping Rust tests because -SkipTests was provided."
|
||||
}
|
||||
|
||||
Clear-ReleaseBundleOutput
|
||||
Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build")
|
||||
}
|
||||
|
||||
function Copy-ReleaseArtifacts {
|
||||
param([string]$ReleaseDir)
|
||||
function Get-ArtifactVersionPattern {
|
||||
param([string]$TargetVersion)
|
||||
|
||||
if ($SkipBuild) {
|
||||
return @()
|
||||
}
|
||||
"(^|[^0-9A-Za-z])$([regex]::Escape($TargetVersion))([^0-9A-Za-z]|$)"
|
||||
}
|
||||
|
||||
function Copy-ReleaseArtifacts {
|
||||
param(
|
||||
[string]$ReleaseDir,
|
||||
[string]$TargetVersion
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BundleRoot)) {
|
||||
throw "Tauri bundle output was not found: $BundleRoot"
|
||||
}
|
||||
|
||||
$artifactDir = Join-Path $ReleaseDir "artifacts"
|
||||
$files = Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
|
||||
Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") }
|
||||
$allFiles = @(Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
|
||||
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) {
|
||||
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 = @()
|
||||
@@ -592,7 +634,7 @@ try {
|
||||
Invoke-ReleaseBuild
|
||||
|
||||
$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-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]]
|
||||
name = "proxywarden"
|
||||
version = "0.1.0"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"percent-encoding",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2324,6 +2325,8 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"url",
|
||||
"uuid",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxywarden"
|
||||
version = "0.1.0"
|
||||
version = "1.0.1"
|
||||
description = "Standalone Windows desktop proxy management app for ProxyWarden."
|
||||
authors = ["ProxyWarden"]
|
||||
edition = "2021"
|
||||
@@ -18,5 +18,10 @@ 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"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "socks"] }
|
||||
percent-encoding = "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::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
@@ -7,11 +6,6 @@ use crate::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
|
||||
use crate::process::command_no_window;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::Path,
|
||||
process::Command,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
@@ -215,7 +215,7 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
|
||||
)
|
||||
})?;
|
||||
|
||||
let output = Command::new(binary_path)
|
||||
let output = command_no_window(binary_path)
|
||||
.arg("check")
|
||||
.arg("-c")
|
||||
.arg(&config_path)
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker,
|
||||
SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest,
|
||||
@@ -20,18 +17,7 @@ use crate::models::{
|
||||
Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||||
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
|
||||
};
|
||||
#[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::process::command_no_window;
|
||||
use crate::singbox_service::{
|
||||
build_singbox_setup_status, ensure_safe_singbox_install_dir,
|
||||
parse_service_command_output as parse_singbox_service_command_output, service_control_script,
|
||||
@@ -58,6 +44,44 @@ const NDISAPI_RELEASE_API_URL: &str =
|
||||
"https://api.github.com/repos/wiresock/ndisapi/releases/latest";
|
||||
const VC_REDIST_X64_URL: &str = "https://aka.ms/vc14/vc_redist.x64.exe";
|
||||
const VC_REDIST_X86_URL: &str = "https://aka.ms/vc14/vc_redist.x86.exe";
|
||||
const PROXY_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
const PROXY_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const PROXY_CHECK_USER_AGENT: &str = "proxywarden route-check";
|
||||
|
||||
const DEFAULT_PROXY_PROBES: &[ProxyProbeEndpoint] = &[
|
||||
ProxyProbeEndpoint {
|
||||
id: "cloudflare-trace",
|
||||
name: "Cloudflare Trace",
|
||||
url: "https://www.cloudflare.com/cdn-cgi/trace",
|
||||
ip_source: ProbeIpSource::CloudflareTrace,
|
||||
},
|
||||
ProxyProbeEndpoint {
|
||||
id: "cloudflare-speed",
|
||||
name: "Cloudflare Speed",
|
||||
url: "https://speed.cloudflare.com/meta",
|
||||
ip_source: ProbeIpSource::JsonField("clientIp"),
|
||||
},
|
||||
ProxyProbeEndpoint {
|
||||
id: "ipify",
|
||||
name: "ipify",
|
||||
url: "https://api.ipify.org?format=json",
|
||||
ip_source: ProbeIpSource::JsonField("ip"),
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ProxyProbeEndpoint {
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
url: &'static str,
|
||||
ip_source: ProbeIpSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ProbeIpSource {
|
||||
CloudflareTrace,
|
||||
JsonField(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandState {
|
||||
@@ -111,6 +135,15 @@ impl CommandError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminStatusResponse {
|
||||
pub is_windows: bool,
|
||||
pub is_elevated: bool,
|
||||
pub can_restart_elevated: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ValidationIssue {
|
||||
@@ -138,6 +171,17 @@ pub struct SavedStateResponse {
|
||||
pub generated_config_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartupSnapshotResponse {
|
||||
pub admin_status: AdminStatusResponse,
|
||||
pub saved_state: SavedStateResponse,
|
||||
pub components: Vec<ComponentStatusDto>,
|
||||
pub proxifyre_setup_status: ProxiFyreSetupStatusDto,
|
||||
pub singbox_status: LocalSingBoxStatusResponse,
|
||||
pub singbox_setup_status: SingBoxSetupStatusDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxiFyreSetupStatusDto {
|
||||
@@ -166,6 +210,8 @@ pub struct LocalSingBoxStatusResponse {
|
||||
pub component: ComponentStatusDto,
|
||||
pub generated_config_path: String,
|
||||
pub lan_listen_host: Option<String>,
|
||||
#[cfg(debug_assertions)]
|
||||
pub subscription_identity: SubscriptionRequestIdentityDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -199,6 +245,21 @@ pub struct SubscriptionServerDto {
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveSingBoxSubscriptionInputDto {
|
||||
@@ -239,6 +300,31 @@ pub struct PingServerResponse {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyProbeResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub ok: bool,
|
||||
pub status: Option<u16>,
|
||||
pub latency: Option<u128>,
|
||||
pub ip: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyTargetCheckResponse {
|
||||
pub tag: String,
|
||||
pub server: String,
|
||||
pub server_port: u16,
|
||||
pub ok: bool,
|
||||
pub latency: Option<u128>,
|
||||
pub error: Option<String>,
|
||||
pub probes: Vec<ProxyProbeResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenerateSingBoxConfigResponse {
|
||||
@@ -407,6 +493,7 @@ pub trait SubscriptionFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError>;
|
||||
}
|
||||
|
||||
@@ -416,11 +503,27 @@ impl SubscriptionFetcher for SystemSubscriptionFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> 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 {
|
||||
fn now(&self) -> String;
|
||||
}
|
||||
@@ -479,6 +582,28 @@ pub async fn get_status(
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_admin_status() -> AdminStatusResponse {
|
||||
admin_status()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restart_as_admin(app: tauri::AppHandle) -> Result<(), CommandError> {
|
||||
launch_app_as_admin()?;
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_startup_snapshot(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
) -> Result<StartupSnapshotResponse, CommandError> {
|
||||
let storage = state.storage();
|
||||
tauri::async_runtime::spawn_blocking(move || read_startup_snapshot(&storage))
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_saved_state(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
@@ -610,7 +735,7 @@ pub fn ping_all_singbox_servers(
|
||||
#[tauri::command]
|
||||
pub fn ping_proxy_target(
|
||||
input: PingProxyTargetInputDto,
|
||||
) -> Result<PingServerResponse, CommandError> {
|
||||
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||||
ping_proxy_target_endpoint(input)
|
||||
}
|
||||
|
||||
@@ -751,6 +876,87 @@ pub async fn uninstall_singbox() -> Result<ComponentStatusDto, CommandError> {
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
pub fn admin_status() -> AdminStatusResponse {
|
||||
let is_windows = cfg!(windows);
|
||||
let is_elevated = is_running_elevated();
|
||||
let message = if !is_windows {
|
||||
"Проверка прав администратора нужна только в Windows.".to_string()
|
||||
} else if is_elevated {
|
||||
"ProxyWarden уже запущен от имени администратора.".to_string()
|
||||
} else {
|
||||
"Для установки компонентов и управления службами можно перезапустить ProxyWarden от имени администратора один раз.".to_string()
|
||||
};
|
||||
|
||||
AdminStatusResponse {
|
||||
is_windows,
|
||||
is_elevated,
|
||||
can_restart_elevated: is_windows && !is_elevated,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_app_as_admin() -> Result<(), CommandError> {
|
||||
if !cfg!(windows) {
|
||||
return Err(CommandError::new(
|
||||
"admin_restart_unsupported",
|
||||
"Перезапуск от имени администратора доступен только в Windows.",
|
||||
));
|
||||
}
|
||||
|
||||
if is_running_elevated() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().map_err(|error| {
|
||||
CommandError::new(
|
||||
"admin_restart_failed",
|
||||
format!("Не удалось определить путь текущего приложения: {error}"),
|
||||
)
|
||||
})?;
|
||||
let working_dir = env::current_dir().ok();
|
||||
let working_dir_arg = working_dir
|
||||
.as_ref()
|
||||
.map(|path| {
|
||||
format!(
|
||||
" -WorkingDirectory '{}'",
|
||||
escape_powershell_single(&path.display().to_string())
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let script = format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try {{
|
||||
Start-Process -FilePath '{}' -Verb RunAs{}
|
||||
exit 0
|
||||
}} catch {{
|
||||
Write-Error ($_ | Out-String)
|
||||
exit 1
|
||||
}}
|
||||
"#,
|
||||
escape_powershell_single(&exe_path.display().to_string()),
|
||||
working_dir_arg
|
||||
);
|
||||
let output = run_powershell_command(&script).map_err(|error| {
|
||||
CommandError::new(
|
||||
"admin_restart_failed",
|
||||
format!("Не удалось запросить права администратора: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(CommandError::new(
|
||||
"admin_restart_failed",
|
||||
powershell_output_message(
|
||||
&output,
|
||||
"Перезапуск от имени администратора отменен или не был запущен.",
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandError> {
|
||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
let targets = storage.read_targets().map_err(storage_error)?;
|
||||
@@ -846,6 +1052,41 @@ pub fn read_components(storage: &JsonStorage) -> Result<Vec<ComponentStatusDto>,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_startup_snapshot(
|
||||
storage: &JsonStorage,
|
||||
) -> Result<StartupSnapshotResponse, CommandError> {
|
||||
let detected_proxyfier = detect_proxyfier_install();
|
||||
let detected_singbox = detect_singbox_install();
|
||||
let saved_state = read_saved_state_with_proxifyre_config(
|
||||
storage,
|
||||
detected_proxyfier
|
||||
.as_ref()
|
||||
.and_then(|detected| detected.config_path.as_deref()),
|
||||
)?;
|
||||
let stored_components = storage.read_components().map_err(storage_error)?;
|
||||
let components = resolve_component_statuses(
|
||||
stored_components,
|
||||
detected_proxyfier.clone(),
|
||||
detected_singbox.clone(),
|
||||
)
|
||||
.iter()
|
||||
.map(ComponentStatusDto::from)
|
||||
.collect();
|
||||
let proxifyre_setup_status =
|
||||
build_proxifyre_setup_status_with_detection(detected_proxyfier.as_ref());
|
||||
let singbox_status = read_singbox_status_with_detection(storage, detected_singbox.as_ref())?;
|
||||
let singbox_setup_status = build_singbox_setup_status(detected_singbox.as_ref());
|
||||
|
||||
Ok(StartupSnapshotResponse {
|
||||
admin_status: admin_status(),
|
||||
saved_state,
|
||||
components,
|
||||
proxifyre_setup_status,
|
||||
singbox_status,
|
||||
singbox_setup_status,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_activity(storage: &JsonStorage) -> Result<Vec<ActivityEntryDto>, CommandError> {
|
||||
storage
|
||||
.read_activity()
|
||||
@@ -1103,10 +1344,29 @@ pub fn apply_profiles_with_services(
|
||||
adapter: &impl ProxyRouterAdapter,
|
||||
helper: &impl ProxyApplyHelper,
|
||||
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> {
|
||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
let targets = storage.read_targets().map_err(storage_error)?;
|
||||
let components = components_or_defaults(storage)?;
|
||||
let components =
|
||||
components_or_defaults_with_detection(storage, detected_proxyfier, detected_singbox)?;
|
||||
let generated =
|
||||
match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) {
|
||||
Ok(generated) => generated,
|
||||
@@ -1150,13 +1410,20 @@ pub fn apply_profiles_with_services(
|
||||
|
||||
pub fn read_singbox_status(
|
||||
storage: &JsonStorage,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let detected = detect_singbox_install();
|
||||
read_singbox_status_with_detection(storage, detected.as_ref())
|
||||
}
|
||||
|
||||
fn read_singbox_status_with_detection(
|
||||
storage: &JsonStorage,
|
||||
detected: Option<&DetectedSingBox>,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?;
|
||||
let detected = detect_singbox_install();
|
||||
let component = singbox_component_from_detection(detected.as_ref());
|
||||
let component = singbox_component_from_detection(detected);
|
||||
|
||||
Ok(LocalSingBoxStatusResponse {
|
||||
config: LocalSingBoxConfigDto::from(&config),
|
||||
@@ -1169,6 +1436,8 @@ pub fn read_singbox_status(
|
||||
.display()
|
||||
.to_string(),
|
||||
lan_listen_host: local_lan_ipv4(),
|
||||
#[cfg(debug_assertions)]
|
||||
subscription_identity: subscription_request_identity_for_display(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1182,6 +1451,7 @@ pub fn save_singbox_subscription_to_storage(
|
||||
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
config.subscription_url = Some(subscription_url);
|
||||
ensure_device_hwid(&mut config);
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
@@ -1209,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
|
||||
.fetch_subscription(&subscription_url)
|
||||
.fetch_subscription(&subscription_url, &identity)
|
||||
.map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?;
|
||||
let selected_tag = config
|
||||
.selected_server_tag
|
||||
@@ -1328,7 +1608,14 @@ pub fn ping_all_singbox_servers_in_storage(
|
||||
|
||||
pub fn ping_proxy_target_endpoint(
|
||||
input: PingProxyTargetInputDto,
|
||||
) -> Result<PingServerResponse, CommandError> {
|
||||
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||||
ping_proxy_target_endpoint_with_probes(input, DEFAULT_PROXY_PROBES)
|
||||
}
|
||||
|
||||
pub fn ping_proxy_target_endpoint_with_probes(
|
||||
input: PingProxyTargetInputDto,
|
||||
probes: &[ProxyProbeEndpoint],
|
||||
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||||
let host = input.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(CommandError::new(
|
||||
@@ -1337,7 +1624,40 @@ pub fn ping_proxy_target_endpoint(
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ping_endpoint("external-proxy", host, input.port))
|
||||
let tcp = ping_endpoint("route-proxy", host, input.port);
|
||||
if !tcp.ok {
|
||||
return Ok(ProxyTargetCheckResponse {
|
||||
tag: "route-proxy".to_string(),
|
||||
server: host.to_string(),
|
||||
server_port: input.port,
|
||||
ok: false,
|
||||
latency: tcp.latency,
|
||||
error: tcp.error,
|
||||
probes: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let probe_results = run_proxy_probes(host, input.port, probes);
|
||||
let has_probe_success = probe_results.iter().any(|probe| probe.ok);
|
||||
let ok = probe_results.is_empty() || has_probe_success;
|
||||
let error = if ok {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
"SOCKS5 порт доступен, но тестовые HTTP endpoints не ответили через прокси."
|
||||
.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(ProxyTargetCheckResponse {
|
||||
tag: "route-proxy".to_string(),
|
||||
server: host.to_string(),
|
||||
server_port: input.port,
|
||||
ok,
|
||||
latency: tcp.latency,
|
||||
error,
|
||||
probes: probe_results,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate_singbox_config_with_services<C>(
|
||||
@@ -1420,6 +1740,19 @@ fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError>
|
||||
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 {
|
||||
ping_endpoint(&server.tag, &server.server, server.server_port)
|
||||
}
|
||||
@@ -1479,6 +1812,167 @@ fn ping_endpoint(tag: &str, server: &str, server_port: u16) -> PingServerRespons
|
||||
}
|
||||
}
|
||||
|
||||
fn run_proxy_probes(
|
||||
proxy_host: &str,
|
||||
proxy_port: u16,
|
||||
probes: &[ProxyProbeEndpoint],
|
||||
) -> Vec<ProxyProbeResponse> {
|
||||
if probes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let proxy_url = socks5h_proxy_url(proxy_host, proxy_port);
|
||||
let client = match reqwest::Proxy::all(&proxy_url).and_then(|proxy| {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(PROXY_CHECK_TIMEOUT)
|
||||
.connect_timeout(PROXY_CHECK_CONNECT_TIMEOUT)
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
}) {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
return probes
|
||||
.iter()
|
||||
.map(|probe| {
|
||||
failed_probe(
|
||||
*probe,
|
||||
format!("Не удалось подготовить SOCKS5 проверку: {error}"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
|
||||
let handles = probes
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|probe| {
|
||||
let client = client.clone();
|
||||
std::thread::spawn(move || run_proxy_probe(&client, probe))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
handles
|
||||
.into_iter()
|
||||
.zip(probes.iter().copied())
|
||||
.map(|(handle, probe)| {
|
||||
handle
|
||||
.join()
|
||||
.unwrap_or_else(|_| failed_probe(probe, "Проверка была прервана.".to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_proxy_probe(
|
||||
client: &reqwest::blocking::Client,
|
||||
probe: ProxyProbeEndpoint,
|
||||
) -> ProxyProbeResponse {
|
||||
let started = Instant::now();
|
||||
let response = match client
|
||||
.get(probe.url)
|
||||
.header(reqwest::header::USER_AGENT, PROXY_CHECK_USER_AGENT)
|
||||
.send()
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => return failed_probe(probe, format!("HTTP через SOCKS5 не прошел: {error}")),
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
let status_code = status.as_u16();
|
||||
let body = match response.text() {
|
||||
Ok(body) => body,
|
||||
Err(error) => {
|
||||
return failed_probe_with_status(
|
||||
probe,
|
||||
status_code,
|
||||
format!("Ответ не прочитан: {error}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
let latency = started.elapsed().as_millis();
|
||||
|
||||
if !status.is_success() {
|
||||
return ProxyProbeResponse {
|
||||
id: probe.id.to_string(),
|
||||
name: probe.name.to_string(),
|
||||
url: probe.url.to_string(),
|
||||
ok: false,
|
||||
status: Some(status_code),
|
||||
latency: Some(latency),
|
||||
ip: None,
|
||||
error: Some(format!("HTTP {status_code}")),
|
||||
};
|
||||
}
|
||||
|
||||
let ip = extract_probe_ip(probe, &body);
|
||||
|
||||
ProxyProbeResponse {
|
||||
id: probe.id.to_string(),
|
||||
name: probe.name.to_string(),
|
||||
url: probe.url.to_string(),
|
||||
ok: true,
|
||||
status: Some(status_code),
|
||||
latency: Some(latency),
|
||||
ip,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_probe(probe: ProxyProbeEndpoint, error: String) -> ProxyProbeResponse {
|
||||
failed_probe_with_status(probe, 0, error)
|
||||
}
|
||||
|
||||
fn failed_probe_with_status(
|
||||
probe: ProxyProbeEndpoint,
|
||||
status: u16,
|
||||
error: String,
|
||||
) -> ProxyProbeResponse {
|
||||
ProxyProbeResponse {
|
||||
id: probe.id.to_string(),
|
||||
name: probe.name.to_string(),
|
||||
url: probe.url.to_string(),
|
||||
ok: false,
|
||||
status: (status > 0).then_some(status),
|
||||
latency: None,
|
||||
ip: None,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn socks5h_proxy_url(host: &str, port: u16) -> String {
|
||||
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
|
||||
if host.contains(':') {
|
||||
format!("socks5h://[{host}]:{port}")
|
||||
} else {
|
||||
format!("socks5h://{host}:{port}")
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_probe_ip(probe: ProxyProbeEndpoint, body: &str) -> Option<String> {
|
||||
match probe.ip_source {
|
||||
ProbeIpSource::CloudflareTrace => body
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("ip=").and_then(normalize_ip)),
|
||||
ProbeIpSource::JsonField(field) => serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get(field)
|
||||
.and_then(|field| field.as_str())
|
||||
.and_then(normalize_ip)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ip(value: &str) -> Option<String> {
|
||||
let candidate = value.trim().trim_matches('"');
|
||||
if candidate.parse::<IpAddr>().is_ok() {
|
||||
Some(candidate.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn local_lan_ipv4() -> Option<String> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||
socket.connect("8.8.8.8:80").ok()?;
|
||||
@@ -1602,7 +2096,7 @@ fn control_singbox_service(
|
||||
config_source,
|
||||
config_target.as_deref(),
|
||||
);
|
||||
let output = Command::new("powershell")
|
||||
let output = command_no_window("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
@@ -1669,16 +2163,11 @@ fn run_elevated_singbox_service_command(
|
||||
"$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode",
|
||||
escape_powershell_single(&script_path.display().to_string())
|
||||
);
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
launch_script.as_str(),
|
||||
])
|
||||
.output();
|
||||
let output = if is_running_elevated() {
|
||||
run_powershell_file(&script_path)
|
||||
} else {
|
||||
run_powershell_command(&launch_script)
|
||||
};
|
||||
|
||||
let _ = fs::remove_file(&script_path);
|
||||
|
||||
@@ -1954,16 +2443,11 @@ try {{
|
||||
escape_powershell_single(&result_path.display().to_string()),
|
||||
escape_powershell_single(&runner_path.display().to_string())
|
||||
);
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
launch_script.as_str(),
|
||||
])
|
||||
.output();
|
||||
let output = if is_running_elevated() {
|
||||
run_powershell_file(&runner_path)
|
||||
} else {
|
||||
run_powershell_command(&launch_script)
|
||||
};
|
||||
|
||||
let _ = fs::remove_file(&installer_path);
|
||||
let _ = fs::remove_file(&runner_path);
|
||||
@@ -1995,7 +2479,7 @@ try {{
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn singbox_installer_runner_script(
|
||||
pub fn singbox_installer_runner_script(
|
||||
installer_path: &Path,
|
||||
result_path: &Path,
|
||||
installer_args: &[String],
|
||||
@@ -2114,11 +2598,23 @@ fn elevated_singbox_service_failed_message(
|
||||
}
|
||||
|
||||
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)?;
|
||||
Ok(resolve_component_statuses(
|
||||
components,
|
||||
detect_proxyfier_install(),
|
||||
detect_singbox_install(),
|
||||
detected_proxyfier,
|
||||
detected_singbox,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -2468,7 +2964,7 @@ Write-ServiceResult $false 'stop_failed' $status $processId
|
||||
"#
|
||||
);
|
||||
|
||||
let output = Command::new("powershell")
|
||||
let output = command_no_window("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
@@ -2517,16 +3013,11 @@ fn run_elevated_proxifyre_service_command(
|
||||
"$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode",
|
||||
escape_powershell_single(&script_path.display().to_string())
|
||||
);
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
launch_script.as_str(),
|
||||
])
|
||||
.output();
|
||||
let output = if is_running_elevated() {
|
||||
run_powershell_file(&script_path)
|
||||
} else {
|
||||
run_powershell_command(&launch_script)
|
||||
};
|
||||
|
||||
let _ = fs::remove_file(&script_path);
|
||||
|
||||
@@ -2735,9 +3226,15 @@ fn uninstall_proxifyre_component() -> Result<ComponentStatusDto, CommandError> {
|
||||
}
|
||||
|
||||
fn build_proxifyre_setup_status() -> ProxiFyreSetupStatusDto {
|
||||
let proxifyre = detect_proxyfier_install();
|
||||
build_proxifyre_setup_status_with_detection(proxifyre.as_ref())
|
||||
}
|
||||
|
||||
fn build_proxifyre_setup_status_with_detection(
|
||||
proxifyre: Option<&DetectedProxyfier>,
|
||||
) -> ProxiFyreSetupStatusDto {
|
||||
let vc_runtime = detect_vc_runtime();
|
||||
let packet_filter = detect_windows_packet_filter();
|
||||
let proxifyre = detect_proxyfier_install();
|
||||
|
||||
let vc_runtime_item = setup_item_from_program(
|
||||
"vc-runtime",
|
||||
@@ -2848,7 +3345,7 @@ if ($null -ne $program) {{
|
||||
escape_powershell_single(pattern)
|
||||
);
|
||||
|
||||
let output = Command::new("powershell")
|
||||
let output = command_no_window("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
@@ -2961,16 +3458,11 @@ try {{
|
||||
escape_powershell_single(&result_path.display().to_string()),
|
||||
escape_powershell_single(&script_path.display().to_string())
|
||||
);
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
launch_script.as_str(),
|
||||
])
|
||||
.output();
|
||||
let output = if is_running_elevated() {
|
||||
run_powershell_file(&script_path)
|
||||
} else {
|
||||
run_powershell_command(&launch_script)
|
||||
};
|
||||
|
||||
let _ = fs::remove_file(&script_path);
|
||||
|
||||
@@ -3001,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();
|
||||
script.push_str("$ErrorActionPreference = 'Stop'\n");
|
||||
script.push_str(&format!(
|
||||
@@ -3032,7 +3524,63 @@ fn write_powershell_script(path: &Path, script: &str) -> std::io::Result<()> {
|
||||
fs::write(path, bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||
fn run_powershell_command(script: &str) -> std::io::Result<Output> {
|
||||
command_no_window("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script,
|
||||
])
|
||||
.output()
|
||||
}
|
||||
|
||||
fn run_powershell_file(script_path: &Path) -> std::io::Result<Output> {
|
||||
command_no_window("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
])
|
||||
.arg(script_path)
|
||||
.output()
|
||||
}
|
||||
|
||||
fn is_running_elevated() -> bool {
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let script = r#"([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#;
|
||||
let Ok(output) = run_powershell_command(script) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
output.status.success()
|
||||
&& String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("true")
|
||||
}
|
||||
|
||||
fn powershell_output_message(output: &Output, fallback: &str) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if !stderr.is_empty() {
|
||||
return stderr;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
return stdout;
|
||||
}
|
||||
|
||||
fallback.to_string()
|
||||
}
|
||||
|
||||
pub fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||
let mut script = String::new();
|
||||
script.push_str(&format!(
|
||||
"$targetDir = '{}'\n",
|
||||
|
||||
@@ -2,11 +2,11 @@ use crate::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT,
|
||||
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME,
|
||||
};
|
||||
use crate::process::command_no_window;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -460,7 +460,7 @@ fn same_path(left: &Path, right: &Path) -> bool {
|
||||
}
|
||||
|
||||
fn powershell_bool(script: &str) -> bool {
|
||||
Command::new("powershell")
|
||||
command_no_window("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||
.output()
|
||||
.ok()
|
||||
@@ -492,7 +492,7 @@ $items |
|
||||
ConvertTo-Json -Compress
|
||||
"#;
|
||||
|
||||
let Ok(output) = Command::new("powershell")
|
||||
let Ok(output) = command_no_window("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||
.output()
|
||||
else {
|
||||
|
||||
@@ -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() {
|
||||
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!())
|
||||
.expect("не удалось запустить клиент ProxyWarden");
|
||||
}
|
||||
|
||||
@@ -1,71 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
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)]
|
||||
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() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(commands::CommandState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
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");
|
||||
proxywarden_lib::run();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use percent_encoding::percent_decode_str;
|
||||
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_PORT: u16 = 1080;
|
||||
@@ -138,6 +140,8 @@ pub struct LocalSingBoxConfig {
|
||||
#[serde(default)]
|
||||
pub subscription_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub device_hwid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_tag: Option<String>,
|
||||
#[serde(default = "default_local_singbox_listen_host")]
|
||||
pub listen_host: String,
|
||||
@@ -157,12 +161,19 @@ impl LocalSingBoxConfig {
|
||||
.as_deref()
|
||||
.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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
subscription_url: None,
|
||||
device_hwid: None,
|
||||
selected_server_tag: None,
|
||||
listen_host: default_local_singbox_listen_host(),
|
||||
listen_port: default_local_singbox_listen_port(),
|
||||
@@ -183,6 +194,36 @@ pub struct SubscriptionCache {
|
||||
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)]
|
||||
pub struct SubscriptionServer {
|
||||
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())
|
||||
}
|
||||
|
||||
18
src-tauri/src/process.rs
Normal file
18
src-tauri/src/process.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use std::{ffi::OsStr, process::Command};
|
||||
|
||||
pub fn command_no_window(program: impl AsRef<OsStr>) -> Command {
|
||||
let mut command = Command::new(program);
|
||||
hide_console_window(&mut command);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn hide_console_window(command: &mut Command) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn hide_console_window(_command: &mut Command) {}
|
||||
@@ -96,7 +96,10 @@ impl JsonStorage {
|
||||
}
|
||||
|
||||
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<()> {
|
||||
@@ -104,7 +107,12 @@ impl JsonStorage {
|
||||
}
|
||||
|
||||
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<()> {
|
||||
|
||||
@@ -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 serde_json::{json, Map, Value};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
|
||||
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||
const DEFAULT_APP_NAME: &str = "ProxyWarden";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionError {
|
||||
@@ -33,6 +34,67 @@ pub struct ParsedSubscription {
|
||||
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> {
|
||||
let config = match serde_json::from_str::<Value>(body) {
|
||||
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> {
|
||||
fetch_subscription_with_identity(url, &SubscriptionFetchIdentity::default())
|
||||
}
|
||||
|
||||
pub fn fetch_subscription_with_identity(
|
||||
url: &str,
|
||||
identity: &SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
let parsed_url =
|
||||
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
|
||||
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()
|
||||
.get(parsed_url)
|
||||
.header("user-agent", "singbox")
|
||||
.header("x-device-os", std::env::consts::OS)
|
||||
.header("x-device-model", "proxywarden")
|
||||
let mut request = reqwest::blocking::Client::new().get(parsed_url);
|
||||
|
||||
for (name, value) in identity.request_headers_without_device_hwid() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
|
||||
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()
|
||||
.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 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 server = parsed.host_str().map(str::to_string).unwrap_or_default();
|
||||
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())
|
||||
}
|
||||
|
||||
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 {
|
||||
let seconds = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyWarden",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"identifier": "ru.dokops.proxywarden.windows",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -13,9 +13,10 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "ProxyWarden",
|
||||
"width": 1120,
|
||||
"width": 820,
|
||||
"height": 760,
|
||||
"minWidth": 760,
|
||||
"minWidth": 820,
|
||||
"maxWidth": 820,
|
||||
"minHeight": 560,
|
||||
"resizable": true
|
||||
}
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
#[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::{
|
||||
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 proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter;
|
||||
use proxywarden_lib::commands::{
|
||||
self, apply_profiles_with_services, apply_profiles_with_services_and_detection, 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,
|
||||
};
|
||||
use models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target, TargetKind,
|
||||
use proxywarden_lib::models::{
|
||||
self, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType,
|
||||
Protocol, ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use proxifyre::ProxiFyreAdapter;
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::net::TcpListener;
|
||||
@@ -42,7 +21,6 @@ use std::path::{Path, PathBuf};
|
||||
#[cfg(windows)]
|
||||
use std::process::Command as ProcessCommand;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use storage::JsonStorage;
|
||||
|
||||
#[test]
|
||||
fn save_commands_normalize_and_persist_profile_and_target() {
|
||||
@@ -222,17 +200,21 @@ fn ping_proxy_target_reports_open_tcp_endpoint() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
|
||||
let port = listener.local_addr().expect("read local addr").port();
|
||||
|
||||
let result = commands::ping_proxy_target_endpoint(commands::PingProxyTargetInputDto {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port,
|
||||
})
|
||||
let result = commands::ping_proxy_target_endpoint_with_probes(
|
||||
commands::PingProxyTargetInputDto {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port,
|
||||
},
|
||||
&[],
|
||||
)
|
||||
.expect("ping should return response");
|
||||
|
||||
assert_eq!(result.tag, "external-proxy");
|
||||
assert_eq!(result.tag, "route-proxy");
|
||||
assert_eq!(result.server, "127.0.0.1");
|
||||
assert_eq!(result.server_port, port);
|
||||
assert!(result.ok);
|
||||
assert!(result.latency.is_some());
|
||||
assert!(result.probes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -348,11 +330,13 @@ fn apply_blocks_local_singbox_target_when_component_is_missing() {
|
||||
.expect("write targets");
|
||||
write_json(&storage.paths().components_file, &[singbox_missing()]);
|
||||
|
||||
let error = apply_profiles_with_services(
|
||||
let error = apply_profiles_with_services_and_detection(
|
||||
&storage,
|
||||
&ProxiFyreAdapter::default(),
|
||||
&MockApplyHelper,
|
||||
&FixedClock,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("missing sing-box should block local target apply");
|
||||
let activity = storage.read_activity().expect("read blocked activity");
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
|
||||
use component_detection::{
|
||||
use proxywarden_lib::component_detection::{
|
||||
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
|
||||
proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost,
|
||||
ProxyfierEngine, RegistryInstallEntry,
|
||||
};
|
||||
use models::ComponentState;
|
||||
use proxywarden_lib::models::ComponentState;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/validation.rs"]
|
||||
mod validation;
|
||||
|
||||
use models::{
|
||||
use proxywarden_lib::models::{
|
||||
ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||||
TargetInput, TargetKind,
|
||||
};
|
||||
use validation::{normalize_profile, normalize_target};
|
||||
use proxywarden_lib::validation::{normalize_profile, normalize_target};
|
||||
|
||||
#[test]
|
||||
fn normalizes_profile_source_items() {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
#[path = "../src/helper.rs"]
|
||||
mod helper;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
|
||||
use helper::{
|
||||
use proxywarden_lib::helper::{
|
||||
helper_action_requires_elevation, install_request, parse_helper_response,
|
||||
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
|
||||
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
|
||||
};
|
||||
use models::ComponentId;
|
||||
use proxywarden_lib::models::ComponentId;
|
||||
use serde_json::json;
|
||||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/adapters/proxifyre.rs"]
|
||||
mod proxifyre;
|
||||
#[path = "../src/adapters/proxy_router.rs"]
|
||||
mod proxy_router;
|
||||
|
||||
use models::{
|
||||
use proxywarden_lib::adapters::proxifyre::{
|
||||
ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE,
|
||||
};
|
||||
use proxywarden_lib::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest,
|
||||
};
|
||||
use proxywarden_lib::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE};
|
||||
use proxy_router::{ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest};
|
||||
|
||||
#[test]
|
||||
fn generates_proxifyre_config_for_discord_external_socks5_target() {
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
#[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;
|
||||
|
||||
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::{
|
||||
use proxywarden_lib::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
||||
use proxywarden_lib::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||
use proxywarden_lib::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
|
||||
SINGBOX_OUTPUT_FILE,
|
||||
};
|
||||
use proxywarden_lib::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
|
||||
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
|
||||
TargetKind,
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
path::{Path, PathBuf},
|
||||
@@ -215,6 +206,7 @@ impl SingBoxConfigChecker for RecordingChecker {
|
||||
fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
|
||||
LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/list".to_string()),
|
||||
device_hwid: None,
|
||||
selected_server_tag: Some(selected_server_tag.to_string()),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
|
||||
@@ -1,42 +1,22 @@
|
||||
#[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 proxywarden_lib::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||
};
|
||||
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,
|
||||
SubscriptionServer, TargetKind,
|
||||
};
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use proxywarden_lib::subscription;
|
||||
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() {
|
||||
@@ -59,6 +39,7 @@ fn saves_subscription_url_without_exposing_secret_query() {
|
||||
config.subscription_url,
|
||||
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_eq!(
|
||||
status.config.subscription_display_url,
|
||||
@@ -68,6 +49,39 @@ fn saves_subscription_url_without_exposing_secret_query() {
|
||||
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]
|
||||
fn rejects_non_http_subscription_url() {
|
||||
let root = test_root("invalid-subscription");
|
||||
@@ -125,6 +139,64 @@ fn fetches_subscription_cache_and_selects_first_server() {
|
||||
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]
|
||||
fn selects_server_from_cached_subscription() {
|
||||
let root = test_root("select-server");
|
||||
@@ -191,6 +263,7 @@ fn generate_writes_config_and_local_singbox_target() {
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
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()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
@@ -254,9 +327,11 @@ fn generate_requires_cached_subscription() {
|
||||
fn forget_subscription_clears_url_selection_and_cache() {
|
||||
let root = test_root("forget-subscription");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let existing_hwid = "C34C14C9-94BC-4918-B053-A249BC117A91".to_string();
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||
device_hwid: Some(existing_hwid.clone()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
@@ -276,6 +351,7 @@ fn forget_subscription_clears_url_selection_and_cache() {
|
||||
|
||||
assert!(!status.config.has_subscription);
|
||||
assert_eq!(config.subscription_url, None);
|
||||
assert_eq!(config.device_hwid, Some(existing_hwid));
|
||||
assert_eq!(config.selected_server_tag, None);
|
||||
assert_eq!(cache, None);
|
||||
|
||||
@@ -288,12 +364,59 @@ impl SubscriptionFetcher for MockFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
_identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||
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;
|
||||
|
||||
impl SingBoxConfigChecker for MockChecker {
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
#[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::{
|
||||
use proxywarden_lib::component_detection::DetectedSingBox;
|
||||
use proxywarden_lib::singbox_service::{
|
||||
build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output,
|
||||
service_control_script, SingBoxServiceAction,
|
||||
};
|
||||
@@ -55,7 +48,8 @@ noise
|
||||
#[test]
|
||||
fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
|
||||
assert!(
|
||||
ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\ProxyWarden\sing-box")).is_ok()
|
||||
ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\ProxyWarden\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());
|
||||
@@ -63,7 +57,12 @@ fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
|
||||
|
||||
#[test]
|
||||
fn service_control_script_targets_named_service_and_action() {
|
||||
let script = service_control_script(SingBoxServiceAction::Start, "ProxyWardenSingBox", None, None);
|
||||
let script = service_control_script(
|
||||
SingBoxServiceAction::Start,
|
||||
"ProxyWardenSingBox",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(script.contains("$serviceName = 'ProxyWardenSingBox'"));
|
||||
assert!(script.contains("$action = 'start'"));
|
||||
@@ -84,9 +83,9 @@ fn service_control_script_syncs_generated_config_before_start() {
|
||||
assert!(script.contains(
|
||||
"$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'"
|
||||
));
|
||||
assert!(script.contains(
|
||||
"$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'"
|
||||
));
|
||||
assert!(
|
||||
script.contains("$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'")
|
||||
);
|
||||
assert!(script.contains("Copy-Item -LiteralPath $configSource"));
|
||||
assert!(script.contains("'config_sync_failed'"));
|
||||
}
|
||||
@@ -119,7 +118,9 @@ fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) ->
|
||||
DetectedSingBox {
|
||||
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box"),
|
||||
executable_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe"),
|
||||
wrapper_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe"),
|
||||
wrapper_path: PathBuf::from(
|
||||
r"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe",
|
||||
),
|
||||
binary_exists,
|
||||
wrapper_exists,
|
||||
running,
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
#[path = "../src/activity.rs"]
|
||||
mod activity;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/storage.rs"]
|
||||
mod storage;
|
||||
|
||||
use models::{
|
||||
use proxywarden_lib::models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||||
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
|
||||
SubscriptionServer, Target, TargetKind,
|
||||
};
|
||||
use proxywarden_lib::storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
|
||||
|
||||
#[test]
|
||||
fn storage_defaults_to_programdata_root() {
|
||||
@@ -59,6 +52,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let config = LocalSingBoxConfig {
|
||||
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()),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
@@ -91,7 +85,6 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
config.subscription_display_url(),
|
||||
Some("https://sub.example.test/...".to_string())
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
@@ -104,6 +97,7 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
|
||||
.expect("read default local sing-box config");
|
||||
|
||||
assert_eq!(config.subscription_url, None);
|
||||
assert_eq!(config.device_hwid, None);
|
||||
assert_eq!(config.selected_server_tag, None);
|
||||
assert_eq!(config.listen_host, "127.0.0.1");
|
||||
assert_eq!(config.listen_port, 1080);
|
||||
@@ -112,6 +106,58 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
|
||||
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]
|
||||
fn invalid_subscription_cache_falls_back_to_none() {
|
||||
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 models::redact_subscription_url;
|
||||
use subscription::{parse_subscription_body, parse_user_info};
|
||||
use proxywarden_lib::models::redact_subscription_url;
|
||||
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]
|
||||
fn parses_singbox_json_config_servers() {
|
||||
@@ -41,6 +41,19 @@ fn parses_base64_vless_link_list() {
|
||||
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]
|
||||
fn rejects_body_without_supported_outbounds() {
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn redacts_subscription_url_for_display() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -30,12 +30,28 @@ export interface StatusResponse {
|
||||
generatedConfigPath: string;
|
||||
}
|
||||
|
||||
export interface AdminStatusResponse {
|
||||
isWindows: boolean;
|
||||
isElevated: boolean;
|
||||
canRestartElevated: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SavedStateResponse {
|
||||
profiles: Profile[];
|
||||
targets: Target[];
|
||||
generatedConfigPath: string;
|
||||
}
|
||||
|
||||
export interface StartupSnapshotResponse {
|
||||
adminStatus: AdminStatusResponse;
|
||||
savedState: SavedStateResponse;
|
||||
components: ComponentStatus[];
|
||||
proxifyreSetupStatus: ProxiFyreSetupStatus;
|
||||
singboxStatus: LocalSingBoxStatusResponse;
|
||||
singboxSetupStatus: SingBoxSetupStatus;
|
||||
}
|
||||
|
||||
export interface ProxiFyreSetupItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -64,6 +80,16 @@ export interface LocalSingBoxStatusResponse {
|
||||
component: ComponentStatus;
|
||||
generatedConfigPath: string;
|
||||
lanListenHost?: string;
|
||||
subscriptionIdentity?: SubscriptionRequestIdentity;
|
||||
}
|
||||
|
||||
export interface SubscriptionRequestIdentity {
|
||||
headers: SubscriptionRequestHeader[];
|
||||
}
|
||||
|
||||
export interface SubscriptionRequestHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PingServerResponse {
|
||||
@@ -75,6 +101,27 @@ export interface PingServerResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ProxyProbeResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
latency?: number;
|
||||
ip?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ProxyTargetCheckResponse {
|
||||
tag: string;
|
||||
server: string;
|
||||
serverPort: number;
|
||||
ok: boolean;
|
||||
latency?: number;
|
||||
error?: string;
|
||||
probes: ProxyProbeResponse[];
|
||||
}
|
||||
|
||||
export interface GenerateSingBoxConfigResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -114,6 +161,18 @@ export function getStatus(): Promise<StatusResponse> {
|
||||
return invoke<StatusResponse>('get_status');
|
||||
}
|
||||
|
||||
export function getAdminStatus(): Promise<AdminStatusResponse> {
|
||||
return invoke<AdminStatusResponse>('get_admin_status');
|
||||
}
|
||||
|
||||
export function restartAsAdmin(): Promise<void> {
|
||||
return invoke<void>('restart_as_admin');
|
||||
}
|
||||
|
||||
export function getStartupSnapshot(): Promise<StartupSnapshotResponse> {
|
||||
return invoke<StartupSnapshotResponse>('get_startup_snapshot');
|
||||
}
|
||||
|
||||
export function getSavedState(): Promise<SavedStateResponse> {
|
||||
return invoke<SavedStateResponse>('get_saved_state');
|
||||
}
|
||||
@@ -184,8 +243,8 @@ export function pingAllSingBoxServers(): Promise<PingServerResponse[]> {
|
||||
return invoke<PingServerResponse[]>('ping_all_singbox_servers');
|
||||
}
|
||||
|
||||
export function pingProxyTarget(host: string, port: number): Promise<PingServerResponse> {
|
||||
return invoke<PingServerResponse>('ping_proxy_target', {
|
||||
export function pingProxyTarget(host: string, port: number): Promise<ProxyTargetCheckResponse> {
|
||||
return invoke<ProxyTargetCheckResponse>('ping_proxy_target', {
|
||||
input: { host, port },
|
||||
});
|
||||
}
|
||||
|
||||
994
src/app/App.tsx
994
src/app/App.tsx
File diff suppressed because it is too large
Load Diff
BIN
src/assets/proxywarden-toggle-off.png
Normal file
BIN
src/assets/proxywarden-toggle-off.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
src/assets/proxywarden-toggle-on.png
Normal file
BIN
src/assets/proxywarden-toggle-on.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 873 KiB |
1194
src/styles/app.css
1194
src/styles/app.css
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,13 @@
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
export interface ActionMenuItem {
|
||||
@@ -16,6 +25,17 @@ export interface ActionMenuProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ActionMenuPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
placement: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
const MENU_WIDTH = 190;
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
const MENU_OFFSET = 6;
|
||||
|
||||
export function ActionMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -23,30 +43,137 @@ export function ActionMenu({
|
||||
items,
|
||||
disabled,
|
||||
}: ActionMenuProps) {
|
||||
const menuId = useId();
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<ActionMenuPosition>({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: MENU_WIDTH,
|
||||
placement: 'bottom',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled && open) onOpenChange(false);
|
||||
}, [disabled, onOpenChange, open]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const width = Math.min(MENU_WIDTH, Math.max(180, window.innerWidth - VIEWPORT_MARGIN * 2));
|
||||
const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
|
||||
const left = Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
Math.min(rect.right - width, window.innerWidth - width - VIEWPORT_MARGIN),
|
||||
);
|
||||
let top = rect.bottom + MENU_OFFSET;
|
||||
let placement: ActionMenuPosition['placement'] = 'bottom';
|
||||
|
||||
if (
|
||||
popoverHeight
|
||||
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
|
||||
&& rect.top > popoverHeight + VIEWPORT_MARGIN + MENU_OFFSET
|
||||
) {
|
||||
top = rect.top - popoverHeight - MENU_OFFSET;
|
||||
placement = 'top';
|
||||
}
|
||||
|
||||
const maxTop = popoverHeight
|
||||
? window.innerHeight - popoverHeight - VIEWPORT_MARGIN
|
||||
: window.innerHeight - VIEWPORT_MARGIN;
|
||||
|
||||
setPosition({
|
||||
top: Math.max(VIEWPORT_MARGIN, Math.min(top, maxTop)),
|
||||
left,
|
||||
width,
|
||||
placement,
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
const frame = window.requestAnimationFrame(updatePosition);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (triggerRef.current?.contains(target)) return;
|
||||
if (popoverRef.current?.contains(target)) return;
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [onOpenChange, open]);
|
||||
|
||||
const popoverStyle = {
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: position.width,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div className="ui-action-menu">
|
||||
<div className="ui-action-menu" ref={triggerRef}>
|
||||
<IconButton
|
||||
label={label}
|
||||
icon={<MoreHorizontal size={20} strokeWidth={2} />}
|
||||
onClick={() => onOpenChange(!open)}
|
||||
disabled={disabled}
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
/>
|
||||
{open ? (
|
||||
<div className="ui-action-menu-popover" role="menu">
|
||||
{open && typeof document !== 'undefined' ? createPortal(
|
||||
<div
|
||||
className="ui-action-menu-popover"
|
||||
data-placement={position.placement}
|
||||
id={menuId}
|
||||
ref={popoverRef}
|
||||
role="menu"
|
||||
style={popoverStyle}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item.danger ? 'is-danger' : ''}
|
||||
onClick={item.onClick}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
item.onClick();
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
key={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
16
src/ui/BusyRing.tsx
Normal file
16
src/ui/BusyRing.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface BusyRingProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BusyRing({ className }: BusyRingProps) {
|
||||
const classes = ['ui-busy-ring', className ?? ''].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<span className={classes} aria-hidden="true">
|
||||
<span className="ui-busy-ring-segment top" />
|
||||
<span className="ui-busy-ring-segment right" />
|
||||
<span className="ui-busy-ring-segment bottom" />
|
||||
<span className="ui-busy-ring-segment left" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { BusyRing } from './BusyRing';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
@@ -37,8 +38,10 @@ export function Button({
|
||||
{...props}
|
||||
className={classes}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
>
|
||||
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : leftIcon ? (
|
||||
{loading ? <BusyRing /> : null}
|
||||
{!loading && leftIcon ? (
|
||||
<span className="ui-button-icon" aria-hidden="true">{leftIcon}</span>
|
||||
) : null}
|
||||
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { BusyRing } from './BusyRing';
|
||||
|
||||
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
|
||||
|
||||
@@ -36,8 +37,10 @@ export function IconButton({
|
||||
aria-label={label}
|
||||
data-tooltip={tooltipText}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
>
|
||||
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}
|
||||
{loading ? <BusyRing /> : null}
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,9 +59,11 @@ export function ServiceControlRow({
|
||||
</span>
|
||||
<span className="ui-service-dot" aria-hidden="true" />
|
||||
<div className="ui-service-text">
|
||||
<strong>{title}</strong>
|
||||
<div className="ui-service-title-line">
|
||||
<strong>{title}</strong>
|
||||
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
|
||||
</div>
|
||||
<span>{detail}</span>
|
||||
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
|
||||
</div>
|
||||
<div className="ui-service-actions">
|
||||
{primaryAction ? (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BusyRing } from './BusyRing';
|
||||
|
||||
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
|
||||
|
||||
export interface StatusPillProps {
|
||||
@@ -6,6 +8,14 @@ export interface StatusPillProps {
|
||||
}
|
||||
|
||||
export function StatusPill({ tone = 'muted', children }: StatusPillProps) {
|
||||
return <span className={`ui-status-pill ui-status-pill--${tone}`}>{children}</span>;
|
||||
return (
|
||||
<span
|
||||
className={`ui-status-pill ui-status-pill--${tone}`}
|
||||
aria-busy={tone === 'checking' || undefined}
|
||||
>
|
||||
{tone === 'checking' ? <BusyRing /> : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { ActionMenu } from './ActionMenu';
|
||||
export type { ActionMenuItem } from './ActionMenu';
|
||||
export { BusyRing } from './BusyRing';
|
||||
export type { BusyRingProps } from './BusyRing';
|
||||
export { Button } from './Button';
|
||||
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
|
||||
export { DetailsPopover } from './DetailsPopover';
|
||||
|
||||
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