Compare commits

..
Author SHA1 Message Date
Tunglies c2bb6cc362 fix(clash): take config write lock when changing mode
change_clash_mode committed the clash draft and saved config.yaml without
claiming CONFIG_WRITE_LOCK, so a concurrent patch_clash transaction could
have its staged draft committed here or its file-snapshot rollback swallow
the mode change. Take lock_config_write (waiting) rather than
try_lock_config_write: two of the three callers (hotkey, tray) discard the
result, so fail-fast would silently drop the switch, and the lock is only
attempted after the mihomo patch already applied, where an Err would roll
the frontend back while the core runs the new mode.
2026-10-28 09:17:54 +08:00
Tunglies cefc0d8c62 perf(backup): extract archive off the runtime
Run File::open, ZipArchive::new and the extraction of the backup
archive inside spawn_blocking. Extracting a multi-file backup blocked
a tokio worker for hundreds of milliseconds to seconds while the
runtime kept serving other work on the remaining workers.

Move the PROFILE_WRITE_LOCK acquisition after the extraction, right
before finalize_restored_verge_config: the lock protects the
config-data transition (extracted files reloaded into drafts), not the
plain filesystem writes, so profile operations no longer queue behind
the blocking unzip.
2026-10-28 09:17:38 +08:00
Tunglies 34607dce55 fix(backup): serialize restore with config write lock
Acquire the configuration write lock at the entry of
finalize_restored_verge_config so the clash, profiles and verge draft
applies all happen under it, instead of only the verge layer. A
concurrent patch (patch_clash / patch_verge via try_lock_config_write)
could otherwise stage a draft that the unlocked restore applies
overwrite, or interleave its persistence with the restore.

Also drop the pointless clones in the clash and profiles edit_draft
closures; edit_draft takes FnOnce so the restored values can be moved
in. The lock is released before patch_verge, which re-acquires it via
try_lock_config_write.
2026-10-28 09:17:15 +08:00
Tunglies acef575935 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-10-28 09:15:46 +08:00
Tunglies f489c7afa6 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.
2026-10-28 09:15:46 +08:00
Tunglies 238d4de328 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.
2026-10-28 09:15:46 +08:00
Tunglies ab0e1b957a 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.
2026-10-28 09:15:46 +08:00
Tunglies 1313692b3d 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.
2026-10-28 09:15:46 +08:00
Tunglies a5d0b8dacb 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.
2026-10-28 09:15:46 +08:00
Tunglies 1a5094caf0 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.
2026-10-28 09:15:46 +08:00
Tunglies 93f70f4c36 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.
2026-10-28 09:15:46 +08:00
Tunglies e5b691b47a 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.
2026-10-28 09:15:45 +08:00
Tunglies 7e9fa61f52 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.
2026-10-28 09:15:45 +08:00
Tunglies acde039820 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.
2026-10-28 09:15:45 +08:00
Tunglies 0474c9cd0c 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.
2026-10-28 09:15:45 +08:00
Tunglies c82719af45 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.
2026-10-28 09:15:45 +08:00
Tunglies 808f27abb4 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.
2026-10-28 09:14:31 +08:00
Tunglies ad0035edb9 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.
2026-10-28 09:14:31 +08:00
Tunglies 9063181468 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
2026-10-28 09:14:31 +08:00
Tunglies bfb50ac0d7 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.
2026-10-28 09:14:31 +08:00
Tunglies b79c5615a5 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.
2026-10-28 09:14:31 +08:00
Tunglies 8702779375 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.
2026-10-28 09:14:31 +08:00
Tunglies a6d680236f 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").
2026-10-28 09:14:31 +08:00
Tunglies 4c778a93db 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.
2026-10-28 09:14:31 +08:00
Tunglies dbaee8a5e4 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.
2026-10-28 09:14:30 +08:00
Tunglies 3bb708233e 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
2026-10-28 09:14:30 +08:00
78 changed files with 1615 additions and 1237 deletions
+2
View File
@@ -10,6 +10,8 @@
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
- 修复 代理 在全局模式下无法展开节点组
- 修复 自定义 CSS 无法修改编辑器字体和字号的问题
- 修复恢复备份的同时修改配置,可能导致配置丢失或错乱的问题
- 修复切换规则模式与配置变更并发时可能丢失或错乱的问题
**🐧 Linux**
+6 -7
View File
@@ -266,13 +266,13 @@ async function getLatestReleaseVersion() {
}
if (!META_MAP[`${platform}-${arch}`]) {
throw new Error(`clash meta unsupported platform "${platform}-${arch}"`)
throw new Error(`mihomo unsupported platform "${platform}-${arch}"`)
}
if (!META_ALPHA_MAP[`${platform}-${arch}`]) {
throw new Error(`clash meta alpha unsupported platform "${platform}-${arch}"`)
throw new Error(`mihomo alpha unsupported platform "${platform}-${arch}"`)
}
function clashMetaAlpha() {
function mihomoAlpha() {
const name = META_ALPHA_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
@@ -285,7 +285,7 @@ function clashMetaAlpha() {
}
}
function clashMeta() {
function mihomo() {
const name = META_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
@@ -733,13 +733,12 @@ const tasks = [
{
name: 'verge-mihomo-alpha',
func: () =>
getLatestAlphaVersion().then(() => resolveSidecar(clashMetaAlpha())),
getLatestAlphaVersion().then(() => resolveSidecar(mihomoAlpha())),
retry: 5,
},
{
name: 'verge-mihomo',
func: () =>
getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())),
func: () => getLatestReleaseVersion().then(() => resolveSidecar(mihomo())),
retry: 5,
},
// After both sidecar tasks: it hashes what they downloaded.
+13 -56
View File
@@ -184,84 +184,41 @@ impl IClashTemp {
_ => None,
})
}
#[cfg(not(target_os = "windows"))]
fn guard_redir_port(config: &Mapping) -> u16 {
fn guard_port_value(config: &Mapping, key: &str, default: u16) -> u16 {
let mut port = config
.get("redir-port")
.get(key)
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(7895);
.unwrap_or(default);
if port == 0 {
port = 7895;
port = default;
}
port
}
#[cfg(not(target_os = "windows"))]
fn guard_redir_port(config: &Mapping) -> u16 {
Self::guard_port_value(config, "redir-port", network::ports::DEFAULT_REDIR)
}
#[cfg(target_os = "linux")]
fn guard_tproxy_port(config: &Mapping) -> u16 {
let mut port = config
.get("tproxy-port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(network::ports::DEFAULT_TPROXY);
if port == 0 {
port = network::ports::DEFAULT_TPROXY;
}
port
Self::guard_port_value(config, "tproxy-port", network::ports::DEFAULT_TPROXY)
}
fn guard_mixed_port(config: &Mapping) -> u16 {
let raw_value = config.get("mixed-port");
let mut port = raw_value
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(network::ports::DEFAULT_MIXED);
if port == 0 {
port = network::ports::DEFAULT_MIXED;
}
port
Self::guard_port_value(config, "mixed-port", network::ports::DEFAULT_MIXED)
}
fn guard_socks_port(config: &Mapping) -> u16 {
let mut port = config
.get("socks-port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(7898);
if port == 0 {
port = 7898;
}
port
Self::guard_port_value(config, "socks-port", network::ports::DEFAULT_SOCKS)
}
fn guard_port(config: &Mapping) -> u16 {
let mut port = config
.get("port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(7899);
if port == 0 {
port = 7899;
}
port
Self::guard_port_value(config, "port", network::ports::DEFAULT_HTTP)
}
pub(super) fn guard_server_ctrl(config: &Mapping) -> String {
+9
View File
@@ -14,6 +14,15 @@ mod verge;
pub(crate) use self::config::Config;
pub use self::{clash::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*};
macro_rules! patch_opt_field {
($lv:expr, $rv:expr, $key:tt) => {
if ($rv.$key).is_some() {
$lv.$key = $rv.$key.clone();
}
};
}
pub(crate) use patch_opt_field;
pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) {
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
}
+71 -108
View File
@@ -1,3 +1,5 @@
use super::patch_opt_field;
use crate::{
config::profiles,
utils::{
@@ -131,19 +133,18 @@ impl PrfOption {
match (one, other) {
(Some(a_ref), Some(b_ref)) => {
let mut result = a_ref.clone();
result.user_agent = b_ref.user_agent.clone().or(result.user_agent);
result.with_proxy = b_ref.with_proxy.or(result.with_proxy);
result.self_proxy = b_ref.self_proxy.or(result.self_proxy);
result.danger_accept_invalid_certs =
b_ref.danger_accept_invalid_certs.or(result.danger_accept_invalid_certs);
result.allow_auto_update = b_ref.allow_auto_update.or(result.allow_auto_update);
result.update_interval = b_ref.update_interval.or(result.update_interval);
result.merge = b_ref.merge.clone().or(result.merge);
result.script = b_ref.script.clone().or(result.script);
result.rules = b_ref.rules.clone().or(result.rules);
result.proxies = b_ref.proxies.clone().or(result.proxies);
result.groups = b_ref.groups.clone().or(result.groups);
result.timeout_seconds = b_ref.timeout_seconds.or(result.timeout_seconds);
patch_opt_field!(result, b_ref, user_agent);
patch_opt_field!(result, b_ref, with_proxy);
patch_opt_field!(result, b_ref, self_proxy);
patch_opt_field!(result, b_ref, danger_accept_invalid_certs);
patch_opt_field!(result, b_ref, allow_auto_update);
patch_opt_field!(result, b_ref, update_interval);
patch_opt_field!(result, b_ref, merge);
patch_opt_field!(result, b_ref, script);
patch_opt_field!(result, b_ref, rules);
patch_opt_field!(result, b_ref, proxies);
patch_opt_field!(result, b_ref, groups);
patch_opt_field!(result, b_ref, timeout_seconds);
Some(result)
}
(Some(a_ref), None) => Some(a_ref.clone()),
@@ -181,6 +182,38 @@ impl PrfItem {
}
}
async fn append_companion_item(item: &mut Self) -> Result<String> {
profiles::profiles_append_item_safe(item).await?;
item.uid
.clone()
.ok_or_else(|| anyhow::anyhow!("appended companion item has no uid"))
}
async fn ensure_companion_items(
merge: &mut Option<String>,
script: &mut Option<String>,
rules: &mut Option<String>,
proxies: &mut Option<String>,
groups: &mut Option<String>,
) -> Result<()> {
if merge.is_none() {
*merge = Some(Self::append_companion_item(&mut Self::from_merge(None)).await?);
}
if script.is_none() {
*script = Some(Self::append_companion_item(&mut Self::from_script(None)).await?);
}
if rules.is_none() {
*rules = Some(Self::append_companion_item(&mut Self::from_rules()).await?);
}
if proxies.is_none() {
*proxies = Some(Self::append_companion_item(&mut Self::from_proxies()).await?);
}
if groups.is_none() {
*groups = Some(Self::append_companion_item(&mut Self::from_groups()).await?);
}
Ok(())
}
async fn from_local(
name: String,
desc: String,
@@ -197,31 +230,7 @@ impl PrfItem {
let mut proxies = opt_ref.and_then(|o| o.proxies.clone());
let mut groups = opt_ref.and_then(|o| o.groups.clone());
if merge.is_none() {
let merge_item = &mut Self::from_merge(None);
profiles::profiles_append_item_safe(merge_item).await?;
merge = merge_item.uid.clone();
}
if script.is_none() {
let script_item = &mut Self::from_script(None);
profiles::profiles_append_item_safe(script_item).await?;
script = script_item.uid.clone();
}
if rules.is_none() {
let rules_item = &mut Self::from_rules();
profiles::profiles_append_item_safe(rules_item).await?;
rules = rules_item.uid.clone();
}
if proxies.is_none() {
let proxies_item = &mut Self::from_proxies();
profiles::profiles_append_item_safe(proxies_item).await?;
proxies = proxies_item.uid.clone();
}
if groups.is_none() {
let groups_item = &mut Self::from_groups();
profiles::profiles_append_item_safe(groups_item).await?;
groups = groups_item.uid.clone();
}
Self::ensure_companion_items(&mut merge, &mut script, &mut rules, &mut proxies, &mut groups).await?;
Ok(Self {
uid: Some(uid),
itype: Some("local".into()),
@@ -375,31 +384,7 @@ impl PrfItem {
bail!("profile does not contain `proxies` or `proxy-providers`");
}
if merge.is_none() {
let merge_item = &mut Self::from_merge(None);
profiles::profiles_append_item_safe(merge_item).await?;
merge = merge_item.uid.clone();
}
if script.is_none() {
let script_item = &mut Self::from_script(None);
profiles::profiles_append_item_safe(script_item).await?;
script = script_item.uid.clone();
}
if rules.is_none() {
let rules_item = &mut Self::from_rules();
profiles::profiles_append_item_safe(rules_item).await?;
rules = rules_item.uid.clone();
}
if proxies.is_none() {
let proxies_item = &mut Self::from_proxies();
profiles::profiles_append_item_safe(proxies_item).await?;
proxies = proxies_item.uid.clone();
}
if groups.is_none() {
let groups_item = &mut Self::from_groups();
profiles::profiles_append_item_safe(groups_item).await?;
groups = groups_item.uid.clone();
}
Self::ensure_companion_items(&mut merge, &mut script, &mut rules, &mut proxies, &mut groups).await?;
Ok(Self {
uid: Some(uid),
@@ -461,46 +446,30 @@ impl PrfItem {
}
}
fn from_rules() -> Self {
let uid = help::get_uid("r").into();
fn new_seq_item(prefix: &str, itype: &str, template: &str) -> Self {
let uid = help::get_uid(prefix).into();
let file = format!("{uid}.yaml").into(); // yaml ext
Self {
uid: Some(uid),
itype: Some("rules".into()),
itype: Some(itype.into()),
file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(tmpl::ITEM_RULES.into()),
file_data: Some(template.into()),
..Default::default()
}
}
fn from_rules() -> Self {
Self::new_seq_item("r", "rules", tmpl::ITEM_RULES)
}
fn from_proxies() -> Self {
let uid = help::get_uid("p").into();
let file = format!("{uid}.yaml").into(); // yaml ext
Self {
uid: Some(uid),
itype: Some("proxies".into()),
file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(tmpl::ITEM_PROXIES.into()),
..Default::default()
}
Self::new_seq_item("p", "proxies", tmpl::ITEM_PROXIES)
}
fn from_groups() -> Self {
let uid = help::get_uid("g").into();
let file = format!("{uid}.yaml").into(); // yaml ext
Self {
uid: Some(uid),
itype: Some("groups".into()),
file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(tmpl::ITEM_GROUPS.into()),
..Default::default()
}
Self::new_seq_item("g", "groups", tmpl::ITEM_GROUPS)
}
pub(crate) async fn read_file(&self) -> Result<String> {
@@ -516,26 +485,20 @@ impl PrfItem {
}
}
macro_rules! current_field {
($name:ident, $field:ident) => {
pub(crate) fn $name(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.$field.as_ref())
}
};
}
impl PrfItem {
pub(crate) fn current_merge(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.merge.as_ref())
}
pub(crate) fn current_script(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.script.as_ref())
}
pub(crate) fn current_rules(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.rules.as_ref())
}
pub(crate) fn current_proxies(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.proxies.as_ref())
}
pub(crate) fn current_groups(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.groups.as_ref())
}
current_field!(current_merge, merge);
current_field!(current_script, script);
current_field!(current_rules, rules);
current_field!(current_proxies, proxies);
current_field!(current_groups, groups);
}
fn allow_auto_update_enabled(option: Option<&PrfOption>) -> bool {
+40 -62
View File
@@ -1,5 +1,5 @@
use super::{
PrfOption,
PrfOption, patch_opt_field,
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
};
use crate::core::notify::{Refresh, announce};
@@ -86,14 +86,6 @@ impl ProfileDeletePlan {
}
}
macro_rules! patch {
($lv: expr, $rv: expr, $key: tt) => {
if ($rv.$key).is_some() {
$lv.$key = $rv.$key.to_owned();
}
};
}
impl IProfiles {
fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<&str>) -> Option<String> {
let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?;
@@ -244,15 +236,15 @@ impl IProfiles {
for each in items.iter_mut() {
if each.uid.as_deref() == Some(uid) {
patch!(each, item, itype);
patch!(each, item, name);
patch!(each, item, desc);
patch!(each, item, file);
patch!(each, item, url);
patch!(each, item, selected);
patch!(each, item, extra);
patch!(each, item, updated);
patch!(each, item, option);
patch_opt_field!(each, item, itype);
patch_opt_field!(each, item, name);
patch_opt_field!(each, item, desc);
patch_opt_field!(each, item, file);
patch_opt_field!(each, item, url);
patch_opt_field!(each, item, selected);
patch_opt_field!(each, item, extra);
patch_opt_field!(each, item, updated);
patch_opt_field!(each, item, option);
self.items = Some(items);
return self.save_file().await;
@@ -420,6 +412,21 @@ pub(crate) async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file
profiles_append_item_safe(item).await
}
/// Applies `edit` to the committed profiles; commits only when the edit succeeds.
async fn with_profiles_edit<F, Fut>(edit: F) -> Result<()>
where
F: FnOnce(IProfiles) -> Fut + Send,
Fut: std::future::Future<Output = Result<IProfiles>> + Send,
{
Config::profiles()
.await
.with_data_modify(|profiles| async move {
let profiles = edit(profiles).await?;
Ok((profiles, ()))
})
.await
}
pub(crate) async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> {
let profiles = Config::profiles().await;
profiles_append_item_to_safe(&profiles, item).await
@@ -435,43 +442,19 @@ pub(super) async fn profiles_append_item_to_safe(profiles: &Draft<IProfiles>, it
}
pub(crate) async fn profiles_patch_item_safe(index: &str, item: &PrfItem) -> Result<()> {
Config::profiles()
.await
.with_data_modify(|mut profiles| async move {
profiles.patch_item(index, item).await?;
Ok((profiles, ()))
})
.await
with_profiles_edit(|mut profiles| async move { profiles.patch_item(index, item).await.map(|_| profiles) }).await
}
pub(crate) async fn profiles_reorder_safe(active_id: &str, over_id: &str) -> Result<()> {
Config::profiles()
.await
.with_data_modify(|mut profiles| async move {
profiles.reorder(active_id, over_id).await?;
Ok((profiles, ()))
})
.await
with_profiles_edit(|mut profiles| async move { profiles.reorder(active_id, over_id).await.map(|_| profiles) }).await
}
pub(crate) async fn profiles_save_file_safe() -> Result<()> {
Config::profiles()
.await
.with_data_modify(|profiles| async move {
profiles.save_file().await?;
Ok((profiles, ()))
})
.await
with_profiles_edit(|profiles| async move { profiles.save_file().await.map(|_| profiles) }).await
}
pub(crate) async fn profiles_update_item_safe(index: &str, item: &mut PrfItem) -> Result<()> {
Config::profiles()
.await
.with_data_modify(|mut profiles| async move {
profiles.update_item(index, item).await?;
Ok((profiles, ()))
})
.await
with_profiles_edit(|mut profiles| async move { profiles.update_item(index, item).await.map(|_| profiles) }).await
}
#[derive(Debug, PartialEq, Eq)]
@@ -713,19 +696,21 @@ where
Ok(())
}
fn current_item_mut(profiles: &mut IProfiles) -> Option<&mut PrfItem> {
let current = profiles.current.clone()?;
profiles
.items
.as_mut()?
.iter_mut()
.find(|item| item.uid.as_ref() == Some(&current))
}
/// Records a backend-made selection so the next core start restores it.
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
let group_name = String::from(group_name);
let node = String::from(node);
persist_selected_update(true, move |mut profiles| async move {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
let Some(item) = current_item_mut(&mut profiles) else {
return Ok((profiles, false));
};
@@ -760,14 +745,7 @@ fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bo
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
persist_selected_update(true, move |mut profiles| async move {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
let Some(item) = current_item_mut(&mut profiles) else {
return Ok((profiles, false));
};
+76 -84
View File
@@ -1,6 +1,6 @@
use crate::config::Config;
use crate::{
config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted},
config::{DEFAULT_PAC, deserialize_encrypted, patch_opt_field, serialize_encrypted},
constants::network,
utils::{dirs, help},
};
@@ -39,7 +39,7 @@ pub struct IVerge {
pub traffic_graph: Option<bool>,
/// show memory info (only for Clash Meta)
/// show memory info (only for Mihomo)
pub enable_memory_usage: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -432,98 +432,90 @@ impl IVerge {
#[allow(clippy::cognitive_complexity)]
pub fn patch_config(&mut self, patch: &Self) {
macro_rules! patch {
($key: tt) => {
if patch.$key.is_some() {
self.$key = patch.$key.clone();
}
};
}
patch_opt_field!(self, patch, app_log_level);
patch_opt_field!(self, patch, app_log_max_size);
patch_opt_field!(self, patch, app_log_max_count);
patch!(app_log_level);
patch!(app_log_max_size);
patch!(app_log_max_count);
patch!(language);
patch!(theme_mode);
patch!(tray_event);
patch!(env_type);
patch!(start_page);
patch!(startup_script);
patch!(traffic_graph);
patch!(enable_memory_usage);
patch!(enable_group_icon);
patch!(pause_render_traffic_stats_on_blur);
patch_opt_field!(self, patch, language);
patch_opt_field!(self, patch, theme_mode);
patch_opt_field!(self, patch, tray_event);
patch_opt_field!(self, patch, env_type);
patch_opt_field!(self, patch, start_page);
patch_opt_field!(self, patch, startup_script);
patch_opt_field!(self, patch, traffic_graph);
patch_opt_field!(self, patch, enable_memory_usage);
patch_opt_field!(self, patch, enable_group_icon);
patch_opt_field!(self, patch, pause_render_traffic_stats_on_blur);
#[cfg(target_os = "macos")]
patch!(tray_icon);
patch!(menu_icon);
patch!(menu_order);
patch!(proxy_group_tools_position);
patch!(notice_position);
patch!(collapse_navbar);
patch!(common_tray_icon);
patch!(sysproxy_tray_icon);
patch!(tun_tray_icon);
patch_opt_field!(self, patch, tray_icon);
patch_opt_field!(self, patch, menu_icon);
patch_opt_field!(self, patch, menu_order);
patch_opt_field!(self, patch, proxy_group_tools_position);
patch_opt_field!(self, patch, notice_position);
patch_opt_field!(self, patch, collapse_navbar);
patch_opt_field!(self, patch, common_tray_icon);
patch_opt_field!(self, patch, sysproxy_tray_icon);
patch_opt_field!(self, patch, tun_tray_icon);
patch!(enable_tun_mode);
patch!(enable_auto_launch);
patch!(enable_silent_start);
patch!(enable_hover_jump_navigator);
patch!(hover_jump_navigator_delay);
patch_opt_field!(self, patch, enable_tun_mode);
patch_opt_field!(self, patch, enable_auto_launch);
patch_opt_field!(self, patch, enable_silent_start);
patch_opt_field!(self, patch, enable_hover_jump_navigator);
patch_opt_field!(self, patch, hover_jump_navigator_delay);
#[cfg(not(target_os = "windows"))]
patch!(verge_redir_port);
patch_opt_field!(self, patch, verge_redir_port);
#[cfg(not(target_os = "windows"))]
patch!(verge_redir_enabled);
patch_opt_field!(self, patch, verge_redir_enabled);
#[cfg(target_os = "linux")]
patch!(verge_tproxy_port);
patch_opt_field!(self, patch, verge_tproxy_port);
#[cfg(target_os = "linux")]
patch!(verge_tproxy_enabled);
patch!(verge_mixed_port);
patch!(verge_socks_port);
patch!(verge_socks_enabled);
patch!(verge_port);
patch!(verge_http_enabled);
patch!(enable_system_proxy);
patch!(enable_proxy_guard);
patch!(enable_bypass_check);
patch!(use_default_bypass);
patch!(system_proxy_bypass);
patch!(proxy_guard_duration);
patch!(proxy_auto_config);
patch!(pac_file_content);
patch!(proxy_host);
patch!(theme_setting);
patch!(web_ui_list);
patch!(clash_core);
patch!(hotkeys);
patch!(enable_global_hotkey);
patch_opt_field!(self, patch, verge_tproxy_enabled);
patch_opt_field!(self, patch, verge_mixed_port);
patch_opt_field!(self, patch, verge_socks_port);
patch_opt_field!(self, patch, verge_socks_enabled);
patch_opt_field!(self, patch, verge_port);
patch_opt_field!(self, patch, verge_http_enabled);
patch_opt_field!(self, patch, enable_system_proxy);
patch_opt_field!(self, patch, enable_proxy_guard);
patch_opt_field!(self, patch, enable_bypass_check);
patch_opt_field!(self, patch, use_default_bypass);
patch_opt_field!(self, patch, system_proxy_bypass);
patch_opt_field!(self, patch, proxy_guard_duration);
patch_opt_field!(self, patch, proxy_auto_config);
patch_opt_field!(self, patch, pac_file_content);
patch_opt_field!(self, patch, proxy_host);
patch_opt_field!(self, patch, theme_setting);
patch_opt_field!(self, patch, web_ui_list);
patch_opt_field!(self, patch, clash_core);
patch_opt_field!(self, patch, hotkeys);
patch_opt_field!(self, patch, enable_global_hotkey);
patch!(auto_close_connection);
patch!(auto_check_update);
patch!(default_latency_test);
patch!(default_latency_timeout);
patch!(enable_auto_delay_detection);
patch!(auto_delay_detection_interval_minutes);
patch!(enable_builtin_enhanced);
patch!(proxy_layout_column);
patch!(test_list);
patch!(auto_log_clean);
patch!(enable_auto_backup_schedule);
patch!(auto_backup_interval_hours);
patch!(auto_backup_on_change);
patch_opt_field!(self, patch, auto_close_connection);
patch_opt_field!(self, patch, auto_check_update);
patch_opt_field!(self, patch, default_latency_test);
patch_opt_field!(self, patch, default_latency_timeout);
patch_opt_field!(self, patch, enable_auto_delay_detection);
patch_opt_field!(self, patch, auto_delay_detection_interval_minutes);
patch_opt_field!(self, patch, enable_builtin_enhanced);
patch_opt_field!(self, patch, proxy_layout_column);
patch_opt_field!(self, patch, test_list);
patch_opt_field!(self, patch, auto_log_clean);
patch_opt_field!(self, patch, enable_auto_backup_schedule);
patch_opt_field!(self, patch, auto_backup_interval_hours);
patch_opt_field!(self, patch, auto_backup_on_change);
patch!(webdav_url);
patch!(webdav_username);
patch!(webdav_password);
patch_opt_field!(self, patch, webdav_url);
patch_opt_field!(self, patch, webdav_username);
patch_opt_field!(self, patch, webdav_password);
#[cfg(target_os = "macos")]
patch!(enable_tray_speed);
patch!(tray_proxy_groups_display_mode);
patch!(tray_inline_outbound_modes);
patch!(enable_auto_light_weight_mode);
patch!(auto_light_weight_minutes);
patch!(enable_dns_settings);
patch!(home_cards);
patch!(enable_external_controller);
patch_opt_field!(self, patch, enable_tray_speed);
patch_opt_field!(self, patch, tray_proxy_groups_display_mode);
patch_opt_field!(self, patch, tray_inline_outbound_modes);
patch_opt_field!(self, patch, enable_auto_light_weight_mode);
patch_opt_field!(self, patch, auto_light_weight_minutes);
patch_opt_field!(self, patch, enable_dns_settings);
patch_opt_field!(self, patch, home_cards);
patch_opt_field!(self, patch, enable_external_controller);
}
pub fn get_log_level(&self) -> LevelFilter {
+7
View File
@@ -14,6 +14,13 @@ pub mod network {
}
}
pub mod sysproxy {
/// Single source of the macOS default bypass list; both proxy backends compose from it.
#[cfg(target_os = "macos")]
pub const MACOS_DEFAULT_BYPASS: &str =
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
}
pub mod timing {
use super::Duration;
+7 -13
View File
@@ -7,11 +7,10 @@ use crate::{
use anyhow::Result;
use chrono::Local;
use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
use std::{
path::PathBuf,
sync::{
Arc, OnceLock,
OnceLock,
atomic::{AtomicBool, AtomicI64, Ordering},
},
time::{Duration, UNIX_EPOCH},
@@ -82,8 +81,9 @@ impl Default for AutoBackupSettings {
}
pub struct AutoBackupManager {
settings: Arc<RwLock<AutoBackupSettings>>,
settings_tx: watch::Sender<AutoBackupSettings>,
// The watch channel is the single settings store; no duplicate copies.
settings_rx: watch::Receiver<AutoBackupSettings>,
runner_started: AtomicBool,
exec_lock: Mutex<()>,
last_backup: AtomicI64,
@@ -93,10 +93,10 @@ impl AutoBackupManager {
pub fn global() -> &'static Self {
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
INSTANCE.get_or_init(|| {
let (tx, _rx) = watch::channel(AutoBackupSettings::default());
let (settings_tx, settings_rx) = watch::channel(AutoBackupSettings::default());
Self {
settings: Arc::new(RwLock::new(AutoBackupSettings::default())),
settings_tx: tx,
settings_tx,
settings_rx,
runner_started: AtomicBool::new(false),
exec_lock: Mutex::new(()),
last_backup: AtomicI64::new(0),
@@ -106,9 +106,6 @@ impl AutoBackupManager {
pub async fn init(&self) -> Result<()> {
let settings = Self::load_settings().await;
{
*self.settings.write() = settings;
}
let _ = self.settings_tx.send(settings);
self.maybe_start_runner(settings);
Ok(())
@@ -116,9 +113,6 @@ impl AutoBackupManager {
pub async fn refresh_settings(&self) -> Result<()> {
let settings = Self::load_settings().await;
{
*self.settings.write() = settings;
}
let _ = self.settings_tx.send(settings);
self.maybe_start_runner(settings);
Ok(())
@@ -193,7 +187,7 @@ impl AutoBackupManager {
}
async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> {
let snapshot = *self.settings.read();
let snapshot = *self.settings_rx.borrow();
if trigger.is_schedule() && !snapshot.schedule_enabled {
return Ok(());
-9
View File
@@ -466,12 +466,3 @@ impl Hotkey {
(del_list, add_list)
}
}
impl Drop for Hotkey {
fn drop(&mut self) {
let app_handle = handle::Handle::app_handle();
if let Err(e) = app_handle.global_shortcut().unregister_all() {
logging!(error, Type::Hotkey, "Error unregistering all hotkeys: {:?}", e);
}
}
}
+53 -42
View File
@@ -40,8 +40,47 @@ impl LightweightState {
static LIGHTWEIGHT_STATE: AtomicU8 = AtomicU8::new(LightweightState::Normal as u8);
static WINDOW_CLOSE_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
static WEBVIEW_FOCUS_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
struct WindowListener {
handler_id: AtomicU32,
event: &'static str,
label: &'static str,
}
impl WindowListener {
fn setup(&self, on_event: impl Fn() + Send + 'static) {
if let Some(window) = WindowManager::get_main_window() {
let previous_handler_id = self.handler_id.swap(0, Ordering::AcqRel);
if previous_handler_id != 0 {
window.unlisten(previous_handler_id);
logging!(debug, Type::Lightweight, "覆盖旧的{}监听", self.label);
}
let handler_id = window.listen(self.event, move |_event| on_event());
self.handler_id.store(handler_id, Ordering::Release);
}
}
fn cancel(&self) {
let id = self.handler_id.swap(0, Ordering::AcqRel);
if id != 0 {
if let Some(window) = WindowManager::get_main_window() {
window.unlisten(id);
}
logging!(debug, Type::Lightweight, "取消了{}监听", self.label);
}
}
}
static WINDOW_CLOSE_LISTENER: WindowListener = WindowListener {
handler_id: AtomicU32::new(0),
event: "tauri://close-requested",
label: "窗口关闭",
};
static WEBVIEW_FOCUS_LISTENER: WindowListener = WindowListener {
handler_id: AtomicU32::new(0),
event: "tauri://focus",
label: "窗口焦点",
};
static CANCEL_TX: Mutex<Option<oneshot::Sender<()>>> = Mutex::new(None);
@@ -154,55 +193,27 @@ pub async fn add_light_weight_timer() {
}
fn setup_window_close_listener() {
if let Some(window) = WindowManager::get_main_window() {
let previous_handler_id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
if previous_handler_id != 0 {
window.unlisten(previous_handler_id);
logging!(debug, Type::Lightweight, "覆盖旧的窗口关闭监听");
}
let handler_id = window.listen("tauri://close-requested", move |_event| {
std::mem::drop(AsyncHandler::spawn(|| async {
setup_light_weight_timer().await;
}));
logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时");
});
WINDOW_CLOSE_HANDLER_ID.store(handler_id, Ordering::Release);
}
WINDOW_CLOSE_LISTENER.setup(|| {
std::mem::drop(AsyncHandler::spawn(|| async {
setup_light_weight_timer().await;
}));
logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时");
});
}
fn cancel_window_close_listener() {
let id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
if id != 0 {
if let Some(window) = WindowManager::get_main_window() {
window.unlisten(id);
}
logging!(debug, Type::Lightweight, "取消了窗口关闭监听");
}
WINDOW_CLOSE_LISTENER.cancel();
}
fn setup_webview_focus_listener() {
if let Some(window) = WindowManager::get_main_window() {
let previous_handler_id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
if previous_handler_id != 0 {
window.unlisten(previous_handler_id);
logging!(debug, Type::Lightweight, "覆盖旧的窗口焦点监听");
}
let handler_id = window.listen("tauri://focus", move |_event| {
cancel_light_weight_timer();
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
});
WEBVIEW_FOCUS_HANDLER_ID.store(handler_id, Ordering::Release);
}
WEBVIEW_FOCUS_LISTENER.setup(|| {
cancel_light_weight_timer();
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
});
}
fn cancel_webview_focus_listener() {
let id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
if id != 0 {
if let Some(window) = WindowManager::get_main_window() {
window.unlisten(id);
}
logging!(debug, Type::Lightweight, "取消了窗口焦点监听");
}
WEBVIEW_FOCUS_LISTENER.cancel();
}
async fn setup_light_weight_timer() {
+2 -10
View File
@@ -6,7 +6,7 @@ use crate::{
notification::{self, FailedOperation},
runstate::RUN_STATE,
service,
sysopt::Sysopt,
sysopt::{Sysopt, format_bypass},
},
process::AsyncHandler,
utils::{
@@ -204,8 +204,6 @@ const fn refusal_classification(service_usable: Option<bool>) -> SysproxyFailure
}
const LOOPBACK_HOST: &str = "127.0.0.1";
const MACOS_DEFAULT_BYPASS: &str =
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
const MAX_SERVICE_BYPASS_LEN: usize = 8192;
static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new();
@@ -327,13 +325,7 @@ fn service_bypass(verge: &IVerge) -> Result<String> {
let custom = verge.system_proxy_bypass.as_deref().unwrap_or("");
ensure!(!custom.contains('\0'), "system proxy bypass contains NUL");
let mut bypass = if custom.is_empty() {
MACOS_DEFAULT_BYPASS.to_owned()
} else if verge.use_default_bypass.unwrap_or(true) {
format!("{MACOS_DEFAULT_BYPASS},{custom}")
} else {
custom.to_owned()
};
let mut bypass: String = format_bypass(verge.use_default_bypass.unwrap_or(true), custom).into();
truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN);
Ok(bypass)
}
+43 -40
View File
@@ -593,50 +593,71 @@ fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> R
}
#[cfg(target_os = "windows")]
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
pub(super) fn run_maybe_elevated(program: &Path, hide_window: bool) -> Result<std::process::ExitStatus> {
use deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand;
use std::os::windows::process::CommandExt as _;
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall.exe", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
})?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let token = Token::with_current_process()?;
let level = token.privilege_level()?;
let status = match level {
PrivilegeLevel::NotPrivileged => RunasCommand::new(uninstall_path).show(false).status()?,
_ => StdCommand::new(uninstall_path).creation_flags(0x08000000).status()?,
PrivilegeLevel::NotPrivileged => {
let mut command = RunasCommand::new(program);
if hide_window {
command.show(false);
}
command.status()?
}
_ => {
let mut command = StdCommand::new(program);
if hide_window {
// 0x08000000 is CREATE_NO_WINDOW, the direct counterpart of runas's show(false).
command.creation_flags(0x08000000);
}
command.status()?
}
};
Ok(status)
}
fn uninstaller_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> {
let uninstall_path = packaged_service_tool_path(file_name, packaged_path)?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
Ok(uninstall_path)
}
fn ensure_uninstall_success(status: &std::process::ExitStatus) -> Result<()> {
if !status.success() {
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
}
#[cfg(target_os = "windows")]
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall.exe", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
})?;
let status = run_maybe_elevated(&uninstall_path, true)?;
ensure_uninstall_success(&status)
}
#[cfg(target_os = "linux")]
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || {
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall", || {
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall"))
})?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let elevator = crate::utils::help::linux_elevator();
let status = if linux_running_as_root() {
StdCommand::new(&uninstall_path).status()?
@@ -666,14 +687,7 @@ fn uninstall_service() -> Result<()> {
status.code().unwrap_or(-1)
);
if !status.success() {
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
ensure_uninstall_success(&status)
}
#[cfg(target_os = "linux")]
@@ -688,14 +702,10 @@ fn linux_running_as_root() -> bool {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || {
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall"))
})?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let uninstall_path = macos_service_tool_path(&uninstall_path)?;
let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
@@ -711,14 +721,7 @@ fn uninstall_service() -> Result<()> {
let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?;
if !status.success() {
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
ensure_uninstall_success(&status)
}
fn install_service() -> Result<()> {
+19 -14
View File
@@ -241,20 +241,19 @@ async fn current_os_proxy_state(sys: Sysproxy, auto: Autoproxy) -> OsProxyState
}
pub(crate) struct Sysopt {
update_lock: TokioMutex<()>,
// Owns the proxy target pair; every reader and writer goes through this lock.
proxy_state_lock: TokioMutex<(Sysproxy, Autoproxy)>,
guard_operation_lock: TokioMutex<()>,
reset_sysproxy: AtomicBool,
inner_proxy: Arc<RwLock<(Sysproxy, Autoproxy)>>,
guard: Arc<RwLock<GuardMonitor>>,
}
impl Default for Sysopt {
fn default() -> Self {
Self {
update_lock: TokioMutex::new(()),
proxy_state_lock: TokioMutex::new((Sysproxy::default(), Autoproxy::default())),
guard_operation_lock: TokioMutex::new(()),
reset_sysproxy: AtomicBool::new(false),
inner_proxy: Arc::new(RwLock::new((Sysproxy::default(), Autoproxy::default()))),
guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))),
}
}
@@ -269,10 +268,9 @@ static DEFAULT_BYPASS: &str = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172
#[cfg(any(target_os = "linux", target_os = "macos"))]
static BYPASS_SEPARATOR: &str = ",";
#[cfg(target_os = "macos")]
static DEFAULT_BYPASS: &str =
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
static DEFAULT_BYPASS: &str = crate::constants::sysproxy::MACOS_DEFAULT_BYPASS;
fn format_bypass(use_default: bool, custom_bypass: &str) -> String {
pub(super) fn format_bypass(use_default: bool, custom_bypass: &str) -> String {
if custom_bypass.is_empty() {
DEFAULT_BYPASS.into()
} else if use_default {
@@ -361,7 +359,7 @@ impl Sysopt {
}
}
while self.access_guard().read().get_state().is_pendding() {
tokio::task::yield_now().await;
tokio::time::sleep(Duration::from_millis(10)).await;
}
// `start()` false may also mean a guard was already running.
!self.access_guard().read().get_state().is_stopped()
@@ -409,18 +407,21 @@ impl Sysopt {
/// configuration instead of a partially-applied one (e.g. SOCKS already
/// disabled but HTTP still enabled mid-transition).
pub(crate) async fn wait_idle(&self) {
let _ = self.update_lock.lock().await;
let _ = self.proxy_state_lock.lock().await;
}
/// init the sysproxy
#[allow(
clippy::significant_drop_tightening,
reason = "the proxy state lock must stay held until the OS write finishes; wait_idle relies on it"
)]
pub(super) async fn update_sysproxy(&self) -> Result<()> {
let _lock = self.update_lock.lock().await;
let mut proxy_state = self.proxy_state_lock.lock().await;
let verge = Config::verge().await.latest_arc();
// Configured, not live: this runs while the Core is being started or restarted, and
// asking a Core that is not up yet would only fall back here anyway.
let port = MixedPort::desired().await;
let pac_port = server::embedded_server_port()?;
// 先 await, 避免持有锁导致的 Send 问题
let bypass = get_bypass().await;
let (sys_enable, pac_enable, proxy_host, proxy_guard) = (
@@ -431,7 +432,7 @@ impl Sysopt {
);
let (sys, auto, guard_type) = {
let (sys, auto) = &mut *self.inner_proxy.write();
let (sys, auto) = &mut *proxy_state;
sys.host = proxy_host.into();
sys.port = port;
sys.bypass = bypass.into();
@@ -528,6 +529,10 @@ impl Sysopt {
}
/// reset the sysproxy
#[allow(
clippy::significant_drop_tightening,
reason = "the proxy state lock must stay held until the disable finishes; wait_idle relies on it"
)]
pub(super) async fn reset_sysproxy(&self) -> Result<()> {
if self
.reset_sysproxy
@@ -539,13 +544,13 @@ impl Sysopt {
defer! {
self.reset_sysproxy.store(false, Ordering::SeqCst);
}
let _lock = self.update_lock.lock().await;
let mut proxy_state = self.proxy_state_lock.lock().await;
let _guard_operation = self.guard_operation_lock.lock().await;
let drained = self.stop_proxy_guard_locked().await;
// 直接关闭所有代理
let (sys, auto) = {
let (sys, auto) = &mut *self.inner_proxy.write();
let (sys, auto) = &mut *proxy_state;
sys.enable = false;
auto.enable = false;
(sys.clone(), auto.clone())
+2 -1
View File
@@ -636,7 +636,8 @@ async fn create_tray_menu(
.tray_proxy_groups_display_mode
.as_deref()
.unwrap_or("default");
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
let include_proxy_groups =
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
let proxy_group_order_map = runtime_proxy_groups_order;
+2 -11
View File
@@ -1,10 +1,7 @@
#![cfg(target_os = "windows")]
use crate::utils::dirs;
use crate::{core::service, utils::dirs};
use anyhow::{Result, bail};
use deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand;
use std::process::Command as StdCommand;
pub fn invoke_uwptools() -> Result<()> {
let resource_dir = dirs::app_resources_dir()?;
@@ -14,13 +11,7 @@ pub fn invoke_uwptools() -> Result<()> {
bail!("enableLoopback exe not found");
}
let token = Token::with_current_process()?;
let level = token.privilege_level()?;
match level {
PrivilegeLevel::NotPrivileged => RunasCommand::new(tool_path).status()?,
_ => StdCommand::new(tool_path).status()?,
};
service::run_maybe_elevated(&tool_path, false)?;
Ok(())
}
@@ -1,8 +0,0 @@
// This function is exported for use by the Clash core
// eslint-disable-next-line unused-imports/no-unused-vars
function main(config, _name) {
if (config.mode === 'script') {
config.mode = 'rule'
}
return config
}
@@ -1,12 +0,0 @@
// This function is exported for use by the Clash core
// eslint-disable-next-line unused-imports/no-unused-vars
function main(config, _name) {
if (Array.isArray(config.proxies)) {
config.proxies.forEach((p, i) => {
if (p.type === 'hysteria' && typeof p.alpn === 'string') {
config.proxies[i].alpn = [p.alpn]
}
})
}
return config
}
+7 -67
View File
@@ -22,12 +22,6 @@ pub enum ChainType {
Groups(SeqMap),
}
#[derive(Debug, Clone)]
pub enum ChainSupport {
ClashMeta,
ClashMetaAlpha,
}
// Helper trait to allow async conversion
pub trait AsyncChainItemFrom {
async fn from_async(item: &PrfItem) -> Option<ChainItem>;
@@ -53,70 +47,16 @@ impl AsyncChainItemFrom for Option<ChainItem> {
uid,
data: ChainType::Merge(help::read_mapping(&path).await.ok()?),
}),
"rules" => {
"rules" | "proxies" | "groups" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
Some(ChainItem {
uid,
data: ChainType::Rules(seq_map),
})
}
"proxies" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
Some(ChainItem {
uid,
data: ChainType::Proxies(seq_map),
})
}
"groups" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
Some(ChainItem {
uid,
data: ChainType::Groups(seq_map),
})
let data = match itype {
"rules" => ChainType::Rules(seq_map),
"proxies" => ChainType::Proxies(seq_map),
_ => ChainType::Groups(seq_map),
};
Some(ChainItem { uid, data })
}
_ => None,
}
}
}
impl ChainItem {
/// 内建支持一些脚本
pub fn builtin() -> Vec<(ChainSupport, Self)> {
// meta 的一些处理
let meta_guard = Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js"));
// meta 1.13.2 alpn string 转 数组
let hy_alpn = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js"));
// meta 的一些处理
let meta_guard_alpha = Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js"));
// meta 1.13.2 alpn string 转 数组
let hy_alpn_alpha = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js"));
vec![
(ChainSupport::ClashMeta, hy_alpn),
(ChainSupport::ClashMeta, meta_guard),
(ChainSupport::ClashMetaAlpha, hy_alpn_alpha),
(ChainSupport::ClashMetaAlpha, meta_guard_alpha),
]
}
pub fn to_script<U: Into<String>, D: Into<String>>(uid: U, data: D) -> Self {
Self {
uid: uid.into(),
data: ChainType::Script(data.into()),
}
}
}
impl ChainSupport {
pub fn is_support(&self, core: Option<&String>) -> bool {
match core {
Some(core) => matches!(
(self, core.as_str()),
(Self::ClashMeta, "verge-mihomo") | (Self::ClashMetaAlpha, "verge-mihomo-alpha")
),
None => true,
}
}
}
+1 -12
View File
@@ -27,7 +27,7 @@ fn lowercase_key(key: &str) -> Value {
pub fn use_lowercase(config: &Mapping) -> Mapping {
let mut lowercased = Mapping::new();
for (key, value) in config.into_iter() {
for (key, value) in config.iter() {
if let Some(key_str) = key.as_str() {
lowercased.insert(lowercase_key(key_str), value.clone());
}
@@ -35,17 +35,6 @@ pub fn use_lowercase(config: &Mapping) -> Mapping {
lowercased
}
pub fn use_lowercase_owned(config: Mapping) -> Mapping {
let mut lowercased = Mapping::new();
for (key, value) in config {
if let Some(key_str) = key.as_str() {
lowercased.insert(lowercase_key(key_str), value);
}
}
lowercased
}
pub fn use_sort(mut config: Mapping) -> Mapping {
let mut sorted = Mapping::new();
HANDLE_FIELDS.into_iter().for_each(|key| {
+64 -127
View File
@@ -8,7 +8,7 @@ mod tun;
use self::{
chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
field::{use_keys, use_lowercase_owned, use_sort},
field::{use_keys, use_lowercase, use_sort},
merge::use_merge,
script::use_script,
seq::{SeqMap, use_seq},
@@ -34,7 +34,6 @@ type ResultLog = Vec<(String, String)>;
#[derive(Debug)]
struct ConfigValues {
clash_config: Mapping,
clash_core: Option<String>,
enable_tun: bool,
enable_builtin: bool,
socks_enabled: bool,
@@ -128,8 +127,7 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
let dns_settings = verge_arc.dns_settings_for(profile_uid);
let dns_override_confirmation = dns_settings.confirmation;
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
Some(verge_arc.get_valid_clash_core()),
let (enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
enable_tun_mode.unwrap_or(false),
enable_builtin_enhanced.unwrap_or(true),
verge_socks_enabled.unwrap_or(false),
@@ -148,7 +146,6 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
ConfigValues {
clash_config,
clash_core,
enable_tun,
enable_builtin,
socks_enabled,
@@ -199,35 +196,25 @@ async fn collect_profile_items(profiles: &IProfiles) -> Result<ProfileItems> {
let name = current_item.name.clone().unwrap_or_default();
let ProfileItems {
merge_item: default_merge,
script_item: default_script,
rules_item: default_rules,
proxies_item: default_proxies,
groups_item: default_groups,
global_merge: default_global_merge,
global_script: default_global_script,
..
} = ProfileItems::default();
let (merge_item, script_item, rules_item, proxies_item, groups_item, global_merge, global_script) = tokio::join!(
chain_item_or_default(profiles.get_item(&merge_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Merge(Mapping::new()),
},),
chain_item_or_default(profiles.get_item(&script_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},),
chain_item_or_default(profiles.get_item(&rules_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Rules(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Proxies(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item(&groups_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Groups(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item("Merge").ok(), || ChainItem {
uid: "Merge".into(),
data: ChainType::Merge(Mapping::new()),
},),
chain_item_or_default(profiles.get_item("Script").ok(), || ChainItem {
uid: "Script".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},),
chain_item_or_default(profiles.get_item(&merge_uid).ok(), || default_merge,),
chain_item_or_default(profiles.get_item(&script_uid).ok(), || default_script,),
chain_item_or_default(profiles.get_item(&rules_uid).ok(), || default_rules,),
chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || default_proxies,),
chain_item_or_default(profiles.get_item(&groups_uid).ok(), || default_groups,),
chain_item_or_default(profiles.get_item("Merge").ok(), || default_global_merge,),
chain_item_or_default(profiles.get_item("Script").ok(), || default_global_script,),
);
Ok(ProfileItems {
@@ -243,16 +230,16 @@ async fn collect_profile_items(profiles: &IProfiles) -> Result<ProfileItems> {
})
}
async fn process_global_items(
async fn process_override_items(
mut config: Mapping,
mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>,
global_merge: ChainItem,
global_script: ChainItem,
merge_item: ChainItem,
script_item: ChainItem,
profile_name: &String,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = global_merge.data {
if let ChainType::Merge(merge) = merge_item.data {
exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config);
config = use_merge(&merge, config);
@@ -262,11 +249,11 @@ async fn process_global_items(
.map(discarded_note)
.collect();
if !notes.is_empty() {
result_map.entry(global_merge.uid).or_default().extend(notes);
result_map.entry(merge_item.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = global_script.data {
if let ChainType::Script(script) = script_item.data {
let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
@@ -277,7 +264,7 @@ async fn process_global_items(
.into_iter()
.map(discarded_note),
);
result_map.insert(global_script.uid, logs);
result_map.insert(script_item.uid, logs);
}
(config, exists_keys, result_map)
@@ -544,46 +531,6 @@ fn ensure_lan_bind_address(mut config: Mapping) -> Mapping {
config
}
async fn process_profile_items(
mut config: Mapping,
mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>,
merge_item: ChainItem,
script_item: ChainItem,
profile_name: &String,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = merge_item.data {
exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config);
config = use_merge(&merge, config);
let notes: ResultLog = authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note)
.collect();
if !notes.is_empty() {
result_map.entry(merge_item.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = script_item.data {
let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
config = res_config;
logs.extend(
authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note),
);
result_map.insert(script_item.uid, logs);
}
(config, exists_keys, result_map)
}
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
fn merge_default_config(
mut config: Mapping,
@@ -655,25 +602,12 @@ fn merge_default_config(
config
}
fn apply_builtin_scripts(mut config: Mapping, clash_core: Option<String>, enable_builtin: bool) -> Mapping {
fn apply_builtin_scripts(mut config: Mapping, 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();
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,
};
}
}
// The JS path saw a lowercased view; lowercase once, only when the builtin runs.
config = use_lowercase(&config);
logging!(debug, Type::Core, "run builtin script verge_hy_alpn");
config = builtin_hy_alpn(config);
}
config
@@ -693,14 +627,6 @@ fn builtin_hy_alpn(mut config: Mapping) -> Mapping {
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"];
@@ -879,7 +805,6 @@ pub async fn enhance(
let cfg_vals = get_config_values(profile_uid).await;
let ConfigValues {
clash_config,
clash_core,
enable_tun,
enable_builtin,
socks_enabled,
@@ -929,13 +854,13 @@ pub async fn enhance(
tproxy_enabled,
);
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
let config = apply_builtin_scripts(config, enable_builtin);
let config = use_tun(config, enable_tun);
let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await;
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings);
let (config, exists_keys, result_map) = process_global_items(
let (config, exists_keys, result_map) = process_override_items(
config,
exists_keys,
result_map,
@@ -946,7 +871,7 @@ pub async fn enhance(
)
.await;
let (config, exists_keys, result_map) = process_profile_items(
let (config, exists_keys, result_map) = process_override_items(
config,
exists_keys,
result_map,
@@ -1428,7 +1353,7 @@ mod authoritative_field_tests {
mod tests {
use super::{
AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address,
process_global_items, process_profile_items, use_keys,
process_override_items, use_keys,
};
use std::collections::HashMap;
@@ -1476,22 +1401,28 @@ mod tests {
data: ChainType::Merge(merge.clone()),
};
let profile_name = "test-profile".into();
let (config, keys, logs) = process_global_items(
let (config, keys, logs) = process_override_items(
config,
vec![],
HashMap::new(),
merge_item("global-merge"),
ChainItem::to_script("global-script", script),
ChainItem {
uid: "global-script".into(),
data: ChainType::Script(script.into()),
},
&profile_name,
&authoritative,
)
.await;
let (config, _, logs) = process_profile_items(
let (config, _, logs) = process_override_items(
config,
keys,
logs,
merge_item("profile-merge"),
ChainItem::to_script("profile-script", script),
ChainItem {
uid: "profile-script".into(),
data: ChainType::Script(script.into()),
},
&profile_name,
&authoritative,
)
@@ -1536,16 +1467,19 @@ mod tests {
nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}",
)),
};
let global_script = ChainItem::to_script(
"Script",
r#"function main(config) {
let global_script = ChainItem {
uid: "Script".into(),
data: ChainType::Script(
r#"function main(config) {
config["global-script-wins"] = "global-script";
config["profile-merge-wins"] = "global-script";
config["profile-script-wins"] = "global-script";
config.nested.winner = "global-script";
return config;
}"#,
);
}"#
.into(),
),
};
let profile_merge = ChainItem {
uid: "profile-merge".into(),
data: ChainType::Merge(mapping(
@@ -1553,18 +1487,21 @@ mod tests {
nested: {winner: profile-merge}}",
)),
};
let profile_script = ChainItem::to_script(
"profile-script",
r#"function main(config) {
let profile_script = ChainItem {
uid: "profile-script".into(),
data: ChainType::Script(
r#"function main(config) {
config["profile-script-wins"] = "profile-script";
config.nested.winner = "profile-script";
return config;
}"#,
);
}"#
.into(),
),
};
let profile_name = "test-profile".into();
let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new());
let (config, exists_keys, result_map) = process_global_items(
let (config, exists_keys, result_map) = process_override_items(
config,
exists_keys,
HashMap::new(),
@@ -1574,7 +1511,7 @@ mod tests {
&authoritative,
)
.await;
let (config, exists_keys, _) = process_profile_items(
let (config, exists_keys, _) = process_override_items(
config,
exists_keys,
result_map,
+3 -3
View File
@@ -1,6 +1,6 @@
use crate::{process::AsyncHandler, utils::tmpl};
use super::field::{use_lowercase, use_lowercase_owned};
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};
@@ -22,7 +22,7 @@ type EvalOutcome = (Result<Mapping, std::string::String>, Vec<(String, String)>)
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![]);
return (use_lowercase(&config), vec![], vec![]);
}
let json = match serde_json::to_string(&use_lowercase(&config)) {
@@ -173,7 +173,7 @@ fn eval_script(script: &str, config_json: &str, name: &String) -> EvalOutcome {
}
match parse_json_safely(&result) {
Ok(config) => (Ok(use_lowercase_owned(config)), outputs.lock().to_vec()),
Ok(config) => (Ok(use_lowercase(&config)), outputs.lock().to_vec()),
Err(err) => {
logging!(
error,
+1 -1
View File
@@ -28,7 +28,7 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
.and_then(|v| v.as_str())
.unwrap_or("fake-ip");
if current_mode == "fake-ip" || !dns_val.contains_key(Value::from("enhanced-mode")) {
if current_mode == "fake-ip" {
revise!(dns_val, "enable", true);
revise!(dns_val, "ipv6", ipv6_val);
+53 -50
View File
@@ -33,6 +33,9 @@ async fn finalize_restored_verge_config(
webdav_username: Option<String>,
webdav_password: Option<String>,
) -> Result<()> {
// The lock must cover every restored layer; an unlocked apply races a concurrently staged draft.
let config_write = Config::lock_config_write().await;
// A broken restored `verge.yaml` is a restore failure, not a reason to load defaults.
let mut restored = help::read_yaml::<IVerge>(&verge_path()?).await?;
restored.webdav_url = webdav_url;
@@ -43,43 +46,42 @@ async fn finalize_restored_verge_config(
let restored_clash = IClashTemp::new().await;
let clash_draft = Config::clash().await;
clash_draft.edit_draft(|d| {
*d = restored_clash.clone();
*d = restored_clash;
});
clash_draft.apply();
let restored_profiles = IProfiles::new().await;
let profiles_draft = Config::profiles().await;
profiles_draft.edit_draft(|d| {
*d = restored_profiles.clone();
*d = restored_profiles;
});
profiles_draft.apply();
let verge_draft = Config::verge().await;
{
// Hold the write lock so a concurrent patch cannot stage a draft the proxy write reads.
let _config_write = Config::lock_config_write().await;
verge_draft.edit_draft(|d| {
*d = restored.clone();
});
verge_draft.apply();
verge_draft.edit_draft(|d| {
*d = restored.clone();
});
verge_draft.apply();
// Turn it off here; a failing core side effect below would otherwise skip this step.
if !restored.enable_system_proxy.unwrap_or_default() {
let result = async {
let _lifecycle = CoreManager::global().lifecycle_lock.lock().await;
proxy_control::apply().await?;
proxy_control::refresh_guard().await
}
.await;
if let Err(err) = result {
logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}");
if SystemProxyStateUnknown::is_in(&err) {
return Err(err);
}
// Turn it off here; a failing core side effect below would otherwise skip this step.
if !restored.enable_system_proxy.unwrap_or_default() {
let result = async {
let _lifecycle = CoreManager::global().lifecycle_lock.lock().await;
proxy_control::apply().await?;
proxy_control::refresh_guard().await
}
.await;
if let Err(err) = result {
logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}");
if SystemProxyStateUnknown::is_in(&err) {
return Err(err);
}
}
}
// `patch_verge` re-acquires the lock through `try_lock_config_write`.
drop(config_write);
// Run configuration side effects without rewriting the already-restored file.
if let Err(err) = super::patch_verge(&restored, true).await {
logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#}");
@@ -134,14 +136,35 @@ pub async fn delete_webdav_backup(filename: String) -> Result<()> {
})
}
/// Backup creation strips the WebDAV credentials, so the live ones are kept.
async fn restore_backup_archive(path: PathBuf) -> Result<()> {
let (webdav_url, webdav_username, webdav_password) = {
let verge = Config::verge().await;
let verge = verge.latest_arc();
(
verge.webdav_url.clone(),
verge.webdav_username.clone(),
verge.webdav_password.clone(),
)
};
let home_dir = app_home_dir()?;
AsyncHandler::spawn_blocking(move || -> Result<()> {
let file = std::fs::File::open(&path)?;
let mut zip = zip::ZipArchive::new(file)?;
zip.extract(&home_dir)?;
Ok(())
})
.await
.map_err(|join| anyhow!("backup extraction task failed: {join}"))??;
// Held for the config-data transition only; extraction is plain filesystem work.
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await
}
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename))]
pub async fn restore_webdav_backup(filename: String) -> Result<()> {
let verge = Config::verge().await;
let verge_data = verge.latest_arc();
let webdav_url = verge_data.webdav_url.clone();
let webdav_username = verge_data.webdav_username.clone();
let webdav_password = verge_data.webdav_password.clone();
let backup_storage_path = app_home_dir()
.map_err(|e| anyhow::anyhow!("Failed to get app home dir: {e}"))?
.join(filename.as_str());
@@ -158,12 +181,7 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> {
err
})?;
let value = backup_storage_path.clone();
let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&value)).await??;
let mut zip = zip::ZipArchive::new(file)?;
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
zip.extract(app_home_dir()?)?;
let res = finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await;
let res = restore_backup_archive(backup_storage_path.clone()).await;
let _ = backup_storage_path.remove_if_exists().await;
res
}
@@ -332,22 +350,7 @@ pub async fn restore_local_backup(filename: String) -> Result<()> {
return Err(anyhow!("Backup file not found: {}", filename));
}
let (webdav_url, webdav_username, webdav_password) = {
let verge = Config::verge().await;
let verge = verge.latest_arc();
(
verge.webdav_url.clone(),
verge.webdav_username.clone(),
verge.webdav_password.clone(),
)
};
let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&target_path)).await??;
let mut zip = zip::ZipArchive::new(file)?;
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
zip.extract(app_home_dir()?)?;
finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await?;
Ok(())
restore_backup_archive(target_path).await
}
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename, destination = %destination))]
+12 -45
View File
@@ -3,7 +3,7 @@ use crate::core::notify::{Refresh, announce};
use crate::{
config::{Config, MixedPort},
core::{CoreManager, handle, tray},
feat::clean_async,
feat::run_exit_cleanup_flow,
process::AsyncHandler,
utils,
};
@@ -14,15 +14,8 @@ use smartstring::alias::String;
use std::sync::{Arc, LazyLock};
#[allow(clippy::expect_used)]
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> = LazyLock::new(|| {
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.expect("Failed to set TLS versions")
.with_root_certificates(root_store)
.with_no_client_auth();
Arc::new(config)
});
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> =
LazyLock::new(|| Arc::new(utils::network::build_webpki_rooted_tls_config().expect("Failed to set TLS versions")));
pub async fn restart_clash_core() {
match CoreManager::global().restart_core().await {
@@ -38,38 +31,9 @@ pub async fn restart_clash_core() {
pub async fn restart_app() {
logging!(debug, Type::System, "启动重启应用流程");
handle::Handle::global().set_is_exiting();
Config::apply_all_and_save_file().await;
let cleanup_result = clean_async().await;
logging!(
info,
Type::System,
"资源清理完成,退出代码: {}",
if cleanup_result.all_success { 0 } else { 1 }
);
let cleanup_result = run_exit_cleanup_flow(NoticeStatus::AppRestartCoreStopFailed).await;
if !cleanup_result.core_stopped {
handle::Handle::global().clear_is_exiting();
// A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore.
// A core that kept running keeps its DNS, whatever an unapplied config draft says.
#[cfg(target_os = "macos")]
{
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
*manager.get_running_mode(),
crate::core::manager::RunningMode::NotRunning
) {
crate::utils::resolve::dns::sync_public_dns().await;
}
}
handle::Handle::notice(
NoticeStatus::AppRestartCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(),
);
return;
}
@@ -103,11 +67,14 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> {
}
let clash = Config::clash().await;
clash.edit_draft(|d| d.patch_config(&mapping));
clash.apply();
let clash_data = clash.data_arc();
if clash_data.save_config().await.is_ok() {
// Serialize with patch transactions: an unlocked commit can flush a staged draft mid-rollback.
let save_result = {
let _config_write = Config::lock_config_write().await;
clash.edit_draft(|d| d.patch_config(&mapping));
clash.apply();
clash.data_arc().save_config().await
};
if save_result.is_ok() {
announce(Refresh::Clash);
tray::Tray::global().update_menu_and_icon().await;
}
-15
View File
@@ -47,26 +47,11 @@ pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) {
record_switched_node(group_name, proxy_name).await;
announce(Refresh::Proxies);
let _ = tray::Tray::global().update_menu().await;
return;
}
Err(err) => {
logging!(error, Type::Tray, "切换代理失败: {err:?}");
}
}
match handle::Handle::mihomo()
.select_node_for_group(group_name, proxy_name)
.await
{
Ok(_) => {
logging!(info, Type::Tray, "代理切换回退成功: {} -> {}", group_name, proxy_name);
record_switched_node(group_name, proxy_name).await;
let _ = tray::Tray::global().update_menu().await;
}
Err(err) => {
logging!(error, Type::Tray, "代理切换最终失败: {err:?}");
}
}
}
async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result<Option<(String, Option<PrfOption>)>> {
+10 -5
View File
@@ -130,9 +130,7 @@ pub async fn open_or_close_dashboard() {
logging!(info, Type::Window, "Window toggle result: {result:?}");
}
pub async fn quit() {
logging!(debug, Type::System, "启动退出流程");
// 设置退出标志
pub(crate) async fn run_exit_cleanup_flow(stop_failed_notice: NoticeStatus) -> CleanupResult {
handle::Handle::global().set_is_exiting();
Config::apply_all_and_save_file().await;
@@ -162,11 +160,18 @@ pub async fn quit() {
}
}
handle::Handle::notice(
NoticeStatus::AppQuitCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(),
stop_failed_notice,
cleanup_result.stop_error.clone().unwrap_or_default(),
);
}
cleanup_result
}
pub async fn quit() {
logging!(debug, Type::System, "启动退出流程");
let cleanup_result = run_exit_cleanup_flow(NoticeStatus::AppQuitCoreStopFailed).await;
utils::server::shutdown_embedded_server();
let app_handle = handle::Handle::app_handle();
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
+2 -5
View File
@@ -354,7 +354,6 @@ pub(super) async fn init_dns_config() -> Result<()> {
Value::String("*.arpa".into()),
Value::String("time.*.com".into()),
Value::String("ntp.*.com".into()),
Value::String("time.*.com".into()),
Value::String("+.market.xiaomi.com".into()),
Value::String("localhost.ptlogin2.qq.com".into()),
Value::String("*.msftncsi.com".into()),
@@ -509,11 +508,9 @@ pub async fn init_resources() -> Result<()> {
let app_dir = dirs::app_home_dir()?;
let res_dir = dirs::app_resources_dir()?;
if !app_dir.exists() {
std::mem::drop(fs::create_dir_all(&app_dir).await);
}
// app_home is created by `ensure_directories` in `init_config`, which always runs first.
if !res_dir.exists() {
std::mem::drop(fs::create_dir_all(&res_dir).await);
fs::create_dir_all(&res_dir).await?;
}
let file_list = ["Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat"];
+41 -41
View File
@@ -48,6 +48,17 @@ enum TlsRootMode {
StaticWebpkiRoots,
}
/// ALPN stays unset: raw TLS connectors offer none, while reqwest needs the h2 offer.
pub(crate) fn build_webpki_rooted_tls_config() -> Result<rustls::ClientConfig> {
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()?
.with_root_certificates(root_store)
.with_no_client_auth();
Ok(config)
}
pub struct NetworkManager;
impl NetworkManager {
@@ -102,13 +113,7 @@ impl NetworkManager {
}
fn build_static_webpki_tls_config() -> Result<rustls::ClientConfig> {
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut config =
rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()?
.with_root_certificates(root_store)
.with_no_client_auth();
let mut config = build_webpki_rooted_tls_config()?;
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Ok(config)
@@ -155,6 +160,23 @@ impl NetworkManager {
detail.contains("protocolversion") || detail.contains("protocol version")
}
/// `allow_fallback` stays false when the caller already disabled certificate verification.
async fn request_with_static_webpki_fallback<T, F, Fut>(allow_fallback: bool, mut request: F) -> Result<T>
where
F: FnMut(TlsRootMode) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
match request(TlsRootMode::PlatformVerifier).await {
Ok(value) => Ok(value),
Err(err) if allow_fallback && Self::should_retry_with_static_webpki_roots(&err) => {
request(TlsRootMode::StaticWebpkiRoots).await.map_err(|fallback_err| {
fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
})
}
Err(err) => Err(err),
}
}
async fn get_with_tls_mode(
&self,
url: &str,
@@ -264,34 +286,21 @@ impl NetworkManager {
user_agent: Option<String>,
accept_invalid_certs: bool,
) -> Result<HttpResponse> {
let platform_result = self
.get_with_tls_mode(
url,
proxy_type,
timeout_secs,
user_agent.clone(),
accept_invalid_certs,
TlsRootMode::PlatformVerifier,
)
.await;
match platform_result {
Ok(response) => Ok(response),
Err(err) if !accept_invalid_certs && Self::should_retry_with_static_webpki_roots(&err) => self
.get_with_tls_mode(
Self::request_with_static_webpki_fallback(!accept_invalid_certs, |tls_root_mode| {
let user_agent = user_agent.clone();
async move {
self.get_with_tls_mode(
url,
proxy_type,
timeout_secs,
user_agent,
accept_invalid_certs,
TlsRootMode::StaticWebpkiRoots,
tls_root_mode,
)
.await
.map_err(|fallback_err| {
fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
}),
Err(err) => Err(err),
}
}
})
.await
}
async fn get_bytes_with_tls_mode(
@@ -340,19 +349,10 @@ impl NetworkManager {
timeout_secs: Option<u64>,
max_bytes: usize,
) -> Result<Vec<u8>> {
let platform_result = self
.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, TlsRootMode::PlatformVerifier)
.await;
match platform_result {
Ok(body) => Ok(body),
Err(err) if Self::should_retry_with_static_webpki_roots(&err) => self
.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, TlsRootMode::StaticWebpkiRoots)
Self::request_with_static_webpki_fallback(true, |tls_root_mode| async move {
self.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, tls_root_mode)
.await
.map_err(|fallback_err| {
fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
}),
Err(err) => Err(err),
}
})
.await
}
}
-14
View File
@@ -1,8 +1,6 @@
use crate::core::notify::NoticeStatus;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Result;
use crate::{
config::Config,
core::{
@@ -97,18 +95,6 @@ async fn resolve_setup() {
resolve_done();
}
pub async fn resolve_reset_async() -> Result<(), anyhow::Error> {
CoreManager::global().stop_core().await?;
#[cfg(target_os = "macos")]
{
use dns::restore_public_dns;
restore_public_dns().await?;
}
Ok(())
}
fn init_scheme() {
logging_error!(Type::Setup, init::init_scheme());
}
-24
View File
@@ -21,27 +21,3 @@ These rules supplement the repository's root `AGENTS.md`.
- **Command ownership.** `services` owns backend command invocation and transport
wrappers. Components and hooks use those boundaries; plugin mutations follow
the same intent ownership as backend commands.
- **State liveness.** Every `useState`/`useRef` member has a reachable write
that differs from its initial value and a read that observes the difference.
A value that can only ever hold its initial value is a constant: declare the
constant and delete the state. A ref nothing reads is deleted, not kept.
- **Surface liveness.** Anything a module exposes — props, parameters, context
members, imperative-handle methods — ships with at least one external caller
in the same change. "Reserved for future use" fields, `_`-prefixed params
that callers still pass, and error or callback channels that every receiver
ignores are removed, not maintained.
- **Path liveness.** A fallback or branch names the concrete runtime condition
that reaches it. If no supported runtime can reach it, the branch is not
added — or it replaces the primary path. Constant subexpressions and cleanup
of resources that cannot exist yet are simplified away.
- **Effect economy.** One user interaction produces one state write and one
command. A handler does not re-apply a value its wrapper already applied,
and an effect does not re-run a refresh another branch of the same effect
just ran.
- **Mount economy.** Components that issue commands or subscriptions on mount
are mounted only where their UI is reachable. A dialog no rendered control
can open stays unmounted, not mounted-and-idle.
- **Resource bounds.** Every cache key, timer and subscription a feature
allocates is reused or released on refresh; refresh never allocates an
unbounded sequence of new keys. Retries re-create per-attempt resources so
a retry can actually succeed.
+30 -13
View File
@@ -22,16 +22,23 @@ interface BaseSplitChipEditorProps {
error?: boolean
helperText?: ReactNode
placeholder?: string
rows?: number
separator?: string
splitPattern?: RegExp
defaultMode?: BaseSplitChipEditorMode
showModeToggle?: boolean
ariaLabel?: string
addLabel?: ReactNode
emptyLabel?: ReactNode
modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>
renderHeader?: (modeToggle: ReactNode) => ReactNode
}
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
const splitValue = (value: string) =>
const splitValue = (value: string, splitPattern: RegExp) =>
value
.split(DEFAULT_SPLIT_PATTERN)
.split(splitPattern)
.map((item) => item.trim())
.filter(Boolean)
@@ -42,25 +49,35 @@ export const BaseSplitChipEditor = ({
error = false,
helperText,
placeholder,
rows = 4,
separator = ',',
splitPattern = DEFAULT_SPLIT_PATTERN,
defaultMode = 'visual',
showModeToggle = true,
ariaLabel,
addLabel,
emptyLabel,
modeLabels,
renderHeader,
}: BaseSplitChipEditorProps) => {
const { t } = useTranslation()
const [mode, setMode] = useState<BaseSplitChipEditorMode>('visual')
const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode)
const [draft, setDraft] = useState('')
const resolvedLabels = useMemo(
() => ({
visual: t('shared.editorModes.visualization'),
advanced: t('shared.editorModes.advanced'),
add: t('shared.actions.new'),
empty: t('shared.statuses.empty'),
visual: modeLabels?.visual ?? t('shared.editorModes.visualization'),
advanced: modeLabels?.advanced ?? t('shared.editorModes.advanced'),
add: addLabel ?? t('shared.actions.new'),
empty: emptyLabel ?? t('shared.statuses.empty'),
}),
[t],
[t, modeLabels, addLabel, emptyLabel],
)
const values = useMemo(() => splitValue(value), [value])
const values = useMemo(
() => splitValue(value, splitPattern),
[value, splitPattern],
)
const items = useMemo(() => {
const counts = new Map<string, number>()
@@ -75,7 +92,7 @@ export const BaseSplitChipEditor = ({
}, [values])
const handleAddDraft = () => {
const nextValues = splitValue(draft)
const nextValues = splitValue(draft, splitPattern)
if (!nextValues.length) {
return
}
@@ -96,7 +113,7 @@ export const BaseSplitChipEditor = ({
const resolvedAriaLabel =
ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
const modeToggle = (
const modeToggle = showModeToggle ? (
<Tooltip title={toggleLabel}>
<IconButton
size="small"
@@ -111,7 +128,7 @@ export const BaseSplitChipEditor = ({
<ToggleIcon fontSize="small" />
</IconButton>
</Tooltip>
)
) : null
return (
<>
@@ -185,7 +202,7 @@ export const BaseSplitChipEditor = ({
disabled={disabled}
size="small"
multiline
rows={4}
rows={rows}
sx={{ width: '100%' }}
value={value}
helperText={helperText}
+9 -3
View File
@@ -596,7 +596,7 @@ export const CurrentProxyCard = () => {
[getProfileStorageKey],
)
const { select } = useProxySelection({
const { handleSelectChange } = useProxySelection({
onSuccess: () => {
refreshProxy()
},
@@ -766,9 +766,15 @@ export const CurrentProxyCard = () => {
const previousProxy = selectedGroup.now
const nextName = option.member.ref.name
// The profile selection is the durable source across core restarts and run modes.
select(selectedGroup.name, nextName, previousProxy!, selectedGroup.fixed!)
handleSelectChange(
selectedGroup.name,
previousProxy!,
selectedGroup.fixed!,
)({
target: { value: nextName },
})
},
[isDirectMode, select, selectedGroup, unsortedProxyOptions],
[handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions],
)
const goToProxies = useCallback(() => {
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next'
import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor'
import { useVerge } from '@/hooks/use-verge'
import { debugLog } from '@/utils/debug'
import parseTraffic from '@/utils/parse-traffic'
import {
formatTrafficHourMinute,
@@ -22,7 +23,14 @@ import {
isSameTrafficData,
} from '@/utils/traffic-sampler'
interface ITrafficItem {
up: number
down: number
timestamp?: number
}
export interface EnhancedCanvasTrafficGraphRef {
appendData: (data: ITrafficItem) => void
toggleStyle: () => void
}
@@ -134,6 +142,7 @@ export const EnhancedCanvasTrafficGraph = memo(
[],
)
const debounceTimeoutRef = useRef<number | null>(null)
const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS)
const colors = useMemo(
() => ({
@@ -174,6 +183,8 @@ export const EnhancedCanvasTrafficGraph = memo(
if (displayData.length === 0) {
lastDataTimestampRef.current = 0
dataStaleRef.current = false
// eslint-disable-next-line @eslint-react/set-state-in-effect
setCurrentFPS(GRAPH_CONFIG.targetFPS)
return
}
@@ -189,11 +200,18 @@ export const EnhancedCanvasTrafficGraph = memo(
}
}, [displayData])
const handleFocusStateChange = useCallback((focused: boolean) => {
isWindowFocusedRef.current = focused
const handleFocusStateChange = useCallback(
(focused: boolean) => {
isWindowFocusedRef.current = focused
scheduleDrawGraphRef.current()
}, [])
if (focused || !pause_render_traffic_stats_on_blur) {
setCurrentFPS(GRAPH_CONFIG.targetFPS)
}
scheduleDrawGraphRef.current()
},
[pause_render_traffic_stats_on_blur],
)
useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
@@ -666,13 +684,19 @@ export const EnhancedCanvasTrafficGraph = memo(
ctx.beginPath()
ctx.moveTo(getX(0), getY(0))
for (let i = 1; i < data.length; i++) {
const currentX = getX(i)
const currentY = getY(i)
const nextIndex = Math.min(i + 1, lastIndex)
const controlX = (currentX + getX(nextIndex)) / 2
const controlY = (currentY + getY(nextIndex)) / 2
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
if (chartStyle === 'bezier') {
for (let i = 1; i < data.length; i++) {
const currentX = getX(i)
const currentY = getY(i)
const nextIndex = Math.min(i + 1, lastIndex)
const controlX = (currentX + getX(nextIndex)) / 2
const controlY = (currentY + getY(nextIndex)) / 2
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
}
} else {
for (let i = 1; i < data.length; i++) {
ctx.lineTo(getX(i), getY(i))
}
}
ctx.lineTo(getX(lastIndex), height - padding.bottom)
@@ -960,7 +984,21 @@ export const EnhancedCanvasTrafficGraph = memo(
setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier'))
}, [])
useImperativeHandle(ref, () => ({ toggleStyle }), [toggleStyle])
const appendData = useCallback((data: ITrafficItem) => {
debugLog(
'[EnhancedCanvasTrafficGraphV2] appendData called (using global data):',
data,
)
}, [])
useImperativeHandle(
ref,
() => ({
appendData,
toggleStyle,
}),
[appendData, toggleStyle],
)
const getTimeRangeText = useCallback(() => {
return t('home.components.traffic.patterns.minutes', {
@@ -1104,7 +1142,7 @@ export const EnhancedCanvasTrafficGraph = memo(
{t('home.components.traffic.diagnostics', {
points: displayData.length,
compressed: samplerStats.compressedBufferSize,
fps: GRAPH_CONFIG.targetFPS,
fps: currentFPS,
})}
</Box>
+6 -55
View File
@@ -3,14 +3,10 @@ import {
ArrowUpwardRounded,
CloudDownloadRounded,
CloudUploadRounded,
ErrorOutlineRounded,
LinkRounded,
MemoryRounded,
} from '@mui/icons-material'
import {
Alert,
Box,
Button,
Grid,
PaletteColor,
Paper,
@@ -19,9 +15,9 @@ import {
useTheme,
} from '@mui/material'
import { ReactNode, memo, useMemo, useRef } from 'react'
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useConnectionSummaryData } from '@/hooks/use-connection-data'
import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from '@/hooks/use-traffic-data'
@@ -131,50 +127,6 @@ const CompactStatCard = memo(
// 添加显示名称
CompactStatCard.displayName = 'CompactStatCard'
const fallbackSx = {
p: 2,
minHeight: 200,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed',
borderColor: 'error.main',
borderRadius: 2,
bgcolor: 'error.light',
}
const reloadPage = () => window.location.reload()
const TrafficFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
const { t } = useTranslation()
return (
<Box sx={fallbackSx}>
<ErrorOutlineRounded color="error" sx={{ fontSize: 48, mb: 2 }} />
<Typography variant="h6" gutterBottom>
{t('shared.feedback.errors.trafficStats')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t('shared.feedback.errors.trafficStatsDescription')}
</Typography>
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
{error instanceof Error
? error.message
: t('shared.feedback.errors.unknown')}
</Alert>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" size="small" onClick={resetErrorBoundary}>
{t('shared.actions.retry')}
</Button>
<Button variant="outlined" size="small" onClick={reloadPage}>
{t('shared.actions.refreshPage')}
</Button>
</Box>
</Box>
)
}
export const EnhancedTrafficStats = () => {
const { t } = useTranslation()
const theme = useTheme()
@@ -302,11 +254,10 @@ export const EnhancedTrafficStats = () => {
}, [t, parsedData, displayMemory])
return (
<ErrorBoundary
FallbackComponent={TrafficFallback}
onError={(error, errorInfo) =>
console.error('[EnhancedTrafficStats]', error, errorInfo)
}
<TrafficErrorBoundary
onError={(error, errorInfo) => {
console.error('[EnhancedTrafficStats] 组件错误:', error, errorInfo)
}}
>
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
{trafficGraph && (
@@ -322,6 +273,6 @@ export const EnhancedTrafficStats = () => {
</Grid>
))}
</Grid>
</ErrorBoundary>
</TrafficErrorBoundary>
)
}
+13 -6
View File
@@ -86,11 +86,16 @@ export const SystemInfoCard = () => {
}, [verge, patchVerge])
const handleRunningModeClick = useCallback(async () => {
if (isSidecarMode) {
if (isSidecarMode || (isAdminMode && isSidecarMode)) {
await installServiceAndRestartCore()
await mutateSystemState()
}
}, [isSidecarMode, installServiceAndRestartCore, mutateSystemState])
}, [
isSidecarMode,
isAdminMode,
installServiceAndRestartCore,
mutateSystemState,
])
const onCheckUpdate = useLockFn(async () => {
try {
@@ -117,16 +122,18 @@ export const SystemInfoCard = () => {
const runningModeStyle = useMemo(
() => ({
cursor: isSidecarMode ? 'pointer' : 'default',
textDecoration: isSidecarMode ? 'underline' : 'none',
cursor:
isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default',
textDecoration:
isSidecarMode || (isAdminMode && isSidecarMode) ? 'underline' : 'none',
display: 'flex',
alignItems: 'center',
gap: 0.5,
'&:hover': {
opacity: isSidecarMode ? 0.7 : 1,
opacity: isSidecarMode || (isAdminMode && isSidecarMode) ? 0.7 : 1,
},
}),
[isSidecarMode],
[isSidecarMode, isAdminMode],
)
const getModeIcon = () => {
+3 -3
View File
@@ -87,7 +87,7 @@ export const TestCard = () => {
x.uid === uid ? { ...x, ...patch } : x,
)
mutateVerge({ ...verge, test_list: newList })
mutateVerge({ ...verge, test_list: newList }, false)
},
[testList, verge, mutateVerge],
)
@@ -96,7 +96,7 @@ export const TestCard = () => {
(uid: string) => {
const newList = testList.filter((x) => x.uid !== uid)
patchVerge({ test_list: newList })
mutateVerge({ ...verge, test_list: newList })
mutateVerge({ ...verge, test_list: newList }, false)
},
[testList, verge, patchVerge, mutateVerge],
)
@@ -129,7 +129,7 @@ export const TestCard = () => {
newList.splice(newIndex, 0, removed)
// 优化:先本地更新,再异步 patch,避免UI卡死
mutateVerge({ ...verge, test_list: newList })
mutateVerge({ ...verge, test_list: newList }, false)
const patchFn = () => {
try {
patchVerge({ test_list: newList })
+4 -1
View File
@@ -35,7 +35,10 @@ export const LayoutSidebar = (props: LayoutSidebarProps) => {
const handleMenuOrderOptimisticUpdate = useCallback(
(order: string[]) => {
mutateVerge((prev) => (prev ? { ...prev, menu_order: order } : prev))
mutateVerge(
(prev) => (prev ? { ...prev, menu_order: order } : prev),
false,
)
},
[mutateVerge],
)
+4 -33
View File
@@ -1,15 +1,14 @@
import {
ArrowDownwardRounded,
ArrowUpwardRounded,
ErrorOutlineRounded,
MemoryRounded,
} from '@mui/icons-material'
import type { BoxProps, SvgIconProps, TypographyProps } from '@mui/material'
import { Box, Typography } from '@mui/material'
import { useEffect, useRef } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import { LightweightTrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from '@/hooks/use-traffic-data'
import { useVerge } from '@/hooks/use-verge'
@@ -18,30 +17,6 @@ import parseTraffic from '@/utils/parse-traffic'
import { TrafficGraph, type TrafficRef } from './traffic-graph'
const unavailableSx = {
p: 1,
minHeight: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'error.light',
borderRadius: 1,
color: 'error.contrastText',
}
const TrafficUnavailableFallback = () => {
const { t } = useTranslation()
return (
<Box sx={unavailableSx}>
<ErrorOutlineRounded sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="caption">
{t('shared.feedback.errors.trafficUnavailable')}
</Typography>
</Box>
)
}
export const LayoutTraffic = () => {
const { t } = useTranslation()
const { verge } = useVerge()
@@ -100,12 +75,7 @@ export const LayoutTraffic = () => {
}
return (
<ErrorBoundary
FallbackComponent={TrafficUnavailableFallback}
onError={(error, errorInfo) =>
console.error('[LayoutTraffic]', error, errorInfo)
}
>
<LightweightTrafficErrorBoundary>
<Box sx={{ position: 'relative' }}>
{trafficGraph && pageVisible && (
<div
@@ -160,6 +130,7 @@ export const LayoutTraffic = () => {
cursor: 'auto',
}}
color={'disabled'}
onClick={async () => {}}
>
<MemoryRounded {...iconStyle} />
<Typography {...valStyle}>{inuse}</Typography>
@@ -168,6 +139,6 @@ export const LayoutTraffic = () => {
)}
</Box>
</Box>
</ErrorBoundary>
</LightweightTrafficErrorBoundary>
)
}
+36 -4
View File
@@ -1,6 +1,11 @@
import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material'
import { Box, IconButton } from '@mui/material'
import { type PointerEvent, useCallback } from 'react'
import {
forwardRef,
type PointerEvent,
useCallback,
useImperativeHandle,
} from 'react'
import { useWindowControls } from '@/hooks/use-window'
import getSystem from '@/utils/get-system'
@@ -58,9 +63,36 @@ export const WindowResizeHandles = () => {
)
}
export const WindowControls = () => {
export const WindowControls = forwardRef(function WindowControls(props, ref) {
const OS = getSystem()
const { maximized, minimize, close, toggleMaximize } = useWindowControls()
const {
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
} = useWindowControls()
useImperativeHandle(
ref,
() => ({
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
}),
[
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
],
)
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
@@ -154,4 +186,4 @@ export const WindowControls = () => {
)}
</Box>
)
}
})
+3 -3
View File
@@ -462,7 +462,7 @@ function NormalProxyGroups(props: { mode: string }) {
}
}, [handleScroll])
const { select } = useProxySelection({
const { handleProxyGroupChange } = useProxySelection({
onSuccess: () => {
onProxies()
},
@@ -477,9 +477,9 @@ function NormalProxyGroups(props: { mode: string }) {
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
if (!isInteractableMember(member)) return
select(group.name, member.ref.name, group.now!, group.fixed!)
handleProxyGroupChange(group, { name: member.ref.name })
},
[select],
[handleProxyGroupChange],
)
const handleLocation = useStableCallback((group: ProxyGroupView) => {
@@ -30,8 +30,10 @@ export const useProxyGroupHeaderLayout = () => {
if (canceled || !operation.source || !operation.target) return
const position = toolsOnLeft ? 'right' : 'left'
mutateVerge((prev) =>
prev ? { ...prev, proxy_group_tools_position: position } : prev,
mutateVerge(
(prev) =>
prev ? { ...prev, proxy_group_tools_position: position } : prev,
false,
)
try {
await patchVerge({ proxy_group_tools_position: position })
@@ -113,15 +113,17 @@ export const BackupConfigViewer = memo(
errorNotice: false,
},
)
await mutateVerge((current) =>
current
? {
...current,
webdav_url: trimmedUrl,
webdav_username: trimmedUsername,
webdav_password: data.password,
}
: current,
await mutateVerge(
(current) =>
current
? {
...current,
webdav_url: trimmedUrl,
webdav_username: trimmedUsername,
webdav_password: data.password,
}
: current,
false,
)
setWebdavStatus(signature, 'unknown')
showNotice.success('settings.modals.backup.messages.webdavConfigSaved')
@@ -107,7 +107,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
showNotice.error(err)
}
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch })
mutateVerge({ ...verge, ...patch }, false)
}
return (
+29 -18
View File
@@ -15,33 +15,44 @@ import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
import { useVerge } from '@/hooks/use-verge'
import { showNotice } from '@/services/notice-service'
const buildInitialValues = (verge?: IVergeConfig | null) => ({
appLogLevel: verge?.app_log_level ?? 'warn',
appLogMaxSize: verge?.app_log_max_size ?? 128,
appLogMaxCount: verge?.app_log_max_count ?? 8,
autoCloseConnection: verge?.auto_close_connection ?? true,
autoCheckUpdate: verge?.auto_check_update ?? true,
enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true,
proxyLayoutColumn: verge?.proxy_layout_column || 6,
enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false,
autoDelayDetectionIntervalMinutes:
verge?.auto_delay_detection_interval_minutes ?? 5,
defaultLatencyTest: verge?.default_latency_test || '',
autoLogClean: verge?.auto_log_clean || 0,
defaultLatencyTimeout: verge?.default_latency_timeout || 10000,
})
export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation()
const { verge, patchVerge } = useVerge()
const [open, setOpen] = useState(false)
const [values, setValues] = useState(() => buildInitialValues(null))
const [values, setValues] = useState({
appLogLevel: 'warn',
appLogMaxSize: 8,
appLogMaxCount: 12,
autoCloseConnection: true,
autoCheckUpdate: true,
enableBuiltinEnhanced: true,
proxyLayoutColumn: 6,
enableAutoDelayDetection: false,
autoDelayDetectionIntervalMinutes: 5,
defaultLatencyTest: '',
autoLogClean: 2,
defaultLatencyTimeout: 10000,
})
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true)
setValues(buildInitialValues(verge))
setValues({
appLogLevel: verge?.app_log_level ?? 'warn',
appLogMaxSize: verge?.app_log_max_size ?? 128,
appLogMaxCount: verge?.app_log_max_count ?? 8,
autoCloseConnection: verge?.auto_close_connection ?? true,
autoCheckUpdate: verge?.auto_check_update ?? true,
enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true,
proxyLayoutColumn: verge?.proxy_layout_column || 6,
enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false,
autoDelayDetectionIntervalMinutes:
verge?.auto_delay_detection_interval_minutes ?? 5,
defaultLatencyTest: verge?.default_latency_test || '',
autoLogClean: verge?.auto_log_clean || 0,
defaultLatencyTimeout: verge?.default_latency_timeout || 10000,
})
},
close: () => setOpen(false),
}))
+21 -14
View File
@@ -87,17 +87,6 @@ const splitBypass = (value?: string) =>
.map((item) => item.trim())
.filter(Boolean)
const buildInitialValues = (verge?: IVergeConfig | null) => ({
guard: verge?.enable_proxy_guard,
enable_bypass_check: verge?.enable_bypass_check ?? true,
bypass: verge?.system_proxy_bypass,
duration: verge?.proxy_guard_duration ?? 10,
use_default: verge?.use_default_bypass ?? true,
pac: verge?.proxy_auto_config,
pac_content: verge?.pac_file_content ?? DEFAULT_PAC,
proxy_host: verge?.proxy_host ?? '127.0.0.1',
})
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation()
const systemName = getSystem()
@@ -127,7 +116,16 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
proxy_host,
} = verge ?? {}
const [value, setValue] = useState(() => buildInitialValues(null))
const [value, setValue] = useState({
guard: enable_proxy_guard,
enable_bypass_check: enable_bypass_check ?? true,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? '127.0.0.1',
})
const [embeddedServerPort, setEmbeddedServerPort] = useState<number | null>(
null,
)
@@ -194,7 +192,16 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true)
setValue(buildInitialValues(verge))
setValue({
guard: enable_proxy_guard,
enable_bypass_check: enable_bypass_check ?? true,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? '127.0.0.1',
})
fetchNetworkInterfaces()
},
close: () => setOpen(false),
@@ -332,7 +339,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
Promise.resolve().then(async () => {
try {
if (Object.keys(patch).length > 0) {
mutateVerge({ ...verge, ...patch })
mutateVerge({ ...verge, ...patch }, false)
}
if (Object.keys(patch).length > 0) {
await patchVerge(patch)
+18 -12
View File
@@ -76,28 +76,34 @@ const proxyOptionToken = ({ memberIndex, member }: TunnelProxyOption) =>
member.kind === 'node' ? member.node.recordId : member.ref.name
}`
const buildInitialValues = (): TunnelFormValues => ({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: null,
})
export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
const { t } = useTranslation()
const { clash, mutateClash, patchClash } = useClash()
const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState(false)
const [values, setValues] = useState<TunnelFormValues>(buildInitialValues)
const [values, setValues] = useState<TunnelFormValues>({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: null,
})
const [draftTunnels, setDraftTunnels] = useState<TunnelEntry[]>([])
useImperativeHandle(ref, () => ({
open: () => {
setValues(buildInitialValues())
setValues(() => ({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: null,
}))
setDraftTunnels(() => clash?.tunnels ?? [])
setOpen(true)
// 如果没有隧道,则自动展开
@@ -50,21 +50,21 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
const handleAdd = useLockFn(async (value: string) => {
const newList = [...webUIList, value]
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old))
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
await patchVerge({ web_ui_list: newList })
})
const handleChange = useLockFn(async (index: number, value?: string) => {
const newList = [...webUIList]
newList[index] = value ?? ''
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old))
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
await patchVerge({ web_ui_list: newList })
})
const handleDelete = useLockFn(async (index: number) => {
const newList = [...webUIList]
newList.splice(index, 1)
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old))
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
await patchVerge({ web_ui_list: newList })
})
+13 -3
View File
@@ -1,12 +1,14 @@
import React from 'react'
import React, { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Switch, TooltipIcon } from '@/components/base'
import { DialogRef, Switch, TooltipIcon } from '@/components/base'
import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
import { useVerge } from '@/hooks/use-verge'
import { GuardState } from './mods/guard-state'
import { SettingList, SettingItem } from './mods/setting-comp'
import { SysproxyViewer } from './mods/sysproxy-viewer'
import { TunViewer } from './mods/tun-viewer'
interface Props {
onError?: (err: Error) => void
@@ -19,16 +21,22 @@ const SettingSystem = ({ onError }: Props) => {
const { enable_auto_launch, enable_silent_start } = verge ?? {}
const sysproxyRef = useRef<DialogRef>(null)
const tunRef = useRef<DialogRef>(null)
const onSwitchFormat = (
_e: React.ChangeEvent<HTMLInputElement>,
value: boolean,
) => value
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch })
mutateVerge({ ...verge, ...patch }, false)
}
return (
<SettingList title={t('settings.sections.system.title')}>
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
<ProxyControlSwitches
label={t('settings.sections.system.toggles.tunMode')}
onError={onError}
@@ -50,6 +58,8 @@ const SettingSystem = ({ onError }: Props) => {
}}
onGuard={async (e) => {
try {
// 先触发UI更新立即看到反馈
onChangeData({ enable_auto_launch: e })
await patchVerge({ enable_auto_launch: e })
return Promise.resolve()
} catch (error) {
@@ -72,7 +72,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
const backupRef = useRef<DialogRef>(null)
const onChangeData = (patch: any) => {
mutateVerge({ ...verge, ...patch })
mutateVerge({ ...verge, ...patch }, false)
}
const onCopyClashEnv = useCallback(async () => {
@@ -163,7 +163,7 @@ const ProxyControlSwitches = ({
})
return false
}
mutateVerge({ ...verge, enable_tun_mode: value })
mutateVerge({ ...verge, enable_tun_mode: value }, false)
await patchVerge({ enable_tun_mode: value })
}
@@ -229,8 +229,8 @@ const ProxyControlSwitches = ({
/>
)}
{isSystemProxyMode && <SysproxyViewer ref={sysproxyRef} />}
{isTunMode && <TunViewer ref={tunRef} />}
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
</Box>
)
}
@@ -0,0 +1,301 @@
import {
ErrorOutlineRounded,
RefreshRounded,
BugReportRounded,
} from '@mui/icons-material'
import { Box, Typography, Button, Alert, Collapse } from '@mui/material'
import React, { Component, ErrorInfo, ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
children: ReactNode
fallbackComponent?: ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void
}
interface State {
hasError: boolean
error: Error | null
errorInfo: ErrorInfo | null
showDetails: boolean
}
export class TrafficErrorBoundary extends Component<Props, State> {
private retryCount = 0
private maxRetries = 3
constructor(props: Props) {
super(props)
this.state = {
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
}
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[TrafficErrorBoundary] 捕获到组件错误:', error, errorInfo)
this.setState({
error,
errorInfo,
})
if (this.props.onError) {
this.props.onError(error, errorInfo)
}
this.reportError(error, errorInfo)
}
private reportError = (error: Error, errorInfo: ErrorInfo) => {
const errorReport = {
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
}
console.error('[TrafficErrorBoundary] 错误报告:', errorReport)
}
private handleRetry = () => {
if (this.retryCount < this.maxRetries) {
this.retryCount++
console.log(
`[TrafficErrorBoundary] 尝试重试 (${this.retryCount}/${this.maxRetries})`,
)
this.setState({
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
})
} else {
console.warn('[TrafficErrorBoundary] 已达到最大重试次数')
}
}
private handleRefresh = () => {
window.location.reload()
}
private toggleDetails = () => {
this.setState((prev) => ({ showDetails: !prev.showDetails }))
}
render() {
if (this.state.hasError) {
if (this.props.fallbackComponent) {
return this.props.fallbackComponent
}
return (
<TrafficErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo}
showDetails={this.state.showDetails}
canRetry={this.retryCount < this.maxRetries}
retryCount={this.retryCount}
maxRetries={this.maxRetries}
onRetry={this.handleRetry}
onRefresh={this.handleRefresh}
onToggleDetails={this.toggleDetails}
/>
)
}
return this.props.children
}
}
interface TrafficErrorFallbackProps {
error: Error | null
errorInfo: ErrorInfo | null
showDetails: boolean
canRetry: boolean
retryCount: number
maxRetries: number
onRetry: () => void
onRefresh: () => void
onToggleDetails: () => void
}
const TrafficErrorFallback: React.FC<TrafficErrorFallbackProps> = ({
error,
errorInfo,
showDetails,
canRetry,
retryCount,
maxRetries,
onRetry,
onRefresh,
onToggleDetails,
}) => {
const { t } = useTranslation()
return (
<Box
sx={{
p: 2,
minHeight: 200,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed',
borderColor: 'error.main',
borderRadius: 2,
bgcolor: 'error.light',
color: 'error.contrastText',
}}
>
<ErrorOutlineRounded sx={{ fontSize: 48, mb: 2, color: 'error.main' }} />
<Typography variant="h6" gutterBottom>
{t('shared.feedback.errors.trafficStats')}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2, textAlign: 'center' }}
>
{t('shared.feedback.errors.trafficStatsDescription')}
</Typography>
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
<Typography variant="body2">
<strong>{t('shared.feedback.errors.label')}:</strong>{' '}
{error instanceof Error
? error.message
: t('shared.feedback.errors.unknown')}
</Typography>
{retryCount > 0 && (
<Typography variant="caption" sx={{ mt: 1, display: 'block' }}>
{t('shared.labels.retryAttempts')}: {retryCount}/{maxRetries}
</Typography>
)}
</Alert>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
{canRetry && (
<Button
variant="contained"
color="primary"
startIcon={<RefreshRounded />}
onClick={onRetry}
size="small"
>
{t('shared.actions.retry')}
</Button>
)}
<Button variant="outlined" onClick={onRefresh} size="small">
{t('shared.actions.refreshPage')}
</Button>
<Button
variant="text"
startIcon={<BugReportRounded />}
onClick={onToggleDetails}
size="small"
>
{showDetails
? t('shared.actions.hideDetails')
: t('shared.actions.showDetails')}
</Button>
</Box>
<Collapse in={showDetails} sx={{ width: '100%', maxWidth: 600 }}>
<Box
sx={{
p: 2,
bgcolor: 'background.paper',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="subtitle2" gutterBottom>
{t('shared.feedback.errors.details')}:
</Typography>
<Typography
variant="caption"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace',
fontSize: '0.75rem',
color: 'text.secondary',
}}
>
{error?.stack}
</Typography>
{errorInfo?.componentStack && (
<>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
{t('shared.feedback.errors.componentStack')}:
</Typography>
<Typography
variant="caption"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace',
fontSize: '0.75rem',
color: 'text.secondary',
}}
>
{errorInfo.componentStack}
</Typography>
</>
)}
</Box>
</Collapse>
</Box>
)
}
export const LightweightTrafficErrorBoundary: React.FC<{
children: ReactNode
}> = ({ children }) => {
const { t } = useTranslation()
return (
<TrafficErrorBoundary
fallbackComponent={
<Box
sx={{
p: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 60,
bgcolor: 'error.light',
borderRadius: 1,
color: 'error.contrastText',
}}
>
<ErrorOutlineRounded sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="caption">
{t('shared.feedback.errors.trafficUnavailable')}
</Typography>
</Box>
}
>
{children}
</TrafficErrorBoundary>
)
}
+4 -2
View File
@@ -1,4 +1,5 @@
import { convertFileSrc } from '@tauri-apps/api/core'
import { useMemo } from 'react'
import { downloadIconCache } from '@/services/cmds'
import { useQuery } from '@/services/query-client'
@@ -22,8 +23,9 @@ export const useIconCache = ({
const iconValue = icon?.trim() ?? ''
const cacheKeyValue = cacheKey?.trim() ?? ''
const isEnabled =
enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
const isEnabled = useMemo(() => {
return enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
}, [enabled, iconValue, cacheKeyValue])
const { data } = useQuery({
queryKey: ['icon-cache', iconValue, cacheKeyValue],
+8 -4
View File
@@ -54,7 +54,8 @@ export const useLogData = () => {
const hasLoadedInitialLogsRef = useRef(false)
const { response, refresh, setData } = useMihomoWsSubscription<ILogItem[]>({
subscriptionKey: enableLog ? 'getClashLog' : null,
storageKey: 'mihomo_logs_date',
buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null),
fallbackData: [],
connect: () => MihomoWebSocket.connect_logs(logLevel),
setupHandlers: ({ next, scheduleReconnect, isMounted }) => {
@@ -76,13 +77,14 @@ export const useLogData = () => {
}
const pendingLogs = buffer.splice(0, buffer.length)
flushTimeStr = null
next((current) => appendLogs(current, pendingLogs))
next(null, (current) => appendLogs(current, pendingLogs))
flushTimer = null
}
return {
handleMessage: (data) => {
if (data.startsWith('Websocket error')) {
next(data)
void scheduleReconnect()
return
}
@@ -106,7 +108,9 @@ export const useLogData = () => {
if (!flushTimer) {
flushTimer = setTimeout(flush, FLUSH_DELAY_MS)
}
} catch {}
} catch (error) {
next(error)
}
},
async onConnected() {
if (hasLoadedInitialLogsRef.current) {
@@ -115,7 +119,7 @@ export const useLogData = () => {
const logs = await getClashLogs()
hasLoadedInitialLogsRef.current = true
if (isMounted()) {
next((current) => {
next(null, (current) => {
if (!current || current.length === 0) {
return clampLogs(filterLogsByLevel(logs, allowedTypes))
}
+7 -3
View File
@@ -33,13 +33,15 @@ export const useMemoryData = (options?: { enabled?: boolean }) => {
const enabled = options?.enabled ?? true
const { response, refresh } = useMihomoWsSubscription<IMemoryUsageItem>({
subscriptionKey: enabled ? 'getClashMemory' : null,
storageKey: 'mihomo_memory_date',
buildSubscriptKey: (date) => (enabled ? `getClashMemory-${date}` : null),
fallbackData: FALLBACK_MEMORY_USAGE,
connect: () => MihomoWebSocket.connect_memory(),
throttleMs: 500,
setupHandlers: ({ next, scheduleReconnect }) => ({
handleMessage: (data) => {
if (data.startsWith('Websocket error')) {
next(data, FALLBACK_MEMORY_USAGE)
void scheduleReconnect()
return
}
@@ -49,8 +51,10 @@ export const useMemoryData = (options?: { enabled?: boolean }) => {
if (shouldSkipDuplicateMemory(parsed)) {
return
}
next(parsed)
} catch {}
next(null, parsed)
} catch (error) {
next(error, FALLBACK_MEMORY_USAGE)
}
},
}),
})
+68 -32
View File
@@ -1,7 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocalStorage } from 'foxact/use-local-storage'
import { useCallback, useEffect, useRef } from 'react'
import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api'
import { setCacheData, useQuery } from '@/services/query-client'
import {
removeCacheData,
setCacheData,
useQuery,
} from '@/services/query-client'
const RECONNECT_DELAY_MS = 1000
@@ -26,6 +31,7 @@ interface SharedSubscriptionEntry {
const sharedSubscriptions = new Map<string, SharedSubscriptionEntry>()
const subscriptionSnapshots = new Map<string, unknown>()
const initialSubscriptionDate = Date.now()
const getSubscriptionSnapshot = <T>(key: string) =>
subscriptionSnapshots.get(key) as T | undefined
@@ -130,13 +136,15 @@ const createSharedSubscriptionEntry = (
return entry
}
type Updater<T> = (current?: T) => T
/**
* Pass either a plain value or a functional updater; the updater is resolved
* against the current subscription snapshot before the cache is written.
* Mirrors SWR's MutatorCallback: consumers can pass either a plain value or a
* functional updater `(current?: T) => T`. The functional form is resolved
* against the current subscription snapshot before updating SWR.
*/
type NextFn<T> = (data: T | Updater<T>) => void
type NextFn<T> = (
error?: any,
data?: T | ((current?: T) => T | undefined),
) => void
interface HandlerContext<T> {
next: NextFn<T>
@@ -151,7 +159,8 @@ interface HandlerResult {
}
interface UseMihomoWsSubscriptionOptions<T> {
subscriptionKey: string | null
storageKey: string
buildSubscriptKey: (date: number) => string | null
fallbackData: T
connect: () => Promise<MihomoWebSocket>
/**
@@ -169,12 +178,18 @@ interface UseMihomoWsSubscriptionOptions<T> {
export const useMihomoWsSubscription = <T>(
options: UseMihomoWsSubscriptionOptions<T>,
) => {
const { subscriptionKey, fallbackData, connect, throttleMs, setupHandlers } =
options
const {
storageKey,
buildSubscriptKey,
fallbackData,
connect,
throttleMs,
setupHandlers,
} = options
const subscriptionCacheKey = subscriptionKey
? `$sub$${subscriptionKey}`
: null
const [date, setDate] = useLocalStorage(storageKey, initialSubscriptionDate)
const subscriptKey = buildSubscriptKey(date)
const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null
const lastSubscriptionCacheKeyRef = useRef<string | null>(null)
if (subscriptionCacheKey) {
lastSubscriptionCacheKeyRef.current = subscriptionCacheKey
@@ -182,9 +197,20 @@ export const useMihomoWsSubscription = <T>(
const responseCacheKey =
subscriptionCacheKey ?? lastSubscriptionCacheKeyRef.current
// Bumping the epoch re-runs the subscription effect, rebuilding the shared
// entry (and its captured `connect`/handler closures) under the same key.
const [refreshEpoch, setRefreshEpoch] = useState(0)
const resolveNextData = useCallback(
(
data: T | ((current?: T) => T | undefined) | undefined,
cacheKey: string,
): T => {
if (typeof data === 'function') {
const updater = data as (current?: T) => T | undefined
const current = getSubscriptionSnapshot<T>(cacheKey)
return updater(current) ?? fallbackData
}
return data ?? fallbackData
},
[fallbackData],
)
const response = useQuery<T>({
queryKey: responseCacheKey ? [responseCacheKey] : ['$sub$__disabled__'],
@@ -212,35 +238,44 @@ export const useMihomoWsSubscription = <T>(
let throttleCleanup: (() => void) | undefined
let wrappedNext: NextFn<T>
const baseNext: NextFn<T> = (data) => {
const resolved =
typeof data === 'function'
? (data as Updater<T>)(
getSubscriptionSnapshot<T>(subscriptionCacheKey),
)
: (data as T)
const baseNext: NextFn<T> = (error, data) => {
if (error !== undefined && error !== null) {
return
}
if (data === undefined) return
const resolved = resolveNextData(data, subscriptionCacheKey)
writeSubscriptionSnapshot(subscriptionCacheKey, resolved)
}
if (throttleMs && throttleMs > 0) {
let pendingData: T | Updater<T> | undefined
let pendingData: T | ((current?: T) => T | undefined) | undefined
let hasPending = false
let timerId: ReturnType<typeof setTimeout> | null = null
const flush = () => {
timerId = null
if (pendingData !== undefined) {
if (hasPending) {
const data = pendingData
pendingData = undefined
baseNext(data)
hasPending = false
baseNext(undefined, data)
}
}
wrappedNext = (data) => {
wrappedNext = (
error?: any,
data?: T | ((current?: T) => T | undefined),
) => {
if (error !== undefined && error !== null) {
baseNext(error, data)
return
}
if (!timerId) {
baseNext(data)
baseNext(undefined, data)
timerId = setTimeout(flush, throttleMs)
} else {
pendingData = data
hasPending = true
}
}
@@ -306,14 +341,15 @@ export const useMihomoWsSubscription = <T>(
}
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps
}, [subscriptionCacheKey, refreshEpoch])
}, [subscriptionCacheKey])
const refresh = useCallback(() => {
if (subscriptionCacheKey) {
writeSubscriptionSnapshot(subscriptionCacheKey, fallbackData)
subscriptionSnapshots.delete(subscriptionCacheKey)
void removeCacheData([subscriptionCacheKey])
}
setRefreshEpoch((epoch) => epoch + 1)
}, [subscriptionCacheKey, fallbackData])
setDate(Date.now())
}, [subscriptionCacheKey, setDate])
const setData = useCallback(
(data: T) => {
+2 -2
View File
@@ -35,8 +35,8 @@ it('applies and records independent group selections while another group is pend
const secondSuccess = vi.fn()
const first = useProxySelection({ onSuccess: firstSuccess })
const second = useProxySelection({ onSuccess: secondSuccess })
first.select('Group A', 'Node A')
second.select('Group B', 'Node B')
first.changeProxy('Group A', 'Node A')
second.changeProxy('Group B', 'Node B')
await vi.waitFor(() =>
expect(record).toHaveBeenCalledWith('Group B', 'Node B'),
)
+28 -4
View File
@@ -35,6 +35,7 @@ const cleanupConnections = async (previousProxy: string) => {
interface ProxySelectionOptions {
onSuccess?: () => void
onError?: (error: any) => void
enableConnectionCleanup?: boolean
}
interface ProxyChangeRequest {
@@ -52,7 +53,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
const pendingRequestRef = useRef<ProxyChangeRequest | null>(null)
const isProcessingRef = useRef(false)
const { onSuccess, onError } = options
const { onSuccess, onError, enableConnectionCleanup = true } = options
const autoCloseConnection = verge?.auto_close_connection ?? false
@@ -97,7 +98,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
)
}
if (autoCloseConnection && previousProxy) {
if (enableConnectionCleanup && autoCloseConnection && previousProxy) {
void cleanupConnections(previousProxy)
}
} catch (error) {
@@ -111,6 +112,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
[
autoCloseConnection,
forgetSelection,
enableConnectionCleanup,
onError,
onSuccess,
recordSelection,
@@ -136,7 +138,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
}
}, [executeChange])
const select = useCallback(
const changeProxy = useCallback(
(
groupName: string,
proxyName: string,
@@ -154,5 +156,27 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
[flushChangeQueue],
)
return { select }
const handleSelectChange = useCallback(
(groupName: string, previousProxy?: string, fixed?: string) =>
(event: { target: { value: string } }) => {
changeProxy(groupName, event.target.value, previousProxy, fixed)
},
[changeProxy],
)
const handleProxyGroupChange = useCallback(
(
group: { name: string; now?: string | null; fixed?: string | null },
proxy: { name: string },
) => {
changeProxy(group.name, proxy.name, group.now!, group.fixed!)
},
[changeProxy],
)
return {
changeProxy,
handleSelectChange,
handleProxyGroupChange,
}
}
+6 -4
View File
@@ -52,8 +52,9 @@ export const useSystemProxyState = () => {
const toggleSystemProxy = async (enabled: boolean) => {
// Roll failed optimistic writes back to the latest confirmed state.
let confirmed = verge?.enable_system_proxy ?? false
mutateVerge((prev) =>
prev ? { ...prev, enable_system_proxy: enabled } : prev,
mutateVerge(
(prev) => (prev ? { ...prev, enable_system_proxy: enabled } : prev),
false,
)
pendingRef.current = enabled
@@ -77,8 +78,9 @@ export const useSystemProxyState = () => {
}
}
} catch (error) {
mutateVerge((prev) =>
prev ? { ...prev, enable_system_proxy: confirmed } : prev,
mutateVerge(
(prev) => (prev ? { ...prev, enable_system_proxy: confirmed } : prev),
false,
)
// Queued requests were based on a state that never landed.
pendingRef.current = null
+1 -2
View File
@@ -41,8 +41,7 @@ export function useAppUptime() {
const { data: uptime = 0 } = useQuery({
queryKey: appUptimeQueryKey,
queryFn: getAppUptime,
// The wrapper maps staleTime to SWR's dedupingInterval, which drops
// refetchInterval ticks while it exceeds the interval.
staleTime: 5000,
refetchInterval: 3000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
+7 -3
View File
@@ -32,13 +32,15 @@ export const useTrafficData = (options?: { enabled?: boolean }) => {
graphData: { appendData },
} = useTrafficMonitorEnhanced({ subscribe: false, enabled })
const { response, refresh } = useMihomoWsSubscription<ITrafficItem>({
subscriptionKey: enabled ? 'getClashTraffic' : null,
storageKey: 'mihomo_traffic_date',
buildSubscriptKey: (date) => (enabled ? `getClashTraffic-${date}` : null),
fallbackData: FALLBACK_TRAFFIC,
connect: () => MihomoWebSocket.connect_traffic(),
throttleMs: 200,
setupHandlers: ({ next, scheduleReconnect }) => ({
handleMessage: (data) => {
if (data.startsWith('Websocket error')) {
next(data, FALLBACK_TRAFFIC)
void scheduleReconnect()
return
}
@@ -49,8 +51,10 @@ export const useTrafficData = (options?: { enabled?: boolean }) => {
return
}
appendData(parsed)
next(parsed)
} catch {}
next(null, parsed)
} catch (error) {
next(error, FALLBACK_TRAFFIC)
}
},
}),
})
+216 -32
View File
@@ -3,6 +3,7 @@ import { Traffic } from 'tauri-plugin-mihomo-api'
import { useVisibility } from '@/hooks/use-visibility'
import { debugLog } from '@/utils/debug'
import { TrafficDataSampler, isSameTrafficData } from '@/utils/traffic-sampler'
// 引用计数管理器
class ReferenceCounter {
@@ -31,11 +32,119 @@ const WORKER_CONFIG = {
defaultRangeMinutes: 10,
}
class InlineTrafficMonitor {
private config = { ...WORKER_CONFIG }
private sampler = new TrafficDataSampler(this.config)
private throttleTimer: ReturnType<typeof setTimeout> | null = null
private agingTimer: ReturnType<typeof setInterval> | null = null
private currentRange = this.config.defaultRangeMinutes
private lastTimestamp: number | undefined
private lastBroadcast: ITrafficDataPoint[] = []
constructor(
private emit: (snapshot: ITrafficWorkerSnapshotMessage) => void,
) {}
start(rangeMinutes?: number) {
this.currentRange = rangeMinutes ?? this.currentRange
this.handle({
type: 'init',
config: {
...this.config,
defaultRangeMinutes: this.currentRange,
},
})
}
stop() {
if (this.throttleTimer !== null) {
clearTimeout(this.throttleTimer)
this.throttleTimer = null
}
if (this.agingTimer !== null) {
clearInterval(this.agingTimer)
this.agingTimer = null
}
this.sampler.clear()
this.lastTimestamp = undefined
}
handle(message: TrafficWorkerRequestMessage) {
switch (message.type) {
case 'init': {
this.config = { ...message.config }
this.sampler = new TrafficDataSampler(this.config)
this.currentRange = message.config.defaultRangeMinutes
// Re-broadcast when points age out of the range, not on every tick
this.agingTimer = setInterval(() => {
if (this.throttleTimer !== null) return
const slice = this.sampler.getDataForTimeRange(this.currentRange)
if (!isSameTrafficData(this.lastBroadcast, slice)) {
this.emitSnapshot()
}
}, 1000)
this.emitSnapshot()
break
}
case 'append': {
const timestamp = message.payload.timestamp ?? Date.now()
const dataPoint: ITrafficDataPoint = {
up: message.payload.up || 0,
down: message.payload.down || 0,
timestamp,
}
this.lastTimestamp = timestamp
this.sampler.addDataPoint(dataPoint)
this.scheduleSnapshot()
break
}
case 'setRange': {
if (this.currentRange !== message.minutes) {
this.currentRange = message.minutes
this.emitSnapshot()
}
break
}
case 'requestSnapshot': {
this.emitSnapshot()
break
}
default:
break
}
}
private emitSnapshot() {
const dataPoints = this.sampler.getDataForTimeRange(this.currentRange)
this.emit({
type: 'snapshot',
dataPoints,
samplerStats: this.sampler.getStats(),
lastTimestamp: this.lastTimestamp,
})
this.lastBroadcast = dataPoints
}
private scheduleSnapshot() {
if (this.throttleTimer !== null) return
this.throttleTimer = setTimeout(() => {
this.throttleTimer = null
this.emitSnapshot()
}, this.config.snapshotIntervalMs)
}
}
class TrafficWorkerClient {
private worker: Worker | null = null
private inlineMonitor: InlineTrafficMonitor | null = null
private mode: 'worker' | 'inline' | null = null
private listeners = new Set<
(snapshot: ITrafficWorkerSnapshotMessage) => void
>()
private pendingMessages: TrafficWorkerRequestMessage[] = []
private ready = false
private currentRange = WORKER_CONFIG.defaultRangeMinutes
start(rangeMinutes?: number) {
@@ -46,52 +155,101 @@ class TrafficWorkerClient {
this.currentRange = rangeMinutes ?? this.currentRange
if (this.worker) return
if (this.ready) return
const initMessage: TrafficWorkerRequestMessage = {
type: 'init',
config: { ...WORKER_CONFIG, defaultRangeMinutes: this.currentRange },
config: {
rawDataMinutes: WORKER_CONFIG.rawDataMinutes,
compressedDataMinutes: WORKER_CONFIG.compressedDataMinutes,
compressionRatio: WORKER_CONFIG.compressionRatio,
snapshotIntervalMs: WORKER_CONFIG.snapshotIntervalMs,
defaultRangeMinutes: this.currentRange,
},
}
this.worker = new Worker(
new URL('../services/traffic-monitor-worker.ts', import.meta.url),
{ type: 'module' },
)
if (typeof Worker !== 'undefined') {
try {
this.worker = new Worker(
new URL('../services/traffic-monitor-worker.ts', import.meta.url),
{ type: 'module' },
)
this.mode = 'worker'
this.worker.onmessage = (
event: MessageEvent<TrafficWorkerResponseMessage>,
) => {
const message = event.data
if (message.type === 'snapshot') {
if (import.meta.env.MODE === 'perf') {
document.documentElement.dataset.perfWorker = String(
message.lastTimestamp ?? 0,
)
document.documentElement.dataset.perfPoints = String(
message.dataPoints.length,
)
const last = message.dataPoints.at(-1)
document.documentElement.dataset.perfLastUp = String(last?.up ?? 0)
document.documentElement.dataset.perfLastDown = String(
last?.down ?? 0,
)
this.worker.onmessage = (
event: MessageEvent<TrafficWorkerResponseMessage>,
) => {
const message = event.data
if (message.type === 'snapshot') {
if (import.meta.env.MODE === 'perf') {
document.documentElement.dataset.perfWorker = String(
message.lastTimestamp ?? 0,
)
document.documentElement.dataset.perfPoints = String(
message.dataPoints.length,
)
const last = message.dataPoints.at(-1)
document.documentElement.dataset.perfLastUp = String(
last?.up ?? 0,
)
document.documentElement.dataset.perfLastDown = String(
last?.down ?? 0,
)
}
this.listeners.forEach((listener) => {
listener(message)
})
}
}
this.listeners.forEach((listener) => {
listener(message)
})
this.worker.onerror = (error) => {
debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`)
}
this.ready = true
this.post(initMessage)
this.flushQueue()
return
} catch (error) {
debugLog(
`[TrafficWorkerClient] Worker initialization failed, falling back to inline sampler: ${String(error)}`,
)
this.worker = null
this.mode = null
}
} else {
debugLog(
'[TrafficWorkerClient] Worker not supported, using inline sampler',
)
}
this.worker.onerror = (error) => {
debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`)
}
this.startInline(initMessage)
}
private startInline(initMessage: TrafficWorkerRequestMessage) {
this.inlineMonitor = new InlineTrafficMonitor((snapshot) => {
this.listeners.forEach((listener) => {
listener(snapshot)
})
})
this.mode = 'inline'
this.ready = true
this.post(initMessage)
this.flushQueue()
}
stop() {
this.worker?.terminate()
if (this.worker) {
this.worker.terminate()
}
if (this.inlineMonitor) {
this.inlineMonitor.stop()
}
this.worker = null
this.inlineMonitor = null
this.mode = null
this.ready = false
this.pendingMessages = []
}
onSnapshot(listener: (snapshot: ITrafficWorkerSnapshotMessage) => void) {
@@ -102,11 +260,37 @@ class TrafficWorkerClient {
}
private post(message: TrafficWorkerRequestMessage) {
this.worker?.postMessage(message)
if (!this.ready) {
this.pendingMessages.push(message)
return
}
if (this.mode === 'worker' && this.worker) {
this.worker.postMessage(message)
return
}
if (this.mode === 'inline' && this.inlineMonitor) {
this.inlineMonitor.handle(message)
return
}
this.pendingMessages.push(message)
}
private flushQueue() {
if (!this.ready || this.pendingMessages.length === 0) {
return
}
const queued = [...this.pendingMessages]
this.pendingMessages = []
queued.forEach((message) => {
this.post(message)
})
}
private ensureStarted() {
if (!this.worker) {
if (!this.ready) {
this.start(this.currentRange)
}
}
+1
View File
@@ -25,6 +25,7 @@ export const useVerge = () => {
| IVergeConfig
| ((prev: IVergeConfig | undefined) => IVergeConfig | undefined)
| undefined,
_revalidate?: boolean,
) => {
if (updaterOrData === undefined) {
void refetch()
+18 -6
View File
@@ -11,24 +11,36 @@ const useWindow = () => {
}
export const useWindowControls = () => {
const { maximized, minimize, toggleMaximize, close, currentWindow } =
useWindow()
const {
maximized,
minimize,
toggleMaximize,
close,
toggleFullscreen,
currentWindow,
} = useWindow()
return {
maximized,
minimize,
toggleMaximize,
close,
toggleFullscreen,
currentWindow,
} satisfies Pick<
WindowContextType,
'maximized' | 'minimize' | 'toggleMaximize' | 'close' | 'currentWindow'
| 'maximized'
| 'minimize'
| 'toggleMaximize'
| 'close'
| 'toggleFullscreen'
| 'currentWindow'
>
}
export const useWindowDecorations = () => {
const { decorated, toggleDecorations } = useWindow()
return { decorated, toggleDecorations } satisfies Pick<
const { decorated, toggleDecorations, refreshDecorated } = useWindow()
return { decorated, toggleDecorations, refreshDecorated } satisfies Pick<
WindowContextType,
'decorated' | 'toggleDecorations'
'decorated' | 'toggleDecorations' | 'refreshDecorated'
>
}
+21 -4
View File
@@ -1,7 +1,8 @@
import { Paper, ThemeProvider } from '@mui/material'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Outlet, useNavigate } from 'react-router'
import { BaseErrorBoundary } from '@/components/base'
@@ -37,6 +38,7 @@ const OS = getSystem()
const Layout = () => {
const mode = useThemeMode()
const isDark = mode !== 'light'
const { t } = useTranslation()
const { theme } = useCustomTheme()
const { verge } = useVerge()
const { language } = verge ?? {}
@@ -44,6 +46,7 @@ const Layout = () => {
const { switchLanguage } = useI18n()
const navigate = useNavigate()
const themeReady = useMemo(() => Boolean(theme), [theme])
const windowControlsRef = useRef<any>(null)
const { decorated } = useWindowDecorations()
const customTitlebar = useMemo(
@@ -54,7 +57,7 @@ const Layout = () => {
className="the_titlebar-drag-region"
data-tauri-drag-region="true"
/>
<WindowControls />
<WindowControls ref={windowControlsRef} />
</div>
) : null,
[decorated],
@@ -66,12 +69,12 @@ const Layout = () => {
(payload: [string, string]) => {
const [status, msg] = payload
try {
handleNoticeMessage(status, msg, navigate)
handleNoticeMessage(status, msg, t, navigate)
} catch (error) {
console.error('[通知处理] 失败:', error)
}
},
[navigate],
[t, navigate],
)
useEventBus(handleNotice)
@@ -107,6 +110,20 @@ const Layout = () => {
<NoticeManager position={verge?.notice_position} />
<ServiceMigrationDialog />
<SysproxyPrivilegeDialog />
<div
style={{
animation: 'fadeIn 0.5s',
WebkitAnimation: 'fadeIn 0.5s',
}}
/>
<style>
{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`}
</style>
<Paper
square
elevation={0}
+4 -2
View File
@@ -83,6 +83,7 @@ const ProfilePage = () => {
const { t } = useTranslation()
const location = useLocation()
const [url, setUrl] = useState('')
const [disabled, setDisabled] = useState(false)
const [profileDndRevision, setProfileDndRevision] = useState(0)
const [activatings, setActivatings] = useState<string[]>([])
const [switchTarget, setSwitchTarget] = useState<string | null>(null)
@@ -256,6 +257,7 @@ const ProfilePage = () => {
)
}
} finally {
setDisabled(false)
setLoading(false)
}
}
@@ -822,7 +824,7 @@ const ProfilePage = () => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
return
}
if (!url || loading) {
if (!url || disabled || loading) {
return
}
event.preventDefault()
@@ -855,7 +857,7 @@ const ProfilePage = () => {
}}
/>
<Button
disabled={!url}
disabled={!url || disabled}
loading={loading}
variant="contained"
size="small"
+4 -1
View File
@@ -24,8 +24,11 @@ const RulesPage = () => {
const [showScrollTop, setShowScrollTop] = useState(false)
const pageVisible = useVisibility()
// 页面可见时刷新规则数据:挂载时可见或从隐藏恢复可见各触发一次
// 在组件挂载时和页面获得焦点时刷新规则数据
useEffect(() => {
refreshRules()
refreshRuleProviders()
if (pageVisible) {
refreshRules()
refreshRuleProviders()
+1 -3
View File
@@ -26,9 +26,7 @@ const TQ_MIHOMO = {
refetchOnReconnect: false,
staleTime: 1500,
retry: 3,
// SWR's default retry waits about 2^retryCount * errorRetryInterval with
// jitter, so 100 reproduces the previous 200 * 2^attempt backoff.
retryDelay: 100,
retryDelay: (attempt: number) => Math.min(200 * 2 ** attempt, 3000),
} as const
const TQ_DEFAULTS = {
+2
View File
@@ -5,9 +5,11 @@ export interface WindowContextType {
decorated: boolean | null
maximized: boolean | null
toggleDecorations: () => Promise<void>
refreshDecorated: () => Promise<boolean>
minimize: () => Promise<void>
close: () => Promise<void>
toggleMaximize: () => Promise<void>
toggleFullscreen: () => Promise<void>
currentWindow: ReturnType<typeof getCurrentWindow>
}
+8
View File
@@ -61,6 +61,10 @@ export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({
}
}, [currentWindow])
const toggleFullscreen = useCallback(async () => {
await currentWindow.setFullscreen(!(await currentWindow.isFullscreen()))
}, [currentWindow])
const refreshDecorated = useCallback(async () => {
const val = await currentWindow.isDecorated()
setDecorated(val)
@@ -83,18 +87,22 @@ export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({
decorated,
maximized,
toggleDecorations,
refreshDecorated,
minimize,
close,
toggleMaximize,
toggleFullscreen,
currentWindow,
}),
[
decorated,
maximized,
toggleDecorations,
refreshDecorated,
minimize,
close,
toggleMaximize,
toggleFullscreen,
currentWindow,
],
)
+37 -38
View File
@@ -35,6 +35,7 @@ interface IpInfo {
interface ServiceConfig {
url: string
mapping: (data: any) => IpInfo
timeout?: number // 保留timeout字段(如有需要)
}
// 可用的IP检测服务列表及字段映射
@@ -155,52 +156,48 @@ export const getIpInfo = async (): Promise<
for (const service of shuffledServices) {
debugLog(`尝试IP检测服务: ${service.url}`)
const timeoutController = new AbortController()
const timeoutId = setTimeout(() => {
timeoutController.abort()
}, service.timeout || serviceTimeout)
try {
return await asyncRetry(
async (bail) => {
console.debug('Fetching IP information:', service.url)
const timeoutController = new AbortController()
const timeoutId = setTimeout(() => {
timeoutController.abort()
}, serviceTimeout)
const response = await fetch(service.url, {
method: 'GET',
signal: timeoutController.signal, // AbortSignal.timeout(service.timeout || serviceTimeout),
connectTimeout: service.timeout || serviceTimeout,
headers: {
'User-Agent': userAgent,
},
})
if (!response.ok) {
return bail(
new Error(
`IP 检测服务出错,状态码: ${response.status} from ${service.url}`,
),
)
}
let data: any
try {
const response = await fetch(service.url, {
method: 'GET',
signal: timeoutController.signal,
connectTimeout: serviceTimeout,
headers: {
'User-Agent': userAgent,
},
data = await response.json()
} catch {
return bail(new Error(`无法解析 JSON 响应 from ${service.url}`))
}
if (data && data.ip) {
debugLog(`IP检测成功,使用服务: ${service.url}`)
return Object.assign(service.mapping(data), {
// use last fetch success timestamp
lastFetchTs: Date.now(),
})
if (!response.ok) {
return bail(
new Error(
`IP 检测服务出错,状态码: ${response.status} from ${service.url}`,
),
)
}
let data: any
try {
data = await response.json()
} catch {
return bail(new Error(`无法解析 JSON 响应 from ${service.url}`))
}
if (data && data.ip) {
debugLog(`IP检测成功,使用服务: ${service.url}`)
return Object.assign(service.mapping(data), {
// use last fetch success timestamp
lastFetchTs: Date.now(),
})
} else {
return bail(new Error(`无效的响应格式 from ${service.url}`))
}
} finally {
clearTimeout(timeoutId)
} else {
return bail(new Error(`无效的响应格式 from ${service.url}`))
}
},
{
@@ -213,6 +210,8 @@ export const getIpInfo = async (): Promise<
} catch (error) {
debugLog(`IP检测服务失败: ${service.url}`, error)
lastError = error
} finally {
clearTimeout(timeoutId)
}
}
+2 -2
View File
@@ -365,7 +365,7 @@ export type IVerge_Deserialize = {
start_page?: string | null
startup_script?: string | null
traffic_graph?: boolean | null
/** show memory info (only for Clash Meta) */
/** show memory info (only for Mihomo) */
enable_memory_usage?: boolean | null
enable_group_icon?: boolean | null
pause_render_traffic_stats_on_blur?: boolean | null
@@ -455,7 +455,7 @@ export type IVerge_Serialize = {
start_page: string | null
startup_script: string | null
traffic_graph: boolean | null
/** show memory info (only for Clash Meta) */
/** show memory info (only for Mihomo) */
enable_memory_usage: boolean | null
enable_group_icon?: boolean | null
pause_render_traffic_stats_on_blur: boolean | null
+3 -3
View File
@@ -51,7 +51,7 @@ const mountBus = async () => {
return createEventBus({
dispatch,
handleNotice: ([status, message]: [string, string]) => {
notice(status, message, vi.fn())
notice(status, message, (key) => key, vi.fn())
},
revalidateKeys: (keys) => void revalidateQueries(keys.map((key) => [key])),
readPendingFailures: () => {},
@@ -72,7 +72,7 @@ it('does not replay a recovered startup error after WebView recreation', async (
kind: 'startFailed',
detail: 'startup failed',
})
first.handleNoticeMessage('core_start::error', '', vi.fn())
first.handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
await flush()
expect(showNotice.error).toHaveBeenCalledOnce()
@@ -81,7 +81,7 @@ it('does not replay a recovered startup error after WebView recreation', async (
vi.mocked(getCoreStartupError).mockResolvedValue(null)
vi.resetModules()
const recreated = await import('@/services/notice-handlers')
recreated.handleNoticeMessage('core_start::error', '', vi.fn())
recreated.handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
await flush()
expect(showNotice.error).toHaveBeenCalledOnce()
})
+4 -2
View File
@@ -277,8 +277,6 @@ class DelayManager {
group: string,
timeout: number,
): Promise<DelayUpdate> {
// Entry points own the pending mark; measureDelay only writes results.
this.setDelay(member.ref.name, group, -2)
const update = await this.measureDelay(member, group, timeout)
this.queueGroupNotification(group)
return update
@@ -300,6 +298,8 @@ class DelayManager {
`[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`,
)
this.setDelay(name, group, -2)
const startTime = Date.now()
try {
@@ -359,6 +359,8 @@ class DelayManager {
const currName = currMember.ref.name
try {
this.setDelay(currName, group, -2)
// Stagger requests so a batch does not hit the core at once.
if (index > 1) {
await new Promise((resolve) =>
+2
View File
@@ -13,6 +13,7 @@ import { hideNotice, showNotice } from '@/services/notice-service'
import { requestService } from '@/services/service-request'
type NavigateFunction = (path: string, options?: any) => void
type TranslateFunction = (key: string) => string
let shownStartupError: string | null = null
// Reads can settle out of order; only the newest may act, and a reset counts as one.
@@ -240,6 +241,7 @@ const handlers: Record<
export const handleNoticeMessage = (
status: string,
msg: string,
t: TranslateFunction,
navigate: NavigateFunction,
) => {
const handler = handlers[status as NoticeStatus]
+9
View File
@@ -148,6 +148,15 @@ function parseNoticeExtras(extras: NoticeExtra[]): ParsedNoticeExtras {
raw = extra
continue
}
if (!params && isPlainRecord(extra)) {
params = extra
continue
}
if (duration === undefined && typeof extra === 'number') {
duration = extra
}
}
return {
+18 -2
View File
@@ -18,7 +18,7 @@ type QueryOptions<T> = {
placeholderData?: T | (() => T | undefined)
staleTime?: number
retry?: number | false
retryDelay?: number
retryDelay?: number | ((attempt: number) => number)
refetchInterval?: number | false
refetchIntervalInBackground?: boolean
revalidateOnMount?: boolean
@@ -90,9 +90,25 @@ export function useQuery<T>(options: QueryOptions<T>): QueryResult<T> {
const swr = useSWR<T>(enabled ? queryKey : null, queryFn, {
dedupingInterval: staleTime,
errorRetryCount: retry === false ? 0 : retry,
errorRetryInterval: retryDelay ?? swrConfig.errorRetryInterval,
errorRetryInterval:
typeof retryDelay === 'number'
? retryDelay
: swrConfig.errorRetryInterval,
fallbackData,
keepPreviousData: placeholderData !== undefined,
onErrorRetry: (_error, _key, config, revalidate, { retryCount }) => {
const maxRetries = config.errorRetryCount
if (maxRetries !== undefined && retryCount > maxRetries) return
const interval =
typeof retryDelay === 'function'
? retryDelay(Math.max(retryCount - 1, 0))
: config.errorRetryInterval
setTimeout(() => {
revalidate({ retryCount, dedupe: true })
}, interval)
},
revalidateOnFocus: refetchOnWindowFocus,
revalidateOnMount,
revalidateOnReconnect: refetchOnReconnect,
+24
View File
@@ -105,6 +105,30 @@ type IProxyGroupItem = Omit<IProxyItem, 'all'> & {
all: IProxyItem[]
}
interface IProxyProviderItem {
name: string
type: string
proxies: IProxyItem[]
updatedAt: string
vehicleType: string
subscriptionInfo?: {
Upload: number
Download: number
Total: number
Expire: number
}
}
interface IRuleProviderItem {
name: string
behavior: string
format: string
ruleCount: number
type: string
updatedAt: string
vehicleType: string
}
interface ITrafficItem {
up: number
down: number