Files
ProxyWarden/src-tauri/src/commands.rs
T

4963 lines
164 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PathBuf>) -> 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<ValidationIssue>,
}
impl CommandError {
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
details: Vec::new(),
}
}
fn with_details(
code: impl Into<String>,
message: impl Into<String>,
details: Vec<ValidationIssue>,
) -> 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<TargetDto>,
pub components: Vec<ComponentStatusDto>,
pub recent_activity: Vec<ActivityEntryDto>,
pub generated_config_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SavedStateResponse {
pub profiles: Vec<ProfileDto>,
pub targets: Vec<TargetDto>,
pub generated_config_path: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartupSnapshotResponse {
pub admin_status: AdminStatusResponse,
pub saved_state: SavedStateResponse,
pub components: Vec<ComponentStatusDto>,
pub proxifyre_setup_status: ProxiFyreSetupStatusDto,
pub singbox_status: LocalSingBoxStatusResponse,
pub singbox_setup_status: SingBoxSetupStatusDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxiFyreSetupStatusDto {
pub ready: bool,
pub missing_count: usize,
pub items: Vec<ProxiFyreSetupItemDto>,
}
#[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<String>,
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<String>,
pub percent: u8,
pub message: String,
pub updated_at: Option<String>,
}
pub type SingBoxSetupStatusDto = SingBoxSetupStatus;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSingBoxStatusResponse {
pub config: LocalSingBoxConfigDto,
pub cache: Option<SubscriptionCacheDto>,
pub component: ComponentStatusDto,
pub generated_config_path: String,
pub lan_listen_host: Option<String>,
#[cfg(debug_assertions)]
pub subscription_identity: SubscriptionRequestIdentityDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSingBoxConfigDto {
pub subscription_display_url: Option<String>,
pub has_subscription: bool,
pub selected_server_tag: Option<String>,
pub listen_host: String,
pub listen_port: u16,
pub service_name: String,
pub install_root: String,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionCacheDto {
pub servers: Vec<SubscriptionServerDto>,
pub user_info: serde_json::Map<String, serde_json::Value>,
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<SubscriptionRequestHeaderDto>,
}
#[cfg(debug_assertions)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionRequestHeaderDto {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveSingBoxSubscriptionInputDto {
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<String>,
#[serde(default)]
pub server_port: Option<u16>,
}
#[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<u128>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyProbeResponse {
pub id: String,
pub name: String,
pub url: String,
pub ok: bool,
pub status: Option<u16>,
pub latency: Option<u128>,
pub ip: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyTargetCheckResponse {
pub tag: String,
pub server: String,
pub server_port: u16,
pub ok: bool,
pub latency: Option<u128>,
pub error: Option<String>,
pub probes: Vec<ProxyProbeResponse>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GenerateSingBoxConfigResponse {
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<SingBoxCheckResult>,
pub activity: ActivityEntryDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInputDto {
pub id: Option<String>,
pub name: String,
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub target_id: Option<String>,
#[serde(default)]
pub protocols: Option<Vec<String>>,
#[serde(default)]
pub items: Option<Vec<ProfileItemInputDto>>,
}
#[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<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetInputDto {
pub id: Option<String>,
pub name: String,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub protocol: Option<String>,
pub host: String,
pub port: u32,
#[serde(default)]
pub requires_component: Option<String>,
}
#[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<Protocol>,
pub items: Vec<ProfileItemDto>,
}
#[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<ComponentId>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_status: Option<String>,
pub problems: Vec<String>,
pub actions: Vec<String>,
}
#[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<ResolvedAppDto>,
pub warnings: Vec<String>,
}
#[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<String>,
}
#[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<HelperApplyResult, CommandError>;
}
pub trait SubscriptionFetcher {
fn fetch_subscription(
&self,
url: &str,
identity: &subscription::SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, subscription::SubscriptionError>;
}
pub struct SystemSubscriptionFetcher;
impl SubscriptionFetcher for SystemSubscriptionFetcher {
fn fetch_subscription(
&self,
url: &str,
identity: &subscription::SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
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<H = SystemProxyfierDetectionHost> {
host: H,
}
impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
pub fn system() -> Self {
SystemProxyfierDetectionHost.into()
}
}
impl<H> From<H> for DetectedProxyApplyHelper<H> {
fn from(host: H) -> Self {
Self { host }
}
}
impl<H> ProxyApplyHelper for DetectedProxyApplyHelper<H>
where
H: ProxyfierDetectionHost,
{
fn apply_proxy_config(
&self,
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
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<StatusResponse, CommandError> {
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<StartupSnapshotResponse, CommandError> {
let storage = state.storage();
tauri::async_runtime::spawn_blocking(move || read_startup_snapshot(&storage))
.await
.map_err(background_task_error)?
}
#[tauri::command]
pub fn get_saved_state(
state: tauri::State<'_, CommandState>,
) -> Result<SavedStateResponse, CommandError> {
read_saved_state(&state.storage())
}
#[tauri::command]
pub fn get_profiles(
state: tauri::State<'_, CommandState>,
) -> Result<Vec<ProfileDto>, CommandError> {
read_profiles(&state.storage())
}
#[tauri::command]
pub fn save_profile(
state: tauri::State<'_, CommandState>,
input: ProfileInputDto,
) -> Result<ProfileDto, CommandError> {
save_profile_to_storage(&state.storage(), input)
}
#[tauri::command]
pub fn get_targets(state: tauri::State<'_, CommandState>) -> Result<Vec<TargetDto>, CommandError> {
read_targets(&state.storage())
}
#[tauri::command]
pub fn save_target(
state: tauri::State<'_, CommandState>,
input: TargetInputDto,
) -> Result<TargetDto, CommandError> {
save_target_to_storage(&state.storage(), input)
}
#[tauri::command]
pub async fn get_components(
state: tauri::State<'_, CommandState>,
) -> Result<Vec<ComponentStatusDto>, 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<ProxiFyreSetupStatusDto, CommandError> {
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<ProxiFyreSetupProgressDto, CommandError> {
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<LocalSingBoxStatusResponse, CommandError> {
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<SingBoxSetupStatusDto, CommandError> {
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<ResolveProfilePreviewResponse, CommandError> {
resolve_preview(input)
}
#[tauri::command]
pub fn save_singbox_subscription(
state: tauri::State<'_, CommandState>,
input: SaveSingBoxSubscriptionInputDto,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
save_singbox_subscription_to_storage(&state.storage(), input, &SystemClock)
}
#[tauri::command]
pub async fn fetch_singbox_subscription(
state: tauri::State<'_, CommandState>,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
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<LocalSingBoxStatusResponse, CommandError> {
forget_singbox_subscription_in_storage(&state.storage(), &SystemClock)
}
#[tauri::command]
pub fn select_singbox_server(
state: tauri::State<'_, CommandState>,
input: SelectSingBoxServerInputDto,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
select_singbox_server_in_storage(&state.storage(), input, &SystemClock)
}
#[tauri::command]
pub async fn ping_singbox_server(
state: tauri::State<'_, CommandState>,
input: PingSingBoxServerInputDto,
) -> Result<PingServerResponse, CommandError> {
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<Vec<PingServerResponse>, 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<ProxyTargetCheckResponse, CommandError> {
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<GenerateSingBoxConfigResponse, CommandError> {
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<ApplyProfilesResponse, CommandError> {
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<Vec<ActivityEntryDto>, CommandError> {
read_activity(&state.storage())
}
#[tauri::command]
pub fn open_config_location(state: tauri::State<'_, CommandState>) -> Result<String, CommandError> {
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<ComponentStatusDto, CommandError> {
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<ComponentStatusDto, CommandError> {
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<ComponentStatusDto, CommandError> {
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<ComponentStatusDto, CommandError> {
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<ComponentStatusDto, CommandError> {
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<ComponentStatusDto, CommandError> {
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<ComponentStatusDto, CommandError> {
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<ComponentStatusDto, CommandError> {
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<StatusResponse, CommandError> {
let profiles = storage.read_profiles().map_err(storage_error)?;
let targets = storage.read_targets().map_err(storage_error)?;
let components = components_or_defaults(storage)?;
let 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<Vec<ProfileDto>, 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<ProfileDto, CommandError> {
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<Vec<TargetDto>, 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<TargetDto, CommandError> {
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<Vec<ComponentStatusDto>, CommandError> {
components_or_defaults(storage).map(|components| {
components
.iter()
.map(ComponentStatusDto::from)
.collect::<Vec<_>>()
})
}
pub fn read_startup_snapshot(
storage: &JsonStorage,
) -> Result<StartupSnapshotResponse, CommandError> {
let detected_proxyfier = detect_proxyfier_install();
let detected_singbox = detect_singbox_install();
let saved_state = read_saved_state_with_proxifyre_config(
storage,
detected_proxyfier
.as_ref()
.and_then(|detected| detected.config_path.as_deref()),
)?;
let stored_components = storage.read_components().map_err(storage_error)?;
let components = resolve_component_statuses(
stored_components,
detected_proxyfier.clone(),
detected_singbox.clone(),
)
.iter()
.map(ComponentStatusDto::from)
.collect();
let proxifyre_setup_status = build_proxifyre_setup_status_with_detection(
detected_proxyfier.as_ref(),
&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<Vec<ActivityEntryDto>, CommandError> {
storage
.read_activity()
.map_err(storage_error)
.map(|entries| entries.iter().map(ActivityEntryDto::from).collect())
}
pub fn read_saved_state(storage: &JsonStorage) -> Result<SavedStateResponse, CommandError> {
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<SavedStateResponse, CommandError> {
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<Profile>,
targets: Vec<Target>,
}
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<ImportedSavedState> {
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::<Vec<_>>();
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<ProfileItem>,
protocols: Vec<Protocol>,
host: String,
port: u16,
}
fn import_proxy_entry(proxy: &ProxiFyreProxy) -> Option<ImportedProxyEntry> {
let items = proxy
.app_names
.iter()
.filter_map(|name| imported_profile_item(name))
.collect::<Vec<_>>();
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<ProfileItem> {
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<Protocol> {
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::<u16>().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::<u16>().ok()?;
(!host.is_empty()).then(|| (host.to_string(), port))
}
fn upsert_targets(targets: &mut Vec<Target>, imported_targets: Vec<Target>) {
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<ResolveProfilePreviewResponse, CommandError> {
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<ApplyProfilesResponse, CommandError> {
apply_profiles_with_services_and_detection(
storage,
adapter,
helper,
clock,
detect_proxyfier_install(),
detect_singbox_install(),
)
}
pub fn apply_profiles_with_services_and_detection(
storage: &JsonStorage,
adapter: &impl ProxyRouterAdapter,
helper: &impl ProxyApplyHelper,
clock: &impl Clock,
detected_proxyfier: Option<DetectedProxyfier>,
detected_singbox: Option<DetectedSingBox>,
) -> Result<ApplyProfilesResponse, CommandError> {
let profiles = storage.read_profiles().map_err(storage_error)?;
let targets = storage.read_targets().map_err(storage_error)?;
let components =
components_or_defaults_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<LocalSingBoxStatusResponse, CommandError> {
let detected = detect_singbox_install();
read_singbox_status_with_detection(storage, detected.as_ref())
}
fn read_singbox_status_with_detection(
storage: &JsonStorage,
detected: Option<&DetectedSingBox>,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let config = storage.read_local_singbox_config().map_err(storage_error)?;
let cache = storage
.read_singbox_subscription_cache()
.map_err(storage_error)?;
let 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<LocalSingBoxStatusResponse, CommandError> {
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<LocalSingBoxStatusResponse, CommandError> {
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<LocalSingBoxStatusResponse, CommandError> {
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<LocalSingBoxStatusResponse, CommandError> {
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<PingServerResponse, CommandError> {
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<Vec<PingServerResponse>, 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<ProxyTargetCheckResponse, CommandError> {
ping_proxy_target_endpoint_with_probes(input, DEFAULT_PROXY_PROBES)
}
pub fn ping_proxy_target_endpoint_with_probes(
input: PingProxyTargetInputDto,
probes: &[ProxyProbeEndpoint],
) -> Result<ProxyTargetCheckResponse, CommandError> {
let host = input.host.trim();
if host.is_empty() {
return Err(CommandError::new(
"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<C>(
storage: &JsonStorage,
adapter: &SingBoxAdapter,
checker: &C,
clock: &impl Clock,
binary_path: Option<&Path>,
) -> Result<GenerateSingBoxConfigResponse, CommandError>
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<SubscriptionCache, CommandError> {
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::<Vec<_>>(),
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<ProxyProbeResponse> {
if probes.is_empty() {
return Vec::new();
}
let proxy_url = socks5h_proxy_url(proxy_host, proxy_port);
let client = match reqwest::Proxy::all(&proxy_url).and_then(|proxy| {
reqwest::blocking::Client::builder()
.timeout(PROXY_CHECK_TIMEOUT)
.connect_timeout(PROXY_CHECK_CONNECT_TIMEOUT)
.proxy(proxy)
.build()
}) {
Ok(client) => client,
Err(error) => {
return probes
.iter()
.map(|probe| {
failed_probe(
*probe,
format!("Не удалось подготовить SOCKS5 проверку: {error}"),
)
})
.collect();
}
};
let handles = probes
.iter()
.copied()
.map(|probe| {
let client = client.clone();
std::thread::spawn(move || run_proxy_probe(&client, probe))
})
.collect::<Vec<_>>();
handles
.into_iter()
.zip(probes.iter().copied())
.map(|(handle, probe)| {
handle
.join()
.unwrap_or_else(|_| failed_probe(probe, "Проверка была прервана.".to_string()))
})
.collect()
}
fn run_proxy_probe(
client: &reqwest::blocking::Client,
probe: ProxyProbeEndpoint,
) -> ProxyProbeResponse {
let started = Instant::now();
let response = match client
.get(probe.url)
.header(reqwest::header::USER_AGENT, PROXY_CHECK_USER_AGENT)
.send()
{
Ok(response) => response,
Err(error) => return failed_probe(probe, format!("HTTP через SOCKS5 не прошел: {error}")),
};
let status = response.status();
let status_code = status.as_u16();
let body = match response.text() {
Ok(body) => body,
Err(error) => {
return failed_probe_with_status(
probe,
status_code,
format!("Ответ не прочитан: {error}"),
)
}
};
let latency = started.elapsed().as_millis();
if !status.is_success() {
return ProxyProbeResponse {
id: probe.id.to_string(),
name: probe.name.to_string(),
url: probe.url.to_string(),
ok: false,
status: Some(status_code),
latency: Some(latency),
ip: None,
error: Some(format!("HTTP {status_code}")),
};
}
let ip = extract_probe_ip(probe, &body);
ProxyProbeResponse {
id: probe.id.to_string(),
name: probe.name.to_string(),
url: probe.url.to_string(),
ok: true,
status: Some(status_code),
latency: Some(latency),
ip,
error: None,
}
}
fn failed_probe(probe: ProxyProbeEndpoint, error: String) -> ProxyProbeResponse {
failed_probe_with_status(probe, 0, error)
}
fn failed_probe_with_status(
probe: ProxyProbeEndpoint,
status: u16,
error: String,
) -> ProxyProbeResponse {
ProxyProbeResponse {
id: probe.id.to_string(),
name: probe.name.to_string(),
url: probe.url.to_string(),
ok: false,
status: (status > 0).then_some(status),
latency: None,
ip: None,
error: Some(error),
}
}
fn socks5h_proxy_url(host: &str, port: u16) -> String {
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
if host.contains(':') {
format!("socks5h://[{host}]:{port}")
} else {
format!("socks5h://{host}:{port}")
}
}
fn extract_probe_ip(probe: ProxyProbeEndpoint, body: &str) -> Option<String> {
match probe.ip_source {
ProbeIpSource::CloudflareTrace => body
.lines()
.find_map(|line| line.strip_prefix("ip=").and_then(normalize_ip)),
ProbeIpSource::JsonField(field) => serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|value| {
value
.get(field)
.and_then(|field| field.as_str())
.and_then(normalize_ip)
}),
}
}
fn normalize_ip(value: &str) -> Option<String> {
let candidate = value.trim().trim_matches('"');
if candidate.parse::<IpAddr>().is_ok() {
Some(candidate.to_string())
} else {
None
}
}
fn local_lan_ipv4() -> Option<String> {
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
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<u16>,
) -> 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::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.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<ComponentStatusDto, CommandError> {
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<PathBuf, CommandError> {
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<ComponentStatusDto, CommandError> {
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<ComponentStatusDto, CommandError> {
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<String>,
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::<Vec<_>>()
.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<i32>,
) -> 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<i32>,
) -> 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<Vec<ComponentStatus>, CommandError> {
components_or_defaults_with_detection(
storage,
detect_proxyfier_install(),
detect_singbox_install(),
)
}
fn components_or_defaults_with_detection(
storage: &JsonStorage,
detected_proxyfier: Option<DetectedProxyfier>,
detected_singbox: Option<DetectedSingBox>,
) -> Result<Vec<ComponentStatus>, CommandError> {
let components = storage.read_components().map_err(storage_error)?;
Ok(resolve_component_statuses(
components,
detected_proxyfier,
detected_singbox,
))
}
pub fn resolve_component_statuses(
stored_components: Vec<ComponentStatus>,
detected_proxyfier: Option<DetectedProxyfier>,
detected_singbox: Option<DetectedSingBox>,
) -> Vec<ComponentStatus> {
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<ComponentStatus> {
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<ComponentStatus>, 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<String>) -> 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<String, CommandError> {
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<ComponentStatusDto, CommandError> {
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<String> {
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<String>,
status: Option<String>,
process_id: Option<u32>,
}
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::<Vec<_>>()
.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<PathBuf, CommandError> {
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::<Vec<_>>()
.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<ComponentStatusDto, CommandError> {
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<PathBuf> {
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<PathBuf, CommandError> {
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<PathBuf, CommandError> {
Ok(proxifyre_install_dir_from_app_dir(&app_install_dir(app)?))
}
fn singbox_install_dir_for_app(app: &tauri::AppHandle) -> Result<PathBuf, CommandError> {
Ok(singbox_install_dir_from_app_dir(&app_install_dir(app)?))
}
fn uninstall_proxifyre_component() -> Result<ComponentStatusDto, CommandError> {
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<ProxiFyreSetupProgressDto, CommandError> {
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<InstalledProgram>,
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<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct InstalledProgramJson {
display_name: Option<String>,
display_version: Option<String>,
}
fn detect_vc_runtime() -> Option<InstalledProgram> {
installed_program(&vc_runtime_registry_pattern())
}
fn detect_windows_packet_filter() -> Option<InstalledProgram> {
installed_program("Windows Packet Filter|WinpkFilter|NDISAPI")
}
fn installed_program(pattern: &str) -> Option<InstalledProgram> {
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<Output> {
command_no_window("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
])
.output()
}
fn run_powershell_file(script_path: &Path) -> std::io::Result<Output> {
command_no_window("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(script_path)
.output()
}
fn is_running_elevated() -> bool {
if !cfg!(windows) {
return false;
}
let script = r#"([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#;
let Ok(output) = run_powershell_command(script) else {
return false;
};
output.status.success()
&& String::from_utf8_lossy(&output.stdout)
.trim()
.eq_ignore_ascii_case("true")
}
fn powershell_output_message(output: &Output, fallback: &str) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
return stderr;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stdout.is_empty() {
return stdout;
}
fallback.to_string()
}
pub fn install_proxifyre_script(generated_config_path: &Path) -> String {
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 '<invalid-url>'
}
}
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<HelperApplyResult, CommandError> {
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<HelperApplyResult, CommandError> {
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::<Vec<_>>()
.join(" ");
let max_chars = 1400;
if text.chars().count() <= max_chars {
return text;
}
let truncated = text.chars().take(max_chars).collect::<String>();
format!("{truncated}...")
}
fn parse_service_command_output(stdout: &[u8]) -> Option<ServiceCommandOutput> {
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<i32>) -> 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<i32>,
) -> 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<ValidationError>) -> 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<ProfileInputDto> 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<ProfileItemInputDto> for ProfileItemInput {
fn from(input: ProfileItemInputDto) -> Self {
Self {
item_type: input.item_type,
value: input.value,
recursive: input.recursive,
}
}
}
impl From<TargetInputDto> 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,
}
}
}