Files
clash-verge-rev/src-tauri/src/enhance/script.rs
T
Tunglies 9a904af3aa refactor: backend cleanup (#8436)
* refactor(utils): drop dead reset helper and tidy init_resources

- remove resolve_reset_async, which had no callers
- drop the duplicated time.*.com entry from the default fake-ip-filter
- init_resources no longer recreates app_home (ensure_directories during
  init_config already created it) and now propagates res_dir creation errors

* refactor(profile): remove identical blind retry when switching node

The second select_node_for_group call in switch_proxy_node was a
zero-interval retry with identical arguments. Its success path was a
degraded copy of the first (missing the Refresh::Proxies announcement
ever since the old force-refresh emit was removed), and its distinct
"回退/最终失败" log wording misled maintainers. Keep a single call,
the primary success aftermath, and one error log.

* refactor(enhance): simplify tun mode check and lowercase helpers

- Drop the always-true second clause in use_tun's enhanced-mode check:
  the key is absent only when the unwrap_or default already makes
  current_mode == "fake-ip" true.
- Collapse use_lowercase into a delegation to use_lowercase_owned.

* refactor(core): drop unreachable hotkey dtor and dedupe tray predicate

Remove impl Drop for Hotkey: the instance lives in a OnceLock static via
the singleton! macro and is never dropped; exit-time unregistration is
already handled by Hotkey::reset().

Spell the include_proxy_groups/display-mode predicate identically at
both evaluation sites in tray menu building; behavior is unchanged
(Option != Some("disable") is equivalent to unwrap_or("default") != "disable").

* refactor(config): fold port guards into single helper

The five IClashTemp port guards duplicated the same read/parse/default
logic with drifting defaults: some hardcoded 7895/7898/7899 while others
referenced network::ports constants. Route them all through one
guard_port_value helper and use the constants everywhere, keeping each
guard's name, key and cfg attributes.

* refactor(config): dedupe prfitem factories and companion seeding

from_rules/from_proxies/from_groups differed only in uid prefix, type
string and template, so they now delegate to a shared from_seq_item.
The five current_* accessors collapse into a local macro. The identical
five-block companion seeding in from_local and from_url moves into
ensure_companion_items, preserving seeding order and error propagation.

* refactor(config): share patch macro and profiles helpers

IVerge::patch_config, IProfiles::patch_item and PrfOption::merge each
hand-rolled the same copy-Some-overwrite pattern; they now share one
patch_opt_field macro (the or-else merge form is field-for-field
equivalent for Option fields). The four profiles *_safe wrappers that
inlined Config::profiles().with_data_modify boilerplate delegate to a
with_profiles_edit helper, and record/forget_selected_node share the
current-item lookup prelude via current_item_mut.

* refactor(enhance): merge chain processing and drop unused builtin js payload

- collapse the identical process_global_items/process_profile_items into
  one parameterized process_override_items (entry uid sources unchanged)
- fold the identical rules/proxies/groups arms of ChainItem::from_async
  into a single arm with an inner dispatch
- stop embedding builtin JS payloads never executed by any path: the
  enhance loop dispatches builtin items by uid to native implementations,
  so ChainItem::builtin now carries empty script data (uids unchanged)
- source ProfileItems fallback literals from Default instead of a second
  hand-written copy in collect_profile_items

* refactor(net): reuse webpki tls config and fallback

Extract build_webpki_rooted_tls_config so the delay-test TLS connector
and the reqwest clients share one static-webpki-roots builder; ALPN
stays caller-specific (only reqwest offers h2).

Collapse the duplicated platform-verifier -> static-webpki-roots retry
in get/get_bytes into request_with_static_webpki_fallback, gated by the
caller's certificate-verification setting.

* refactor(app): extract common exit cleanup

restart_app and quit duplicate the whole exit preparation: set the
exiting flag, persist configs, run interactive cleanup, and on a failed
core stop clear the flag, restore public DNS under the lifecycle lock
on macOS, and send the stop-failed notice.

Move that flow into run_exit_cleanup_flow next to clean_async, taking
the notice variant as a parameter and returning the CleanupResult so
each caller keeps its own tail: restart aborts before shutdown, quit
always shuts down and exits with the cleanup code.

* refactor(backup): share restore finalize path

restore_webdav_backup and restore_local_backup duplicated the
credential extraction (backup creation strips the WebDAV credentials,
so both re-inject the live ones), the archive extraction and the
finalize tail. Move that tail into a private restore_backup_archive
helper so the two entries keep only their download/locate logic; the
config-write lock scope and the off-runtime extraction land in one
place. The webdav entry keeps its downloaded-file cleanup.

* refactor(sysopt): drop redundant lock and busy-wait

The proxy target pair sat behind an Arc<RwLock> with no readers and an
Arc that was never cloned; both writers already serialize on
update_lock, so store the pair inside that mutex and delete the RwLock.

refresh_guard waited for the guard to become ready with a yield_now spin
at full CPU while holding guard_operation_lock; a 10 ms sleep keeps the
same wait condition and lock scope without the spin.

* refactor(sysopt): share macos bypass formatting

The macOS default bypass list and its three-way composition (empty ->
default, default + custom, custom only) were maintained twice: in
sysopt's format_bypass and inline in proxy_control's service_bypass.
Keep the literal once in constants and compose both paths through
format_bypass; service_bypass keeps its NUL check and UTF-8 truncation.

* refactor(core): dedupe window listeners, elevated runners, backup settings

- Collapse the close/focus window listener pairs in lightweight.rs onto a shared WindowListener with per-listener event and log label.
- Extract the windows runas-or-direct execution into service::run_maybe_elevated, shared by win_uwp and the service uninstaller, and factor the common uninstaller path resolution and status check shared across platforms.
- Make the auto-backup watch channel the single settings source, replacing the duplicate Arc<RwLock> copy with a stored receiver.

* refactor(enhance): fold lowercase helpers into single owned function

use_lowercase_owned disappears; the surviving use_lowercase takes the
owned Mapping and borrowed callers clone at the call site.

* refactor(enhance): rename ChainSupport variants to Mihomo names

Clash is a legacy name; the variants match the verge-mihomo and
verge-mihomo-alpha cores they gate.

* refactor(enhance): lowercase takes a reference and chain support is copy

use_lowercase borrows the mapping and clones only the values; call
sites no longer clone whole configs. ChainSupport is a fieldless enum
and now Copy; builtin() registers each support pair without clones.

* refactor(config): name prfitem constructor for what it builds

new_seq_item states that a fresh item is built from a template; the
from_ prefix wrongly implied an existing source. append_companion_item
returns the uid directly: append_item bails on a missing uid, so the
Option was always Some on the Ok path.

* refactor(sysopt): name the proxy state lock for its full role

The lock now owns the (Sysproxy, Autoproxy) state, so proxy_state_lock
says what it guards; the guard locals follow. constants::proxy held a
single sysproxy constant and is renamed sysproxy.

* refactor: replace remaining clash meta naming with mihomo

Doc comment, generated bindings and the prebuild sidecar helpers stop
referring to the legacy Clash Meta name; installer legacy-process
cleanup keeps the old executable names on purpose.

* refactor(enhance): drop builtin core filtering that never discriminates

Both valid cores matched a different ChainSupport variant, yet each
variant registered the same uid pair, so the filtered builtin list was
identical for stable and alpha. Invalid cores are coerced at startup
(validate_and_fix_config / get_valid_clash_core) and the None arm of
is_support was unreachable. builtin() now returns the items directly
and the clash_core threading through ConfigValues goes away; re-add
core discrimination when a builtin actually differs per core.

* refactor(enhance): drop unreachable mode script guard

merge_default_config overwrites the profile's mode with the verge-owned
clash config, whose mode is always present (template seeds rule and
patch semantics never clear it) and restricted to rule/global/direct;
change_clash_mode is rejected by the core before persisting anything
else. mode:script can therefore only arrive through a hand-crafted
patch_clash call, and for that path an explicit invalid-mode validation
error is more honest than silently rewriting to rule. mihomo rejects
mode:script outright (invalid mode), and user merge/script chains run
after the builtins, so the guard never covered them anyway.

* refactor(enhance): call the hysteria alpn builtin directly

builtin() existed to feed the uid-dispatch loop; with a single
builtin the vec and match are ceremony. apply_builtin_scripts calls
builtin_hy_alpn directly and to_script goes away; tests construct
ChainItem literals the way the merge items already did.
2026-11-03 10:48:44 +00:00

225 lines
8.2 KiB
Rust

use crate::{process::AsyncHandler, utils::tmpl};
use super::field::use_lowercase;
use anyhow::{Error, Result};
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;
use smartstring::alias::String;
use std::sync::Arc;
const MAX_OUTPUTS: usize = 1000;
const MAX_OUTPUT_SIZE: usize = 1024 * 1024; // 1MB
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);
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(&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)
}
}
}
/// Console logs survive every failure path.
fn eval_script(script: &str, config_json: &str, name: &String) -> EvalOutcome {
let mut context = Context::default();
context
.runtime_limits_mut()
.set_loop_iteration_limit(MAX_LOOP_ITERATIONS);
let outputs = Arc::new(Mutex::new(vec![]));
let total_size = Arc::new(Mutex::new(0usize));
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
.first()
.ok_or_else(|| boa_engine::JsError::from_opaque(JsString::from("Missing level argument").into()))?;
let level = level.to_string(context)?;
let level = level.to_std_string().map_err(|_| {
boa_engine::JsError::from_opaque(JsString::from("Failed to convert level to string").into())
})?;
let data = args
.get(1)
.ok_or_else(|| boa_engine::JsError::from_opaque(JsString::from("Missing data argument").into()))?;
let data = data.to_string(context)?;
let data = data.to_std_string().map_err(|_| {
boa_engine::JsError::from_opaque(JsString::from("Failed to convert data to string").into())
})?;
// 检查输出限制
if outputs_clone.lock().len() >= MAX_OUTPUTS {
return Err(boa_engine::JsError::from_opaque(
JsString::from("Maximum number of log outputs exceeded").into(),
));
}
let mut size = total_size_clone.lock();
let new_size = *size + level.len() + data.len();
if new_size > MAX_OUTPUT_SIZE {
return Err(boa_engine::JsError::from_opaque(
JsString::from("Maximum output size exceeded").into(),
));
}
*size = new_size;
drop(size);
outputs_clone.lock().push((level.into(), data.into()));
Ok(JsValue::undefined())
})
});
let _ = context.eval(Source::from_bytes(
r#"var console = Object.freeze({
log(data){__verge_log__("log",JSON.stringify(data, null, 2))},
info(data){__verge_log__("info",JSON.stringify(data, null, 2))},
error(data){__verge_log__("error",JSON.stringify(data, null, 2))},
debug(data){__verge_log__("debug",JSON.stringify(data, null, 2))},
warn(data){__verge_log__("warn",JSON.stringify(data, null, 2))},
table(data){__verge_log__("table",JSON.stringify(data, null, 2))},
});"#,
));
// 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 {
return bail("Name parameter too long".to_owned());
}
let code = format!(
r"try{{
{script};
JSON.stringify(main(JSON.parse(globalThis.__verge_config__),'{safe_name}')||'')
}} catch(err) {{
`__error_flag__ ${{err.toString()}}`
}}"
);
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 {
return bail("Script result exceeds maximum allowed size".to_owned());
}
match parse_json_safely(&result) {
Ok(config) => (Ok(use_lowercase(&config)), outputs.lock().to_vec()),
Err(err) => {
logging!(
error,
Type::Config,
"Script execution error: {err:#}. Script name: {name}"
);
bail("Script execution failed".to_owned())
}
}
}
fn parse_json_safely(json_str: &str) -> Result<Mapping, Error> {
if json_str.len() > MAX_JSON_SIZE {
anyhow::bail!("JSON string too large");
}
let json_str = strip_outer_quotes(json_str);
Ok(serde_json::from_str::<Mapping>(json_str)?)
}
// 安全地移除外层引号
fn strip_outer_quotes(s: &str) -> &str {
let s = s.trim();
if s.len() < 2 {
return s;
}
if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
&s[1..s.len() - 1]
} else {
s
}
}
// 安全地转义字符串
fn escape_js_string_for_single_quote(s: &str) -> String {
// 限制处理的字符串长度
if s.len() > 10240 {
return s[..10240].replace('\\', "\\\\").replace('\'', "\\'").into();
}
s.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('\n', "\\n") // 添加换行符转义
.replace('\r', "\\r") // 添加回车转义
.into()
}