mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-04 22:52:23 +08:00
* refactor(utils): drop dead reset helper and tidy init_resources
- remove resolve_reset_async, which had no callers
- drop the duplicated time.*.com entry from the default fake-ip-filter
- init_resources no longer recreates app_home (ensure_directories during
init_config already created it) and now propagates res_dir creation errors
* refactor(profile): remove identical blind retry when switching node
The second select_node_for_group call in switch_proxy_node was a
zero-interval retry with identical arguments. Its success path was a
degraded copy of the first (missing the Refresh::Proxies announcement
ever since the old force-refresh emit was removed), and its distinct
"回退/最终失败" log wording misled maintainers. Keep a single call,
the primary success aftermath, and one error log.
* refactor(enhance): simplify tun mode check and lowercase helpers
- Drop the always-true second clause in use_tun's enhanced-mode check:
the key is absent only when the unwrap_or default already makes
current_mode == "fake-ip" true.
- Collapse use_lowercase into a delegation to use_lowercase_owned.
* refactor(core): drop unreachable hotkey dtor and dedupe tray predicate
Remove impl Drop for Hotkey: the instance lives in a OnceLock static via
the singleton! macro and is never dropped; exit-time unregistration is
already handled by Hotkey::reset().
Spell the include_proxy_groups/display-mode predicate identically at
both evaluation sites in tray menu building; behavior is unchanged
(Option != Some("disable") is equivalent to unwrap_or("default") != "disable").
* refactor(config): fold port guards into single helper
The five IClashTemp port guards duplicated the same read/parse/default
logic with drifting defaults: some hardcoded 7895/7898/7899 while others
referenced network::ports constants. Route them all through one
guard_port_value helper and use the constants everywhere, keeping each
guard's name, key and cfg attributes.
* refactor(config): dedupe prfitem factories and companion seeding
from_rules/from_proxies/from_groups differed only in uid prefix, type
string and template, so they now delegate to a shared from_seq_item.
The five current_* accessors collapse into a local macro. The identical
five-block companion seeding in from_local and from_url moves into
ensure_companion_items, preserving seeding order and error propagation.
* refactor(config): share patch macro and profiles helpers
IVerge::patch_config, IProfiles::patch_item and PrfOption::merge each
hand-rolled the same copy-Some-overwrite pattern; they now share one
patch_opt_field macro (the or-else merge form is field-for-field
equivalent for Option fields). The four profiles *_safe wrappers that
inlined Config::profiles().with_data_modify boilerplate delegate to a
with_profiles_edit helper, and record/forget_selected_node share the
current-item lookup prelude via current_item_mut.
* refactor(enhance): merge chain processing and drop unused builtin js payload
- collapse the identical process_global_items/process_profile_items into
one parameterized process_override_items (entry uid sources unchanged)
- fold the identical rules/proxies/groups arms of ChainItem::from_async
into a single arm with an inner dispatch
- stop embedding builtin JS payloads never executed by any path: the
enhance loop dispatches builtin items by uid to native implementations,
so ChainItem::builtin now carries empty script data (uids unchanged)
- source ProfileItems fallback literals from Default instead of a second
hand-written copy in collect_profile_items
* refactor(net): reuse webpki tls config and fallback
Extract build_webpki_rooted_tls_config so the delay-test TLS connector
and the reqwest clients share one static-webpki-roots builder; ALPN
stays caller-specific (only reqwest offers h2).
Collapse the duplicated platform-verifier -> static-webpki-roots retry
in get/get_bytes into request_with_static_webpki_fallback, gated by the
caller's certificate-verification setting.
* refactor(app): extract common exit cleanup
restart_app and quit duplicate the whole exit preparation: set the
exiting flag, persist configs, run interactive cleanup, and on a failed
core stop clear the flag, restore public DNS under the lifecycle lock
on macOS, and send the stop-failed notice.
Move that flow into run_exit_cleanup_flow next to clean_async, taking
the notice variant as a parameter and returning the CleanupResult so
each caller keeps its own tail: restart aborts before shutdown, quit
always shuts down and exits with the cleanup code.
* refactor(backup): share restore finalize path
restore_webdav_backup and restore_local_backup duplicated the
credential extraction (backup creation strips the WebDAV credentials,
so both re-inject the live ones), the archive extraction and the
finalize tail. Move that tail into a private restore_backup_archive
helper so the two entries keep only their download/locate logic; the
config-write lock scope and the off-runtime extraction land in one
place. The webdav entry keeps its downloaded-file cleanup.
* refactor(sysopt): drop redundant lock and busy-wait
The proxy target pair sat behind an Arc<RwLock> with no readers and an
Arc that was never cloned; both writers already serialize on
update_lock, so store the pair inside that mutex and delete the RwLock.
refresh_guard waited for the guard to become ready with a yield_now spin
at full CPU while holding guard_operation_lock; a 10 ms sleep keeps the
same wait condition and lock scope without the spin.
* refactor(sysopt): share macos bypass formatting
The macOS default bypass list and its three-way composition (empty ->
default, default + custom, custom only) were maintained twice: in
sysopt's format_bypass and inline in proxy_control's service_bypass.
Keep the literal once in constants and compose both paths through
format_bypass; service_bypass keeps its NUL check and UTF-8 truncation.
* refactor(core): dedupe window listeners, elevated runners, backup settings
- Collapse the close/focus window listener pairs in lightweight.rs onto a shared WindowListener with per-listener event and log label.
- Extract the windows runas-or-direct execution into service::run_maybe_elevated, shared by win_uwp and the service uninstaller, and factor the common uninstaller path resolution and status check shared across platforms.
- Make the auto-backup watch channel the single settings source, replacing the duplicate Arc<RwLock> copy with a stored receiver.
* refactor(enhance): fold lowercase helpers into single owned function
use_lowercase_owned disappears; the surviving use_lowercase takes the
owned Mapping and borrowed callers clone at the call site.
* refactor(enhance): rename ChainSupport variants to Mihomo names
Clash is a legacy name; the variants match the verge-mihomo and
verge-mihomo-alpha cores they gate.
* refactor(enhance): lowercase takes a reference and chain support is copy
use_lowercase borrows the mapping and clones only the values; call
sites no longer clone whole configs. ChainSupport is a fieldless enum
and now Copy; builtin() registers each support pair without clones.
* refactor(config): name prfitem constructor for what it builds
new_seq_item states that a fresh item is built from a template; the
from_ prefix wrongly implied an existing source. append_companion_item
returns the uid directly: append_item bails on a missing uid, so the
Option was always Some on the Ok path.
* refactor(sysopt): name the proxy state lock for its full role
The lock now owns the (Sysproxy, Autoproxy) state, so proxy_state_lock
says what it guards; the guard locals follow. constants::proxy held a
single sysproxy constant and is renamed sysproxy.
* refactor: replace remaining clash meta naming with mihomo
Doc comment, generated bindings and the prebuild sidecar helpers stop
referring to the legacy Clash Meta name; installer legacy-process
cleanup keeps the old executable names on purpose.
* refactor(enhance): drop builtin core filtering that never discriminates
Both valid cores matched a different ChainSupport variant, yet each
variant registered the same uid pair, so the filtered builtin list was
identical for stable and alpha. Invalid cores are coerced at startup
(validate_and_fix_config / get_valid_clash_core) and the None arm of
is_support was unreachable. builtin() now returns the items directly
and the clash_core threading through ConfigValues goes away; re-add
core discrimination when a builtin actually differs per core.
* refactor(enhance): drop unreachable mode script guard
merge_default_config overwrites the profile's mode with the verge-owned
clash config, whose mode is always present (template seeds rule and
patch semantics never clear it) and restricted to rule/global/direct;
change_clash_mode is rejected by the core before persisting anything
else. mode:script can therefore only arrive through a hand-crafted
patch_clash call, and for that path an explicit invalid-mode validation
error is more honest than silently rewriting to rule. mihomo rejects
mode:script outright (invalid mode), and user merge/script chains run
after the builtins, so the guard never covered them anyway.
* refactor(enhance): call the hysteria alpn builtin directly
builtin() existed to feed the uid-dispatch loop; with a single
builtin the vec and match are ceremony. apply_builtin_scripts calls
builtin_hy_alpn directly and to_script goes away; tests construct
ChainItem literals the way the merge items already did.
307 lines
8.5 KiB
Rust
307 lines
8.5 KiB
Rust
use crate::{
|
|
config::{Config, IVerge},
|
|
feat::create_local_backup_with_namer,
|
|
process::AsyncHandler,
|
|
utils::dirs::local_backup_dir,
|
|
};
|
|
use anyhow::Result;
|
|
use chrono::Local;
|
|
use clash_verge_logging::{Type, logging};
|
|
use std::{
|
|
path::PathBuf,
|
|
sync::{
|
|
OnceLock,
|
|
atomic::{AtomicBool, AtomicI64, Ordering},
|
|
},
|
|
time::{Duration, UNIX_EPOCH},
|
|
};
|
|
use tokio::{
|
|
fs,
|
|
sync::{Mutex, watch},
|
|
};
|
|
|
|
const DEFAULT_INTERVAL_HOURS: u64 = 24;
|
|
const MIN_INTERVAL_HOURS: u64 = 1;
|
|
const MAX_INTERVAL_HOURS: u64 = 168;
|
|
const MIN_BACKUP_INTERVAL_SECS: i64 = 60;
|
|
const AUTO_BACKUP_KEEP: usize = 20;
|
|
const AUTO_MARKER: &str = "-auto-";
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum AutoBackupTrigger {
|
|
Scheduled,
|
|
GlobalMerge,
|
|
GlobalScript,
|
|
}
|
|
|
|
impl AutoBackupTrigger {
|
|
const fn slug(self) -> &'static str {
|
|
match self {
|
|
Self::Scheduled => "scheduled",
|
|
Self::GlobalMerge => "merge",
|
|
Self::GlobalScript => "script",
|
|
}
|
|
}
|
|
|
|
const fn is_schedule(self) -> bool {
|
|
matches!(self, Self::Scheduled)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct AutoBackupSettings {
|
|
schedule_enabled: bool,
|
|
interval_hours: u64,
|
|
change_enabled: bool,
|
|
}
|
|
|
|
impl AutoBackupSettings {
|
|
fn from_verge(verge: &IVerge) -> Self {
|
|
let interval = verge
|
|
.auto_backup_interval_hours
|
|
.unwrap_or(DEFAULT_INTERVAL_HOURS)
|
|
.clamp(MIN_INTERVAL_HOURS, MAX_INTERVAL_HOURS);
|
|
|
|
Self {
|
|
schedule_enabled: verge.enable_auto_backup_schedule.unwrap_or(false),
|
|
interval_hours: interval,
|
|
change_enabled: verge.auto_backup_on_change.unwrap_or(true),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for AutoBackupSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
schedule_enabled: false,
|
|
interval_hours: DEFAULT_INTERVAL_HOURS,
|
|
change_enabled: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct AutoBackupManager {
|
|
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,
|
|
}
|
|
|
|
impl AutoBackupManager {
|
|
pub fn global() -> &'static Self {
|
|
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
|
|
INSTANCE.get_or_init(|| {
|
|
let (settings_tx, settings_rx) = watch::channel(AutoBackupSettings::default());
|
|
Self {
|
|
settings_tx,
|
|
settings_rx,
|
|
runner_started: AtomicBool::new(false),
|
|
exec_lock: Mutex::new(()),
|
|
last_backup: AtomicI64::new(0),
|
|
}
|
|
})
|
|
}
|
|
|
|
pub async fn init(&self) -> Result<()> {
|
|
let settings = Self::load_settings().await;
|
|
let _ = self.settings_tx.send_replace(settings);
|
|
self.maybe_start_runner(settings);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn refresh_settings(&self) -> Result<()> {
|
|
let settings = Self::load_settings().await;
|
|
let _ = self.settings_tx.send_replace(settings);
|
|
self.maybe_start_runner(settings);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn trigger_backup(trigger: AutoBackupTrigger) {
|
|
AsyncHandler::spawn(move || async move {
|
|
if let Err(err) = Self::global().execute_trigger(trigger).await {
|
|
logging!(
|
|
warn,
|
|
Type::Backup,
|
|
"Auto backup execution failed ({:?}): {err:#?}",
|
|
trigger
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn maybe_start_runner(&self, settings: AutoBackupSettings) {
|
|
if settings.schedule_enabled {
|
|
self.ensure_runner();
|
|
}
|
|
}
|
|
|
|
fn ensure_runner(&self) {
|
|
if self.runner_started.swap(true, Ordering::SeqCst) {
|
|
return;
|
|
}
|
|
|
|
let mut rx = self.settings_tx.subscribe();
|
|
AsyncHandler::spawn(move || async move {
|
|
Self::run_scheduler(&mut rx).await;
|
|
});
|
|
}
|
|
|
|
async fn run_scheduler(rx: &mut watch::Receiver<AutoBackupSettings>) {
|
|
let mut current = *rx.borrow();
|
|
loop {
|
|
if !current.schedule_enabled {
|
|
if rx.changed().await.is_err() {
|
|
break;
|
|
}
|
|
current = *rx.borrow();
|
|
continue;
|
|
}
|
|
|
|
let duration = Duration::from_secs(current.interval_hours.saturating_mul(3600));
|
|
let sleeper = tokio::time::sleep(duration);
|
|
tokio::pin!(sleeper);
|
|
|
|
tokio::select! {
|
|
_ = &mut sleeper => {
|
|
if let Err(err) = Self::global()
|
|
.execute_trigger(AutoBackupTrigger::Scheduled)
|
|
.await
|
|
{
|
|
logging!(
|
|
warn,
|
|
Type::Backup,
|
|
"Scheduled auto backup failed: {err:#?}"
|
|
);
|
|
}
|
|
}
|
|
changed = rx.changed() => {
|
|
if changed.is_err() {
|
|
break;
|
|
}
|
|
current = *rx.borrow();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> {
|
|
let snapshot = *self.settings_rx.borrow();
|
|
|
|
if trigger.is_schedule() && !snapshot.schedule_enabled {
|
|
return Ok(());
|
|
}
|
|
if !trigger.is_schedule() && !snapshot.change_enabled {
|
|
return Ok(());
|
|
}
|
|
|
|
if !self.should_run_now() {
|
|
return Ok(());
|
|
}
|
|
|
|
let _guard = self.exec_lock.lock().await;
|
|
if !self.should_run_now() {
|
|
return Ok(());
|
|
}
|
|
|
|
let file_name = create_local_backup_with_namer(|name| append_auto_suffix(name, trigger.slug()).into()).await?;
|
|
self.last_backup.store(Local::now().timestamp(), Ordering::Release);
|
|
|
|
if let Err(err) = cleanup_auto_backups().await {
|
|
logging!(warn, Type::Backup, "Failed to cleanup old auto backups: {err:#?}");
|
|
}
|
|
|
|
logging!(info, Type::Backup, "Auto backup created ({:?}): {}", trigger, file_name);
|
|
Ok(())
|
|
}
|
|
|
|
fn should_run_now(&self) -> bool {
|
|
let last = self.last_backup.load(Ordering::Acquire);
|
|
if last == 0 {
|
|
return true;
|
|
}
|
|
let now = Local::now().timestamp();
|
|
now.saturating_sub(last) >= MIN_BACKUP_INTERVAL_SECS
|
|
}
|
|
|
|
async fn load_settings() -> AutoBackupSettings {
|
|
let verge = Config::verge().await;
|
|
AutoBackupSettings::from_verge(&verge.latest_arc())
|
|
}
|
|
}
|
|
|
|
fn append_auto_suffix(file_name: &str, slug: &str) -> String {
|
|
match file_name.rsplit_once('.') {
|
|
Some((stem, ext)) => format!("{stem}{AUTO_MARKER}{slug}.{ext}"),
|
|
None => format!("{file_name}{AUTO_MARKER}{slug}"),
|
|
}
|
|
}
|
|
|
|
async fn cleanup_auto_backups() -> Result<()> {
|
|
if AUTO_BACKUP_KEEP == 0 {
|
|
return Ok(());
|
|
}
|
|
|
|
let backup_dir = local_backup_dir()?;
|
|
if !backup_dir.exists() {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut entries = match fs::read_dir(&backup_dir).await {
|
|
Ok(dir) => dir,
|
|
Err(err) => {
|
|
logging!(warn, Type::Backup, "Failed to read backup directory: {err:#?}");
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let mut files: Vec<(PathBuf, u64)> = Vec::new();
|
|
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let path = entry.path();
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
|
|
let file_name = match entry.file_name().into_string() {
|
|
Ok(name) => name,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
if !file_name.contains(AUTO_MARKER) {
|
|
continue;
|
|
}
|
|
|
|
let modified = entry
|
|
.metadata()
|
|
.await
|
|
.and_then(|meta| meta.modified())
|
|
.ok()
|
|
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
|
.map(|dur| dur.as_secs())
|
|
.unwrap_or(0);
|
|
|
|
files.push((path, modified));
|
|
}
|
|
|
|
if files.len() <= AUTO_BACKUP_KEEP {
|
|
return Ok(());
|
|
}
|
|
|
|
files.sort_by_key(|(_, ts)| *ts);
|
|
let remove_count = files.len() - AUTO_BACKUP_KEEP;
|
|
for (path, _) in files.into_iter().take(remove_count) {
|
|
if let Err(err) = fs::remove_file(&path).await {
|
|
logging!(
|
|
warn,
|
|
Type::Backup,
|
|
"Failed to remove auto backup {}: {err:#?}",
|
|
path.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|