Compare commits
5 Commits
6439dbfeaa
...
7316e932f0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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`.
|
- В UI держать стиль компактной Windows-утилиты, а не landing/dashboard. Использовать existing `Button`, `Tabs`, `ServiceControlRow`, `StatusPill`, `Field`, `ActionMenu`, `LogDock`.
|
||||||
- Всплывающие подсказки при наведении делать быстрыми, кастомными и читаемыми: темная compact-плашка с мягкой рамкой/тенью, появление ~120ms, без нативного browser `title` как основного UI. Для иконок расширять общий `IconButton`/tooltip-паттерн, а не дублировать JSX/CSS локально.
|
- Всплывающие подсказки при наведении делать быстрыми, кастомными и читаемыми: темная compact-плашка с мягкой рамкой/тенью, появление ~120ms, без нативного browser `title` как основного UI. Для иконок расширять общий `IconButton`/tooltip-паттерн, а не дублировать JSX/CSS локально.
|
||||||
- Apply actions должны быть disabled с объяснением, когда нет приложений, ProxiFyre отсутствует, proxy input неверный или local route не готов.
|
- 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",
|
"name": "proxywarden",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "proxywarden",
|
"name": "proxywarden",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxywarden",
|
"name": "proxywarden",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Standalone Windows desktop proxy management app for ProxyWarden.",
|
"description": "Standalone Windows desktop proxy management app for ProxyWarden.",
|
||||||
|
|||||||
@@ -353,13 +353,12 @@ function New-ReleaseDirectory {
|
|||||||
|
|
||||||
$releaseDir = Join-Path $root "proxywarden-v$TargetVersion"
|
$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)) {
|
if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) {
|
||||||
throw "Refusing to remove release directory outside OutputRoot: $releaseDir"
|
throw "Refusing to remove release directory outside OutputRoot: $releaseDir"
|
||||||
}
|
}
|
||||||
|
Write-Host "Replacing existing release directory: $releaseDir"
|
||||||
Remove-Item -LiteralPath $releaseDir -Recurse -Force
|
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
|
New-Item -ItemType Directory -Path (Join-Path $releaseDir "artifacts") -Force | Out-Null
|
||||||
@@ -409,10 +408,6 @@ function Invoke-ReleaseBuild {
|
|||||||
function Copy-ReleaseArtifacts {
|
function Copy-ReleaseArtifacts {
|
||||||
param([string]$ReleaseDir)
|
param([string]$ReleaseDir)
|
||||||
|
|
||||||
if ($SkipBuild) {
|
|
||||||
return @()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath $BundleRoot)) {
|
if (-not (Test-Path -LiteralPath $BundleRoot)) {
|
||||||
throw "Tauri bundle output was not found: $BundleRoot"
|
throw "Tauri bundle output was not found: $BundleRoot"
|
||||||
}
|
}
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -2314,7 +2314,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proxywarden"
|
name = "proxywarden"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "proxywarden"
|
name = "proxywarden"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = "Standalone Windows desktop proxy management app for ProxyWarden."
|
description = "Standalone Windows desktop proxy management app for ProxyWarden."
|
||||||
authors = ["ProxyWarden"]
|
authors = ["ProxyWarden"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -18,5 +18,5 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri-plugin-dialog = "2.7.1"
|
tauri-plugin-dialog = "2.7.1"
|
||||||
base64 = "0.22"
|
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"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
|
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
|
||||||
|
use crate::process::command_no_window;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::{
|
use std::{
|
||||||
env, fs,
|
env, fs,
|
||||||
path::Path,
|
path::Path,
|
||||||
process::Command,
|
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
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("check")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg(&config_path)
|
.arg(&config_path)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use crate::models::{
|
|||||||
Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||||||
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
|
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
|
||||||
};
|
};
|
||||||
|
use crate::process::command_no_window;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
|
use crate::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -58,6 +59,44 @@ const NDISAPI_RELEASE_API_URL: &str =
|
|||||||
"https://api.github.com/repos/wiresock/ndisapi/releases/latest";
|
"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_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 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CommandState {
|
pub struct CommandState {
|
||||||
@@ -111,6 +150,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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ValidationIssue {
|
pub struct ValidationIssue {
|
||||||
@@ -138,6 +186,17 @@ pub struct SavedStateResponse {
|
|||||||
pub generated_config_path: String,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProxiFyreSetupStatusDto {
|
pub struct ProxiFyreSetupStatusDto {
|
||||||
@@ -239,6 +298,31 @@ pub struct PingServerResponse {
|
|||||||
pub error: Option<String>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GenerateSingBoxConfigResponse {
|
pub struct GenerateSingBoxConfigResponse {
|
||||||
@@ -479,6 +563,28 @@ pub async fn get_status(
|
|||||||
.map_err(background_task_error)?
|
.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]
|
#[tauri::command]
|
||||||
pub fn get_saved_state(
|
pub fn get_saved_state(
|
||||||
state: tauri::State<'_, CommandState>,
|
state: tauri::State<'_, CommandState>,
|
||||||
@@ -610,7 +716,7 @@ pub fn ping_all_singbox_servers(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn ping_proxy_target(
|
pub fn ping_proxy_target(
|
||||||
input: PingProxyTargetInputDto,
|
input: PingProxyTargetInputDto,
|
||||||
) -> Result<PingServerResponse, CommandError> {
|
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||||||
ping_proxy_target_endpoint(input)
|
ping_proxy_target_endpoint(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,6 +857,87 @@ pub async fn uninstall_singbox() -> Result<ComponentStatusDto, CommandError> {
|
|||||||
.map_err(background_task_error)?
|
.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> {
|
pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandError> {
|
||||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||||
let targets = storage.read_targets().map_err(storage_error)?;
|
let targets = storage.read_targets().map_err(storage_error)?;
|
||||||
@@ -846,6 +1033,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> {
|
pub fn read_activity(storage: &JsonStorage) -> Result<Vec<ActivityEntryDto>, CommandError> {
|
||||||
storage
|
storage
|
||||||
.read_activity()
|
.read_activity()
|
||||||
@@ -1150,13 +1372,20 @@ pub fn apply_profiles_with_services(
|
|||||||
|
|
||||||
pub fn read_singbox_status(
|
pub fn read_singbox_status(
|
||||||
storage: &JsonStorage,
|
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> {
|
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||||
let config = storage.read_local_singbox_config().map_err(storage_error)?;
|
let config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||||
let cache = storage
|
let cache = storage
|
||||||
.read_singbox_subscription_cache()
|
.read_singbox_subscription_cache()
|
||||||
.map_err(storage_error)?;
|
.map_err(storage_error)?;
|
||||||
let detected = detect_singbox_install();
|
let component = singbox_component_from_detection(detected);
|
||||||
let component = singbox_component_from_detection(detected.as_ref());
|
|
||||||
|
|
||||||
Ok(LocalSingBoxStatusResponse {
|
Ok(LocalSingBoxStatusResponse {
|
||||||
config: LocalSingBoxConfigDto::from(&config),
|
config: LocalSingBoxConfigDto::from(&config),
|
||||||
@@ -1328,7 +1557,14 @@ pub fn ping_all_singbox_servers_in_storage(
|
|||||||
|
|
||||||
pub fn ping_proxy_target_endpoint(
|
pub fn ping_proxy_target_endpoint(
|
||||||
input: PingProxyTargetInputDto,
|
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();
|
let host = input.host.trim();
|
||||||
if host.is_empty() {
|
if host.is_empty() {
|
||||||
return Err(CommandError::new(
|
return Err(CommandError::new(
|
||||||
@@ -1337,7 +1573,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>(
|
pub fn generate_singbox_config_with_services<C>(
|
||||||
@@ -1479,6 +1748,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> {
|
fn local_lan_ipv4() -> Option<String> {
|
||||||
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
|
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||||
socket.connect("8.8.8.8:80").ok()?;
|
socket.connect("8.8.8.8:80").ok()?;
|
||||||
@@ -1602,7 +2032,7 @@ fn control_singbox_service(
|
|||||||
config_source,
|
config_source,
|
||||||
config_target.as_deref(),
|
config_target.as_deref(),
|
||||||
);
|
);
|
||||||
let output = Command::new("powershell")
|
let output = command_no_window("powershell")
|
||||||
.args([
|
.args([
|
||||||
"-NoProfile",
|
"-NoProfile",
|
||||||
"-NonInteractive",
|
"-NonInteractive",
|
||||||
@@ -1669,16 +2099,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",
|
"$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())
|
escape_powershell_single(&script_path.display().to_string())
|
||||||
);
|
);
|
||||||
let output = Command::new("powershell")
|
let output = if is_running_elevated() {
|
||||||
.args([
|
run_powershell_file(&script_path)
|
||||||
"-NoProfile",
|
} else {
|
||||||
"-NonInteractive",
|
run_powershell_command(&launch_script)
|
||||||
"-ExecutionPolicy",
|
};
|
||||||
"Bypass",
|
|
||||||
"-Command",
|
|
||||||
launch_script.as_str(),
|
|
||||||
])
|
|
||||||
.output();
|
|
||||||
|
|
||||||
let _ = fs::remove_file(&script_path);
|
let _ = fs::remove_file(&script_path);
|
||||||
|
|
||||||
@@ -1954,16 +2379,11 @@ try {{
|
|||||||
escape_powershell_single(&result_path.display().to_string()),
|
escape_powershell_single(&result_path.display().to_string()),
|
||||||
escape_powershell_single(&runner_path.display().to_string())
|
escape_powershell_single(&runner_path.display().to_string())
|
||||||
);
|
);
|
||||||
let output = Command::new("powershell")
|
let output = if is_running_elevated() {
|
||||||
.args([
|
run_powershell_file(&runner_path)
|
||||||
"-NoProfile",
|
} else {
|
||||||
"-NonInteractive",
|
run_powershell_command(&launch_script)
|
||||||
"-ExecutionPolicy",
|
};
|
||||||
"Bypass",
|
|
||||||
"-Command",
|
|
||||||
launch_script.as_str(),
|
|
||||||
])
|
|
||||||
.output();
|
|
||||||
|
|
||||||
let _ = fs::remove_file(&installer_path);
|
let _ = fs::remove_file(&installer_path);
|
||||||
let _ = fs::remove_file(&runner_path);
|
let _ = fs::remove_file(&runner_path);
|
||||||
@@ -2468,7 +2888,7 @@ Write-ServiceResult $false 'stop_failed' $status $processId
|
|||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
|
|
||||||
let output = Command::new("powershell")
|
let output = command_no_window("powershell")
|
||||||
.args([
|
.args([
|
||||||
"-NoProfile",
|
"-NoProfile",
|
||||||
"-NonInteractive",
|
"-NonInteractive",
|
||||||
@@ -2517,16 +2937,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",
|
"$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())
|
escape_powershell_single(&script_path.display().to_string())
|
||||||
);
|
);
|
||||||
let output = Command::new("powershell")
|
let output = if is_running_elevated() {
|
||||||
.args([
|
run_powershell_file(&script_path)
|
||||||
"-NoProfile",
|
} else {
|
||||||
"-NonInteractive",
|
run_powershell_command(&launch_script)
|
||||||
"-ExecutionPolicy",
|
};
|
||||||
"Bypass",
|
|
||||||
"-Command",
|
|
||||||
launch_script.as_str(),
|
|
||||||
])
|
|
||||||
.output();
|
|
||||||
|
|
||||||
let _ = fs::remove_file(&script_path);
|
let _ = fs::remove_file(&script_path);
|
||||||
|
|
||||||
@@ -2735,9 +3150,15 @@ fn uninstall_proxifyre_component() -> Result<ComponentStatusDto, CommandError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_proxifyre_setup_status() -> ProxiFyreSetupStatusDto {
|
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 vc_runtime = detect_vc_runtime();
|
||||||
let packet_filter = detect_windows_packet_filter();
|
let packet_filter = detect_windows_packet_filter();
|
||||||
let proxifyre = detect_proxyfier_install();
|
|
||||||
|
|
||||||
let vc_runtime_item = setup_item_from_program(
|
let vc_runtime_item = setup_item_from_program(
|
||||||
"vc-runtime",
|
"vc-runtime",
|
||||||
@@ -2848,7 +3269,7 @@ if ($null -ne $program) {{
|
|||||||
escape_powershell_single(pattern)
|
escape_powershell_single(pattern)
|
||||||
);
|
);
|
||||||
|
|
||||||
let output = Command::new("powershell")
|
let output = command_no_window("powershell")
|
||||||
.args([
|
.args([
|
||||||
"-NoProfile",
|
"-NoProfile",
|
||||||
"-NonInteractive",
|
"-NonInteractive",
|
||||||
@@ -2961,16 +3382,11 @@ try {{
|
|||||||
escape_powershell_single(&result_path.display().to_string()),
|
escape_powershell_single(&result_path.display().to_string()),
|
||||||
escape_powershell_single(&script_path.display().to_string())
|
escape_powershell_single(&script_path.display().to_string())
|
||||||
);
|
);
|
||||||
let output = Command::new("powershell")
|
let output = if is_running_elevated() {
|
||||||
.args([
|
run_powershell_file(&script_path)
|
||||||
"-NoProfile",
|
} else {
|
||||||
"-NonInteractive",
|
run_powershell_command(&launch_script)
|
||||||
"-ExecutionPolicy",
|
};
|
||||||
"Bypass",
|
|
||||||
"-Command",
|
|
||||||
launch_script.as_str(),
|
|
||||||
])
|
|
||||||
.output();
|
|
||||||
|
|
||||||
let _ = fs::remove_file(&script_path);
|
let _ = fs::remove_file(&script_path);
|
||||||
|
|
||||||
@@ -3032,6 +3448,62 @@ fn write_powershell_script(path: &Path, script: &str) -> std::io::Result<()> {
|
|||||||
fs::write(path, bytes)
|
fs::write(path, bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||||
let mut script = String::new();
|
let mut script = String::new();
|
||||||
script.push_str(&format!(
|
script.push_str(&format!(
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use crate::models::{
|
|||||||
ComponentId, ComponentState, ComponentStatus, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT,
|
ComponentId, ComponentState, ComponentStatus, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT,
|
||||||
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME,
|
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME,
|
||||||
};
|
};
|
||||||
|
use crate::process::command_no_window;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::Command,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -460,7 +460,7 @@ fn same_path(left: &Path, right: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn powershell_bool(script: &str) -> bool {
|
fn powershell_bool(script: &str) -> bool {
|
||||||
Command::new("powershell")
|
command_no_window("powershell")
|
||||||
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()
|
||||||
@@ -492,7 +492,7 @@ $items |
|
|||||||
ConvertTo-Json -Compress
|
ConvertTo-Json -Compress
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
let Ok(output) = Command::new("powershell")
|
let Ok(output) = command_no_window("powershell")
|
||||||
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||||
.output()
|
.output()
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod activity;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod component_detection;
|
mod component_detection;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod process;
|
||||||
mod singbox_service;
|
mod singbox_service;
|
||||||
mod storage;
|
mod storage;
|
||||||
mod subscription;
|
mod subscription;
|
||||||
@@ -36,6 +37,9 @@ fn main() {
|
|||||||
.manage(commands::CommandState::default())
|
.manage(commands::CommandState::default())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::get_status,
|
commands::get_status,
|
||||||
|
commands::get_admin_status,
|
||||||
|
commands::restart_as_admin,
|
||||||
|
commands::get_startup_snapshot,
|
||||||
commands::get_profiles,
|
commands::get_profiles,
|
||||||
commands::get_saved_state,
|
commands::get_saved_state,
|
||||||
commands::save_profile,
|
commands::save_profile,
|
||||||
|
|||||||
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) {}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ProxyWarden",
|
"productName": "ProxyWarden",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"identifier": "ru.dokops.proxywarden.windows",
|
"identifier": "ru.dokops.proxywarden.windows",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -13,9 +13,10 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "ProxyWarden",
|
"title": "ProxyWarden",
|
||||||
"width": 1120,
|
"width": 820,
|
||||||
"height": 760,
|
"height": 760,
|
||||||
"minWidth": 760,
|
"minWidth": 820,
|
||||||
|
"maxWidth": 820,
|
||||||
"minHeight": 560,
|
"minHeight": 560,
|
||||||
"resizable": true
|
"resizable": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ mod commands;
|
|||||||
mod component_detection;
|
mod component_detection;
|
||||||
#[path = "../src/models.rs"]
|
#[path = "../src/models.rs"]
|
||||||
mod models;
|
mod models;
|
||||||
|
#[path = "../src/process.rs"]
|
||||||
|
mod process;
|
||||||
#[path = "../src/adapters/proxifyre.rs"]
|
#[path = "../src/adapters/proxifyre.rs"]
|
||||||
mod proxifyre;
|
mod proxifyre;
|
||||||
#[path = "../src/adapters/proxy_router.rs"]
|
#[path = "../src/adapters/proxy_router.rs"]
|
||||||
@@ -222,17 +224,21 @@ fn ping_proxy_target_reports_open_tcp_endpoint() {
|
|||||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
|
||||||
let port = listener.local_addr().expect("read local addr").port();
|
let port = listener.local_addr().expect("read local addr").port();
|
||||||
|
|
||||||
let result = commands::ping_proxy_target_endpoint(commands::PingProxyTargetInputDto {
|
let result = commands::ping_proxy_target_endpoint_with_probes(
|
||||||
host: "127.0.0.1".to_string(),
|
commands::PingProxyTargetInputDto {
|
||||||
port,
|
host: "127.0.0.1".to_string(),
|
||||||
})
|
port,
|
||||||
|
},
|
||||||
|
&[],
|
||||||
|
)
|
||||||
.expect("ping should return response");
|
.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, "127.0.0.1");
|
||||||
assert_eq!(result.server_port, port);
|
assert_eq!(result.server_port, port);
|
||||||
assert!(result.ok);
|
assert!(result.ok);
|
||||||
assert!(result.latency.is_some());
|
assert!(result.latency.is_some());
|
||||||
|
assert!(result.probes.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
mod component_detection;
|
mod component_detection;
|
||||||
#[path = "../src/models.rs"]
|
#[path = "../src/models.rs"]
|
||||||
mod models;
|
mod models;
|
||||||
|
#[path = "../src/process.rs"]
|
||||||
|
mod process;
|
||||||
|
|
||||||
use component_detection::{
|
use component_detection::{
|
||||||
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
|
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#[path = "../src/models.rs"]
|
#[path = "../src/models.rs"]
|
||||||
mod models;
|
mod models;
|
||||||
|
#[path = "../src/process.rs"]
|
||||||
|
mod process;
|
||||||
#[path = "../src/adapters/proxifyre.rs"]
|
#[path = "../src/adapters/proxifyre.rs"]
|
||||||
mod proxifyre;
|
mod proxifyre;
|
||||||
#[path = "../src/adapters/proxy_router.rs"]
|
#[path = "../src/adapters/proxy_router.rs"]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ mod commands;
|
|||||||
mod component_detection;
|
mod component_detection;
|
||||||
#[path = "../src/models.rs"]
|
#[path = "../src/models.rs"]
|
||||||
mod models;
|
mod models;
|
||||||
|
#[path = "../src/process.rs"]
|
||||||
|
mod process;
|
||||||
#[path = "../src/adapters/proxifyre.rs"]
|
#[path = "../src/adapters/proxifyre.rs"]
|
||||||
mod proxifyre;
|
mod proxifyre;
|
||||||
#[path = "../src/adapters/proxy_router.rs"]
|
#[path = "../src/adapters/proxy_router.rs"]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
mod component_detection;
|
mod component_detection;
|
||||||
#[path = "../src/models.rs"]
|
#[path = "../src/models.rs"]
|
||||||
mod models;
|
mod models;
|
||||||
|
#[path = "../src/process.rs"]
|
||||||
|
mod process;
|
||||||
#[path = "../src/singbox_service.rs"]
|
#[path = "../src/singbox_service.rs"]
|
||||||
mod singbox_service;
|
mod singbox_service;
|
||||||
|
|
||||||
@@ -55,7 +57,8 @@ noise
|
|||||||
#[test]
|
#[test]
|
||||||
fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
|
fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
|
||||||
assert!(
|
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:\Windows")).is_err());
|
||||||
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err());
|
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err());
|
||||||
@@ -63,7 +66,12 @@ fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn service_control_script_targets_named_service_and_action() {
|
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("$serviceName = 'ProxyWardenSingBox'"));
|
||||||
assert!(script.contains("$action = 'start'"));
|
assert!(script.contains("$action = 'start'"));
|
||||||
@@ -84,9 +92,9 @@ fn service_control_script_syncs_generated_config_before_start() {
|
|||||||
assert!(script.contains(
|
assert!(script.contains(
|
||||||
"$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'"
|
"$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'"
|
||||||
));
|
));
|
||||||
assert!(script.contains(
|
assert!(
|
||||||
"$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'"
|
script.contains("$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'")
|
||||||
));
|
);
|
||||||
assert!(script.contains("Copy-Item -LiteralPath $configSource"));
|
assert!(script.contains("Copy-Item -LiteralPath $configSource"));
|
||||||
assert!(script.contains("'config_sync_failed'"));
|
assert!(script.contains("'config_sync_failed'"));
|
||||||
}
|
}
|
||||||
@@ -119,7 +127,9 @@ fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) ->
|
|||||||
DetectedSingBox {
|
DetectedSingBox {
|
||||||
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box"),
|
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"),
|
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,
|
binary_exists,
|
||||||
wrapper_exists,
|
wrapper_exists,
|
||||||
running,
|
running,
|
||||||
|
|||||||
@@ -30,12 +30,28 @@ export interface StatusResponse {
|
|||||||
generatedConfigPath: string;
|
generatedConfigPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminStatusResponse {
|
||||||
|
isWindows: boolean;
|
||||||
|
isElevated: boolean;
|
||||||
|
canRestartElevated: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SavedStateResponse {
|
export interface SavedStateResponse {
|
||||||
profiles: Profile[];
|
profiles: Profile[];
|
||||||
targets: Target[];
|
targets: Target[];
|
||||||
generatedConfigPath: string;
|
generatedConfigPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StartupSnapshotResponse {
|
||||||
|
adminStatus: AdminStatusResponse;
|
||||||
|
savedState: SavedStateResponse;
|
||||||
|
components: ComponentStatus[];
|
||||||
|
proxifyreSetupStatus: ProxiFyreSetupStatus;
|
||||||
|
singboxStatus: LocalSingBoxStatusResponse;
|
||||||
|
singboxSetupStatus: SingBoxSetupStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProxiFyreSetupItem {
|
export interface ProxiFyreSetupItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -75,6 +91,27 @@ export interface PingServerResponse {
|
|||||||
error?: string;
|
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 {
|
export interface GenerateSingBoxConfigResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -114,6 +151,18 @@ export function getStatus(): Promise<StatusResponse> {
|
|||||||
return invoke<StatusResponse>('get_status');
|
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> {
|
export function getSavedState(): Promise<SavedStateResponse> {
|
||||||
return invoke<SavedStateResponse>('get_saved_state');
|
return invoke<SavedStateResponse>('get_saved_state');
|
||||||
}
|
}
|
||||||
@@ -184,8 +233,8 @@ export function pingAllSingBoxServers(): Promise<PingServerResponse[]> {
|
|||||||
return invoke<PingServerResponse[]>('ping_all_singbox_servers');
|
return invoke<PingServerResponse[]>('ping_all_singbox_servers');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pingProxyTarget(host: string, port: number): Promise<PingServerResponse> {
|
export function pingProxyTarget(host: string, port: number): Promise<ProxyTargetCheckResponse> {
|
||||||
return invoke<PingServerResponse>('ping_proxy_target', {
|
return invoke<ProxyTargetCheckResponse>('ping_proxy_target', {
|
||||||
input: { host, port },
|
input: { host, port },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
957
src/app/App.tsx
957
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 |
1188
src/styles/app.css
1188
src/styles/app.css
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,13 @@
|
|||||||
import { MoreHorizontal } from 'lucide-react';
|
import { MoreHorizontal } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
} from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
|
|
||||||
export interface ActionMenuItem {
|
export interface ActionMenuItem {
|
||||||
@@ -16,6 +25,17 @@ export interface ActionMenuProps {
|
|||||||
disabled?: boolean;
|
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({
|
export function ActionMenu({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -23,30 +43,137 @@ export function ActionMenu({
|
|||||||
items,
|
items,
|
||||||
disabled,
|
disabled,
|
||||||
}: ActionMenuProps) {
|
}: 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 (
|
return (
|
||||||
<div className="ui-action-menu">
|
<div className="ui-action-menu" ref={triggerRef}>
|
||||||
<IconButton
|
<IconButton
|
||||||
label={label}
|
label={label}
|
||||||
icon={<MoreHorizontal size={20} strokeWidth={2} />}
|
icon={<MoreHorizontal size={20} strokeWidth={2} />}
|
||||||
onClick={() => onOpenChange(!open)}
|
onClick={() => onOpenChange(!open)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-controls={open ? menuId : undefined}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-haspopup="menu"
|
||||||
/>
|
/>
|
||||||
{open ? (
|
{open && typeof document !== 'undefined' ? createPortal(
|
||||||
<div className="ui-action-menu-popover" role="menu">
|
<div
|
||||||
|
className="ui-action-menu-popover"
|
||||||
|
data-placement={position.placement}
|
||||||
|
id={menuId}
|
||||||
|
ref={popoverRef}
|
||||||
|
role="menu"
|
||||||
|
style={popoverStyle}
|
||||||
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className={item.danger ? 'is-danger' : ''}
|
className={item.danger ? 'is-danger' : ''}
|
||||||
onClick={item.onClick}
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
item.onClick();
|
||||||
|
}}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
key={item.label}
|
key={item.label}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { BusyRing } from './BusyRing';
|
||||||
|
|
||||||
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
|
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
|
||||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||||
@@ -37,8 +38,10 @@ export function Button({
|
|||||||
{...props}
|
{...props}
|
||||||
className={classes}
|
className={classes}
|
||||||
disabled={disabled || loading}
|
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>
|
<span className="ui-button-icon" aria-hidden="true">{leftIcon}</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>
|
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { BusyRing } from './BusyRing';
|
||||||
|
|
||||||
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
|
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
|
||||||
|
|
||||||
@@ -36,8 +37,10 @@ export function IconButton({
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
data-tooltip={tooltipText}
|
data-tooltip={tooltipText}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
|
aria-busy={loading || undefined}
|
||||||
>
|
>
|
||||||
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}
|
{loading ? <BusyRing /> : null}
|
||||||
|
{icon}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,9 +59,11 @@ export function ServiceControlRow({
|
|||||||
</span>
|
</span>
|
||||||
<span className="ui-service-dot" aria-hidden="true" />
|
<span className="ui-service-dot" aria-hidden="true" />
|
||||||
<div className="ui-service-text">
|
<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>
|
<span>{detail}</span>
|
||||||
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ui-service-actions">
|
<div className="ui-service-actions">
|
||||||
{primaryAction ? (
|
{primaryAction ? (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { BusyRing } from './BusyRing';
|
||||||
|
|
||||||
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
|
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
|
||||||
|
|
||||||
export interface StatusPillProps {
|
export interface StatusPillProps {
|
||||||
@@ -6,6 +8,14 @@ export interface StatusPillProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StatusPill({ tone = 'muted', children }: 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 { ActionMenu } from './ActionMenu';
|
||||||
export type { ActionMenuItem } from './ActionMenu';
|
export type { ActionMenuItem } from './ActionMenu';
|
||||||
|
export { BusyRing } from './BusyRing';
|
||||||
|
export type { BusyRingProps } from './BusyRing';
|
||||||
export { Button } from './Button';
|
export { Button } from './Button';
|
||||||
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
|
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
|
||||||
export { DetailsPopover } from './DetailsPopover';
|
export { DetailsPopover } from './DetailsPopover';
|
||||||
|
|||||||
Reference in New Issue
Block a user