perf(enhance): cut fixed config-generation cost

Every settings toggle reparsed the profile and chain files, ran four
Boa interpreters, serialized the runtime config twice, and re-ran
mihomo -t over bytes the core had just accepted. Builtin and template
scripts now run without Boa, custom scripts bind the config through
JSON.parse and take ownership instead of per-script clones, the config
serializes once, and mihomo -t is skipped unless the bytes, core
build, or geo assets changed. Heavy parses and serializations moved
to spawn_blocking. Large-subscription toggles drop from hundreds of
milliseconds to ~140ms with no new retained state.
This commit is contained in:
Tunglies
2026-09-10 06:32:51 +08:00
parent 92624108c9
commit 8812923eda
13 changed files with 269 additions and 239 deletions
+29 -22
View File
@@ -147,12 +147,16 @@ impl Config {
}
logging!(debug, Type::Config, "生成运行时配置成功");
let config_result = Self::generate_file(ConfigType::Run).await;
// Run file first: startup rewrites it via use_default_config when validation fails.
let config_result = match Self::runtime_config_yaml().await {
Ok(yaml) => Self::write_runtime_file(&yaml).await.map(|_| yaml),
Err(error) => Err(error),
};
if config_result.is_ok() {
if let Ok(yaml) = &config_result {
logging!(debug, Type::Config, "开始验证配置");
match CoreConfigValidator::global().validate_config_outcome().await {
match CoreConfigValidator::global().validate_config_outcome_with(yaml).await {
Ok(outcome) if outcome.is_valid() => {
logging!(debug, Type::Config, "配置验证成功");
Ok(None)
@@ -188,22 +192,31 @@ impl Config {
}
}
pub async fn generate_file(typ: ConfigType) -> Result<PathBuf> {
let path = match typ {
ConfigType::Run => dirs::app_home_dir()?.join(files::RUNTIME_CONFIG),
ConfigType::Check => dirs::app_home_dir()?.join(files::CHECK_CONFIG),
};
pub async fn generate_file() -> Result<PathBuf> {
let yaml = Self::runtime_config_yaml().await?;
Self::write_runtime_file(&yaml).await
}
pub(crate) async fn runtime_config_yaml() -> Result<std::string::String> {
let runtime = Self::runtime().await;
let runtime_lastest = runtime.latest_arc();
let runtime_data = runtime.data_arc();
let config = runtime_lastest
.config
.as_ref()
.or_else(|| runtime_data.config.as_ref())
.ok_or_else(|| anyhow!("failed to generate runtime config, might need to restart application"))?;
AsyncHandler::spawn_blocking(move || {
let runtime_lastest = runtime.latest_arc();
let runtime_data = runtime.data_arc();
let config = runtime_lastest
.config
.as_ref()
.or_else(|| runtime_data.config.as_ref())
.ok_or_else(|| anyhow!("failed to generate runtime config, might need to restart application"))?;
let yaml_str = serde_yaml_ng::to_string(config)?;
Ok(format!("# Generated by Clash Verge\n\n{}", yaml_str))
})
.await
.map_err(|join| anyhow!("runtime serialization task failed: {join}"))?
}
help::save_yaml(&path, config, Some("# Generated by Clash Verge")).await?;
pub(crate) async fn write_runtime_file(yaml: &str) -> Result<PathBuf> {
let path = dirs::app_home_dir()?.join(files::RUNTIME_CONFIG);
help::save_yaml_str(&path, yaml).await?;
Ok(path)
}
@@ -346,9 +359,3 @@ fn collect_names(config: &Mapping, list_key: &str, out: &mut HashSet<String>) {
}
}
}
#[derive(Debug)]
pub(crate) enum ConfigType {
Run,
Check,
}
+1 -1
View File
@@ -11,7 +11,7 @@ pub mod runtime;
pub(crate) mod snapshot;
mod verge;
pub(crate) use self::config::{Config, ConfigType};
pub(crate) use self::config::Config;
pub use self::{clash::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*};
pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) {
+6 -3
View File
@@ -1,4 +1,4 @@
use super::{Config, ConfigType, IClashTemp, IVerge, MixedPort};
use super::{Config, IClashTemp, IVerge, MixedPort};
use crate::{
constants::timing,
core::{
@@ -129,15 +129,18 @@ impl Config {
.await
.context("failed to materialize runtime configuration with fallback port")?;
let yaml = Self::runtime_config_yaml()
.await
.context("failed to validate runtime configuration with fallback port")?;
let validation = CoreConfigValidator::global()
.validate_config_outcome()
.validate_config_outcome_with(&yaml)
.await
.context("failed to validate runtime configuration with fallback port")?;
if !validation.is_valid() {
bail!("runtime configuration with fallback port is invalid: {validation}");
}
Self::generate_file(ConfigType::Run)
Self::generate_file()
.await
.context("failed to write Runtime Configuration")?;
Ok(())
+7 -3
View File
@@ -1,7 +1,7 @@
use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode};
use crate::core::service::StageRequest;
use crate::{
config::{Config, ConfigType, IProfiles, runtime::IRuntime},
config::{Config, IProfiles, runtime::IRuntime},
constants::timing,
core::{
handle,
@@ -299,12 +299,16 @@ impl CoreManager {
/// Validates and applies the caller's transaction, committing only on success.
async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result<ValidationOutcome> {
let outcome = CoreConfigValidator::global().validate_config_outcome().await?;
// One serialization feeds check and run files; the core never applies unvalidated bytes.
let yaml = Config::runtime_config_yaml().await?;
let outcome = CoreConfigValidator::global()
.validate_config_outcome_with(&yaml)
.await?;
if !outcome.is_valid() {
return Ok(outcome);
}
let run_path = Config::generate_file(ConfigType::Run).await?;
let run_path = Config::write_runtime_file(&yaml).await?;
self.apply_config(run_path).await?;
transaction.commit();
Ok(ValidationOutcome::Valid)
+2 -2
View File
@@ -104,7 +104,7 @@ impl CoreManager {
handle::Handle::app_handle()
.mihomo()
.update_socket_path(dirs::path_to_str(&sidecar_ipc)?.to_owned())?;
let config_file = Config::generate_file(crate::config::ConfigType::Run).await?;
let config_file = Config::generate_file().await?;
let app_handle = handle::Handle::app_handle();
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
let config_dir = dirs::app_home_dir()?;
@@ -255,7 +255,7 @@ impl CoreManager {
pub(super) async fn start_core_by_service(&self) -> Result<()> {
self.core_starting();
let service_ipc = dirs::ipc_path()?;
let config_file = Config::generate_file(crate::config::ConfigType::Run).await?;
let config_file = Config::generate_file().await?;
handle::Handle::app_handle()
.mihomo()
.update_socket_path(dirs::path_to_str(&service_ipc)?.to_owned())?;
+1 -1
View File
@@ -5,7 +5,7 @@ use serde_yaml_ng::Value;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
const GEO_ASSETS: &[&str] = &[
pub(crate) const GEO_ASSETS: &[&str] = &[
"Country.mmdb",
"geoip.dat",
"geosite.dat",
+71 -22
View File
@@ -4,17 +4,49 @@ use serde::Serialize;
use smartstring::alias::String;
use std::{
fmt,
hash::{DefaultHasher, Hash as _, Hasher as _},
sync::LazyLock,
sync::atomic::{AtomicBool, Ordering},
};
use tauri_plugin_shell::ShellExt as _;
use tokio::fs;
use crate::config::{Config, ConfigType};
use crate::config::Config;
use crate::core::handle;
use crate::singleton;
use crate::utils::dirs;
use crate::utils::{dirs, help};
use clash_verge_logging::{Type, logging};
const SYNTAX_CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
static LAST_VALIDATED: LazyLock<parking_lot::Mutex<Option<u64>>> = LazyLock::new(|| parking_lot::Mutex::new(None));
async fn validation_fingerprint(yaml: &str) -> u64 {
let core = Config::verge().await.latest_arc().get_valid_clash_core();
let mut hasher = DefaultHasher::new();
yaml.hash(&mut hasher);
core.as_str().hash(&mut hasher);
let binary = std::env::current_exe()
.into_iter()
.map(|exe| exe.with_file_name(format!("{}{}", core.as_str(), std::env::consts::EXE_SUFFIX)));
// The test run reads geo databases from the app dir; a geo update must revalidate.
let geo = dirs::app_home_dir().into_iter().flat_map(|dir| {
super::runtime_bundle::GEO_ASSETS
.iter()
.map(move |asset| dir.join(asset))
});
for path in binary.chain(geo) {
match std::fs::metadata(&path) {
Ok(meta) => {
meta.len().hash(&mut hasher);
meta.modified().ok().hash(&mut hasher);
}
Err(_) => 0u8.hash(&mut hasher),
}
}
hasher.finish()
}
pub struct CoreConfigValidator {
is_processing: AtomicBool,
}
@@ -223,28 +255,33 @@ impl CoreConfigValidator {
};
logging!(debug, Type::Validate, "验证脚本文件: {}", path);
let has_main =
content.contains("function main") || content.contains("const main") || content.contains("let main");
use boa_engine::{Context, Source};
// Boa parsing is pure CPU; keep it off the async worker, with a timeout.
let syntax = crate::process::AsyncHandler::spawn_blocking(move || {
use boa_engine::{Context, Source};
let mut context = Context::default();
let _ = context.eval(Source::from_bytes(
"var console = Object.freeze({
log(...data){},
info(...data){},
error(...data){},
debug(...data){},
});",
));
let result = context.eval(Source::from_bytes(&content));
let mut context = Context::default();
let _ = context.eval(Source::from_bytes(
"var console = Object.freeze({log(...data){},info(...data){},error(...data){},debug(...data){}});",
));
context
.eval(Source::from_bytes(&content))
.map(|_| ())
.map_err(|err| err.to_string())
});
let result = match tokio::time::timeout(SYNTAX_CHECK_TIMEOUT, syntax).await {
Ok(Ok(evaluated)) => evaluated,
Ok(Err(join_err)) => Err(format!("syntax check task failed: {join_err}")),
Err(_) => Err("syntax check timed out".to_owned()),
};
match result {
Ok(_) => {
Ok(()) => {
logging!(debug, Type::Validate, "脚本语法验证通过: {}", path);
if !content.contains("function main")
&& !content.contains("const main")
&& !content.contains("let main")
{
if !has_main {
let error_msg = "Script must contain a main function";
logging!(warn, Type::Validate, "脚本缺少main函数: {}", path);
return Ok(ValidationOutcome::invalid_from_message(error_msg));
@@ -371,7 +408,8 @@ impl CoreConfigValidator {
}
}
pub async fn validate_config_outcome(&self) -> Result<ValidationOutcome> {
/// Skips the subprocess when these bytes were already accepted by the same core build.
pub async fn validate_config_outcome_with(&self, yaml: &str) -> Result<ValidationOutcome> {
if !self.try_start() {
logging!(info, Type::Validate, "验证已在进行中,跳过新的验证请求");
return Ok(ValidationOutcome::Busy);
@@ -381,9 +419,20 @@ impl CoreConfigValidator {
}
logging!(info, Type::Validate, "生成临时配置文件用于验证");
let config_path = Config::generate_file(ConfigType::Check).await?;
let config_path = dirs::path_to_str(&config_path)?;
Self::validate_config_internal_outcome(config_path).await
let fingerprint = validation_fingerprint(yaml).await;
if *LAST_VALIDATED.lock() == Some(fingerprint) {
logging!(info, Type::Validate, "配置与上次通过验证的字节一致,跳过内核检查");
return Ok(ValidationOutcome::Valid);
}
let check_path = dirs::app_home_dir()?.join(crate::constants::files::CHECK_CONFIG);
help::save_yaml_str(&check_path, yaml).await?;
let outcome = Self::validate_config_internal_outcome(dirs::path_to_str(&check_path)?).await?;
if outcome.is_valid() {
*LAST_VALIDATED.lock() = Some(fingerprint);
let _ = fs::remove_file(&check_path).await;
}
Ok(outcome)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use clash_verge_logging::{Type, logging};
use super::use_lowercase;
use super::field::use_lowercase;
use serde_yaml_ng::{self, Mapping, Value};
fn deep_merge(a: &mut Value, b: Value) {
+41 -46
View File
@@ -7,7 +7,7 @@ mod tun;
use self::{
chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
field::{use_keys, use_lowercase, use_sort},
field::{use_keys, use_lowercase_owned, use_sort},
merge::use_merge,
script::use_script,
seq::{SeqMap, use_seq},
@@ -254,15 +254,9 @@ async fn process_global_items(
}
if let ChainType::Script(script) = global_script.data {
let mut logs = vec![];
match use_script(script, config.clone(), profile_name.clone()).await {
Ok((res_config, res_logs)) => {
extend_changed_keys(&mut exists_keys, &config, &res_config);
config = res_config;
logs.extend(res_logs);
}
Err(err) => logs.push(("exception".into(), err.to_string().into())),
}
let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
config = res_config;
result_map.insert(global_script.uid, logs);
}
@@ -290,20 +284,6 @@ fn process_seq_items(
config
}
fn extend_changed_keys(exists_keys: &mut Vec<String>, config: &Mapping, res_config: &Mapping) {
exists_keys.extend(res_config.iter().filter_map(|(key, value)| {
if config.get(key) == Some(value) {
return None;
}
key.as_str().map(|key| {
let mut key: String = key.into();
key.make_ascii_lowercase();
key
})
}));
}
/// App 权威的顶层控制面键:核心连接、监听端口、UI/托盘开关。
/// 平台键随 cfg 门控;`dns.ipv6` 单独处理。
const CONTROL_PLANE_KEYS: &[&str] = &[
@@ -450,15 +430,9 @@ async fn process_profile_items(
}
if let ChainType::Script(script) = script_item.data {
let mut logs = vec![];
match use_script(script, config.clone(), profile_name.clone()).await {
Ok((res_config, res_logs)) => {
extend_changed_keys(&mut exists_keys, &config, &res_config);
config = res_config;
logs.extend(res_logs);
}
Err(err) => logs.push(("exception".into(), err.to_string().into())),
}
let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
config = res_config;
result_map.insert(script_item.uid, logs);
}
@@ -540,24 +514,23 @@ fn merge_default_config(
config
}
async fn apply_builtin_scripts(mut config: Mapping, clash_core: Option<String>, enable_builtin: bool) -> Mapping {
fn apply_builtin_scripts(mut config: Mapping, clash_core: Option<String>, enable_builtin: bool) -> Mapping {
if enable_builtin {
let items: Vec<_> = ChainItem::builtin()
.into_iter()
.filter(|(s, _)| s.is_support(clash_core.as_ref()))
.map(|(_, c)| c)
.collect();
for item in items {
logging!(debug, Type::Core, "run builtin script {}", item.uid);
if let ChainType::Script(script) = item.data {
match use_script(script, config.clone(), String::from("")).await {
Ok((res_config, _)) => {
config = res_config;
}
Err(err) => {
logging!(error, Type::Core, "builtin script error `{err:#}`");
}
}
if !items.is_empty() {
// The JS path saw a lowercased view; lowercase once, only when a builtin runs.
config = use_lowercase_owned(config);
for item in items {
logging!(debug, Type::Core, "run builtin script {}", item.uid);
config = match item.uid.as_str() {
"verge_hy_alpn" => builtin_hy_alpn(config),
"verge_meta_guard" => builtin_meta_guard(config),
_ => config,
};
}
}
}
@@ -565,6 +538,28 @@ async fn apply_builtin_scripts(mut config: Mapping, clash_core: Option<String>,
config
}
fn builtin_hy_alpn(mut config: Mapping) -> Mapping {
if let Some(Value::Sequence(proxies)) = config.get_mut("proxies") {
for proxy in proxies.iter_mut() {
let Some(proxy) = proxy.as_mapping_mut() else { continue };
let is_hysteria = proxy.get("type").and_then(Value::as_str) == Some("hysteria");
if is_hysteria && let Some(Value::String(alpn)) = proxy.get("alpn").cloned() {
proxy.insert("alpn".into(), Value::Sequence(vec![Value::String(alpn)]));
}
}
}
config
}
fn builtin_meta_guard(mut config: Mapping) -> Mapping {
if config.get("mode").and_then(Value::as_str) == Some("script") {
config.insert(Value::from("mode"), Value::from("rule"));
}
config
}
fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
// built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in
const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"];
@@ -760,7 +755,7 @@ pub async fn enhance(
tproxy_enabled,
);
let config = apply_builtin_scripts(config, clash_core, enable_builtin).await;
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
let config = use_tun(config, enable_tun);
let config = apply_dns_settings(config, enable_dns_settings).await;
+86 -131
View File
@@ -1,8 +1,8 @@
use crate::process::AsyncHandler;
use crate::{process::AsyncHandler, utils::tmpl};
use super::field::{use_lowercase, use_lowercase_owned};
use anyhow::{Error, Result};
use boa_engine::{Context, JsString, JsValue, Source, native_function::NativeFunction};
use boa_engine::{Context, JsString, JsValue, Source, js_string, native_function::NativeFunction, property::Attribute};
use clash_verge_logging::{Type, logging};
use parking_lot::Mutex;
use serde_yaml_ng::Mapping;
@@ -15,16 +15,58 @@ const MAX_JSON_SIZE: usize = 10 * 1024 * 1024; // 10MB
const MAX_LOOP_ITERATIONS: u64 = 10_000_000;
const SCRIPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
pub async fn use_script(script: String, config: Mapping, name: String) -> Result<(Mapping, Vec<(String, String)>)> {
let handle = AsyncHandler::spawn_blocking(move || use_script_sync(script, &config, &name));
match tokio::time::timeout(SCRIPT_TIMEOUT, handle).await {
Ok(Ok(result)) => result,
Ok(Err(join_err)) => Err(anyhow::anyhow!("script task panicked: {join_err}")),
Err(_elapsed) => Err(anyhow::anyhow!("script execution timed out after {:?}", SCRIPT_TIMEOUT)),
type ScriptResult = (Mapping, Vec<String>, Vec<(String, String)>);
type EvalOutcome = (Result<Mapping, std::string::String>, Vec<(String, String)>);
/// Never fails: exceptions are logged and the input config is returned unchanged.
pub(super) async fn use_script(script: String, config: Mapping, name: String) -> ScriptResult {
// The template is identity-plus-lowercasing; skip the Boa round-trip.
if script == tmpl::ITEM_SCRIPT {
return (use_lowercase_owned(config), vec![], vec![]);
}
let json = match serde_json::to_string(&use_lowercase(&config)) {
Ok(json) if json.len() <= MAX_JSON_SIZE => json,
Ok(_) => {
let message = "Configuration size exceeds maximum allowed size";
return (config, vec![], vec![("exception".into(), message.into())]);
}
Err(err) => return (config, vec![], vec![("exception".into(), err.to_string().into())]),
};
let evaluated = AsyncHandler::spawn_blocking(move || eval_script(&script, &json, &name));
let (outcome, mut logs) = match tokio::time::timeout(SCRIPT_TIMEOUT, evaluated).await {
Ok(Ok(evaluated)) => evaluated,
Ok(Err(join_err)) => (Err(format!("script task panicked: {join_err}")), vec![]),
Err(_elapsed) => (
Err(format!("script execution timed out after {SCRIPT_TIMEOUT:?}")),
vec![],
),
};
match outcome {
Ok(result) => {
// Compared against the config as handed in, not the lowercased copy the script saw.
let changed_keys = result
.iter()
.filter(|(key, value)| config.get(key) != Some(value))
.filter_map(|(key, _)| {
let mut key: String = key.as_str()?.into();
key.make_ascii_lowercase();
Some(key)
})
.collect();
(result, changed_keys, logs)
}
Err(reason) => {
logs.push(("exception".into(), reason.into()));
(config, vec![], logs)
}
}
}
fn use_script_sync(script: String, config: &Mapping, name: &String) -> Result<(Mapping, Vec<(String, String)>)> {
/// Console logs survive every failure path.
fn eval_script(script: &str, config_json: &str, name: &String) -> EvalOutcome {
let mut context = Context::default();
context
@@ -37,6 +79,8 @@ fn use_script_sync(script: String, config: &Mapping, name: &String) -> Result<(M
let outputs_clone = Arc::clone(&outputs);
let total_size_clone = Arc::clone(&total_size);
let bail = |reason: std::string::String| (Err(reason), outputs.lock().to_vec());
let _ = context.register_global_builtin_callable("__verge_log__".into(), 2, unsafe {
NativeFunction::from_closure(move |_: &JsValue, args: &[JsValue], context: &mut Context| {
let level = args
@@ -87,60 +131,57 @@ fn use_script_sync(script: String, config: &Mapping, name: &String) -> Result<(M
});"#,
));
let config = use_lowercase(config);
let config_str = serde_json::to_string(&config)?;
if config_str.len() > MAX_JSON_SIZE {
anyhow::bail!("Configuration size exceeds maximum allowed size");
// Bind the JSON instead of embedding it in program source; Boa's parser never chews it.
if let Err(err) = context.register_global_property(
js_string!("__verge_config__"),
JsValue::from(JsString::from(config_json)),
Attribute::all(),
) {
return bail(format!("failed to bind config for script: {err}"));
}
// 仅处理 name 参数中的特殊字符
let safe_name = escape_js_string_for_single_quote(name);
if safe_name.len() > 1024 {
anyhow::bail!("Name parameter too long");
return bail("Name parameter too long".to_owned());
}
let code = format!(
r"try{{
{script};
JSON.stringify(main({config_str},'{safe_name}')||'')
JSON.stringify(main(JSON.parse(globalThis.__verge_config__),'{safe_name}')||'')
}} catch(err) {{
`__error_flag__ ${{err.toString()}}`
}}"
);
if let Ok(result) = context.eval(Source::from_bytes(code.as_str())) {
if !result.is_string() {
anyhow::bail!("main function should return object");
}
let result = result
.to_string(&mut context)
.map_err(|e| anyhow::anyhow!("Failed to convert JS result to string: {}", e))?;
let result = result
.to_std_string()
.map_err(|_| anyhow::anyhow!("Failed to convert JS string to std string"))?;
let result = match context.eval(Source::from_bytes(code.as_str())) {
Ok(result) if result.is_string() => result,
_ => return bail("main function should return object".to_owned()),
};
let result = match result.to_string(&mut context) {
Ok(result) => result,
Err(e) => return bail(format!("Failed to convert JS result to string: {e}")),
};
let result = match result.to_std_string() {
Ok(result) => result,
Err(_) => return bail("Failed to convert JS string to std string".to_owned()),
};
if result.len() > MAX_JSON_SIZE {
anyhow::bail!("Script result exceeds maximum allowed size");
}
if result.len() > MAX_JSON_SIZE {
return bail("Script result exceeds maximum allowed size".to_owned());
}
let res: Result<Mapping, Error> = parse_json_safely(&result);
match res {
Ok(config) => Ok((use_lowercase_owned(config), outputs.lock().to_vec())),
Err(err) => {
outputs
.lock()
.push(("exception".into(), "Script execution failed".into()));
logging!(
error,
Type::Config,
"Script execution error: {err:#}. Script name: {name}"
);
Ok((config, outputs.lock().to_vec()))
}
match parse_json_safely(&result) {
Ok(config) => (Ok(use_lowercase_owned(config)), outputs.lock().to_vec()),
Err(err) => {
logging!(
error,
Type::Config,
"Script execution error: {err:#}. Script name: {name}"
);
bail("Script execution failed".to_owned())
}
} else {
anyhow::bail!("main function should return object");
}
}
@@ -181,89 +222,3 @@ fn escape_js_string_for_single_quote(s: &str) -> String {
.replace('\r', "\\r") // 添加回车转义
.into()
}
#[test]
#[allow(unused_variables)]
#[allow(clippy::expect_used)]
fn test_script() {
let script = r#"
function main(config) {
if (Array.isArray(config.rules)) {
config.rules = [...config.rules, "add"];
}
console.log(config);
config.proxies = ["111"];
return config;
}
"#;
let config = r"
rules:
- 111
- 222
tun:
enable: false
dns:
enable: false
";
let config = &serde_yaml_ng::from_str(config).expect("Failed to parse test config YAML");
let (config, results) =
use_script_sync(script.into(), config, &String::from("")).expect("Script execution should succeed in test");
let _ = serde_yaml_ng::to_string(&config).expect("Failed to serialize config to YAML");
let yaml_config_size = std::mem::size_of_val(&config);
let box_yaml_config_size = std::mem::size_of_val(&Box::new(config));
assert!(box_yaml_config_size < yaml_config_size);
}
// 测试特殊字符转义功能
#[test]
#[allow(clippy::expect_used)]
fn test_escape_unescape() {
let test_string = r#"Hello "World"!\nThis is a test with \u00A9 copyright symbol."#;
let escaped = escape_js_string_for_single_quote(test_string);
println!("Original: {test_string}");
println!("Escaped: {escaped}");
let json_str = r#"{"key":"value","nested":{"key":"value"}}"#;
let parsed = parse_json_safely(json_str).expect("Failed to parse test JSON safely");
assert!(parsed.contains_key("key"));
assert!(parsed.contains_key("nested"));
let quoted_json_str = r#""{"key":"value","nested":{"key":"value"}}""#;
let parsed_quoted = parse_json_safely(quoted_json_str).expect("Failed to parse quoted test JSON safely");
assert!(parsed_quoted.contains_key("key"));
assert!(parsed_quoted.contains_key("nested"));
}
#[test]
fn test_strip_outer_quotes_edge_cases() {
assert_eq!(strip_outer_quotes(""), "");
assert_eq!(strip_outer_quotes("'"), "'");
assert_eq!(strip_outer_quotes("\""), "\"");
assert_eq!(strip_outer_quotes("''"), "");
assert_eq!(strip_outer_quotes("\"\""), "");
assert_eq!(strip_outer_quotes("'a'"), "a");
}
#[test]
fn test_memory_limits() {
// 测试输出限制
let script = r#"
function main(config) {
for(let i = 0; i < 2000; i++) {
console.log("test");
}
return config;
}
"#;
#[allow(clippy::expect_used)]
let config = &serde_yaml_ng::from_str("test: value").expect("Failed to parse test YAML");
let result = use_script_sync(script.into(), config, &String::from(""));
// 应该失败或被限制
assert!(result.is_ok()); // 会被限制但不会 panic
}
-1
View File
@@ -25,7 +25,6 @@ pub async fn patch_clash(patch: &Mapping) -> Result<()> {
if patch.get("mode").is_some() {
tray::Tray::global().update_menu_and_icon().await;
}
Config::runtime().await.edit_draft(|d| d.patch_config(patch));
CoreManager::global().update_config_checked().await?;
}
handle::Handle::refresh_clash();
+7 -4
View File
@@ -1,6 +1,6 @@
use crate::{
config::{
Config, ConfigType, MixedPort,
Config, MixedPort,
snapshot::{FileSnapshot, capture_config_files, restore_files},
},
core::{
@@ -71,8 +71,11 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
return Ok(outcome);
}
let yaml = Config::runtime_config_yaml()
.await
.context("failed to serialize candidate proxy port configuration")?;
let validation = CoreConfigValidator::global()
.validate_config_outcome()
.validate_config_outcome_with(&yaml)
.await
.context("failed to validate candidate proxy port configuration")?;
if !validation.is_valid() {
@@ -81,7 +84,7 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
let snapshots = capture_config_files().await?;
// Once files change, failures must restore both files and drafts.
if let Err(error) = Config::generate_file(ConfigType::Run).await {
if let Err(error) = Config::write_runtime_file(&yaml).await {
transaction.rollback();
return match restore_files(&snapshots).await {
Ok(()) => Err(error).context("failed to persist candidate Runtime Configuration"),
@@ -232,7 +235,7 @@ async fn rollback_proxy_ports(snapshots: &[FileSnapshot], was_running: bool) ->
discard_proxy_port_drafts().await;
let file_result = restore_files(snapshots).await;
if file_result.is_err() {
let _ = Config::generate_file(ConfigType::Run).await;
let _ = Config::generate_file().await;
}
let lifecycle_result = if was_running {
+17 -2
View File
@@ -1,4 +1,4 @@
use crate::config::with_encryption;
use crate::{config::with_encryption, process::AsyncHandler};
use anyhow::{Context as _, Result, anyhow, bail};
use clash_verge_logging::{Type, logging};
use nanoid::nanoid;
@@ -26,10 +26,20 @@ pub async fn read_mapping(path: &Path) -> Result<Mapping> {
bail!("file not found \"{}\"", path.display());
}
let yaml_str = tokio::fs::read_to_string(path)
let bytes = tokio::fs::read(path)
.await
.with_context(|| format!("failed to read the file \"{}\"", path.display()))?;
let yaml_str =
String::from_utf8(bytes).with_context(|| format!("failed to read the file \"{}\"", path.display()))?;
let label = path.to_path_buf();
// Parsing a multi-megabyte profile is pure CPU; keep it off the async worker.
AsyncHandler::spawn_blocking(move || parse_mapping(yaml_str, label))
.await
.map_err(|join| anyhow!("yaml parse task failed: {join}"))?
}
fn parse_mapping(yaml_str: String, path: PathBuf) -> Result<Mapping> {
match serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&yaml_str) {
Ok(mut val) => {
val.apply_merge()
@@ -59,6 +69,11 @@ pub async fn save_yaml<T: Serialize + Sync>(path: &Path, data: &T, prefix: Optio
None => data_str,
};
save_yaml_str(path, &yaml_str).await
}
/// Atomic replace with pre-serialized content; the payload must not need the encryption scope.
pub async fn save_yaml_str(path: &Path, yaml_str: &str) -> Result<()> {
let (temporary, file) = loop {
let temporary = path.with_extension(format!("tmp-{}-{}", std::process::id(), nanoid!()));
match std::fs::OpenOptions::new()