Bootstrap saved state from ProxiFyre config

This commit is contained in:
2026-07-08 10:34:40 +03:00
parent c5120669d2
commit e745633d91
6 changed files with 364 additions and 25 deletions

View File

@@ -1,5 +1,5 @@
#[cfg(not(test))]
use crate::adapters::proxifyre::ProxiFyreAdapter;
use crate::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
#[cfg(not(test))]
use crate::adapters::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
@@ -21,7 +21,7 @@ use crate::models::{
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
};
#[cfg(test)]
use crate::proxifyre::ProxiFyreAdapter;
use crate::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
#[cfg(test)]
use crate::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
@@ -50,6 +50,8 @@ use std::time::{Duration, Instant};
use std::time::{SystemTime, UNIX_EPOCH};
const PROXIFYRE_INSTALL_DIR: &str = r"C:\Tools\ProxiFyre";
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 =
@@ -627,7 +629,9 @@ pub fn ping_all_singbox_servers(
}
#[tauri::command]
pub fn ping_proxy_target(input: PingProxyTargetInputDto) -> Result<PingServerResponse, CommandError> {
pub fn ping_proxy_target(
input: PingProxyTargetInputDto,
) -> Result<PingServerResponse, CommandError> {
ping_proxy_target_endpoint(input)
}
@@ -744,9 +748,11 @@ pub async fn start_singbox_service(
#[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::async_runtime::spawn_blocking(|| {
control_singbox_service(SingBoxServiceAction::Stop, None)
})
.await
.map_err(background_task_error)?
}
#[tauri::command]
@@ -869,9 +875,31 @@ pub fn read_activity(storage: &JsonStorage) -> Result<Vec<ActivityEntryDto>, Com
}
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: read_profiles(storage)?,
targets: read_targets(storage)?,
profiles: profiles.iter().map(ProfileDto::from).collect(),
targets: targets.iter().map(TargetDto::from).collect(),
generated_config_path: storage
.paths()
.generated_dir
@@ -881,6 +909,198 @@ pub fn read_saved_state(storage: &JsonStorage) -> Result<SavedStateResponse, Com
})
}
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> {
@@ -1510,7 +1730,8 @@ fn write_elevated_singbox_service_script(
.map(|duration| duration.as_millis())
.unwrap_or(0);
let script_path = env::temp_dir().join(format!("proxywarden-singbox-service-{nonce}.ps1"));
let script = elevated_singbox_service_script(action, service_name, config_source, config_target);
let script =
elevated_singbox_service_script(action, service_name, config_source, config_target);
write_powershell_script(&script_path, &script).map_err(|error| {
CommandError::new(

View File

@@ -22,10 +22,10 @@ mod subscription;
mod validation;
use commands::{
apply_profiles_with_services, build_status, resolve_component_statuses, resolve_preview,
save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper,
HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper,
TargetInputDto,
apply_profiles_with_services, build_status, read_saved_state_with_proxifyre_config,
resolve_component_statuses, resolve_preview, save_profile_to_storage, save_target_to_storage,
Clock, CommandError, DetectedProxyApplyHelper, HelperApplyRequest, HelperApplyResult,
ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper, TargetInputDto,
};
use component_detection::{
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
@@ -95,6 +95,95 @@ fn save_commands_normalize_and_persist_profile_and_target() {
cleanup(&root);
}
#[test]
fn saved_state_bootstraps_from_existing_proxifyre_app_config() {
let root = test_root("proxifyre-config-import");
let storage = JsonStorage::new(root.clone());
let install_dir = root.join("ProxiFyre");
let config_path = install_dir.join("app-config.json");
fs::create_dir_all(&install_dir).expect("create proxifyre dir");
fs::write(
&config_path,
r#"{
"logLevel": "Info",
"bypassLan": true,
"proxies": [
{
"appNames": ["Discord.exe", "C:\\Games\\Launcher.exe"],
"socks5ProxyEndpoint": "127.0.0.1:1090",
"supportedProtocols": ["TCP", "UDP"]
}
]
}"#,
)
.expect("write proxifyre config");
let state = read_saved_state_with_proxifyre_config(&storage, Some(&config_path))
.expect("state should import proxifyre app config");
assert_eq!(state.profiles.len(), 1);
assert_eq!(state.targets.len(), 1);
assert_eq!(state.profiles[0].id, "main-profile");
assert_eq!(state.profiles[0].target_id, "main-proxy");
assert_eq!(state.profiles[0].items.len(), 2);
assert_eq!(
state.profiles[0].items[0].item_type,
ProfileItemType::Process
);
assert_eq!(state.profiles[0].items[0].value, "Discord");
assert_eq!(state.profiles[0].items[1].item_type, ProfileItemType::Exe);
assert_eq!(state.profiles[0].items[1].value, r"C:\Games\Launcher.exe");
assert_eq!(state.targets[0].id, "main-proxy");
assert_eq!(state.targets[0].host, "127.0.0.1");
assert_eq!(state.targets[0].port, 1090);
let persisted = storage.read_profiles().expect("read persisted profiles");
assert_eq!(persisted.len(), 1);
assert_eq!(persisted[0].items.len(), 2);
cleanup(&root);
}
#[test]
fn saved_state_keeps_existing_proxywarden_profiles_over_proxifyre_config() {
let root = test_root("proxifyre-config-keeps-state");
let storage = JsonStorage::new(root.clone());
let install_dir = root.join("ProxiFyre");
let config_path = install_dir.join("app-config.json");
fs::create_dir_all(&install_dir).expect("create proxifyre dir");
fs::write(
&config_path,
r#"{
"logLevel": "Info",
"bypassLan": true,
"proxies": [
{
"appNames": ["Telegram.exe"],
"socks5ProxyEndpoint": "127.0.0.1:1091",
"supportedProtocols": ["TCP"]
}
]
}"#,
)
.expect("write proxifyre config");
storage
.write_profiles(&[discord_profile("home-gateway")])
.expect("write profiles");
storage
.write_targets(&[external_socks5_target()])
.expect("write targets");
let state = read_saved_state_with_proxifyre_config(&storage, Some(&config_path))
.expect("state should keep proxywarden storage");
assert_eq!(state.profiles.len(), 1);
assert_eq!(state.profiles[0].id, "discord");
assert_eq!(state.profiles[0].items[0].value, "Discord");
assert_eq!(state.targets[0].id, "home-gateway");
cleanup(&root);
}
#[test]
fn resolve_preview_returns_structured_apps_without_filesystem_scan() {
let preview = resolve_preview(ProfileInputDto {
@@ -194,9 +283,14 @@ fn singbox_runner_preserves_installer_args_with_spaces() {
],
);
assert!(script.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'"));
assert!(script.contains("& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs"));
assert!(!script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList"));
assert!(script
.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'"));
assert!(script.contains(
"& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs"
));
assert!(
!script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList")
);
}
#[test]