Compare commits

..

5 Commits

29 changed files with 2209 additions and 847 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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.",

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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)

View File

@@ -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!(

View File

@@ -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 {

View File

@@ -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
View 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) {}

View File

@@ -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
} }

View File

@@ -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]

View File

@@ -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,

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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,

View File

@@ -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 },
}); });
} }

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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>
);
}

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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 ? (

View File

@@ -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>
);
} }

View File

@@ -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';