use crate::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy}; use crate::adapters::proxy_router::{ ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig, ProxyRouterRequest, }; use crate::adapters::singbox::{ SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker, SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest, }; use crate::component_detection::{ default_proxifyre_install_dir, default_singbox_install_dir, detect_proxyfier_install, detect_proxyfier_install_with_host, detect_singbox_install, proxifyre_install_dir_from_app_dir, proxyfier_component_from_detection, singbox_component_from_detection, singbox_install_dir_from_app_dir, DetectedProxyfier, DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost, }; use crate::elevated_scripts; use crate::models::{ ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind, }; use crate::process::command_no_window; use crate::safe_fs; use crate::singbox_service::{ build_singbox_setup_status_with_install_root, ensure_safe_singbox_install_dir, parse_service_command_output as parse_singbox_service_command_output, service_control_script, ServiceCommandOutput as SingBoxServiceCommandOutput, SingBoxServiceAction, SingBoxSetupStatus, }; use crate::storage::{default_config_root, JsonStorage}; use crate::subscription; use crate::validation::{normalize_profile, normalize_target, ValidationError}; use serde::{Deserialize, Serialize}; use std::env; use std::fs; use std::net::{IpAddr, TcpStream, ToSocketAddrs, UdpSocket}; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::time::{Duration, Instant}; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::Manager; const MAIN_PROFILE_ID: &str = "main-profile"; const MAIN_TARGET_ID: &str = "main-proxy"; const PROXIFYRE_RELEASE_API_URL: &str = "https://api.github.com/repos/wiresock/proxifyre/releases/latest"; const NDISAPI_RELEASE_API_URL: &str = "https://api.github.com/repos/wiresock/ndisapi/releases/latest"; const PROXIFYRE_PINNED_RELEASE_TAG: &str = "v2.2.1"; const NDISAPI_PINNED_RELEASE_TAG: &str = "v3.6.2"; const NDISAPI_PINNED_INSTALLER_VERSION: &str = "3.6.2.1"; const VC_REDIST_X64_URL: &str = "https://aka.ms/vc14/vc_redist.x64.exe"; const VC_REDIST_X86_URL: &str = "https://aka.ms/vc14/vc_redist.x86.exe"; const PROXY_CHECK_TIMEOUT: Duration = Duration::from_secs(4); const PROXY_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2); const PROXY_CHECK_USER_AGENT: &str = "proxywarden route-check"; const DEFAULT_PROXY_PROBES: &[ProxyProbeEndpoint] = &[ ProxyProbeEndpoint { id: "cloudflare-trace", name: "Cloudflare Trace", url: "https://www.cloudflare.com/cdn-cgi/trace", ip_source: ProbeIpSource::CloudflareTrace, }, ProxyProbeEndpoint { id: "cloudflare-speed", name: "Cloudflare Speed", url: "https://speed.cloudflare.com/meta", ip_source: ProbeIpSource::JsonField("clientIp"), }, ProxyProbeEndpoint { id: "ipify", name: "ipify", url: "https://api.ipify.org?format=json", ip_source: ProbeIpSource::JsonField("ip"), }, ]; #[derive(Debug, Clone, Copy)] pub struct ProxyProbeEndpoint { id: &'static str, name: &'static str, url: &'static str, ip_source: ProbeIpSource, } #[derive(Debug, Clone, Copy)] enum ProbeIpSource { CloudflareTrace, JsonField(&'static str), } #[derive(Debug, Clone)] pub struct CommandState { root: PathBuf, } impl CommandState { pub fn new(root: impl Into) -> Self { Self { root: root.into() } } pub fn storage(&self) -> JsonStorage { JsonStorage::new(self.root.clone()) } } impl Default for CommandState { fn default() -> Self { Self::new(default_config_root()) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CommandError { pub code: String, pub message: String, #[serde(default)] pub details: Vec, } impl CommandError { fn new(code: impl Into, message: impl Into) -> Self { Self { code: code.into(), message: message.into(), details: Vec::new(), } } fn with_details( code: impl Into, message: impl Into, details: Vec, ) -> Self { Self { code: code.into(), message: message.into(), details, } } } #[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 { pub field: String, pub message: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StatusResponse { pub route_line: String, pub active_profile_count: usize, pub routed_app_count: usize, pub active_target: Option, pub components: Vec, pub recent_activity: Vec, pub generated_config_path: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SavedStateResponse { pub profiles: Vec, pub targets: Vec, 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, pub proxifyre_setup_status: ProxiFyreSetupStatusDto, pub singbox_status: LocalSingBoxStatusResponse, pub singbox_setup_status: SingBoxSetupStatusDto, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProxiFyreSetupStatusDto { pub ready: bool, pub missing_count: usize, pub items: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProxiFyreSetupItemDto { pub id: String, pub name: String, pub installed: bool, pub version: Option, pub details: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProxiFyreSetupProgressDto { pub operation: String, pub status: String, pub active_step: Option, pub percent: u8, pub message: String, pub updated_at: Option, } pub type SingBoxSetupStatusDto = SingBoxSetupStatus; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalSingBoxStatusResponse { pub config: LocalSingBoxConfigDto, pub cache: Option, pub component: ComponentStatusDto, pub generated_config_path: String, pub lan_listen_host: Option, #[cfg(debug_assertions)] pub subscription_identity: SubscriptionRequestIdentityDto, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalSingBoxConfigDto { pub subscription_display_url: Option, pub has_subscription: bool, pub selected_server_tag: Option, pub listen_host: String, pub listen_port: u16, pub service_name: String, pub install_root: String, pub updated_at: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SubscriptionCacheDto { pub servers: Vec, pub user_info: serde_json::Map, pub fetched_at: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SubscriptionServerDto { pub tag: String, #[serde(rename = "type")] pub server_type: String, pub server: String, pub server_port: u16, } #[cfg(debug_assertions)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SubscriptionRequestIdentityDto { pub headers: Vec, } #[cfg(debug_assertions)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SubscriptionRequestHeaderDto { pub name: String, pub value: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SaveSingBoxSubscriptionInputDto { pub subscription_url: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SelectSingBoxServerInputDto { pub tag: String, #[serde(default)] pub server: Option, #[serde(default)] pub server_port: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PingSingBoxServerInputDto { pub tag: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PingProxyTargetInputDto { pub host: String, pub port: u16, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PingServerResponse { pub tag: String, pub server: String, pub server_port: u16, pub ok: bool, pub latency: Option, pub error: Option, } #[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, pub latency: Option, pub ip: Option, pub error: Option, } #[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, pub error: Option, pub probes: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GenerateSingBoxConfigResponse { pub success: bool, pub message: String, pub adapter_id: String, pub generated_config_path: String, pub selected_server_tag: String, pub listen_host: String, pub listen_port: u16, pub check: Option, pub activity: ActivityEntryDto, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProfileInputDto { pub id: Option, pub name: String, #[serde(default)] pub enabled: Option, #[serde(default)] pub target_id: Option, #[serde(default)] pub protocols: Option>, #[serde(default)] pub items: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProfileItemInputDto { #[serde(rename = "type")] pub item_type: String, pub value: String, #[serde(default)] pub recursive: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TargetInputDto { pub id: Option, pub name: String, #[serde(default)] pub kind: Option, #[serde(default)] pub protocol: Option, pub host: String, pub port: u32, #[serde(default)] pub requires_component: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProfileDto { pub id: String, pub name: String, pub enabled: bool, pub target_id: String, pub protocols: Vec, pub items: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProfileItemDto { #[serde(rename = "type")] pub item_type: ProfileItemType, pub value: String, pub recursive: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TargetDto { pub id: String, pub name: String, pub kind: TargetKind, pub protocol: ProxyProtocol, pub host: String, pub port: u16, #[serde(skip_serializing_if = "Option::is_none")] pub requires_component: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ComponentStatusDto { pub id: ComponentId, pub name: String, pub state: ComponentState, pub installed: bool, pub running: bool, #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub service_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub service_status: Option, pub problems: Vec, pub actions: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ActivityEntryDto { pub id: String, pub at: String, pub level: ActivityLevel, pub title: String, pub message: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResolveProfilePreviewResponse { pub profile_id: String, pub apps: Vec, pub warnings: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResolvedAppDto { pub source_type: ProfileItemType, pub source_value: String, pub app_name: String, pub notes: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApplyProfilesResponse { pub success: bool, pub changed: bool, pub message: String, pub adapter_id: String, pub generated_config_path: String, pub enabled_profiles: usize, pub routed_apps: usize, pub helper: HelperApplyResult, pub activity: ActivityEntryDto, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HelperApplyResult { pub success: bool, pub changed: bool, pub action: String, pub message: String, } pub struct HelperApplyRequest<'a> { pub adapter_id: &'a str, pub config_path: &'a Path, pub config_contents: &'a str, } pub trait ProxyApplyHelper { fn apply_proxy_config( &self, request: HelperApplyRequest<'_>, ) -> Result; } pub trait SubscriptionFetcher { fn fetch_subscription( &self, url: &str, identity: &subscription::SubscriptionFetchIdentity, ) -> Result; } pub struct SystemSubscriptionFetcher; impl SubscriptionFetcher for SystemSubscriptionFetcher { fn fetch_subscription( &self, url: &str, identity: &subscription::SubscriptionFetchIdentity, ) -> Result { subscription::fetch_subscription_with_identity(url, identity) } } #[cfg(debug_assertions)] fn subscription_request_identity_for_display() -> SubscriptionRequestIdentityDto { let identity = subscription::SubscriptionFetchIdentity::default(); let headers = identity .request_headers_without_device_hwid() .into_iter() .map(|(name, value)| SubscriptionRequestHeaderDto { name: name.to_string(), value, }) .collect(); SubscriptionRequestIdentityDto { headers } } pub trait Clock { fn now(&self) -> String; } pub struct SystemClock; impl Clock for SystemClock { fn now(&self) -> String { let seconds = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_secs()) .unwrap_or(0); format!("unix:{seconds}") } } pub struct DetectedProxyApplyHelper { host: H, } impl DetectedProxyApplyHelper { pub fn system() -> Self { SystemProxyfierDetectionHost.into() } } impl From for DetectedProxyApplyHelper { fn from(host: H) -> Self { Self { host } } } impl ProxyApplyHelper for DetectedProxyApplyHelper where H: ProxyfierDetectionHost, { fn apply_proxy_config( &self, request: HelperApplyRequest<'_>, ) -> Result { let Some(detected) = detect_proxyfier_install_with_host(&self.host) else { return staged_apply_result(request); }; apply_to_detected_proxyfier(request, &detected) } } #[tauri::command] pub async fn get_status( state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || build_status(&storage)) .await .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 { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || read_startup_snapshot(&storage)) .await .map_err(background_task_error)? } #[tauri::command] pub fn get_saved_state( state: tauri::State<'_, CommandState>, ) -> Result { read_saved_state(&state.storage()) } #[tauri::command] pub fn get_profiles( state: tauri::State<'_, CommandState>, ) -> Result, CommandError> { read_profiles(&state.storage()) } #[tauri::command] pub fn save_profile( state: tauri::State<'_, CommandState>, input: ProfileInputDto, ) -> Result { save_profile_to_storage(&state.storage(), input) } #[tauri::command] pub fn get_targets(state: tauri::State<'_, CommandState>) -> Result, CommandError> { read_targets(&state.storage()) } #[tauri::command] pub fn save_target( state: tauri::State<'_, CommandState>, input: TargetInputDto, ) -> Result { save_target_to_storage(&state.storage(), input) } #[tauri::command] pub async fn get_components( state: tauri::State<'_, CommandState>, ) -> Result, CommandError> { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || read_components(&storage)) .await .map_err(background_task_error)? } #[tauri::command] pub async fn get_proxifyre_setup_status( app: tauri::AppHandle, ) -> Result { let install_dir = proxifyre_install_dir_for_app(&app)?; tauri::async_runtime::spawn_blocking(move || { build_proxifyre_setup_status_for_install_dir(&install_dir) }) .await .map_err(background_task_error) } #[tauri::command] pub async fn get_proxifyre_setup_progress( state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || read_proxifyre_setup_progress(&storage)) .await .map_err(background_task_error)? } #[tauri::command] pub async fn get_singbox_status( state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || read_singbox_status(&storage)) .await .map_err(background_task_error)? } #[tauri::command] pub async fn get_singbox_setup_status( app: tauri::AppHandle, ) -> Result { let install_dir = singbox_install_dir_for_app(&app)?; tauri::async_runtime::spawn_blocking(move || { build_singbox_setup_status_with_install_root( detect_singbox_install().as_ref(), &install_dir, ) }) .await .map_err(background_task_error) } #[tauri::command] pub fn resolve_profile_preview( input: ProfileInputDto, ) -> Result { resolve_preview(input) } #[tauri::command] pub fn save_singbox_subscription( state: tauri::State<'_, CommandState>, input: SaveSingBoxSubscriptionInputDto, ) -> Result { save_singbox_subscription_to_storage(&state.storage(), input, &SystemClock) } #[tauri::command] pub async fn fetch_singbox_subscription( state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || { fetch_singbox_subscription_with_fetcher(&storage, &SystemSubscriptionFetcher, &SystemClock) }) .await .map_err(background_task_error)? } #[tauri::command] pub fn forget_singbox_subscription( state: tauri::State<'_, CommandState>, ) -> Result { forget_singbox_subscription_in_storage(&state.storage(), &SystemClock) } #[tauri::command] pub fn select_singbox_server( state: tauri::State<'_, CommandState>, input: SelectSingBoxServerInputDto, ) -> Result { select_singbox_server_in_storage(&state.storage(), input, &SystemClock) } #[tauri::command] pub async fn ping_singbox_server( state: tauri::State<'_, CommandState>, input: PingSingBoxServerInputDto, ) -> Result { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || ping_singbox_server_in_storage(&storage, input)) .await .map_err(background_task_error)? } #[tauri::command] pub async fn ping_all_singbox_servers( state: tauri::State<'_, CommandState>, ) -> Result, CommandError> { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || ping_all_singbox_servers_in_storage(&storage)) .await .map_err(background_task_error)? } #[tauri::command] pub async fn ping_proxy_target( input: PingProxyTargetInputDto, ) -> Result { tauri::async_runtime::spawn_blocking(move || ping_proxy_target_endpoint(input)) .await .map_err(background_task_error)? } #[tauri::command] pub async fn generate_singbox_config( state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || { let detected = detect_singbox_install(); let binary_path = detected .as_ref() .map(|detected| detected.executable_path.as_path()); generate_singbox_config_with_services( &storage, &SingBoxAdapter::default(), &SingBoxCommandChecker, &SystemClock, binary_path, ) }) .await .map_err(background_task_error)? } #[tauri::command] pub async fn apply_profiles( state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || { let adapter = ProxiFyreAdapter::default(); let helper = DetectedProxyApplyHelper::system(); let clock = SystemClock; apply_profiles_with_services(&storage, &adapter, &helper, &clock) }) .await .map_err(background_task_error)? } #[tauri::command] pub fn get_logs( state: tauri::State<'_, CommandState>, ) -> Result, CommandError> { read_activity(&state.storage()) } #[tauri::command] pub fn open_config_location(state: tauri::State<'_, CommandState>) -> Result { let storage = state.storage(); let generated_path = storage .paths() .generated_dir .join("proxifyre-app-config.json"); let config_path = detect_proxyfier_install() .and_then(|detected| detected.config_path) .filter(|path| path.exists()) .or_else(|| generated_path.exists().then_some(generated_path.clone())); let Some(config_path) = config_path else { return open_folder(&storage.paths().generated_dir); }; open_file_or_select(&config_path)?; Ok(config_path.display().to_string()) } #[tauri::command] pub async fn start_proxifyre_service() -> Result { tauri::async_runtime::spawn_blocking(|| control_proxifyre_service(ServiceControlAction::Start)) .await .map_err(background_task_error)? } #[tauri::command] pub async fn stop_proxifyre_service() -> Result { tauri::async_runtime::spawn_blocking(|| control_proxifyre_service(ServiceControlAction::Stop)) .await .map_err(background_task_error)? } #[tauri::command] pub async fn install_proxifyre( app: tauri::AppHandle, state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || install_proxifyre_component(&storage, &app)) .await .map_err(background_task_error)? } #[tauri::command] pub async fn uninstall_proxifyre() -> Result { tauri::async_runtime::spawn_blocking(uninstall_proxifyre_component) .await .map_err(background_task_error)? } #[tauri::command] pub async fn start_singbox_service( state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || { let detected = detect_singbox_install(); let binary_path = detected .as_ref() .map(|detected| detected.executable_path.as_path()); let generated = generate_singbox_config_with_services( &storage, &SingBoxAdapter::default(), &SingBoxCommandChecker, &SystemClock, binary_path, )?; let generated_path = PathBuf::from(generated.generated_config_path); control_singbox_service(SingBoxServiceAction::Start, Some(generated_path.as_path())) }) .await .map_err(background_task_error)? } #[tauri::command] pub async fn stop_singbox_service() -> Result { tauri::async_runtime::spawn_blocking(|| { control_singbox_service(SingBoxServiceAction::Stop, None) }) .await .map_err(background_task_error)? } #[tauri::command] pub async fn install_singbox( app: tauri::AppHandle, state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); let install_dir = singbox_install_dir_for_app(&app)?; tauri::async_runtime::spawn_blocking(move || install_singbox_component(&storage, &install_dir)) .await .map_err(background_task_error)? } #[tauri::command] pub async fn uninstall_singbox() -> Result { tauri::async_runtime::spawn_blocking(uninstall_singbox_component) .await .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)?; let components = components_or_defaults(storage)?; let activity = storage.read_activity().map_err(storage_error)?; let active_profile_count = profiles.iter().filter(|profile| profile.enabled).count(); let routed_app_count = profiles .iter() .filter(|profile| profile.enabled) .map(|profile| profile.items.len()) .sum(); let active_target = profiles .iter() .find(|profile| profile.enabled) .and_then(|profile| targets.iter().find(|target| target.id == profile.target_id)); let route_line = route_line(active_target); Ok(StatusResponse { route_line, active_profile_count, routed_app_count, active_target: active_target.map(TargetDto::from), components: components.iter().map(ComponentStatusDto::from).collect(), recent_activity: activity .iter() .take(10) .map(ActivityEntryDto::from) .collect(), generated_config_path: storage .paths() .generated_dir .join("proxifyre-app-config.json") .display() .to_string(), }) } pub fn read_profiles(storage: &JsonStorage) -> Result, CommandError> { storage .read_profiles() .map_err(storage_error) .map(|profiles| profiles.iter().map(ProfileDto::from).collect()) } pub fn save_profile_to_storage( storage: &JsonStorage, input: ProfileInputDto, ) -> Result { let profile = normalize_profile(input.into()).map_err(validation_error)?; let mut profiles = storage.read_profiles().map_err(storage_error)?; match profiles .iter() .position(|existing| existing.id == profile.id) { Some(index) => profiles[index] = profile.clone(), None => profiles.push(profile.clone()), } storage.write_profiles(&profiles).map_err(storage_error)?; Ok(ProfileDto::from(&profile)) } pub fn read_targets(storage: &JsonStorage) -> Result, CommandError> { storage .read_targets() .map_err(storage_error) .map(|targets| targets.iter().map(TargetDto::from).collect()) } pub fn save_target_to_storage( storage: &JsonStorage, input: TargetInputDto, ) -> Result { let target = normalize_target(input.into()).map_err(validation_error)?; let mut targets = storage.read_targets().map_err(storage_error)?; match targets.iter().position(|existing| existing.id == target.id) { Some(index) => targets[index] = target.clone(), None => targets.push(target.clone()), } storage.write_targets(&targets).map_err(storage_error)?; Ok(TargetDto::from(&target)) } pub fn read_components(storage: &JsonStorage) -> Result, CommandError> { components_or_defaults(storage).map(|components| { components .iter() .map(ComponentStatusDto::from) .collect::>() }) } pub fn read_startup_snapshot( storage: &JsonStorage, ) -> Result { 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(), &default_proxifyre_install_dir(), ); let singbox_status = read_singbox_status_with_detection(storage, detected_singbox.as_ref())?; let singbox_setup_status = build_singbox_setup_status_with_install_root( detected_singbox.as_ref(), &default_singbox_install_dir(), ); Ok(StartupSnapshotResponse { admin_status: admin_status(), saved_state, components, proxifyre_setup_status, singbox_status, singbox_setup_status, }) } pub fn read_activity(storage: &JsonStorage) -> Result, CommandError> { storage .read_activity() .map_err(storage_error) .map(|entries| entries.iter().map(ActivityEntryDto::from).collect()) } pub fn read_saved_state(storage: &JsonStorage) -> Result { let detected_config_path = detect_proxyfier_install().and_then(|detected| detected.config_path); read_saved_state_with_proxifyre_config(storage, detected_config_path.as_deref()) } pub fn read_saved_state_with_proxifyre_config( storage: &JsonStorage, proxifyre_config_path: Option<&Path>, ) -> Result { let mut profiles = storage.read_profiles().map_err(storage_error)?; let mut targets = storage.read_targets().map_err(storage_error)?; if should_bootstrap_profiles(&profiles) { if let Some(imported) = proxifyre_config_path.and_then(import_saved_state_from_proxifyre_config) { profiles = imported.profiles; upsert_targets(&mut targets, imported.targets); storage.write_targets(&targets).map_err(storage_error)?; storage.write_profiles(&profiles).map_err(storage_error)?; } } Ok(SavedStateResponse { profiles: profiles.iter().map(ProfileDto::from).collect(), targets: targets.iter().map(TargetDto::from).collect(), generated_config_path: storage .paths() .generated_dir .join("proxifyre-app-config.json") .display() .to_string(), }) } struct ImportedSavedState { profiles: Vec, targets: Vec, } fn should_bootstrap_profiles(profiles: &[Profile]) -> bool { !profiles .iter() .any(|profile| profile.enabled && !profile.items.is_empty()) } fn import_saved_state_from_proxifyre_config(path: &Path) -> Option { let contents = fs::read_to_string(path).ok()?; let config: ProxiFyreConfig = serde_json::from_str(&contents).ok()?; let proxy_entries = config .proxies .iter() .filter_map(import_proxy_entry) .collect::>(); if proxy_entries.is_empty() { return None; } let single_entry = proxy_entries.len() == 1; let mut profiles = Vec::with_capacity(proxy_entries.len()); let mut targets = Vec::with_capacity(proxy_entries.len()); for (index, entry) in proxy_entries.into_iter().enumerate() { let ordinal = index + 1; let target_id = if single_entry { MAIN_TARGET_ID.to_string() } else { format!("proxifyre-import-target-{ordinal}") }; let profile_id = if single_entry { MAIN_PROFILE_ID.to_string() } else { format!("proxifyre-import-profile-{ordinal}") }; let profile_name = if single_entry { "Приложения через прокси".to_string() } else { format!("Импорт ProxiFyre {ordinal}") }; targets.push(Target { id: target_id.clone(), name: if single_entry { "Основной прокси".to_string() } else { format!("Прокси ProxiFyre {ordinal}") }, kind: TargetKind::External, protocol: ProxyProtocol::Socks5, host: entry.host, port: entry.port, requires_component: None, }); profiles.push(Profile { id: profile_id, name: profile_name, enabled: true, target_id, protocols: entry.protocols, items: entry.items, }); } Some(ImportedSavedState { profiles, targets }) } struct ImportedProxyEntry { items: Vec, protocols: Vec, host: String, port: u16, } fn import_proxy_entry(proxy: &ProxiFyreProxy) -> Option { let items = proxy .app_names .iter() .filter_map(|name| imported_profile_item(name)) .collect::>(); if items.is_empty() { return None; } let (host, port) = parse_socks5_endpoint(&proxy.socks5_proxy_endpoint)?; Some(ImportedProxyEntry { items, protocols: imported_protocols(&proxy.supported_protocols), host, port, }) } fn imported_profile_item(raw_value: &str) -> Option { let value = raw_value.trim().trim_matches('"'); if value.is_empty() { return None; } let looks_like_path = value.contains('\\') || value.contains('/'); let item_type = if looks_like_path && value.to_ascii_lowercase().ends_with(".exe") { ProfileItemType::Exe } else if looks_like_path { ProfileItemType::Folder } else { ProfileItemType::Process }; let value = match item_type { ProfileItemType::Process => { let base = value.rsplit(['\\', '/']).next().unwrap_or(value); if base.to_ascii_lowercase().ends_with(".exe") { base[..base.len() - 4].to_string() } else { base.to_string() } } ProfileItemType::Folder | ProfileItemType::Exe => value.to_string(), }; if value.is_empty() { return None; } Some(ProfileItem { recursive: matches!(item_type, ProfileItemType::Folder), item_type, value, }) } fn imported_protocols(values: &[String]) -> Vec { let mut protocols = Vec::new(); for value in values { let protocol = match value.trim().to_ascii_uppercase().as_str() { "TCP" => Protocol::Tcp, "UDP" => Protocol::Udp, _ => continue, }; if !protocols.contains(&protocol) { protocols.push(protocol); } } if protocols.is_empty() { vec![Protocol::Tcp, Protocol::Udp] } else { protocols } } fn parse_socks5_endpoint(endpoint: &str) -> Option<(String, u16)> { let endpoint = endpoint.trim(); let endpoint = if endpoint .get(.."socks5://".len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case("socks5://")) { &endpoint["socks5://".len()..] } else { endpoint }; if endpoint.is_empty() { return None; } if let Some(rest) = endpoint.strip_prefix('[') { let (host, rest) = rest.split_once(']')?; let port = rest.strip_prefix(':')?.parse::().ok()?; let host = host.trim(); return (!host.is_empty()).then(|| (host.to_string(), port)); } let (host, port) = endpoint.rsplit_once(':')?; let host = host.trim(); let port = port.trim().parse::().ok()?; (!host.is_empty()).then(|| (host.to_string(), port)) } fn upsert_targets(targets: &mut Vec, imported_targets: Vec) { for target in imported_targets { match targets.iter().position(|existing| existing.id == target.id) { Some(index) => targets[index] = target, None => targets.push(target), } } } pub fn resolve_preview( input: ProfileInputDto, ) -> Result { let profile = normalize_profile(input.into()).map_err(validation_error)?; let mut warnings = Vec::new(); let apps = profile .items .iter() .map(|item| resolved_app(item, &mut warnings)) .collect(); Ok(ResolveProfilePreviewResponse { profile_id: profile.id, apps, warnings, }) } pub fn apply_profiles_with_services( storage: &JsonStorage, adapter: &impl ProxyRouterAdapter, helper: &impl ProxyApplyHelper, clock: &impl Clock, ) -> Result { apply_profiles_with_services_and_detection( storage, adapter, helper, clock, detect_proxyfier_install(), detect_singbox_install(), ) } pub fn apply_profiles_with_services_and_detection( storage: &JsonStorage, adapter: &impl ProxyRouterAdapter, helper: &impl ProxyApplyHelper, clock: &impl Clock, detected_proxyfier: Option, detected_singbox: Option, ) -> Result { let profiles = storage.read_profiles().map_err(storage_error)?; let targets = storage.read_targets().map_err(storage_error)?; let components = components_or_defaults_with_detection(storage, detected_proxyfier, detected_singbox)?; let generated = match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) { Ok(generated) => generated, Err(error) => { let command_error = adapter_error(error); let activity = activity_for_apply_error(clock, &command_error); storage.append_activity(activity).map_err(storage_error)?; return Err(command_error); } }; let generated_path = storage .paths() .generated_dir .join(generated.output_file_name.as_str()); write_generated_config(&generated_path, &generated.contents)?; let helper_result = helper.apply_proxy_config(HelperApplyRequest { adapter_id: generated.adapter_id.as_str(), config_path: &generated_path, config_contents: generated.contents.as_str(), })?; let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result); storage .append_activity(activity.clone()) .map_err(storage_error)?; Ok(ApplyProfilesResponse { success: helper_result.success, changed: helper_result.changed, message: helper_result.message.clone(), adapter_id: generated.adapter_id, generated_config_path: generated_path.display().to_string(), enabled_profiles: generated.enabled_profiles, routed_apps: generated.routed_apps, helper: helper_result, activity: ActivityEntryDto::from(&activity), }) } pub fn read_singbox_status( storage: &JsonStorage, ) -> Result { 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 { let config = storage.read_local_singbox_config().map_err(storage_error)?; let cache = storage .read_singbox_subscription_cache() .map_err(storage_error)?; let component = singbox_component_from_detection(detected); Ok(LocalSingBoxStatusResponse { config: LocalSingBoxConfigDto::from(&config), cache: cache.as_ref().map(SubscriptionCacheDto::from), component: ComponentStatusDto::from(&component), generated_config_path: storage .paths() .generated_dir .join("sing-box-config.json") .display() .to_string(), lan_listen_host: local_lan_ipv4(), #[cfg(debug_assertions)] subscription_identity: subscription_request_identity_for_display(), }) } pub fn save_singbox_subscription_to_storage( storage: &JsonStorage, input: SaveSingBoxSubscriptionInputDto, clock: &impl Clock, ) -> Result { let subscription_url = input.subscription_url.trim().to_string(); validate_subscription_url(&subscription_url)?; let mut config = storage.read_local_singbox_config().map_err(storage_error)?; config.subscription_url = Some(subscription_url); ensure_device_hwid(&mut config); config.updated_at = Some(clock.now()); storage .write_local_singbox_config(&config) .map_err(storage_error)?; read_singbox_status(storage) } pub fn fetch_singbox_subscription_with_fetcher( storage: &JsonStorage, fetcher: &impl SubscriptionFetcher, clock: &impl Clock, ) -> Result { let mut config = storage.read_local_singbox_config().map_err(storage_error)?; let subscription_url = config .subscription_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .ok_or_else(|| { CommandError::new( "singbox_subscription_missing", "Ссылка на подписку Local sing-box не сохранена.", ) })?; let device_hwid_created = ensure_device_hwid(&mut config); if device_hwid_created { config.updated_at = Some(clock.now()); storage .write_local_singbox_config(&config) .map_err(storage_error)?; } let identity = subscription::SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref()); let cache = fetcher .fetch_subscription(&subscription_url, &identity) .map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?; let selected_tag = config .selected_server_tag .as_deref() .filter(|tag| cache.servers.iter().any(|server| server.tag == *tag)) .map(str::to_string) .or_else(|| cache.servers.first().map(|server| server.tag.clone())); config.selected_server_tag = selected_tag; config.updated_at = Some(clock.now()); storage .write_singbox_subscription_cache(&cache) .map_err(storage_error)?; storage .write_local_singbox_config(&config) .map_err(storage_error)?; storage .append_activity(ActivityEntry { id: "singbox-subscription-fetched".to_string(), at: clock.now(), level: ActivityLevel::Success, title: "Подписка Local sing-box обновлена".to_string(), message: format!("Серверов найдено: {}", cache.servers.len()), }) .map_err(storage_error)?; read_singbox_status(storage) } pub fn forget_singbox_subscription_in_storage( storage: &JsonStorage, clock: &impl Clock, ) -> Result { let mut config = storage.read_local_singbox_config().map_err(storage_error)?; config.subscription_url = None; config.selected_server_tag = None; config.updated_at = Some(clock.now()); storage .write_local_singbox_config(&config) .map_err(storage_error)?; storage .remove_singbox_subscription_cache() .map_err(storage_error)?; read_singbox_status(storage) } pub fn select_singbox_server_in_storage( storage: &JsonStorage, input: SelectSingBoxServerInputDto, clock: &impl Clock, ) -> Result { let requested_tag = input.tag.trim().to_string(); if requested_tag.is_empty() { return Err(CommandError::new( "singbox_server_tag_missing", "Сервер Local sing-box не выбран.", )); } let cache = storage .read_singbox_subscription_cache() .map_err(storage_error)? .ok_or_else(|| { CommandError::new( "singbox_subscription_cache_missing", "Сначала нужно загрузить подписку Local sing-box.", ) })?; let Some(server) = find_subscription_server( &cache, &requested_tag, input.server.as_deref(), input.server_port, ) else { return Err(CommandError::new( "singbox_server_not_found", format!("Сервер Local sing-box '{requested_tag}' не найден в текущей подписке."), )); }; let selected_tag = server.tag.clone(); let mut config = storage.read_local_singbox_config().map_err(storage_error)?; config.selected_server_tag = Some(selected_tag); config.updated_at = Some(clock.now()); storage .write_local_singbox_config(&config) .map_err(storage_error)?; read_singbox_status(storage) } pub fn ping_singbox_server_in_storage( storage: &JsonStorage, input: PingSingBoxServerInputDto, ) -> Result { let tag = input.tag.trim(); let cache = read_required_singbox_cache(storage)?; let server = find_subscription_server(&cache, tag, None, None).ok_or_else(|| { CommandError::new( "singbox_server_not_found", format!("Сервер Local sing-box '{tag}' не найден в текущей подписке."), ) })?; Ok(ping_subscription_server(server)) } pub fn ping_all_singbox_servers_in_storage( storage: &JsonStorage, ) -> Result, CommandError> { let cache = read_required_singbox_cache(storage)?; Ok(cache.servers.iter().map(ping_subscription_server).collect()) } pub fn ping_proxy_target_endpoint( input: PingProxyTargetInputDto, ) -> Result { ping_proxy_target_endpoint_with_probes(input, DEFAULT_PROXY_PROBES) } pub fn ping_proxy_target_endpoint_with_probes( input: PingProxyTargetInputDto, probes: &[ProxyProbeEndpoint], ) -> Result { let host = input.host.trim(); if host.is_empty() { return Err(CommandError::new( "proxy_target_host_missing", "Хост внешнего прокси не указан.", )); } 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( storage: &JsonStorage, adapter: &SingBoxAdapter, checker: &C, clock: &impl Clock, binary_path: Option<&Path>, ) -> Result where C: SingBoxConfigChecker, { let config = storage.read_local_singbox_config().map_err(storage_error)?; let cache = read_required_singbox_cache(storage)?; let generated = adapter .generate_config( SingBoxGenerationRequest::new(&config, &cache, binary_path), checker, ) .map_err(singbox_adapter_error)?; let generated_path = storage .paths() .generated_dir .join(generated.output_file_name.as_str()); write_generated_config(&generated_path, &generated.contents)?; ensure_local_singbox_target(storage, &config)?; let activity = activity_for_singbox_generate(clock, &generated, &generated_path); storage .append_activity(activity.clone()) .map_err(storage_error)?; Ok(GenerateSingBoxConfigResponse { success: true, message: "Конфиг Local sing-box создан".to_string(), adapter_id: generated.adapter_id, generated_config_path: generated_path.display().to_string(), selected_server_tag: generated.selected_server_tag, listen_host: generated.listen, listen_port: generated.listen_port, check: generated.check, activity: ActivityEntryDto::from(&activity), }) } fn read_required_singbox_cache(storage: &JsonStorage) -> Result { storage .read_singbox_subscription_cache() .map_err(storage_error)? .ok_or_else(|| { CommandError::new( "singbox_subscription_cache_missing", "Сначала нужно загрузить подписку Local sing-box.", ) }) } fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError> { if subscription_url.is_empty() { return Err(CommandError::new( "singbox_subscription_url_missing", "Ссылка на подписку Local sing-box не указана.", )); } let parsed = url::Url::parse(subscription_url).map_err(|_| { CommandError::new( "singbox_subscription_url_invalid", "Ссылка на подписку Local sing-box должна быть корректным URL.", ) })?; if !matches!(parsed.scheme(), "http" | "https") { return Err(CommandError::new( "singbox_subscription_url_invalid", "Ссылка на подписку Local sing-box должна начинаться с http:// или https://.", )); } Ok(()) } fn ensure_device_hwid(config: &mut LocalSingBoxConfig) -> bool { if config .device_hwid .as_deref() .is_some_and(|value| !value.trim().is_empty()) { return false; } config.device_hwid = Some(uuid::Uuid::new_v4().hyphenated().to_string().to_uppercase()); true } fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse { ping_endpoint(&server.tag, &server.server, server.server_port) } fn ping_endpoint(tag: &str, server: &str, server_port: u16) -> PingServerResponse { let started = Instant::now(); let addresses = match (server, server_port).to_socket_addrs() { Ok(addresses) => addresses.collect::>(), Err(error) => { return PingServerResponse { tag: tag.to_string(), server: server.to_string(), server_port, ok: false, latency: None, error: Some(format!("DNS/адрес недоступен: {error}")), }; } }; if addresses.is_empty() { return PingServerResponse { tag: tag.to_string(), server: server.to_string(), server_port, ok: false, latency: None, error: Some("DNS не вернул адреса".to_string()), }; } let timeout = Duration::from_secs(2); let mut last_error = None; for address in addresses { match TcpStream::connect_timeout(&address, timeout) { Ok(_) => { return PingServerResponse { tag: tag.to_string(), server: server.to_string(), server_port, ok: true, latency: Some(started.elapsed().as_millis()), error: None, }; } Err(error) => last_error = Some(error.to_string()), } } PingServerResponse { tag: tag.to_string(), server: server.to_string(), server_port, ok: false, latency: None, error: last_error, } } fn run_proxy_probes( proxy_host: &str, proxy_port: u16, probes: &[ProxyProbeEndpoint], ) -> Vec { 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::>(); 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 { 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::(body) .ok() .and_then(|value| { value .get(field) .and_then(|field| field.as_str()) .and_then(normalize_ip) }), } } fn normalize_ip(value: &str) -> Option { let candidate = value.trim().trim_matches('"'); if candidate.parse::().is_ok() { Some(candidate.to_string()) } else { None } } fn local_lan_ipv4() -> Option { let socket = UdpSocket::bind("0.0.0.0:0").ok()?; socket.connect("8.8.8.8:80").ok()?; let IpAddr::V4(address) = socket.local_addr().ok()?.ip() else { return None; }; if address.is_loopback() || address.is_link_local() || address.is_unspecified() { return None; } Some(address.to_string()) } fn find_subscription_server<'a>( cache: &'a SubscriptionCache, requested_tag: &str, requested_server: Option<&str>, requested_port: Option, ) -> Option<&'a SubscriptionServer> { cache .servers .iter() .find(|server| server.tag == requested_tag) .or_else(|| { let requested = comparable_server_tag(requested_tag); cache .servers .iter() .find(|server| comparable_server_tag(&server.tag) == requested) }) .or_else(|| { let server_name = requested_server?.trim(); let server_port = requested_port?; cache.servers.iter().find(|server| { server.server.eq_ignore_ascii_case(server_name) && server.server_port == server_port }) }) } fn comparable_server_tag(value: &str) -> String { value .chars() .filter(|ch| !matches!(ch, '\u{fe0e}' | '\u{fe0f}' | '\u{200d}')) .collect::() .split_whitespace() .collect::>() .join(" ") } fn ensure_local_singbox_target( storage: &JsonStorage, config: &LocalSingBoxConfig, ) -> Result<(), CommandError> { let mut targets = storage.read_targets().map_err(storage_error)?; let target = Target { id: "local-singbox".to_string(), name: "Локальный sing-box".to_string(), kind: TargetKind::Local, protocol: ProxyProtocol::Socks5, host: config.listen_host.clone(), port: config.listen_port, requires_component: Some(ComponentId::Singbox), }; match targets.iter().position(|existing| existing.id == target.id) { Some(index) => targets[index] = target, None => targets.push(target), } storage.write_targets(&targets).map_err(storage_error) } fn activity_for_singbox_generate( clock: &impl Clock, generated: &SingBoxGeneratedConfig, generated_path: &Path, ) -> ActivityEntry { ActivityEntry { id: "singbox-config-generated".to_string(), at: clock.now(), level: ActivityLevel::Success, title: "Конфиг Local sing-box создан".to_string(), message: format!( "Сервер: {}, listen: {}:{}, конфиг: {}", generated.selected_server_tag, generated.listen, generated.listen_port, generated_path.display() ), } } fn singbox_adapter_error(error: SingBoxConfigError) -> CommandError { let code = match error.kind { SingBoxConfigErrorKind::MissingSelectedServer => "singbox_server_not_selected", SingBoxConfigErrorKind::MissingSelectedOutbound => "singbox_selected_server_missing", SingBoxConfigErrorKind::UnsupportedSelectedOutbound => { "singbox_selected_server_unsupported" } SingBoxConfigErrorKind::Serialization => "serialization_error", SingBoxConfigErrorKind::CheckFailed => "singbox_check_failed", }; CommandError::new(code, error.message) } fn control_singbox_service( action: SingBoxServiceAction, config_source: Option<&Path>, ) -> Result { let Some(detected) = detect_singbox_install() else { return Err(CommandError::new( "singbox_not_found", "Local sing-box не найден на компьютере.", )); }; let config_target = config_source.map(|_| detected.install_dir.join("config.json")); let script = service_control_script( action, &detected.service_name, config_source, config_target.as_deref(), ); let output = command_no_window("powershell") .args([ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script.as_str(), ]) .output() .map_err(|error| { CommandError::new( singbox_service_error_code(action), format!( "Не удалось {} службу Local sing-box: {error}", action.label() ), ) })?; let result = parse_singbox_service_command_output(&output.stdout).ok_or_else(|| { CommandError::new( singbox_service_error_code(action), singbox_service_script_failed_message(action, output.status.code()), ) })?; if result.success { let refreshed = detect_singbox_install(); let component = singbox_component_from_detection(refreshed.as_ref()); return Ok(ComponentStatusDto::from(&component)); } if matches!( result.code.as_str(), "start_failed" | "stop_failed" | "config_sync_failed" ) { run_elevated_singbox_service_command( action, &detected.service_name, config_source, config_target.as_deref(), &result, )?; let refreshed = detect_singbox_install(); let component = singbox_component_from_detection(refreshed.as_ref()); return Ok(ComponentStatusDto::from(&component)); } Err(CommandError::new( singbox_service_error_code(action), singbox_service_command_failed_message(action, &result), )) } fn run_elevated_singbox_service_command( action: SingBoxServiceAction, service_name: &str, config_source: Option<&Path>, config_target: Option<&Path>, direct_result: &SingBoxServiceCommandOutput, ) -> Result<(), CommandError> { let script_path = write_elevated_singbox_service_script(action, service_name, config_source, config_target)?; let launch_script = format!( "$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 = if is_running_elevated() { run_powershell_file(&script_path) } else { run_powershell_command(&launch_script) }; let _ = fs::remove_file(&script_path); match output { Ok(output) if output.status.success() => Ok(()), Ok(output) => Err(CommandError::new( singbox_service_error_code(action), elevated_singbox_service_failed_message(action, direct_result, output.status.code()), )), Err(error) => Err(CommandError::new( singbox_service_error_code(action), format!( "Не удалось запросить права администратора, чтобы {} службу Local sing-box: {error}", action.label() ), )), } } fn write_elevated_singbox_service_script( action: SingBoxServiceAction, service_name: &str, config_source: Option<&Path>, config_target: Option<&Path>, ) -> Result { let script_path = elevated_scripts::temp_script_path("proxywarden-singbox-service"); let script = elevated_singbox_service_script(action, service_name, config_source, config_target); write_powershell_script(&script_path, &script).map_err(|error| { CommandError::new( singbox_service_error_code(action), format!( "Не удалось подготовить временный скрипт для управления Local sing-box '{}': {error}", script_path.display() ), ) })?; Ok(script_path) } fn elevated_singbox_service_script( action: SingBoxServiceAction, service_name: &str, config_source: Option<&Path>, config_target: Option<&Path>, ) -> String { let action_name = action.action_name(); let escaped_service_name = escape_powershell_single(service_name); let escaped_config_source = config_source .map(|path| escape_powershell_single(&path.display().to_string())) .unwrap_or_default(); let escaped_config_target = config_target .map(|path| escape_powershell_single(&path.display().to_string())) .unwrap_or_default(); format!( r#" $ErrorActionPreference = 'SilentlyContinue' $serviceName = '{escaped_service_name}' $action = '{action_name}' $configSource = '{escaped_config_source}' $configTarget = '{escaped_config_target}' if ($action -eq 'start') {{ if (-not [string]::IsNullOrWhiteSpace($configSource)) {{ if (-not (Test-Path -LiteralPath $configSource)) {{ exit 5 }} if (-not [string]::IsNullOrWhiteSpace($configTarget)) {{ try {{ Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop }} catch {{ exit 6 }} }} }} $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue if ($null -eq $service) {{ exit 2 }} if ($service.Status -eq 'Running') {{ exit 0 }} Start-Service -Name $serviceName -ErrorAction SilentlyContinue $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue if ($null -ne $service) {{ try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}} if ($service.Status -eq 'Running') {{ exit 0 }} }} exit 3 }} $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue if ($null -eq $service) {{ exit 2 }} if ($service.Status -eq 'Stopped') {{ exit 0 }} Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue if ($null -ne $service) {{ try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15)) }} catch {{}} if ($service.Status -eq 'Stopped') {{ exit 0 }} }} exit 4 "# ) } fn install_singbox_component( storage: &JsonStorage, install_dir: &Path, ) -> Result { let generated_config_path = storage.paths().generated_dir.join("sing-box-config.json"); run_elevated_singbox_package_script( SingBoxPackageAction::Install, include_str!("../../scripts/install-singbox.ps1"), vec![ "-InstallRoot".to_string(), install_dir.display().to_string(), "-ConfigSource".to_string(), generated_config_path.display().to_string(), ], &storage.paths().state_dir, )?; let refreshed = detect_singbox_install(); let Some(detected) = refreshed.as_ref() else { return Err(CommandError::new( SingBoxPackageAction::Install.error_code(), "Установка Local sing-box завершилась, но приложение не найдено после проверки.", )); }; Ok(ComponentStatusDto::from(&singbox_component_from_detection( Some(detected), ))) } fn uninstall_singbox_component() -> Result { let Some(detected) = detect_singbox_install() else { let component = singbox_component_from_detection(None); return Ok(ComponentStatusDto::from(&component)); }; ensure_safe_singbox_install_dir(&detected.install_dir).map_err(|message| { CommandError::new(SingBoxPackageAction::Uninstall.error_code(), message) })?; let artifact_dir = default_config_root().join("state"); run_elevated_singbox_package_script( SingBoxPackageAction::Uninstall, include_str!("../../scripts/install-singbox.ps1"), vec![ "-InstallRoot".to_string(), detected.install_dir.display().to_string(), "-ServiceName".to_string(), detected.service_name, "-Uninstall".to_string(), ], &artifact_dir, )?; let refreshed = detect_singbox_install(); if refreshed.is_some() { return Err(CommandError::new( SingBoxPackageAction::Uninstall.error_code(), "Удаление Local sing-box завершилось, но приложение все еще найдено на компьютере.", )); } let component = singbox_component_from_detection(None); Ok(ComponentStatusDto::from(&component)) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SingBoxPackageAction { Install, Uninstall, } impl SingBoxPackageAction { fn error_code(self) -> &'static str { match self { SingBoxPackageAction::Install => "singbox_install_failed", SingBoxPackageAction::Uninstall => "singbox_uninstall_failed", } } fn label(self) -> &'static str { match self { SingBoxPackageAction::Install => "установить", SingBoxPackageAction::Uninstall => "удалить", } } fn file_label(self) -> &'static str { match self { SingBoxPackageAction::Install => "install", SingBoxPackageAction::Uninstall => "uninstall", } } } fn run_elevated_singbox_package_script( action: SingBoxPackageAction, installer_body: &str, installer_args: Vec, artifact_dir: &Path, ) -> Result<(), CommandError> { fs::create_dir_all(artifact_dir).map_err(|error| { CommandError::new( action.error_code(), format!( "Не удалось создать папку для временных файлов Local sing-box '{}': {error}", artifact_dir.display() ), ) })?; let prefix = format!("proxywarden-singbox-{}", action.file_label()); let installer_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1"); let runner_path = elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.runner"), "ps1"); let result_path = elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log"); write_powershell_script(&installer_path, installer_body).map_err(|error| { CommandError::new( action.error_code(), format!( "Не удалось подготовить установщик Local sing-box '{}': {error}", installer_path.display() ), ) })?; write_powershell_script( &runner_path, &singbox_installer_runner_script(&installer_path, &result_path, &installer_args), ) .map_err(|error| { CommandError::new( action.error_code(), format!( "Не удалось подготовить runner Local sing-box '{}': {error}", runner_path.display() ), ) })?; let launch_script = format!( r#" $ErrorActionPreference = 'Stop' $resultPath = '{}' try {{ $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}') if ($null -eq $p) {{ Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8 exit 1 }} exit $p.ExitCode }} catch {{ Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 exit 1 }} "#, escape_powershell_single(&result_path.display().to_string()), escape_powershell_single(&runner_path.display().to_string()) ); 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); match output { Ok(output) if output.status.success() => { let _ = fs::remove_file(&result_path); Ok(()) } Ok(output) => { let details = package_failure_details(&result_path, &output); let _ = fs::remove_file(&result_path); Err(CommandError::new( action.error_code(), format!( "Не удалось {} Local sing-box. Код elevated-команды: {}. {details}", action.label(), output.status.code().unwrap_or(-1), ), )) } Err(error) => Err(CommandError::new( action.error_code(), format!( "Не удалось запросить права администратора, чтобы {} Local sing-box: {error}", action.label() ), )), } } pub fn singbox_installer_runner_script( installer_path: &Path, result_path: &Path, installer_args: &[String], ) -> String { let args = installer_args .iter() .map(|arg| format!("'{}'", escape_powershell_single(arg))) .collect::>() .join(", "); format!( r#" $ErrorActionPreference = 'Stop' $installerPath = '{}' $resultPath = '{}' $stdoutPath = "$resultPath.stdout.log" $stderrPath = "$resultPath.stderr.log" $installerArgs = @({args}) try {{ $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs 2>&1 $exitCode = $LASTEXITCODE Set-Content -LiteralPath $stdoutPath -Value ($output | Out-String) -Encoding UTF8 if ($exitCode -ne 0) {{ $stdout = if (Test-Path -LiteralPath $stdoutPath) {{ Get-Content -LiteralPath $stdoutPath -Raw }} else {{ '' }} $stderr = if (Test-Path -LiteralPath $stderrPath) {{ Get-Content -LiteralPath $stderrPath -Raw }} else {{ '' }} throw "install-singbox.ps1 завершился с кодом $exitCode. stdout: $stdout stderr: $stderr" }} Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8 exit 0 }} catch {{ Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 exit 1 }} finally {{ Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue }} "#, escape_powershell_single(&installer_path.display().to_string()), escape_powershell_single(&result_path.display().to_string()) ) } fn singbox_service_error_code(action: SingBoxServiceAction) -> &'static str { match action { SingBoxServiceAction::Start => "singbox_service_start_failed", SingBoxServiceAction::Stop => "singbox_service_stop_failed", } } fn singbox_service_script_failed_message( action: SingBoxServiceAction, exit_code: Option, ) -> String { let exit_code = exit_code .map(|code| format!(" Код выхода PowerShell: {code}.")) .unwrap_or_default(); format!( "Не удалось {} службу Local sing-box: команда управления службой не вернула корректный результат.{exit_code}", action.label() ) } fn singbox_service_command_failed_message( action: SingBoxServiceAction, result: &SingBoxServiceCommandOutput, ) -> String { let service_name = result .service_name .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or("ProxyWardenSingBox"); let status = result .status .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or("неизвестен"); let pid = result .process_id .filter(|value| *value > 0) .map(|value| format!(", PID: {value}")) .unwrap_or_default(); match result.code.as_str() { "service_not_found" => "Служба Local sing-box не найдена.".to_string(), "config_source_missing" => { "Сгенерированный конфиг Local sing-box не найден перед запуском службы.".to_string() } "config_sync_failed" => { "Не удалось обновить config.json службы Local sing-box перед запуском. Попробуй запустить приложение от имени администратора.".to_string() } "start_failed" => format!( "Не удалось запустить службу {service_name}. Текущий статус: {status}{pid}. Попробуй запустить приложение от имени администратора." ), "stop_failed" => format!( "Не удалось остановить службу {service_name}. Текущий статус: {status}{pid}. Запусти приложение от имени администратора или останови службу вручную в services.msc." ), _ => format!( "Не удалось {} службу {service_name}. Текущий статус: {status}{pid}.", action.label() ), } } fn elevated_singbox_service_failed_message( action: SingBoxServiceAction, direct_result: &SingBoxServiceCommandOutput, exit_code: Option, ) -> String { let exit_code = exit_code .map(|code| format!(" Код выхода elevated PowerShell: {code}.")) .unwrap_or_default(); format!( "{} Попытка с правами администратора тоже не сработала.{exit_code}", singbox_service_command_failed_message(action, direct_result) ) } fn components_or_defaults(storage: &JsonStorage) -> Result, CommandError> { components_or_defaults_with_detection( storage, detect_proxyfier_install(), detect_singbox_install(), ) } fn components_or_defaults_with_detection( storage: &JsonStorage, detected_proxyfier: Option, detected_singbox: Option, ) -> Result, CommandError> { let components = storage.read_components().map_err(storage_error)?; Ok(resolve_component_statuses( components, detected_proxyfier, detected_singbox, )) } pub fn resolve_component_statuses( stored_components: Vec, detected_proxyfier: Option, detected_singbox: Option, ) -> Vec { let mut components = default_components(); for component in stored_components { upsert_component(&mut components, component); } if detected_proxyfier.is_some() { upsert_component( &mut components, proxyfier_component_from_detection(detected_proxyfier.as_ref()), ); } if detected_singbox.is_some() { upsert_component( &mut components, singbox_component_from_detection(detected_singbox.as_ref()), ); } components } fn default_components() -> Vec { vec![ ComponentStatus { id: ComponentId::ControlApp, name: "Приложение управления".to_string(), state: ComponentState::Running, installed: true, running: true, version: None, path: None, service_name: None, service_status: None, problems: Vec::new(), actions: vec![ "Открыть журнал".to_string(), "Скопировать диагностику".to_string(), ], }, ComponentStatus { id: ComponentId::Proxyfier, name: "ProxiFyre".to_string(), state: ComponentState::Missing, installed: false, running: false, version: None, path: None, service_name: Some("ProxiFyreService".to_string()), service_status: None, problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()], actions: vec!["Установить ProxiFyre".to_string()], }, ComponentStatus { id: ComponentId::Singbox, name: "Локальный sing-box".to_string(), state: ComponentState::Missing, installed: false, running: false, version: None, path: None, service_name: Some(crate::models::DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()), service_status: None, problems: Vec::new(), actions: vec!["Установить локальный sing-box".to_string()], }, ] } fn upsert_component(components: &mut Vec, component: ComponentStatus) { match components .iter() .position(|existing| existing.id == component.id) { Some(index) => components[index] = component, None => components.push(component), } } fn route_line(active_target: Option<&Target>) -> String { match active_target { Some(target) if target.id == "local-singbox" => { format!( "Выбранные приложения -> ProxiFyre -> локальный sing-box {}:{} -> VPN", target.host, target.port ) } Some(target) => format!( "Выбранные приложения -> ProxiFyre -> внешний прокси {}:{}", target.host, target.port ), None => "Выбранные приложения -> ProxiFyre -> внешний прокси".to_string(), } } fn resolved_app(item: &ProfileItem, warnings: &mut Vec) -> ResolvedAppDto { let mut notes = Vec::new(); match item.item_type { ProfileItemType::Process => notes.push("Имя процесса используется напрямую".to_string()), ProfileItemType::Folder => { let note = "Сканирование папок отложено; ProxiFyre получает путь к папке"; notes.push(note.to_string()); warnings.push(note.to_string()); } ProfileItemType::Exe => { notes.push("Путь к EXE сохраняется для сопоставления в ProxiFyre".to_string()) } } ResolvedAppDto { source_type: item.item_type.clone(), source_value: item.value.clone(), app_name: item.value.clone(), notes, } } fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> { safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error) } fn open_file_or_select(path: &Path) -> Result<(), CommandError> { let status = Command::new("notepad.exe") .arg(path) .spawn() .map_err(|error| { CommandError::new( "open_config_failed", format!("Не удалось открыть конфиг '{}': {error}", path.display()), ) })?; drop(status); Ok(()) } fn open_folder(path: &Path) -> Result { fs::create_dir_all(path).map_err(storage_error)?; Command::new("explorer.exe") .arg(path) .spawn() .map_err(|error| { CommandError::new( "open_config_failed", format!("Не удалось открыть папку '{}': {error}", path.display()), ) })?; Ok(path.display().to_string()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ServiceControlAction { Start, Stop, } impl ServiceControlAction { fn error_code(self) -> &'static str { match self { ServiceControlAction::Start => "proxifyre_service_start_failed", ServiceControlAction::Stop => "proxifyre_service_stop_failed", } } fn label(self) -> &'static str { match self { ServiceControlAction::Start => "запустить", ServiceControlAction::Stop => "остановить", } } } fn control_proxifyre_service( action: ServiceControlAction, ) -> Result { let Some(detected) = detect_proxyfier_install() else { return Err(CommandError::new( "proxifyre_not_found", "ProxiFyre не найден на компьютере.", )); }; run_proxifyre_service_command(action, &service_name_candidates(&detected))?; let refreshed = detect_proxyfier_install(); let component = proxyfier_component_from_detection(refreshed.as_ref()); Ok(ComponentStatusDto::from(&component)) } fn service_name_candidates(detected: &DetectedProxyfier) -> Vec { let mut names = Vec::new(); if let Some(service_name) = &detected.service_name { names.push(service_name.clone()); } for service_name in ["ProxiFyreService", "ProxiFyre"] { if !names .iter() .any(|existing| existing.eq_ignore_ascii_case(service_name)) { names.push(service_name.to_string()); } } names } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ServiceCommandOutput { success: bool, code: String, service_name: Option, status: Option, process_id: Option, } fn run_proxifyre_service_command( action: ServiceControlAction, service_names: &[String], ) -> Result<(), CommandError> { let names = service_names .iter() .map(|name| format!("'{}'", escape_powershell_single(name))) .collect::>() .join(", "); let action_name = match action { ServiceControlAction::Start => "start", ServiceControlAction::Stop => "stop", }; let script = format!( r#" $ErrorActionPreference = 'Stop' $names = @({names}) $action = '{action_name}' $service = $null function Find-ProxiFyreService {{ foreach ($name in $names) {{ $candidate = Get-Service -Name $name -ErrorAction SilentlyContinue if ($null -ne $candidate) {{ return $candidate }} }} return Get-Service | Where-Object {{ $_.Name -match 'ProxiFyre|Proxifyre' -or $_.DisplayName -match 'ProxiFyre|Proxifyre' }} | Select-Object -First 1 }} function Get-ServiceProcessId([string]$name) {{ $escapedName = $name.Replace("'", "''") $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue if ($null -eq $record) {{ return 0 }} return [int]$record.ProcessId }} function Get-ServiceStatus([string]$name) {{ $current = Get-Service -Name $name -ErrorAction SilentlyContinue if ($null -eq $current) {{ return $null }} return $current.Status.ToString() }} function Write-ServiceResult([bool]$success, [string]$code, [string]$status, [int]$processId) {{ [PSCustomObject]@{{ success = $success code = $code serviceName = if ($null -ne $service) {{ $service.Name }} else {{ $null }} status = $status processId = $processId }} | ConvertTo-Json -Compress exit 0 }} $service = Find-ProxiFyreService if ($null -eq $service) {{ Write-ServiceResult $false 'service_not_found' $null 0 }} $status = $service.Status.ToString() $processId = Get-ServiceProcessId $service.Name if ($action -eq 'start') {{ if ($status -eq 'Running') {{ Write-ServiceResult $true 'already_running' $status $processId }} try {{ Start-Service -Name $service.Name -ErrorAction Stop $service = Get-Service -Name $service.Name $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{ Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $service.Name) (Get-ServiceProcessId $service.Name) }} Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $service.Name) }} if ($status -eq 'Stopped') {{ Write-ServiceResult $true 'already_stopped' $status $processId }} try {{ if ($service.CanStop) {{ Stop-Service -Name $service.Name -Force -ErrorAction Stop }} }} catch {{}} try {{ $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue if ($null -ne $service -and $service.Status -ne 'Stopped') {{ $null = & sc.exe stop $service.Name 2>$null }} }} catch {{}} try {{ $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue if ($null -ne $service -and $service.Status -ne 'Stopped') {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }} }} catch {{}} $status = Get-ServiceStatus $service.Name $processId = Get-ServiceProcessId $service.Name if ($status -ne 'Stopped' -and $processId -gt 0) {{ try {{ $null = & taskkill.exe /PID $processId /F 2>$null Start-Sleep -Milliseconds 700 $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue if ($null -ne $service) {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }} }} catch {{}} }} $status = Get-ServiceStatus $service.Name $processId = Get-ServiceProcessId $service.Name if ($status -eq 'Stopped') {{ Write-ServiceResult $true 'stopped' $status $processId }} Write-ServiceResult $false 'stop_failed' $status $processId "# ); let output = command_no_window("powershell") .args([ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script.as_str(), ]) .output() .map_err(|error| { CommandError::new( action.error_code(), format!("Не удалось {} службу ProxiFyre: {error}", action.label()), ) })?; let result = parse_service_command_output(&output.stdout).ok_or_else(|| { CommandError::new( action.error_code(), service_script_failed_message(action, output.status.code()), ) })?; if result.success { return Ok(()); } if matches!(result.code.as_str(), "start_failed" | "stop_failed") { run_elevated_proxifyre_service_command(action, service_names, &result)?; return Ok(()); } Err(CommandError::new( action.error_code(), service_command_failed_message(action, &result), )) } fn run_elevated_proxifyre_service_command( action: ServiceControlAction, service_names: &[String], direct_result: &ServiceCommandOutput, ) -> Result<(), CommandError> { let script_path = write_elevated_service_script(action, service_names)?; let launch_script = format!( "$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 = if is_running_elevated() { run_powershell_file(&script_path) } else { run_powershell_command(&launch_script) }; let _ = fs::remove_file(&script_path); match output { Ok(output) if output.status.success() => Ok(()), Ok(output) => Err(CommandError::new( action.error_code(), elevated_service_failed_message(action, direct_result, output.status.code()), )), Err(error) => Err(CommandError::new( action.error_code(), format!( "Не удалось запросить права администратора, чтобы {} службу ProxiFyre: {error}", action.label() ), )), } } fn write_elevated_service_script( action: ServiceControlAction, service_names: &[String], ) -> Result { let script_path = elevated_scripts::temp_script_path("proxywarden-proxifyre-service"); let script = elevated_service_script(action, service_names); write_powershell_script(&script_path, &script).map_err(|error| { CommandError::new( action.error_code(), format!( "Не удалось подготовить временный скрипт для управления ProxiFyre '{}': {error}", script_path.display() ), ) })?; Ok(script_path) } fn elevated_service_script(action: ServiceControlAction, service_names: &[String]) -> String { let names = service_names .iter() .map(|name| format!("'{}'", escape_powershell_single(name))) .collect::>() .join(", "); let action_name = match action { ServiceControlAction::Start => "start", ServiceControlAction::Stop => "stop", }; format!( r#" $ErrorActionPreference = 'SilentlyContinue' $names = @({names}) $action = '{action_name}' $service = $null foreach ($name in $names) {{ $service = Get-Service -Name $name -ErrorAction SilentlyContinue if ($null -ne $service) {{ break }} }} if ($null -eq $service) {{ $service = Get-Service | Where-Object {{ $_.Name -match 'ProxiFyre|Proxifyre' -or $_.DisplayName -match 'ProxiFyre|Proxifyre' }} | Select-Object -First 1 }} if ($null -eq $service) {{ exit 2 }} function Get-ServiceProcessId([string]$name) {{ $escapedName = $name.Replace("'", "''") $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue if ($null -eq $record) {{ return 0 }} return [int]$record.ProcessId }} if ($action -eq 'start') {{ if ($service.Status -eq 'Running') {{ exit 0 }} Start-Service -Name $service.Name -ErrorAction SilentlyContinue $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue if ($null -ne $service) {{ try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}} if ($service.Status -eq 'Running') {{ exit 0 }} }} exit 3 }} if ($service.Status -eq 'Stopped') {{ exit 0 }} if ($service.CanStop) {{ Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue }} $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue if ($null -ne $service -and $service.Status -ne 'Stopped') {{ $null = & sc.exe stop $service.Name 2>$null }} $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue if ($null -ne $service -and $service.Status -ne 'Stopped') {{ try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }} catch {{}} }} $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue if ($null -ne $service -and $service.Status -ne 'Stopped') {{ $processId = Get-ServiceProcessId $service.Name if ($processId -gt 0) {{ $null = & taskkill.exe /PID $processId /F 2>$null Start-Sleep -Milliseconds 700 $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue if ($null -ne $service) {{ try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }} catch {{}} }} }} }} $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue if ($null -eq $service -or $service.Status -eq 'Stopped') {{ exit 0 }} exit 4 "# ) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProxiFyrePackageAction { Install, Uninstall, } impl ProxiFyrePackageAction { fn error_code(self) -> &'static str { match self { ProxiFyrePackageAction::Install => "proxifyre_install_failed", ProxiFyrePackageAction::Uninstall => "proxifyre_uninstall_failed", } } fn label(self) -> &'static str { match self { ProxiFyrePackageAction::Install => "установить", ProxiFyrePackageAction::Uninstall => "удалить", } } fn file_label(self) -> &'static str { match self { ProxiFyrePackageAction::Install => "install", ProxiFyrePackageAction::Uninstall => "uninstall", } } fn operation(self) -> &'static str { match self { ProxiFyrePackageAction::Install => "install", ProxiFyrePackageAction::Uninstall => "uninstall", } } fn start_message(self) -> &'static str { match self { ProxiFyrePackageAction::Install => "Готовлю установку ProxiFyre.", ProxiFyrePackageAction::Uninstall => "Готовлю удаление ProxiFyre и сетевого драйвера.", } } fn success_message(self) -> &'static str { match self { ProxiFyrePackageAction::Install => "ProxiFyre и сетевой драйвер готовы.", ProxiFyrePackageAction::Uninstall => "ProxiFyre и сетевой драйвер удалены.", } } } fn install_proxifyre_component( storage: &JsonStorage, app: &tauri::AppHandle, ) -> Result { let generated_config_path = storage .paths() .generated_dir .join("proxifyre-app-config.json"); let bundled_asset_dir = bundled_proxifyre_asset_dir(app); let install_dir = proxifyre_install_dir_for_app(app)?; let script = install_proxifyre_script_for_target( &generated_config_path, bundled_asset_dir.as_deref(), &install_dir, ); run_elevated_package_script( ProxiFyrePackageAction::Install, script, &storage.paths().state_dir, )?; if detect_windows_packet_filter().is_none() { return Err(CommandError::new( ProxiFyrePackageAction::Install.error_code(), "Установка ProxiFyre завершилась, но Windows Packet Filter не найден после проверки.", )); } let refreshed = detect_proxyfier_install(); let Some(detected) = refreshed.as_ref() else { return Err(CommandError::new( ProxiFyrePackageAction::Install.error_code(), "Установка ProxiFyre завершилась, но приложение не найдено после проверки.", )); }; Ok(ComponentStatusDto::from( &proxyfier_component_from_detection(Some(detected)), )) } fn bundled_proxifyre_asset_dir(app: &tauri::AppHandle) -> Option { let mut candidates = Vec::new(); if let Ok(resource_dir) = app.path().resource_dir() { candidates.push(resource_dir.join("bundled").join("proxifyre")); } candidates.push( PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("bundled") .join("proxifyre"), ); candidates.into_iter().find(|path| path.is_dir()) } fn app_install_dir(app: &tauri::AppHandle) -> Result { if let Ok(exe_path) = env::current_exe() { if let Some(parent) = exe_path.parent() { return Ok(parent.to_path_buf()); } } app.path().resource_dir().map_err(|error| { CommandError::new( "app_install_dir_unavailable", format!("Не удалось определить папку установки ProxyWarden: {error}"), ) }) } fn proxifyre_install_dir_for_app(app: &tauri::AppHandle) -> Result { Ok(proxifyre_install_dir_from_app_dir(&app_install_dir(app)?)) } fn singbox_install_dir_for_app(app: &tauri::AppHandle) -> Result { Ok(singbox_install_dir_from_app_dir(&app_install_dir(app)?)) } fn uninstall_proxifyre_component() -> Result { let detected = detect_proxyfier_install(); let packet_filter = detect_windows_packet_filter(); if detected.is_none() && packet_filter.is_none() { let component = proxyfier_component_from_detection(None); return Ok(ComponentStatusDto::from(&component)); } if let Some(detected) = detected.as_ref() { ensure_safe_proxifyre_install_dir(&detected.install_dir)?; } let script = uninstall_proxifyre_script(detected.as_ref()); let artifact_dir = default_config_root().join("state"); run_elevated_package_script(ProxiFyrePackageAction::Uninstall, script, &artifact_dir)?; let refreshed = detect_proxyfier_install(); if refreshed.is_some() { return Err(CommandError::new( ProxiFyrePackageAction::Uninstall.error_code(), "Удаление ProxiFyre завершилось, но приложение все еще найдено на компьютере.", )); } if detect_windows_packet_filter().is_some() { return Err(CommandError::new( ProxiFyrePackageAction::Uninstall.error_code(), "Удаление ProxiFyre завершилось, но Windows Packet Filter все еще найден на компьютере.", )); } let component = proxyfier_component_from_detection(None); Ok(ComponentStatusDto::from(&component)) } fn build_proxifyre_setup_status_for_install_dir(install_dir: &Path) -> ProxiFyreSetupStatusDto { let proxifyre = detect_proxyfier_install(); build_proxifyre_setup_status_with_detection(proxifyre.as_ref(), install_dir) } fn build_proxifyre_setup_status_with_detection( proxifyre: Option<&DetectedProxyfier>, default_install_dir: &Path, ) -> ProxiFyreSetupStatusDto { let vc_runtime = detect_vc_runtime(); let packet_filter = detect_windows_packet_filter(); let vc_runtime_item = setup_item_from_program( "vc-runtime", &format!("Microsoft Visual C++ Runtime ({})", runtime_arch_label()), vc_runtime, "Нужен для запуска ProxiFyre.exe. Установщик скачает официальный vc_redist от Microsoft.", ); let packet_filter_item = setup_item_from_program( "packet-filter", "Windows Packet Filter", packet_filter, "Сетевой драйвер NT Kernel/WireSock, через который ProxiFyre перехватывает трафик приложений.", ); let proxifyre_item = match proxifyre { Some(detected) => ProxiFyreSetupItemDto { id: "proxifyre".to_string(), name: "ProxiFyre".to_string(), installed: true, version: Some(proxifyre_service_setup_version(detected)), details: detected.install_dir.display().to_string(), }, None => ProxiFyreSetupItemDto { id: "proxifyre".to_string(), name: "ProxiFyre".to_string(), installed: false, version: None, details: format!( "Будет установлен рядом с ProxyWarden в {}.", default_install_dir.display() ), }, }; let items = vec![vc_runtime_item, packet_filter_item, proxifyre_item]; let missing_count = items.iter().filter(|item| !item.installed).count(); ProxiFyreSetupStatusDto { ready: missing_count == 0, missing_count, items, } } fn proxifyre_service_setup_version(detected: &DetectedProxyfier) -> String { match detected.service_status.as_deref() { Some(status) if status.eq_ignore_ascii_case("running") => "служба запущена".to_string(), Some(_) => "служба остановлена".to_string(), None => "служба не установлена".to_string(), } } fn proxifyre_progress_path(state_dir: &Path) -> PathBuf { state_dir.join("proxifyre-setup-progress.json") } fn idle_proxifyre_setup_progress() -> ProxiFyreSetupProgressDto { ProxiFyreSetupProgressDto { operation: "idle".to_string(), status: "idle".to_string(), active_step: None, percent: 0, message: "Ожидаю действия пользователя.".to_string(), updated_at: None, } } fn read_proxifyre_setup_progress( storage: &JsonStorage, ) -> Result { let path = proxifyre_progress_path(&storage.paths().state_dir); if !path.exists() { return Ok(idle_proxifyre_setup_progress()); } let contents = fs::read_to_string(&path).map_err(|error| { CommandError::new( "proxifyre_setup_progress_read_failed", format!( "Не удалось прочитать прогресс установки ProxiFyre '{}': {error}", path.display() ), ) })?; serde_json::from_str(&contents).map_err(|error| { CommandError::new( "proxifyre_setup_progress_parse_failed", format!( "Не удалось разобрать прогресс установки ProxiFyre '{}': {error}", path.display() ), ) }) } fn write_proxifyre_setup_progress( path: &Path, operation: &str, active_step: Option<&str>, status: &str, percent: u8, message: &str, ) -> Result<(), CommandError> { let progress = ProxiFyreSetupProgressDto { operation: operation.to_string(), status: status.to_string(), active_step: active_step.map(str::to_string), percent: percent.min(100), message: message.to_string(), updated_at: Some(SystemClock.now()), }; let bytes = serde_json::to_vec_pretty(&progress).map_err(|error| { CommandError::new( "proxifyre_setup_progress_write_failed", format!("Не удалось подготовить прогресс установки ProxiFyre: {error}"), ) })?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| { CommandError::new( "proxifyre_setup_progress_write_failed", format!( "Не удалось создать папку прогресса установки ProxiFyre '{}': {error}", parent.display() ), ) })?; } let temp_path = safe_fs::temp_path(path); fs::write(&temp_path, bytes).map_err(|error| { CommandError::new( "proxifyre_setup_progress_write_failed", format!( "Не удалось записать прогресс установки ProxiFyre '{}': {error}", temp_path.display() ), ) })?; fs::rename(&temp_path, path).map_err(|error| { let _ = fs::remove_file(&temp_path); CommandError::new( "proxifyre_setup_progress_write_failed", format!( "Не удалось обновить прогресс установки ProxiFyre '{}': {error}", path.display() ), ) }) } fn setup_item_from_program( id: &str, name: &str, program: Option, missing_details: &str, ) -> ProxiFyreSetupItemDto { match program { Some(program) => ProxiFyreSetupItemDto { id: id.to_string(), name: name.to_string(), installed: true, version: program.display_version, details: program.display_name, }, None => ProxiFyreSetupItemDto { id: id.to_string(), name: name.to_string(), installed: false, version: None, details: missing_details.to_string(), }, } } #[derive(Debug, Clone)] struct InstalledProgram { display_name: String, display_version: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "PascalCase")] struct InstalledProgramJson { display_name: Option, display_version: Option, } fn detect_vc_runtime() -> Option { installed_program(&vc_runtime_registry_pattern()) } fn detect_windows_packet_filter() -> Option { installed_program("Windows Packet Filter|WinpkFilter|NDISAPI") } fn installed_program(pattern: &str) -> Option { let script = format!( r#" $paths = @( 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' ) $program = Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | Where-Object {{ $_.DisplayName -match '{}' }} | Select-Object -First 1 DisplayName, DisplayVersion if ($null -ne $program) {{ $program | ConvertTo-Json -Compress }} "#, escape_powershell_single(pattern) ); let output = command_no_window("powershell") .args([ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script.as_str(), ]) .output() .ok()?; if !output.status.success() { return None; } let stdout = String::from_utf8_lossy(&output.stdout); let payload = stdout.trim(); if payload.is_empty() || payload.eq_ignore_ascii_case("null") { return None; } let parsed: InstalledProgramJson = serde_json::from_str(payload).ok()?; let display_name = parsed.display_name?.trim().to_string(); if display_name.is_empty() { return None; } Some(InstalledProgram { display_name, display_version: parsed .display_version .map(|version| version.trim().to_string()) .filter(|version| !version.is_empty()), }) } fn vc_runtime_registry_pattern() -> String { let arch = runtime_arch_label(); if arch == "ARM64" { return r"Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)".to_string(); } format!(r"Microsoft Visual C\+\+.*Redistributable.*\({arch}\)") } fn runtime_arch_label() -> &'static str { if cfg!(target_arch = "aarch64") { "ARM64" } else if cfg!(target_arch = "x86") { "x86" } else { "x64" } } fn run_elevated_package_script( action: ProxiFyrePackageAction, body: String, artifact_dir: &Path, ) -> Result<(), CommandError> { fs::create_dir_all(artifact_dir).map_err(|error| { CommandError::new( action.error_code(), format!( "Не удалось создать папку для временных файлов ProxiFyre '{}': {error}", artifact_dir.display() ), ) })?; let prefix = format!("proxywarden-proxifyre-{}", action.file_label()); let script_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1"); let result_path = elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log"); let progress_path = proxifyre_progress_path(artifact_dir); let _ = write_proxifyre_setup_progress( &progress_path, action.operation(), None, "running", 1, action.start_message(), ); let script = wrap_elevated_package_script_for_action(&body, &result_path, Some(&progress_path), action); write_powershell_script(&script_path, &script).map_err(|error| { CommandError::new( action.error_code(), format!( "Не удалось подготовить временный скрипт, чтобы {} ProxiFyre '{}': {error}", action.label(), script_path.display() ), ) })?; let launch_script = format!( r#" $ErrorActionPreference = 'Stop' $resultPath = '{}' try {{ $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}') if ($null -eq $p) {{ Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8 exit 1 }} exit $p.ExitCode }} catch {{ Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 exit 1 }} "#, escape_powershell_single(&result_path.display().to_string()), escape_powershell_single(&script_path.display().to_string()) ); let output = if is_running_elevated() { run_powershell_file(&script_path) } else { run_powershell_command(&launch_script) }; let _ = fs::remove_file(&script_path); match output { Ok(output) if output.status.success() => { let _ = fs::remove_file(&result_path); let _ = write_proxifyre_setup_progress( &progress_path, action.operation(), None, "succeeded", 100, action.success_message(), ); Ok(()) } Ok(output) => { let details = package_failure_details(&result_path, &output); let _ = fs::remove_file(&result_path); let _ = write_proxifyre_setup_progress( &progress_path, action.operation(), None, "failed", 100, &details, ); Err(CommandError::new( action.error_code(), format!( "Не удалось {} ProxiFyre. Код elevated-команды: {}. {details}", action.label(), output.status.code().unwrap_or(-1), ), )) } Err(error) => { let message = format!( "Не удалось запросить права администратора, чтобы {} ProxiFyre: {error}", action.label() ); let _ = write_proxifyre_setup_progress( &progress_path, action.operation(), None, "failed", 100, &message, ); Err(CommandError::new(action.error_code(), message)) } } } pub fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String { wrap_elevated_package_script_for_action( body, result_path, None, ProxiFyrePackageAction::Install, ) } fn wrap_elevated_package_script_for_action( body: &str, result_path: &Path, progress_path: Option<&Path>, action: ProxiFyrePackageAction, ) -> String { let mut script = String::new(); script.push_str("$ErrorActionPreference = 'Stop'\n"); script.push_str(&format!( "$resultPath = '{}'\n", escape_powershell_single(&result_path.display().to_string()) )); script.push_str(&format!( "$script:progressOperation = '{}'\n", escape_powershell_single(action.operation()) )); script.push_str("$script:progressActiveStep = $null\n"); if let Some(progress_path) = progress_path { script.push_str(&format!( "$progressPath = '{}'\n", escape_powershell_single(&progress_path.display().to_string()) )); script.push_str( r#" function Write-ProxyWardenProgress([string]$operation, [string]$activeStep, [string]$status, [int]$percent, [string]$message) { $script:progressOperation = $operation $script:progressActiveStep = if ([string]::IsNullOrWhiteSpace($activeStep)) { $null } else { $activeStep } $payload = [ordered]@{ operation = $operation status = $status activeStep = $script:progressActiveStep percent = [Math]::Max(0, [Math]::Min(100, $percent)) message = $message updatedAt = (Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Compress $progressTempPath = "$progressPath.tmp" Set-Content -LiteralPath $progressTempPath -Value $payload -Encoding UTF8 Move-Item -LiteralPath $progressTempPath -Destination $progressPath -Force } "#, ); } else { script.push_str( r#" function Write-ProxyWardenProgress([string]$operation, [string]$activeStep, [string]$status, [int]$percent, [string]$message) {} "#, ); } script.push_str("try {\n"); script.push_str(body); script.push_str( r#" Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8 exit 0 } catch { $message = ($_ | Out-String) Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'failed' 100 $message Set-Content -LiteralPath $resultPath -Value $message -Encoding UTF8 exit 1 } "#, ); script } fn write_powershell_script(path: &Path, script: &str) -> std::io::Result<()> { let mut bytes = Vec::with_capacity(script.len() + 3); bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]); bytes.extend_from_slice(script.as_bytes()); fs::write(path, bytes) } fn run_powershell_command(script: &str) -> std::io::Result { command_no_window("powershell") .args([ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script, ]) .output() } fn run_powershell_file(script_path: &Path) -> std::io::Result { command_no_window("powershell") .args([ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ]) .arg(script_path) .output() } fn is_running_elevated() -> bool { if !cfg!(windows) { return false; } let script = r#"([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#; let Ok(output) = run_powershell_command(script) else { return false; }; output.status.success() && String::from_utf8_lossy(&output.stdout) .trim() .eq_ignore_ascii_case("true") } fn powershell_output_message(output: &Output, fallback: &str) -> String { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); if !stderr.is_empty() { return stderr; } let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !stdout.is_empty() { return stdout; } fallback.to_string() } pub fn install_proxifyre_script(generated_config_path: &Path) -> String { install_proxifyre_script_with_bundle(generated_config_path, None) } pub fn install_proxifyre_script_with_bundle( generated_config_path: &Path, bundled_asset_dir: Option<&Path>, ) -> String { install_proxifyre_script_for_target( generated_config_path, bundled_asset_dir, &default_proxifyre_install_dir(), ) } pub fn install_proxifyre_script_for_target( generated_config_path: &Path, bundled_asset_dir: Option<&Path>, target_dir: &Path, ) -> String { let mut script = String::new(); script.push_str(&format!( "$targetDir = '{}'\n", escape_powershell_single(&target_dir.display().to_string()) )); script.push_str(&format!( "$generatedConfigPath = '{}'\n", escape_powershell_single(&generated_config_path.display().to_string()) )); script.push_str(&format!( "$bundledAssetDir = '{}'\n", escape_powershell_single( &bundled_asset_dir .map(|path| path.display().to_string()) .unwrap_or_default() ) )); script.push_str("$script:bundledAssetDir = [string]$bundledAssetDir\n"); script.push_str(&format!( "$proxifyreReleaseApi = '{}'\n", escape_powershell_single(PROXIFYRE_RELEASE_API_URL) )); script.push_str(&format!( "$ndisapiReleaseApi = '{}'\n", escape_powershell_single(NDISAPI_RELEASE_API_URL) )); script.push_str(&format!( "$proxifyrePinnedReleaseTag = '{}'\n", escape_powershell_single(PROXIFYRE_PINNED_RELEASE_TAG) )); script.push_str(&format!( "$ndisapiPinnedReleaseTag = '{}'\n", escape_powershell_single(NDISAPI_PINNED_RELEASE_TAG) )); script.push_str(&format!( "$ndisapiPinnedInstallerVersion = '{}'\n", escape_powershell_single(NDISAPI_PINNED_INSTALLER_VERSION) )); script.push_str(&format!( "$vcRedistX64Url = '{}'\n", escape_powershell_single(VC_REDIST_X64_URL) )); script.push_str(&format!( "$vcRedistX86Url = '{}'\n", escape_powershell_single(VC_REDIST_X86_URL) )); script.push_str( r#" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 function Get-NativeArchitecture { $processor = Get-CimInstance Win32_Processor | Select-Object -First 1 if ($null -ne $processor -and $processor.Architecture -eq 12) { return 'ARM64' } if ([Environment]::Is64BitOperatingSystem) { return 'x64' } return 'x86' } function Get-SafeUriForLog([string]$uri) { try { $parsed = [Uri]$uri $port = if ($parsed.IsDefaultPort) { '' } else { ":$($parsed.Port)" } return "$($parsed.Scheme)://$($parsed.Host)$port$($parsed.AbsolutePath)" } catch { return '' } } function Invoke-ReleaseApi([string]$uri, [string]$label) { $safeUri = Get-SafeUriForLog $uri $headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/vnd.github+json' } $lastError = $null foreach ($attempt in 1..3) { try { return Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 60 -MaximumRedirection 10 } catch { $lastError = $_.Exception.Message if ($attempt -lt 3) { Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2)) } } } throw "Не удалось получить metadata для $label ($safeUri): $lastError" } function New-ReleaseAsset([string]$name, [string]$url) { [PSCustomObject]@{ name = $name browser_download_url = $url digest = $null } } function Resolve-ReleaseAsset([string]$apiUri, [string]$pattern, [string]$label, $fallbackAsset, [int]$fallbackPercent) { try { $release = Invoke-ReleaseApi $apiUri $label return Select-Asset $release.assets $pattern $label } catch { $fallbackUri = Get-SafeUriForLog $fallbackAsset.browser_download_url Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'running' $fallbackPercent "GitHub API недоступен для $label. Пробую прямую ссылку: $fallbackUri" return $fallbackAsset } } function Get-PinnedProxiFyreAsset([string]$arch) { $archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' } $name = "ProxiFyre-$proxifyrePinnedReleaseTag-$archLabel-signed.zip" $url = "https://github.com/wiresock/proxifyre/releases/download/$proxifyrePinnedReleaseTag/$name" return New-ReleaseAsset $name $url } function Get-PinnedWindowsPacketFilterAsset([string]$arch) { $archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' } $name = "Windows.Packet.Filter.$ndisapiPinnedInstallerVersion.$archLabel.msi" $url = "https://github.com/wiresock/ndisapi/releases/download/$ndisapiPinnedReleaseTag/$name" return New-ReleaseAsset $name $url } function Complete-Download([string]$partialPath, [string]$path, [string]$label) { if (-not (Test-Path -LiteralPath $partialPath)) { throw "${label}: файл не был создан." } $item = Get-Item -LiteralPath $partialPath if ($item.Length -le 0) { throw "${label}: скачанный файл пустой." } Move-Item -LiteralPath $partialPath -Destination $path -Force } function Invoke-WebClientDownload([string]$uri, [string]$partialPath) { $client = New-Object System.Net.WebClient try { $client.Headers.Add('User-Agent', 'proxywarden') $client.Headers.Add('Accept', 'application/octet-stream,*/*') $client.DownloadFile($uri, $partialPath) } finally { $client.Dispose() } } function Invoke-CurlDownload([string]$uri, [string]$partialPath) { $curl = Get-Command 'curl.exe' -ErrorAction SilentlyContinue if ($null -eq $curl) { throw 'curl.exe не найден.' } $curlOutput = & $curl.Source --silent --show-error --fail --location --retry 2 --retry-delay 2 --connect-timeout 30 --max-time 180 --user-agent 'proxywarden' --output $partialPath --url $uri 2>&1 if ($LASTEXITCODE -ne 0) { $curlMessage = ($curlOutput | Out-String).Trim() if ([string]::IsNullOrWhiteSpace($curlMessage)) { throw "curl.exe завершился с кодом $LASTEXITCODE." } throw "curl.exe завершился с кодом ${LASTEXITCODE}: $curlMessage" } } function Invoke-Download([string]$uri, [string]$path, [string]$label) { $safeUri = Get-SafeUriForLog $uri $partialPath = "$path.part" $headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/octet-stream,*/*' } $webRequestError = $null $webClientError = $null $curlError = $null foreach ($attempt in 1..3) { Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue try { Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $partialPath -Headers $headers -TimeoutSec 180 -MaximumRedirection 10 Complete-Download $partialPath $path $label return } catch { $webRequestError = $_.Exception.Message Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue if ($attempt -lt 3) { Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2)) } } } try { Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue Invoke-WebClientDownload $uri $partialPath Complete-Download $partialPath $path $label return } catch { $webClientError = $_.Exception.Message Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue } try { Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue Invoke-CurlDownload $uri $partialPath Complete-Download $partialPath $path $label return } catch { $curlError = $_.Exception.Message Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue } $errors = @() if (-not [string]::IsNullOrWhiteSpace($webRequestError)) { $errors += "Invoke-WebRequest: $webRequestError" } if (-not [string]::IsNullOrWhiteSpace($webClientError)) { $errors += "WebClient: $webClientError" } if (-not [string]::IsNullOrWhiteSpace($curlError)) { $errors += "curl.exe: $curlError" } $details = if ($errors.Count -gt 0) { $errors -join ' | ' } else { 'неизвестная ошибка' } throw "Не удалось скачать $label ($safeUri): $details" } function Select-Asset($assets, [string]$pattern, [string]$label) { $asset = $assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1 if ($null -eq $asset) { throw "Не найден подходящий asset для $label ($pattern)." } return $asset } function Verify-AssetHash([string]$path, $asset) { if ($asset.digest -match '^sha256:(.+)$') { $expected = $Matches[1].ToLowerInvariant() $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $expected) { throw "SHA256 не совпал для $($asset.name). Ожидалось $expected, получилось $actual." } } } function Assert-ExitCode($process, [string]$label) { if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) { throw "$label завершился с кодом $($process.ExitCode)." } } function Get-InstalledProgram([string]$pattern) { $paths = @( 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' ) return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -match $pattern } | Select-Object -First 1 } function Test-VcRuntime([string]$arch) { $pattern = if ($arch -eq 'ARM64') { 'Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)' } else { "Microsoft Visual C\+\+.*Redistributable.*\($arch\)" } return $null -ne (Get-InstalledProgram $pattern) } function Test-WindowsPacketFilter { return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI') } function Get-LogTail([string]$path) { if (-not (Test-Path -LiteralPath $path)) { return '' } return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' ' } function Get-BundledAssetDir { $dir = [string]$script:bundledAssetDir if ([string]::IsNullOrWhiteSpace($dir)) { return $null } if (-not (Test-Path -LiteralPath $dir -PathType Container)) { return $null } return $dir } function Get-BundledAssetManifest { $assetDir = Get-BundledAssetDir if ($null -eq $assetDir) { return $null } $manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json') if (-not (Test-Path -LiteralPath $manifestPath)) { return $null } try { return Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { throw "Не удалось прочитать manifest встроенных пакетов ProxiFyre: $($_.Exception.Message)" } } $script:bundledAssetManifest = Get-BundledAssetManifest function Get-BundledAssetHash([string]$name) { if ($null -eq $script:bundledAssetManifest -or $null -eq $script:bundledAssetManifest.files) { return $null } $entry = $script:bundledAssetManifest.files | Where-Object { $_.name -eq $name } | Select-Object -First 1 if ($null -eq $entry) { return $null } return [string]$entry.sha256 } function Verify-BundledAssetHash([string]$path, [string]$label) { $name = [IO.Path]::GetFileName($path) $expected = Get-BundledAssetHash $name if ([string]::IsNullOrWhiteSpace($expected)) { throw "Во встроенном manifest нет SHA256 для $label ($name)." } $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $expected.ToLowerInvariant()) { throw "SHA256 не совпал для встроенного $label ($name). Ожидалось $expected, получилось $actual." } } function Get-BundledAsset([string]$pattern, [string]$label) { $assetDir = Get-BundledAssetDir if ($null -eq $assetDir) { return $null } $asset = Get-ChildItem -LiteralPath $assetDir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -match $pattern } | Select-Object -First 1 if ($null -eq $asset) { return $null } Verify-BundledAssetHash $asset.FullName $label return $asset.FullName } function Copy-BundledAsset([string]$sourcePath, [string]$targetPath, [string]$label) { Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Force $item = Get-Item -LiteralPath $targetPath if ($item.Length -le 0) { throw "${label}: встроенный файл пустой." } } $arch = Get-NativeArchitecture $workDir = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-proxifyre-install' $extractDir = Join-Path $workDir 'proxifyre' Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $workDir, $extractDir, $targetDir | Out-Null Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 8 'Проверяю сетевой драйвер Windows Packet Filter.' $packetFilterAlreadyInstalled = Test-WindowsPacketFilter if (-not $packetFilterAlreadyInstalled) { Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 14 'Готовлю Windows Packet Filter.' $ndisPattern = if ($arch -eq 'ARM64') { 'ARM64\.msi$' } elseif ($arch -eq 'x86') { 'x86\.msi$' } else { 'x64\.msi$' } $bundledNdisPath = Get-BundledAsset $ndisPattern 'Windows Packet Filter' if ($null -ne $bundledNdisPath) { Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Использую встроенный Windows Packet Filter.' $ndisPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledNdisPath)) Copy-BundledAsset $bundledNdisPath $ndisPath 'Windows Packet Filter' } else { Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Скачиваю Windows Packet Filter.' $ndisAsset = Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter' (Get-PinnedWindowsPacketFilterAsset $arch) 16 $ndisPath = Join-Path $workDir $ndisAsset.name Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter' Verify-AssetHash $ndisPath $ndisAsset } $ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log' Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 26 'Устанавливаю Windows Packet Filter.' $ndisProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $ndisPath, '/qn', '/norestart', '/L*v', $ndisLogPath) -Wait -PassThru -WindowStyle Hidden if ($ndisProcess.ExitCode -ne 0 -and $ndisProcess.ExitCode -ne 3010 -and -not (Test-WindowsPacketFilter)) { $ndisLogTail = Get-LogTail $ndisLogPath throw "Windows Packet Filter завершился с кодом $($ndisProcess.ExitCode). MSI log: $ndisLogPath $ndisLogTail" } } Write-ProxyWardenProgress 'install' 'packet-filter' 'succeeded' 36 'Сетевой драйвер готов.' Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 40 'Проверяю Microsoft Visual C++ Runtime.' if (-not (Test-VcRuntime $arch)) { $vcBundledPattern = if ($arch -eq 'x86') { '^vc_redist\.x86\.exe$' } else { '^vc_redist\.x64\.exe$' } $vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url } $bundledVcPath = Get-BundledAsset $vcBundledPattern 'Microsoft Visual C++ Runtime' $vcRedistPath = Join-Path $workDir 'vc_redist.exe' if ($null -ne $bundledVcPath) { Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Использую встроенный Microsoft Visual C++ Runtime.' Copy-BundledAsset $bundledVcPath $vcRedistPath 'Microsoft Visual C++ Runtime' } else { Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Скачиваю Microsoft Visual C++ Runtime.' Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime' } Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 54 'Устанавливаю Microsoft Visual C++ Runtime.' $vcProcess = Start-Process -FilePath $vcRedistPath -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru -WindowStyle Hidden if ($vcProcess.ExitCode -ne 0 -and $vcProcess.ExitCode -ne 3010 -and $vcProcess.ExitCode -ne 1638 -and -not (Test-VcRuntime $arch)) { throw "Visual C++ Runtime завершился с кодом $($vcProcess.ExitCode)." } } Write-ProxyWardenProgress 'install' 'vc-runtime' 'succeeded' 62 'Среда запуска готова.' Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 66 'Готовлю ProxiFyre.' $proxifyrePattern = if ($arch -eq 'ARM64') { 'ARM64-signed\.zip$' } elseif ($arch -eq 'x86') { 'x86-signed\.zip$' } else { 'x64-signed\.zip$' } $bundledProxiFyrePath = Get-BundledAsset $proxifyrePattern 'ProxiFyre' if ($null -ne $bundledProxiFyrePath) { Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Использую встроенный ProxiFyre.' $proxifyreZipPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledProxiFyrePath)) Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath 'ProxiFyre' } else { Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Скачиваю ProxiFyre.' $proxifyreAsset = Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre' (Get-PinnedProxiFyreAsset $arch) 68 $proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre' Verify-AssetHash $proxifyreZipPath $proxifyreAsset } Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 76 'Распаковываю ProxiFyre.' Expand-Archive -LiteralPath $proxifyreZipPath -DestinationPath $extractDir -Force $proxifyreExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'ProxiFyre.exe' | Select-Object -First 1 if ($null -eq $proxifyreExe) { throw 'В архиве ProxiFyre не найден ProxiFyre.exe.' } Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 82 'Копирую ProxiFyre в папку установки.' Copy-Item -Path (Join-Path $proxifyreExe.Directory.FullName '*') -Destination $targetDir -Recurse -Force $configTarget = Join-Path $targetDir 'app-config.json' if (Test-Path -LiteralPath $generatedConfigPath) { Copy-Item -LiteralPath $generatedConfigPath -Destination $configTarget -Force } elseif (-not (Test-Path -LiteralPath $configTarget)) { $emptyConfig = '{"logLevel":"Info","bypassLan":true,"proxies":[]}' Set-Content -LiteralPath $configTarget -Value $emptyConfig -Encoding UTF8 } $markerPath = Join-Path $targetDir 'proxywarden-component.json' [ordered]@{ manager = 'ProxyWarden' component = 'proxifyre' serviceName = 'ProxiFyreService' installedAt = (Get-Date).ToString('o') installRoot = $targetDir packetFilterInstalledByProxyWarden = (-not $packetFilterAlreadyInstalled) } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8 Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 90 'Устанавливаю и запускаю службу ProxiFyre.' Push-Location $targetDir try { & .\ProxiFyre.exe stop | Out-Null & .\ProxiFyre.exe uninstall | Out-Null & .\ProxiFyre.exe install if ($LASTEXITCODE -ne 0) { throw "ProxiFyre.exe install завершился с кодом $LASTEXITCODE." } & .\ProxiFyre.exe start if ($LASTEXITCODE -ne 0) { Start-Service -Name 'ProxiFyreService' -ErrorAction Stop } } finally { Pop-Location } Write-ProxyWardenProgress 'install' 'proxifyre' 'succeeded' 100 'ProxiFyre и сетевой драйвер готовы.' "#, ); script } pub fn uninstall_proxifyre_script(detected: Option<&DetectedProxyfier>) -> String { let mut script = String::new(); let install_dir = detected .map(|detected| detected.install_dir.display().to_string()) .unwrap_or_default(); let executable_path = detected .map(|detected| detected.executable_path.display().to_string()) .unwrap_or_default(); script.push_str(&format!( "$installDir = '{}'\n", escape_powershell_single(&install_dir) )); script.push_str(&format!( "$exePath = '{}'\n", escape_powershell_single(&executable_path) )); script.push_str( r#" function Get-InstalledProgram([string]$pattern) { $paths = @( 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' ) return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -match $pattern } | Select-Object -First 1 DisplayName, DisplayVersion, PSChildName, UninstallString, QuietUninstallString } function Test-WindowsPacketFilter { return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI') } function Get-LogTail([string]$path) { if (-not (Test-Path -LiteralPath $path)) { return '' } return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' ' } function Resolve-MsiProductCode($program, [string]$label) { if ($null -eq $program) { return $null } if ($program.PSChildName -match '^\{[0-9A-Fa-f-]{36}\}$') { return $program.PSChildName } foreach ($candidate in @($program.QuietUninstallString, $program.UninstallString)) { if ($candidate -match '\{[0-9A-Fa-f-]{36}\}') { return $Matches[0] } } throw "Не удалось найти MSI product code для $label. Отказываюсь запускать произвольный UninstallString." } function Uninstall-MsiProgram($program, [string]$label, [string]$logPath) { $productCode = Resolve-MsiProductCode $program $label if ([string]::IsNullOrWhiteSpace($productCode)) { return } $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/x', $productCode, '/qn', '/norestart', '/L*v', $logPath) -Wait -PassThru -WindowStyle Hidden if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010 -and $process.ExitCode -ne 1605) { $logTail = Get-LogTail $logPath throw "$label uninstall завершился с кодом $($process.ExitCode). MSI log: $logPath $logTail" } } function Find-ProxiFyreService { foreach ($name in @('ProxiFyreService', 'ProxiFyre')) { $candidate = Get-Service -Name $name -ErrorAction SilentlyContinue if ($null -ne $candidate) { return $candidate } } return Get-Service | Where-Object { $_.Name -match 'ProxiFyre|Proxifyre' -or $_.DisplayName -match 'ProxiFyre|Proxifyre' } | Select-Object -First 1 } function Get-ServiceProcessId([string]$name) { $escapedName = $name.Replace("'", "''") $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue if ($null -eq $record) { return 0 } return [int]$record.ProcessId } Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 10 'Останавливаю службу ProxiFyre.' $service = Find-ProxiFyreService if ($null -ne $service -and $service.Status -ne 'Stopped') { try { if ($service.CanStop) { Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue } $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue if ($null -ne $service) { $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) } } catch {} } $service = Find-ProxiFyreService if ($null -ne $service -and $service.Status -ne 'Stopped') { $processId = Get-ServiceProcessId $service.Name if ($processId -gt 0) { taskkill.exe /PID $processId /F | Out-Null Start-Sleep -Milliseconds 700 } } Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 34 'Удаляю службу и файлы ProxiFyre.' if (-not [string]::IsNullOrWhiteSpace($exePath) -and (Test-Path -LiteralPath $exePath)) { Push-Location (Split-Path -Parent $exePath) try { & $exePath uninstall | Out-Null } finally { Pop-Location } } $service = Find-ProxiFyreService if ($null -ne $service) { sc.exe delete $service.Name | Out-Null } Get-Process -Name 'ProxiFyre' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue if (-not [string]::IsNullOrWhiteSpace($installDir) -and (Test-Path -LiteralPath $installDir)) { Remove-Item -LiteralPath $installDir -Recurse -Force } Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'succeeded' 58 'ProxiFyre удален.' Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 68 'Проверяю Windows Packet Filter.' $packetFilter = Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI' if ($null -ne $packetFilter) { Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 78 'Удаляю Windows Packet Filter.' $driverLogPath = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-windows-packet-filter-uninstall.log' Uninstall-MsiProgram $packetFilter 'Windows Packet Filter' $driverLogPath } if (Test-WindowsPacketFilter) { throw 'Windows Packet Filter все еще найден после удаления. Возможно, Windows требует перезагрузку.' } Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'succeeded' 100 'ProxiFyre и Windows Packet Filter удалены.' "#, ); script } fn ensure_safe_proxifyre_install_dir(path: &Path) -> Result<(), CommandError> { let normalized = path .display() .to_string() .replace('/', "\\") .to_ascii_lowercase(); let name = path .file_name() .and_then(|value| value.to_str()) .map(|value| value.to_ascii_lowercase()) .unwrap_or_default(); let marker_path = path.join("proxywarden-component.json"); let is_proxywarden_component = name == "proxifyre" && normalized.contains("\\proxywarden\\components\\"); let is_legacy_proxywarden_child = name == "proxifyre" && normalized.ends_with("\\proxywarden\\proxifyre"); let is_legacy_tools_proxifyre = normalized == r"c:\tools\proxifyre"; let has_proxywarden_marker = marker_path.exists(); if path.parent().is_some() && (is_proxywarden_component || is_legacy_proxywarden_child || is_legacy_tools_proxifyre || has_proxywarden_marker) { return Ok(()); } Err(CommandError::new( ProxiFyrePackageAction::Uninstall.error_code(), format!( "Отказываюсь рекурсивно удалять папку ProxiFyre с небезопасным путем: {}", path.display() ), )) } fn apply_to_detected_proxyfier( request: HelperApplyRequest<'_>, detected: &DetectedProxyfier, ) -> Result { let Some(config_path) = &detected.config_path else { return staged_apply_result(request); }; safe_fs::write_with_backup(config_path, request.config_contents.as_bytes()).map_err( |error| { CommandError::new( "proxyfier_apply_failed", format!( "Не удалось безопасно записать конфиг ProxiFyre '{}': {error}", config_path.display() ), ) }, )?; Ok(HelperApplyResult { success: true, changed: true, action: "proxifyre.apply-detected-config".to_string(), message: format!( "Сгенерированный конфиг записан в найденную установку ProxiFyre: {}", config_path.display() ), }) } fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result { Ok(HelperApplyResult { success: true, changed: true, action: format!("{}.stage-generated-config", request.adapter_id), message: format!( "Сгенерированный конфиг подготовлен в {}; совместимая установка ProxiFyre не найдена", request.config_path.display() ), }) } fn activity_for_apply( clock: &impl Clock, generated: &ProxyRouterGeneratedConfig, generated_path: &Path, helper_result: &HelperApplyResult, ) -> ActivityEntry { let level = if helper_result.success { ActivityLevel::Success } else { ActivityLevel::Error }; ActivityEntry { id: format!("apply-{}", generated.adapter_id), at: clock.now(), level, title: "Конфиг ProxiFyre создан".to_string(), message: format!( "Профилей: {}, приложений: {}, конфиг: {}", generated.enabled_profiles, generated.routed_apps, generated_path.display() ), } } fn activity_for_apply_error(clock: &impl Clock, error: &CommandError) -> ActivityEntry { ActivityEntry { id: format!("apply-error-{}", error.code), at: clock.now(), level: ActivityLevel::Error, title: "Применение ProxiFyre заблокировано".to_string(), message: error.message.clone(), } } fn storage_error(error: std::io::Error) -> CommandError { CommandError::new("storage_error", error.to_string()) } fn background_task_error(error: impl std::fmt::Display) -> CommandError { CommandError::new( "background_task_failed", format!("Фоновая проверка не завершилась: {error}"), ) } fn package_failure_details(result_path: &Path, output: &Output) -> String { let mut parts = Vec::new(); if let Ok(contents) = fs::read_to_string(result_path) { let details = compact_error_text(&contents); if !details.is_empty() && !details.eq_ignore_ascii_case("ok") { parts.push(details); } } let stdout = String::from_utf8_lossy(&output.stdout); let stdout = compact_error_text(&stdout); if !stdout.is_empty() { parts.push(format!("stdout: {stdout}")); } let stderr = String::from_utf8_lossy(&output.stderr); let stderr = compact_error_text(&stderr); if !stderr.is_empty() { parts.push(format!("stderr: {stderr}")); } if parts.is_empty() { parts.push( "Лог elevated-скрипта не создан. Обычно это значит, что окно UAC было отменено или Windows не дала запустить elevated PowerShell." .to_string(), ); } parts.join(" ") } fn compact_error_text(value: &str) -> String { let text = value .lines() .map(str::trim) .filter(|line| !line.is_empty()) .collect::>() .join(" "); let max_chars = 1400; if text.chars().count() <= max_chars { return text; } let truncated = text.chars().take(max_chars).collect::(); format!("{truncated}...") } fn parse_service_command_output(stdout: &[u8]) -> Option { let stdout = String::from_utf8_lossy(stdout); let payload = stdout .lines() .rev() .map(str::trim) .find(|line| line.starts_with('{') && line.ends_with('}'))?; serde_json::from_str(payload).ok() } fn service_script_failed_message(action: ServiceControlAction, exit_code: Option) -> String { let exit_code = exit_code .map(|code| format!(" Код выхода PowerShell: {code}.")) .unwrap_or_default(); format!( "Не удалось {} службу ProxiFyre: команда управления службой не вернула корректный результат.{exit_code}", action.label() ) } fn service_command_failed_message( action: ServiceControlAction, result: &ServiceCommandOutput, ) -> String { let service_name = result .service_name .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or("ProxiFyre"); let status = result .status .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or("неизвестен"); let pid = result .process_id .filter(|value| *value > 0) .map(|value| format!(", PID: {value}")) .unwrap_or_default(); match result.code.as_str() { "service_not_found" => "Служба ProxiFyre не найдена.".to_string(), "start_failed" => format!( "Не удалось запустить службу {service_name}. Текущий статус: {status}{pid}. Попробуй запустить приложение от имени администратора." ), "stop_failed" => format!( "Не удалось остановить службу {service_name} даже после принудительной попытки. Текущий статус: {status}{pid}. Запусти приложение от имени администратора или останови службу вручную в services.msc." ), _ => format!( "Не удалось {} службу {service_name}. Текущий статус: {status}{pid}.", action.label() ), } } fn elevated_service_failed_message( action: ServiceControlAction, direct_result: &ServiceCommandOutput, exit_code: Option, ) -> String { let service_name = direct_result .service_name .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or("ProxiFyre"); let status = direct_result .status .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or("неизвестен"); let pid = direct_result .process_id .filter(|value| *value > 0) .map(|value| format!(", PID: {value}")) .unwrap_or_default(); let exit_code = exit_code .map(|code| format!(" Код elevated-команды: {code}.")) .unwrap_or_default(); format!( "Не удалось {} службу {service_name} даже после запроса прав администратора. До запроса UAC статус был: {status}{pid}.{exit_code} Если появлялось окно UAC, проверь, что оно было подтверждено.", action.label() ) } fn escape_powershell_single(value: &str) -> String { value.replace('\'', "''") } fn validation_error(errors: Vec) -> CommandError { CommandError::with_details( "validation_error", "Проверка введенных данных не прошла", errors .into_iter() .map(|error| ValidationIssue { field: error.field, message: error.message, }) .collect(), ) } fn adapter_error(error: ProxyRouterError) -> CommandError { let code = match error.kind { ProxyRouterErrorKind::EmptyProfileItems => "empty_profile_items", ProxyRouterErrorKind::MissingTarget => "missing_target", ProxyRouterErrorKind::MissingRequiredComponent => "missing_required_component", ProxyRouterErrorKind::RequiredComponentNotRunning => "required_component_not_running", ProxyRouterErrorKind::UnsupportedTargetProtocol => "unsupported_target_protocol", ProxyRouterErrorKind::Serialization => "serialization_error", }; CommandError::new(code, error.message) } impl From for ProfileInput { fn from(input: ProfileInputDto) -> Self { Self { id: input.id, name: input.name, enabled: input.enabled.unwrap_or(true), target_id: input .target_id .unwrap_or_else(|| "local-singbox".to_string()), protocols: input .protocols .unwrap_or_else(|| vec!["TCP".to_string(), "UDP".to_string()]), items: input .items .unwrap_or_default() .into_iter() .map(ProfileItemInput::from) .collect(), } } } impl From for ProfileItemInput { fn from(input: ProfileItemInputDto) -> Self { Self { item_type: input.item_type, value: input.value, recursive: input.recursive, } } } impl From for TargetInput { fn from(input: TargetInputDto) -> Self { Self { id: input.id, name: input.name, kind: input.kind.unwrap_or_else(|| "external".to_string()), protocol: input.protocol.unwrap_or_else(|| "socks5".to_string()), host: input.host, port: input.port, requires_component: input.requires_component, } } } impl From<&Profile> for ProfileDto { fn from(profile: &Profile) -> Self { Self { id: profile.id.clone(), name: profile.name.clone(), enabled: profile.enabled, target_id: profile.target_id.clone(), protocols: profile.protocols.clone(), items: profile.items.iter().map(ProfileItemDto::from).collect(), } } } impl From<&ProfileItem> for ProfileItemDto { fn from(item: &ProfileItem) -> Self { Self { item_type: item.item_type.clone(), value: item.value.clone(), recursive: item.recursive, } } } impl From<&Target> for TargetDto { fn from(target: &Target) -> Self { Self { id: target.id.clone(), name: target.name.clone(), kind: target.kind.clone(), protocol: target.protocol.clone(), host: target.host.clone(), port: target.port, requires_component: target.requires_component.clone(), } } } impl From<&ComponentStatus> for ComponentStatusDto { fn from(component: &ComponentStatus) -> Self { Self { id: component.id.clone(), name: component.name.clone(), state: component.state.clone(), installed: component.installed, running: component.running, version: component.version.clone(), path: component.path.clone(), service_name: component.service_name.clone(), service_status: component.service_status.clone(), problems: component.problems.clone(), actions: component.actions.clone(), } } } impl From<&ActivityEntry> for ActivityEntryDto { fn from(entry: &ActivityEntry) -> Self { Self { id: entry.id.clone(), at: entry.at.clone(), level: entry.level.clone(), title: entry.title.clone(), message: entry.message.clone(), } } } impl From<&LocalSingBoxConfig> for LocalSingBoxConfigDto { fn from(config: &LocalSingBoxConfig) -> Self { Self { subscription_display_url: config.subscription_display_url(), has_subscription: config .subscription_url .as_deref() .is_some_and(|value| !value.trim().is_empty()), selected_server_tag: config.selected_server_tag.clone(), listen_host: config.listen_host.clone(), listen_port: config.listen_port, service_name: config.service_name.clone(), install_root: config.install_root.clone(), updated_at: config.updated_at.clone(), } } } impl From<&SubscriptionCache> for SubscriptionCacheDto { fn from(cache: &SubscriptionCache) -> Self { Self { servers: cache .servers .iter() .map(SubscriptionServerDto::from) .collect(), user_info: cache.user_info.clone(), fetched_at: cache.fetched_at.clone(), } } } impl From<&SubscriptionServer> for SubscriptionServerDto { fn from(server: &SubscriptionServer) -> Self { Self { tag: server.tag.clone(), server_type: server.server_type.clone(), server: server.server.clone(), server_port: server.server_port, } } }