feat(profile): raise auto-update intervals below 24h

Each auto-update reloads the core, which drops every connection running through a
`proxy-providers` entry. Existing profiles are corrected once at startup; the form
warns about shorter intervals but still saves them.
This commit is contained in:
wonfen
2026-08-17 12:14:24 +08:00
parent 73d986369c
commit ec6241b30a
21 changed files with 215 additions and 17 deletions
+73
View File
@@ -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<u64>) -> 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<Option<u64>> = 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 {
+5
View File
@@ -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";
+6
View File
@@ -21,6 +21,8 @@ pub static PORTABLE_FLAG: OnceCell<bool> = 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<PathBuf> {
Ok(app_home_dir()?.join(PROFILE_YAML))
}
pub fn update_interval_migrated_path() -> Result<PathBuf> {
Ok(app_home_dir()?.join(UPDATE_INTERVAL_MIGRATED))
}
#[cfg(target_os = "macos")]
pub fn service_path() -> Result<PathBuf> {
let res_dir = app_resources_dir()?;
+46
View File
@@ -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()?;
+4
View File
@@ -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;
+38 -17
View File
@@ -35,6 +35,9 @@ export interface ProfileViewerRef {
// remote / local
type ProfileViewerProps = Props & { ref?: Ref<ProfileViewerRef> }
// 同后端 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) {
<Controller
name="option.update_interval"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
type="number"
label={t('profiles.modals.profileForm.fields.updateInterval')}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t('shared.units.minutes')}
</InputAdornment>
),
},
}}
/>
)}
render={({ field }) => {
const interval = Number(field.value)
// 0 和空值表示不自动更新,不提醒
const tooFrequent =
Number.isFinite(interval) &&
interval > 0 &&
interval < MIN_UPDATE_INTERVAL
return (
<TextField
{...text}
{...field}
type="number"
label={t('profiles.modals.profileForm.fields.updateInterval')}
helperText={
tooFrequent
? t(
'profiles.modals.profileForm.warnings.frequentUpdate',
{ minutes: MIN_UPDATE_INTERVAL },
)
: undefined
}
slotProps={{
formHelperText: { sx: { color: 'warning.main' } },
input: {
endAdornment: (
<InputAdornment position="end">
{t('shared.units.minutes')}
</InputAdornment>
),
},
}}
/>
)
}}
/>
<Controller
name="option.with_proxy"
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "مُعرّف الملف الشخصي مفقود",
"saveFailed": "تعذّر حفظ الملف الشخصي"
},
"warnings": {
"frequentUpdate": "الفاصل الزمني القصير يعيد تحميل النواة بشكل متكرر ويقطع الاتصالات النشطة. يُنصح بـ {{minutes}} دقيقة (24 ساعة)."
},
"feedback": {
"notifications": {
"creationRetry": "Profile creation failed, retrying with Clash proxy...",
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "Die Abonnement-Kennung fehlt",
"saveFailed": "Abonnement konnte nicht gespeichert werden"
},
"warnings": {
"frequentUpdate": "Ein kurzes Intervall lädt den Kern entsprechend häufig neu und trennt bestehende Verbindungen. Empfohlen werden {{minutes}} Minuten (24 Stunden)."
},
"feedback": {
"notifications": {
"creationRetry": "Erstellung des Abonnements fehlgeschlagen. Versuche es mit dem Clash-Proxy erneut...",
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "The profile identifier is missing",
"saveFailed": "Failed to save profile"
},
"warnings": {
"frequentUpdate": "A short interval reloads the core that often and drops existing connections. {{minutes}} minutes (24 hours) is recommended."
},
"feedback": {
"notifications": {
"creationRetry": "Profile creation failed, retrying with Clash proxy...",
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "Falta el identificador de la suscripción",
"saveFailed": "No se pudo guardar la suscripción"
},
"warnings": {
"frequentUpdate": "Un intervalo corto recarga el núcleo con esa frecuencia y corta las conexiones existentes. Se recomiendan {{minutes}} minutos (24 horas)."
},
"feedback": {
"notifications": {
"creationRetry": "Error al crear la suscripción. Intentando con el proxy de Clash...",
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "شناسه پروفایل موجود نیست",
"saveFailed": "ذخیره پروفایل ناموفق بود"
},
"warnings": {
"frequentUpdate": "بازهٔ کوتاه باعث بارگذاری مکرر هسته و قطع اتصال‌های فعال می‌شود. {{minutes}} دقیقه (۲۴ ساعت) توصیه می‌شود."
},
"feedback": {
"notifications": {
"creationRetry": "Profile creation failed, retrying with Clash proxy...",
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "Pengenal profil tidak ada",
"saveFailed": "Gagal menyimpan profil"
},
"warnings": {
"frequentUpdate": "Interval yang singkat membuat inti dimuat ulang sesering itu dan memutus koneksi yang aktif. Disarankan {{minutes}} menit (24 jam)."
},
"feedback": {
"notifications": {
"creationRetry": "Profile creation failed, retrying with Clash proxy...",
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "プロファイル識別子がありません",
"saveFailed": "プロファイルの保存に失敗しました"
},
"warnings": {
"frequentUpdate": "間隔が短いとその頻度でコアが再読み込みされ、既存の接続が切断されます。{{minutes}} 分(24 時間)を推奨します。"
},
"feedback": {
"notifications": {
"creationRetry": "プロファイルの作成に失敗しました。Clashプロキシを使用して再試行します...",
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "프로필 식별자가 없습니다",
"saveFailed": "프로필을 저장하지 못했습니다"
},
"warnings": {
"frequentUpdate": "간격이 짧으면 코어가 그만큼 자주 다시 로드되어 기존 연결이 끊깁니다. {{minutes}}분(24시간)을 권장합니다."
},
"feedback": {
"notifications": {
"creationRetry": "프로필 생성 실패, Clash 프록시로 다시 시도 중...",
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "Отсутствует идентификатор профиля",
"saveFailed": "Не удалось сохранить профиль"
},
"warnings": {
"frequentUpdate": "Короткий интервал часто перезагружает ядро и обрывает активные соединения. Рекомендуется {{minutes}} минут (24 часа)."
},
"feedback": {
"notifications": {
"creationRetry": "Не удалось создать профиль, повторная попытка через Clash proxy...",
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "Profil tanımlayıcısı eksik",
"saveFailed": "Profil kaydedilemedi"
},
"warnings": {
"frequentUpdate": "Kısa bir aralık çekirdeği o sıklıkta yeniden yükler ve mevcut bağlantıları koparır. {{minutes}} dakika (24 saat) önerilir."
},
"feedback": {
"notifications": {
"creationRetry": "Profil oluşturma başarısız oldu, Clash vekil ile yeniden deneniyor...",
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "Профиль билгесе юк",
"saveFailed": "Профильне саклап булмады"
},
"warnings": {
"frequentUpdate": "Кыска аралык ядрәне еш яңадан йөкли һәм актив тоташуларны өзә. {{minutes}} минут (24 сәгать) тәкъдим ителә."
},
"feedback": {
"notifications": {
"creationRetry": "Profile creation failed, retrying with Clash proxy...",
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "缺少订阅标识",
"saveFailed": "保存订阅失败"
},
"warnings": {
"frequentUpdate": "间隔过短会频繁触发内核重载并中断现有连接,建议使用默认值 {{minutes}} 分钟(24 小时)"
},
"feedback": {
"notifications": {
"creationRetry": "订阅创建失败,尝试使用 Clash 代理创建",
+3
View File
@@ -135,6 +135,9 @@
"uidMissing": "缺少訂閱識別碼",
"saveFailed": "儲存訂閱失敗"
},
"warnings": {
"frequentUpdate": "間隔過短會頻繁觸發核心重新載入並中斷現有連線,建議使用預設值 {{minutes}} 分鐘(24 小時)"
},
"feedback": {
"notifications": {
"creationRetry": "訂閱建立失敗,嘗試使用 Clash 代理建立",
+1
View File
@@ -233,6 +233,7 @@ export const translationKeys = [
'profiles.modals.profileForm.errors.urlRequired',
'profiles.modals.profileForm.errors.uidMissing',
'profiles.modals.profileForm.errors.saveFailed',
'profiles.modals.profileForm.warnings.frequentUpdate',
'profiles.modals.profileForm.feedback.notifications.creationRetry',
'profiles.modals.profileForm.feedback.notifications.creationSuccess',
'profiles.modals.proxiesEditor.title',
+3
View File
@@ -393,6 +393,9 @@ export interface TranslationResources {
local: string
remote: string
}
warnings: {
frequentUpdate: string
}
}
proxiesEditor: {
actions: {