diff --git a/src-tauri/src/config/profiles.rs b/src-tauri/src/config/profiles.rs index b1d067008..a2fb332b9 100644 --- a/src-tauri/src/config/profiles.rs +++ b/src-tauri/src/config/profiles.rs @@ -356,6 +356,27 @@ impl IProfiles { self.save_file().await } + /// Raise intervals below `min_minutes`. `None`/`0` mean "never auto-update", left alone. + /// Returns how many changed so the caller can skip a pointless write. + pub fn raise_short_update_intervals(&mut self, min_minutes: u64) -> usize { + let mut raised = 0; + + for item in self.items.iter_mut().flatten() { + let Some(option) = item.option.as_mut() else { + continue; + }; + if option + .update_interval + .is_some_and(|interval| (1..min_minutes).contains(&interval)) + { + option.update_interval = Some(min_minutes); + raised += 1; + } + } + + raised + } + pub(crate) fn plan_delete_item(&mut self, uid: &String) -> Result<(bool, ProfileDeletePlan)> { let current = self.current.as_ref().unwrap_or(uid).clone(); let delete_uids = self.get_item(uid)?.option.as_ref().map_or_else(Vec::new, |op| { @@ -1295,6 +1316,58 @@ mod tests { } } + fn interval_item(uid: &str, update_interval: Option) -> PrfItem { + PrfItem { + uid: Some(uid.into()), + itype: Some("remote".into()), + option: Some(PrfOption { + update_interval, + ..PrfOption::default() + }), + ..PrfItem::default() + } + } + + #[test] + fn raising_intervals_only_touches_those_scheduled_too_often() { + let mut profiles = IProfiles { + current: None, + items: Some(vec![ + interval_item("too-often", Some(60)), + interval_item("just-under", Some(1439)), + interval_item("at-floor", Some(1440)), + interval_item("relaxed", Some(4320)), + // raising these would switch auto-update on + interval_item("disabled-by-zero", Some(0)), + interval_item("disabled-by-absence", None), + PrfItem { + uid: Some("no-option".into()), + option: None, + ..PrfItem::default() + }, + ]), + }; + + assert_eq!(profiles.raise_short_update_intervals(1440), 2); + + let intervals: Vec> = profiles + .items + .iter() + .flatten() + .map(|item| item.option.as_ref().and_then(|o| o.update_interval)) + .collect(); + assert_eq!( + intervals, + vec![Some(1440), Some(1440), Some(1440), Some(4320), Some(0), None, None] + ); + + assert_eq!( + profiles.raise_short_update_intervals(1440), + 0, + "a second pass must be a no-op, so re-running the migration cannot churn the file" + ); + } + #[test] fn delete_plan_defers_files_and_selects_replacement() -> Result<()> { let mut profiles = IProfiles { diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index 4326afe22..251a14f96 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -43,6 +43,11 @@ pub mod timing { pub const SERVICE_START_RETRY_DELAY: Duration = Duration::from_millis(300); } +pub mod profile { + /// Floor, in minutes, for how often a profile may auto-update (24 hours). + pub const MIN_UPDATE_INTERVAL: u64 = 1440; +} + pub mod files { pub const RUNTIME_CONFIG: &str = "clash-verge.yaml"; pub const CHECK_CONFIG: &str = "clash-verge-check.yaml"; diff --git a/src-tauri/src/utils/dirs.rs b/src-tauri/src/utils/dirs.rs index 5db805a93..3c6b02498 100644 --- a/src-tauri/src/utils/dirs.rs +++ b/src-tauri/src/utils/dirs.rs @@ -21,6 +21,8 @@ pub static PORTABLE_FLAG: OnceCell = OnceCell::new(); pub static CLASH_CONFIG: &str = "config.yaml"; pub static VERGE_CONFIG: &str = "verge.yaml"; pub static PROFILE_YAML: &str = "profiles.yaml"; +/// Marks that the one-shot raise of too-short auto-update intervals has already run. +pub static UPDATE_INTERVAL_MIGRATED: &str = ".update-interval-migrated"; /// init portable flag pub fn init_portable_flag() -> Result<()> { @@ -170,6 +172,10 @@ pub fn profiles_path() -> Result { Ok(app_home_dir()?.join(PROFILE_YAML)) } +pub fn update_interval_migrated_path() -> Result { + Ok(app_home_dir()?.join(UPDATE_INTERVAL_MIGRATED)) +} + #[cfg(target_os = "macos")] pub fn service_path() -> Result { let res_dir = app_resources_dir()?; diff --git a/src-tauri/src/utils/init.rs b/src-tauri/src/utils/init.rs index 2fffed669..ebeb9bcb0 100644 --- a/src-tauri/src/utils/init.rs +++ b/src-tauri/src/utils/init.rs @@ -232,6 +232,52 @@ async fn migrate_legacy_macos_service_logs(log_dir: &Path) -> Result<()> { Ok(()) } +/// Raise existing sub-floor update intervals, once per install. +/// +/// One-off repair, not a standing rule — the UI warns about shorter intervals but still saves +/// them, so the marker is written even when nothing needed raising. +pub async fn migrate_short_update_intervals() -> Result<()> { + let marker = dirs::update_interval_migrated_path()?; + if fs::try_exists(&marker).await? { + return Ok(()); + } + + // Same order as every other writer holding it (lock, then draft permit): save_file runs + // inside the closure, ahead of the optimistic check, so a racing restore can't be undone. + let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await; + + let min = constants::profile::MIN_UPDATE_INTERVAL; + let raised = Config::profiles() + .await + .with_data_modify(|mut profiles: IProfiles| async move { + // `IProfiles::new` normalises items to Some when it read the file, and returns a + // bare default() when it failed. A failed load also raises zero — without this the + // marker would burn the one shot on profiles nobody managed to load. + if profiles.items.is_none() { + anyhow::bail!("profiles.yaml was not loaded; refusing to record the migration as done"); + } + let raised = profiles.raise_short_update_intervals(min); + if raised > 0 { + profiles.save_file().await?; + } + Ok((profiles, raised)) + }) + .await?; + + if raised > 0 { + logging!( + info, + Type::Setup, + "已将 {} 个订阅的自动更新间隔提升到 {} 分钟", + raised, + min + ); + } + + fs::write(&marker, b"").await?; + Ok(()) +} + #[cfg(target_os = "macos")] async fn migrate_legacy_macos_logs() -> Result<()> { let log_dir = dirs::app_logs_dir()?; diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index 2ac3024aa..615d4116b 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -48,6 +48,10 @@ pub fn resolve_setup_async() { AsyncHandler::spawn(|| async { logging!(info, Type::ClashVergeRev, "Version: {}", env!("CARGO_PKG_VERSION")); + // Ahead of the window and init_timer: anything saved or deep-link-imported after the app + // is up is a deliberate choice. Config files already exist (init_work_dir_and_logger). + logging_error!(Type::Setup, init::migrate_short_update_intervals().await); + #[cfg(target_os = "macos")] resolve_dock_show().await; init_startup_script().await; diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index 8ab7c4f5e..3a1565953 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -35,6 +35,9 @@ export interface ProfileViewerRef { // remote / local type ProfileViewerProps = Props & { ref?: Ref } +// 同后端 constants::profile::MIN_UPDATE_INTERVAL +const MIN_UPDATE_INTERVAL = 1440 + export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -354,23 +357,41 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { ( - - {t('shared.units.minutes')} - - ), - }, - }} - /> - )} + render={({ field }) => { + const interval = Number(field.value) + // 0 和空值表示不自动更新,不提醒 + const tooFrequent = + Number.isFinite(interval) && + interval > 0 && + interval < MIN_UPDATE_INTERVAL + + return ( + + {t('shared.units.minutes')} + + ), + }, + }} + /> + ) + }} />