mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-01-29 00:35:38 +08:00
* feat(auto-backup): implement centralized auto-backup manager and UI - Introduced AutoBackupManager to handle verge settings, run a background scheduler, debounce change-driven backups, and trim auto-labeled archives (keeps 20); wired into startup and config refresh hooks (src-tauri/src/module/auto_backup.rs:28-209, src-tauri/src/utils/resolve/mod.rs:64-136, src-tauri/src/feat/config.rs:102-238) - Extended verge schema and backup helpers so scheduled/change-based settings persist, create_local_backup can rename archives, and profile/global-extend mutations now trigger backups (src-tauri/src/config/verge.rs:162-536, src/types/types.d.ts:857-859, src-tauri/src/feat/backup.rs:125-189, src-tauri/src/cmd/profile.rs:66-476, src-tauri/src/cmd/save_profile.rs:21-82) - Added Auto Backup settings panel in backup dialog with dual toggles + interval selector; localized new strings across all locales (src/components/setting/mods/auto-backup-settings.tsx:1-138, src/components/setting/mods/backup-viewer.tsx:28-309, src/locales/en/settings.json:312-326 and mirrored entries) - Regenerated typed i18n resources for strong typing in React (src/types/generated/i18n-keys.ts, src/types/generated/i18n-resources.ts) * refactor(setting/backup): restructure backup dialog for consistent layout * refactor(ui): unify settings dialog style * fix(backup): only trigger auto-backup on valid saves & restore restarts app safely * fix(backup): scrub console.log leak and rewire WebDAV dialog to actually probe server * refactor: rename SubscriptionChange to ProfileChange * chore: update i18n * chore: WebDAV i18n improvements * refactor(backup): error handling * refactor(auto-backup): wrap scheduler startup with maybe_start_runner * refactor: remove the redundant throw in handleExport * feat(backup-history-viewer): improve WebDAV handling and UI fallback * feat(auto-backup): trigger backups on all profile edits & improve interval input UX * refactor: use InputAdornment * docs: Changelog.md
333 lines
8.9 KiB
Rust
333 lines
8.9 KiB
Rust
use crate::{
|
|
config::{Config, IVerge},
|
|
feat::create_local_backup_with_namer,
|
|
logging,
|
|
process::AsyncHandler,
|
|
utils::{dirs::local_backup_dir, logging::Type},
|
|
};
|
|
use anyhow::Result;
|
|
use chrono::Local;
|
|
use once_cell::sync::OnceCell;
|
|
use parking_lot::RwLock;
|
|
use std::{
|
|
path::PathBuf,
|
|
sync::{
|
|
Arc,
|
|
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,
|
|
ProfileChange,
|
|
}
|
|
|
|
impl AutoBackupTrigger {
|
|
const fn slug(self) -> &'static str {
|
|
match self {
|
|
Self::Scheduled => "scheduled",
|
|
Self::GlobalMerge => "merge",
|
|
Self::GlobalScript => "script",
|
|
Self::ProfileChange => "profile",
|
|
}
|
|
}
|
|
|
|
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: Arc<RwLock<AutoBackupSettings>>,
|
|
settings_tx: watch::Sender<AutoBackupSettings>,
|
|
runner_started: AtomicBool,
|
|
exec_lock: Mutex<()>,
|
|
last_backup: AtomicI64,
|
|
}
|
|
|
|
impl AutoBackupManager {
|
|
pub fn global() -> &'static Self {
|
|
static INSTANCE: OnceCell<AutoBackupManager> = OnceCell::new();
|
|
INSTANCE.get_or_init(|| {
|
|
let (tx, _rx) = watch::channel(AutoBackupSettings::default());
|
|
Self {
|
|
settings: Arc::new(RwLock::new(AutoBackupSettings::default())),
|
|
settings_tx: tx,
|
|
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;
|
|
{
|
|
*self.settings.write() = settings;
|
|
}
|
|
let _ = self.settings_tx.send(settings);
|
|
self.maybe_start_runner(settings);
|
|
Ok(())
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
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.read();
|
|
|
|
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(())
|
|
}
|