From 838dea0e035bbfde126d0f9549f79e88e90586b0 Mon Sep 17 00:00:00 2001 From: Dokril Date: Wed, 8 Jul 2026 13:34:43 +0300 Subject: [PATCH] Add admin restart flow and loading indicators --- src-tauri/src/commands.rs | 218 ++++++++++++++++++++++------ src-tauri/src/main.rs | 2 + src/api/tauriCommands.ts | 15 ++ src/app/App.tsx | 64 ++++++++- src/styles/app.css | 290 ++++++++++++++++++++++++++++---------- src/ui/ActionMenu.tsx | 137 +++++++++++++++++- src/ui/BusyRing.tsx | 16 +++ src/ui/Button.tsx | 5 +- src/ui/IconButton.tsx | 5 +- src/ui/StatusPill.tsx | 12 +- src/ui/index.ts | 2 + 11 files changed, 639 insertions(+), 127 deletions(-) create mode 100644 src/ui/BusyRing.tsx diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2a313cf..3a769a6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -149,6 +149,15 @@ impl CommandError { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminStatusResponse { + pub is_windows: bool, + pub is_elevated: bool, + pub can_restart_elevated: bool, + pub message: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ValidationIssue { @@ -542,6 +551,18 @@ pub async fn get_status( .map_err(background_task_error)? } +#[tauri::command] +pub fn get_admin_status() -> AdminStatusResponse { + admin_status() +} + +#[tauri::command] +pub fn restart_as_admin(app: tauri::AppHandle) -> Result<(), CommandError> { + launch_app_as_admin()?; + app.exit(0); + Ok(()) +} + #[tauri::command] pub fn get_saved_state( state: tauri::State<'_, CommandState>, @@ -814,6 +835,87 @@ pub async fn uninstall_singbox() -> Result { .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 { let profiles = storage.read_profiles().map_err(storage_error)?; let targets = storage.read_targets().map_err(storage_error)?; @@ -1933,16 +2035,11 @@ fn run_elevated_singbox_service_command( "$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode", escape_powershell_single(&script_path.display().to_string()) ); - let output = Command::new("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - launch_script.as_str(), - ]) - .output(); + let output = if is_running_elevated() { + run_powershell_file(&script_path) + } else { + run_powershell_command(&launch_script) + }; let _ = fs::remove_file(&script_path); @@ -2218,16 +2315,11 @@ try {{ escape_powershell_single(&result_path.display().to_string()), escape_powershell_single(&runner_path.display().to_string()) ); - let output = Command::new("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - launch_script.as_str(), - ]) - .output(); + let output = if is_running_elevated() { + run_powershell_file(&runner_path) + } else { + run_powershell_command(&launch_script) + }; let _ = fs::remove_file(&installer_path); let _ = fs::remove_file(&runner_path); @@ -2781,16 +2873,11 @@ fn run_elevated_proxifyre_service_command( "$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode", escape_powershell_single(&script_path.display().to_string()) ); - let output = Command::new("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - launch_script.as_str(), - ]) - .output(); + let output = if is_running_elevated() { + run_powershell_file(&script_path) + } else { + run_powershell_command(&launch_script) + }; let _ = fs::remove_file(&script_path); @@ -3225,16 +3312,11 @@ try {{ escape_powershell_single(&result_path.display().to_string()), escape_powershell_single(&script_path.display().to_string()) ); - let output = Command::new("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - launch_script.as_str(), - ]) - .output(); + let output = if is_running_elevated() { + run_powershell_file(&script_path) + } else { + run_powershell_command(&launch_script) + }; let _ = fs::remove_file(&script_path); @@ -3296,6 +3378,62 @@ fn write_powershell_script(path: &Path, script: &str) -> std::io::Result<()> { fs::write(path, bytes) } +fn run_powershell_command(script: &str) -> std::io::Result { + Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]) + .output() +} + +fn run_powershell_file(script_path: &Path) -> std::io::Result { + Command::new("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 { let mut script = String::new(); script.push_str(&format!( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f251a66..80c8fc2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -36,6 +36,8 @@ fn main() { .manage(commands::CommandState::default()) .invoke_handler(tauri::generate_handler![ commands::get_status, + commands::get_admin_status, + commands::restart_as_admin, commands::get_profiles, commands::get_saved_state, commands::save_profile, diff --git a/src/api/tauriCommands.ts b/src/api/tauriCommands.ts index ae49da7..50a13d0 100644 --- a/src/api/tauriCommands.ts +++ b/src/api/tauriCommands.ts @@ -30,6 +30,13 @@ export interface StatusResponse { generatedConfigPath: string; } +export interface AdminStatusResponse { + isWindows: boolean; + isElevated: boolean; + canRestartElevated: boolean; + message: string; +} + export interface SavedStateResponse { profiles: Profile[]; targets: Target[]; @@ -135,6 +142,14 @@ export function getStatus(): Promise { return invoke('get_status'); } +export function getAdminStatus(): Promise { + return invoke('get_admin_status'); +} + +export function restartAsAdmin(): Promise { + return invoke('restart_as_admin'); +} + export function getSavedState(): Promise { return invoke('get_saved_state'); } diff --git a/src/app/App.tsx b/src/app/App.tsx index 744cac4..cbd0caa 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { open } from '@tauri-apps/plugin-dialog'; -import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Power, Trash2, Wand2 } from 'lucide-react'; +import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Power, ShieldAlert, Trash2, Wand2 } from 'lucide-react'; import { applyProfiles, fetchSingBoxSubscription, forgetSingBoxSubscription, generateSingBoxConfig, + getAdminStatus, getComponents, getProxiFyreSetupStatus, getSavedState, @@ -16,6 +17,7 @@ import { pingAllSingBoxServers, pingProxyTarget, pingSingBoxServer, + restartAsAdmin, saveProfile, saveSingBoxSubscription, saveTarget, @@ -26,6 +28,7 @@ import { stopSingBoxService, uninstallProxiFyre, uninstallSingBox, + type AdminStatusResponse, type ApplyProfilesResponse, type LocalSingBoxStatusResponse, type PingServerResponse, @@ -35,7 +38,7 @@ import { type SingBoxSetupStatus, } from '../api/tauriCommands'; import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types'; -import { Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui'; +import { BusyRing, Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui'; import { getApplyReadiness } from './readiness'; import { serviceControlState } from './viewModel'; @@ -180,6 +183,7 @@ export function App() { const [subscriptionInput, setSubscriptionInput] = useState(''); const [serverPings, setServerPings] = useState>({}); const [proxyCheck, setProxyCheck] = useState(null); + const [adminStatus, setAdminStatus] = useState(null); const [generatedConfigPath, setGeneratedConfigPath] = useState(''); const [logEntries, setLogEntries] = useState([]); const [activeLogId, setActiveLogId] = useState(null); @@ -187,6 +191,7 @@ export function App() { const [isLoading, setIsLoading] = useState(true); const [isDetectingComponents, setIsDetectingComponents] = useState(true); const [isApplying, setIsApplying] = useState(false); + const [isRestartingAsAdmin, setIsRestartingAsAdmin] = useState(false); const [isProxyChecking, setIsProxyChecking] = useState(false); const [serverPingTag, setServerPingTag] = useState(null); const [serviceAction, setServiceAction] = useState(null); @@ -259,6 +264,7 @@ export function App() { }, [activeLogId]); async function refresh() { + void refreshAdminStatus(); setIsLoading(true); try { const saved = await getSavedState(); @@ -276,6 +282,30 @@ export function App() { void refreshComponents(); } + async function refreshAdminStatus() { + try { + const status = await getAdminStatus(); + setAdminStatus(status); + } catch { + setAdminStatus(null); + } + } + + async function restartApplicationAsAdmin() { + setIsRestartingAsAdmin(true); + try { + await restartAsAdmin(); + setIsRestartingAsAdmin(false); + } catch (error) { + setIsRestartingAsAdmin(false); + showNotice({ + kind: 'error', + title: 'Перезапуск отменен', + text: errorMessage(error), + }); + } + } + async function refreshComponents() { setIsDetectingComponents(true); try { @@ -933,6 +963,33 @@ export function App() { setActiveLogId(entry.id); } + function renderAdminPrompt() { + if (!adminStatus?.canRestartElevated) return null; + + return ( + + ); + } + function renderTabs() { const tabs: Array<{ id: PanelId; label: string }> = [ { id: 'summary', label: 'Сводка' }, @@ -1176,6 +1233,7 @@ export function App() { return (
+ {systemSummary.tone === 'checking' ? : null}