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

@@ -47,6 +47,7 @@ ProxyWarden - standalone Windows desktop client в корне репозитор
- Для service/install операций сохранять UAC/admin boundary и человекочитаемые ошибки.
- При удалении install folders сохранять safe-path checks; не ослаблять рекурсивное удаление.
- В UI держать стиль компактной Windows-утилиты, а не landing/dashboard. Использовать existing `Button`, `Tabs`, `ServiceControlRow`, `StatusPill`, `Field`, `ActionMenu`, `LogDock`.
- Всплывающие подсказки при наведении делать быстрыми, кастомными и читаемыми: темная compact-плашка с мягкой рамкой/тенью, появление ~120ms, без нативного browser `title` как основного UI. Для иконок расширять общий `IconButton`/tooltip-паттерн, а не дублировать JSX/CSS локально.
- Apply actions должны быть disabled с объяснением, когда нет приложений, ProxiFyre отсутствует, proxy input неверный или local route не готов.
## Проверка

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,7 +748,9 @@ 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))
tauri::async_runtime::spawn_blocking(|| {
control_singbox_service(SingBoxServiceAction::Stop, None)
})
.await
.map_err(background_task_error)?
}
@@ -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]

View File

@@ -1050,7 +1050,7 @@ export function App() {
<span>{items.length}</span>
</div>
<div className="apps-config-actions">
{renderApplyActions('proxifyre', { showConfigPath: false })}
{renderApplyActions('proxifyre', { showConfigPath: false, showState: false })}
</div>
</div>
@@ -1158,9 +1158,10 @@ export function App() {
function renderApplyActions(
context: 'proxifyre' | 'proxy',
options: { showConfigPath?: boolean } = {},
options: { showConfigPath?: boolean; showState?: boolean } = {},
) {
const showConfigPath = options.showConfigPath ?? true;
const showState = options.showState ?? true;
const externalProxyError = routeMode === 'external' ? safeProxyError(proxyInput) : null;
const readiness = getApplyReadiness({
routeMode,
@@ -1173,15 +1174,18 @@ export function App() {
busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction),
});
const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents;
const disabledReason = !readiness.ready && readiness.title && readiness.text
? `${readiness.title}. ${readiness.text}`
: undefined;
return (
<>
{showBlocker ? (
{showState && showBlocker ? (
<div className="apply-state blocked" role="status">
<strong>{readiness.title}</strong>
<span>{readiness.text}</span>
</div>
) : hasUnappliedChanges ? (
) : showState && hasUnappliedChanges ? (
<div className="apply-state pending" role="status">
<strong>Изменения еще не применены в ProxiFyre</strong>
<span>{applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))}</span>
@@ -1197,6 +1201,7 @@ export function App() {
disabled={!readiness.ready}
loading={isApplying}
loadingLabel={applyButtonLabel(context, true, hasUnappliedChanges, singBoxAction)}
title={disabledReason}
>
{applyButtonLabel(context, isApplying, hasUnappliedChanges, singBoxAction)}
</Button>

View File

@@ -188,9 +188,11 @@ button:disabled {
}
.ui-icon-button {
position: relative;
width: 42px;
min-width: 42px;
min-height: 38px;
overflow: visible;
padding: 0;
}
@@ -1889,6 +1891,8 @@ button.summary-card:hover {
background: #182033;
}
.ui-icon-button[data-tooltip]::before,
.ui-icon-button[data-tooltip]::after,
.add-tile[data-tooltip]::before,
.add-tile[data-tooltip]::after {
position: absolute;
@@ -1896,10 +1900,13 @@ button.summary-card:hover {
z-index: 30;
pointer-events: none;
opacity: 0;
transition: opacity 90ms ease, transform 120ms ease;
transition:
opacity 90ms var(--ease-out),
transform var(--motion-fast) var(--ease-out);
transition-delay: 55ms;
}
.ui-icon-button[data-tooltip]::before,
.add-tile[data-tooltip]::before {
bottom: calc(100% + 3px);
width: 8px;
@@ -1911,6 +1918,7 @@ button.summary-card:hover {
transform: translate(-50%, 2px) rotate(45deg);
}
.ui-icon-button[data-tooltip]::after,
.add-tile[data-tooltip]::after {
bottom: calc(100% + 8px);
border: 1px solid #334155;
@@ -1927,6 +1935,10 @@ button.summary-card:hover {
white-space: nowrap;
}
.ui-icon-button[data-tooltip]:hover::before,
.ui-icon-button[data-tooltip]:hover::after,
.ui-icon-button[data-tooltip]:focus-visible::before,
.ui-icon-button[data-tooltip]:focus-visible::after,
.add-tile[data-tooltip]:hover::before,
.add-tile[data-tooltip]:hover::after,
.add-tile[data-tooltip]:focus-visible::before,
@@ -1934,11 +1946,15 @@ button.summary-card:hover {
opacity: 1;
}
.ui-icon-button[data-tooltip]:hover::before,
.ui-icon-button[data-tooltip]:focus-visible::before,
.add-tile[data-tooltip]:hover::before,
.add-tile[data-tooltip]:focus-visible::before {
transform: translate(-50%, 0) rotate(45deg);
}
.ui-icon-button[data-tooltip]:hover::after,
.ui-icon-button[data-tooltip]:focus-visible::after,
.add-tile[data-tooltip]:hover::after,
.add-tile[data-tooltip]:focus-visible::after {
transform: translate(-50%, 0);
@@ -2058,8 +2074,7 @@ button.summary-card:hover {
flex: 0 0 auto;
}
.app-row,
.empty-state {
.app-row {
border: 1px solid #2b3342;
border-radius: 4px;
background: #131720;
@@ -2072,7 +2087,8 @@ button.summary-card:hover {
.empty-state {
color: #8d99ae;
min-height: 48px;
min-height: 30px;
padding: 6px 2px 2px;
}
.list-skeleton {
@@ -2551,6 +2567,7 @@ button.summary-card:hover {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.add-toolbar .ui-icon-button,
.add-tile {
width: 100%;
}

View File

@@ -19,6 +19,7 @@ export function IconButton({
title,
...props
}: IconButtonProps) {
const tooltip = title === '' ? undefined : title ?? label;
const classes = [
'ui-icon-button',
`ui-icon-button--${variant}`,
@@ -32,7 +33,7 @@ export function IconButton({
type={props.type ?? 'button'}
className={classes}
aria-label={label}
title={title ?? label}
data-tooltip={tooltip}
disabled={disabled || loading}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}