mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user