From 7e7216979a285fad4c5650cf0d0028cf4fa8c1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 30 Oct 2026 15:08:29 +0500 Subject: [PATCH] Refresh release notes for the offered update instead of reusing incomplete history --- Cargo.lock | 6 +- Cargo.toml | 2 +- .../20261030-update-history-target-cache.md | 7 ++ crates/coa-core/src/release_history.rs | 113 +++++++++++++----- package-lock.json | 4 +- package.json | 2 +- src-tauri/src/lib.rs | 4 +- src-tauri/tauri.conf.json | 2 +- src/data/latest-note-translations.json | 15 ++- src/screens/ReleaseHistory.tsx | 4 +- 10 files changed, 115 insertions(+), 44 deletions(-) create mode 100644 changelog.d/20261030-update-history-target-cache.md diff --git a/Cargo.lock b/Cargo.lock index be35d78..a57a481 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,7 +586,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.9.5" +version = "0.9.6" dependencies = [ "base64 0.22.1", "chrono", @@ -634,7 +634,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.9.5" +version = "0.9.6" dependencies = [ "chrono", "coa-core", @@ -643,7 +643,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.9.5" +version = "0.9.6" dependencies = [ "coa-control-proto", "coa-core", diff --git a/Cargo.toml b/Cargo.toml index 1f333c7..cc79754 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/coa-core", "crates/coa-release", "crates/coa-registry-proto", exclude = ["services/coa-registry", "services/coa-registry-e2e", "services/coa-coordinator", "services/coa-relay"] [workspace.package] -version = "0.9.5" +version = "0.9.6" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261030-update-history-target-cache.md b/changelog.d/20261030-update-history-target-cache.md new file mode 100644 index 0000000..151fc27 --- /dev/null +++ b/changelog.d/20261030-update-history-target-cache.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Show available update notes even when release history is cached +--- +Fetch the release notes for the offered update when they are missing from the cached history, including when an update becomes available while the history is open. diff --git a/crates/coa-core/src/release_history.rs b/crates/coa-core/src/release_history.rs index 59a37c2..5af4bc3 100644 --- a/crates/coa-core/src/release_history.rs +++ b/crates/coa-core/src/release_history.rs @@ -63,7 +63,46 @@ fn validate(bytes: &[u8], component: &str) -> Result { } pub fn load(component: &str, cache_dir: &Path) -> Result { + load_for_target(component, cache_dir, None) +} + +pub fn load_for_target( + component: &str, + cache_dir: &Path, + target: Option<&str>, +) -> Result { let (repo, branch) = repository(component)?; + load_with_remote(component, cache_dir, target, || { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(8)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| e.to_string())?; + let response = client + .get(format!( + "https://raw.githubusercontent.com/{repo}/{branch}/changelog/history.json" + )) + .query(&[("target", target.unwrap_or(""))]) + .header(reqwest::header::CACHE_CONTROL, "no-cache") + .send() + .and_then(|r| r.error_for_status()) + .map_err(|e| e.to_string())?; + let mut bytes = Vec::new(); + response + .take(LIMIT + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + Ok(bytes) + }) +} + +fn load_with_remote( + component: &str, + cache_dir: &Path, + target: Option<&str>, + remote: impl FnOnce() -> Result, String>, +) -> Result { + repository(component)?; let cache = cache_dir.join(format!("{component}.json")); let cached = || { let mut bytes = Vec::new(); @@ -81,41 +120,33 @@ pub fn load(component: &str, cache_dir: &Path) -> Result { .and_then(|t| t.elapsed().ok()) .is_some_and(|age| age < Duration::from_secs(3600)) { - if let Some(mut v) = cached() { - v["source"] = "cache".into(); - return Ok(v); + if let Some(mut value) = cached() { + if target.is_none_or(|target| { + value["releases"].as_array().is_some_and(|releases| { + releases + .iter() + .any(|release| release["version"].as_str() == Some(target)) + }) + }) { + value["source"] = "cache".into(); + return Ok(value); + } } } - let remote = || -> Result { - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(8)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| e.to_string())?; - let response = client - .get(format!( - "https://raw.githubusercontent.com/{repo}/{branch}/changelog/history.json" - )) - .send() - .and_then(|r| r.error_for_status()) - .map_err(|e| e.to_string())?; - let mut bytes = Vec::new(); - response - .take(LIMIT + 1) - .read_to_end(&mut bytes) - .map_err(|e| e.to_string())?; + let fetched = (|| { + let bytes = remote()?; let mut value = validate(&bytes, component)?; if std::fs::create_dir_all(cache_dir).is_ok() { - let _ = std::fs::write(&cache, &bytes); + let _ = crate::fsx::atomic_write(&cache, &bytes); } value["source"] = "remote".into(); Ok(value) - }; - remote().or_else(|error| { + })(); + fetched.or_else(|error| { cached() - .map(|mut v| { - v["source"] = "cache".into(); - v + .map(|mut value| { + value["source"] = "cache".into(); + value }) .ok_or(error) }) @@ -157,6 +188,34 @@ mod tests { assert!(validate(&serde_json::to_vec(&value).unwrap(), "server").is_err()); } + #[test] + fn available_update_bypasses_fresh_cache_missing_the_target_and_keeps_offline_fallback() { + let dir = tempfile::tempdir().unwrap(); + let feed = |version: &str| { + serde_json::to_vec(&serde_json::json!({ + "schema":1,"component":"manager","repository":"Corfirean/coa-server-manager", + "releases":[{"version":version,"tag":format!("v{version}"),"date":"2026-10-30", + "notes":["Visible update notes"]}] + })) + .unwrap() + }; + std::fs::write(dir.path().join("manager.json"), feed("0.9.4")).unwrap(); + let value = + load_with_remote("manager", dir.path(), Some("0.9.5"), || Ok(feed("0.9.5"))).unwrap(); + assert_eq!(value["source"], "remote"); + assert_eq!(value["releases"][0]["version"], "0.9.5"); + let cached = load_with_remote("manager", dir.path(), Some("0.9.5"), || { + panic!("fresh target is cached") + }) + .unwrap(); + assert_eq!(cached["source"], "cache"); + let offline = load_with_remote("manager", dir.path(), Some("0.9.6"), || { + Err("offline".into()) + }) + .unwrap(); + assert_eq!(offline["releases"][0]["version"], "0.9.5"); + } + #[test] fn fresh_valid_cache_is_available_without_network() { let dir = std::env::temp_dir().join(format!("coa-history-{}", uuid::Uuid::new_v4())); diff --git a/package-lock.json b/package-lock.json index 49cf061..460cf83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.9.5", + "version": "0.9.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.9.5", + "version": "0.9.6", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 39a376b..865a813 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.9.5", + "version": "0.9.6", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8527225..dbf81d0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,9 +32,9 @@ mod control_cmds; /// Informational release notes only; never used to select or authorize an update. #[tauri::command] -async fn release_history(component: String) -> std::result::Result { +async fn release_history(component: String, target: Option) -> std::result::Result { tauri::async_runtime::spawn_blocking(move || { - coa_core::release_history::load(&component, &data_dir().join("release-history")) + coa_core::release_history::load_for_target(&component, &data_dir().join("release-history"), target.as_deref()) }) .await .map_err(|e| e.to_string())? diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3bdba56..59fab04 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CoA Server Manager", - "version": "0.9.5", + "version": "0.9.6", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/data/latest-note-translations.json b/src/data/latest-note-translations.json index 92a658b..9c4d0e2 100644 --- a/src/data/latest-note-translations.json +++ b/src/data/latest-note-translations.json @@ -30,7 +30,8 @@ "Disable public server browsing in Settings": "Отключайте просмотр публичных серверов в настройках", "Keep deliberate stops and update shutdowns from triggering crash restart": "Ручная остановка и остановка для обновления не вызывают перезапуск после сбоя", "Server startup waits for a responsive console before reporting success": "Запуск сервера ожидает ответа консоли перед сообщением об успехе", - "Restore the existing Manager window when launching it again": "Повторный запуск открывает уже существующее окно менеджера" + "Restore the existing Manager window when launching it again": "Повторный запуск открывает уже существующее окно менеджера", + "Show available update notes even when release history is cached": "Показывать изменения доступного обновления даже при сохранённой истории" }, "de": { "Choose Parallel or Global level scaling in module settings": "Parallele oder globale Levelskalierung in den Moduleinstellungen wählen", @@ -62,7 +63,8 @@ "Disable public server browsing in Settings": "Öffentliche Serversuche in den Einstellungen deaktivieren", "Keep deliberate stops and update shutdowns from triggering crash restart": "Bewusstes Stoppen und Herunterfahren für Updates lösen keinen Absturzneustart aus", "Server startup waits for a responsive console before reporting success": "Der Serverstart wartet vor der Erfolgsmeldung auf eine reagierende Konsole", - "Restore the existing Manager window when launching it again": "Erneutes Starten stellt das vorhandene Manager-Fenster wieder her" + "Restore the existing Manager window when launching it again": "Erneutes Starten stellt das vorhandene Manager-Fenster wieder her", + "Show available update notes even when release history is cached": "Änderungen verfügbarer Updates auch bei zwischengespeichertem Verlauf anzeigen" }, "fr": { "Choose Parallel or Global level scaling in module settings": "Choisissez une progression parallèle ou globale des niveaux dans les paramètres du module", @@ -94,7 +96,8 @@ "Disable public server browsing in Settings": "Désactivez la consultation des serveurs publics dans les paramètres", "Keep deliberate stops and update shutdowns from triggering crash restart": "Les arrêts volontaires et ceux des mises à jour ne déclenchent plus de redémarrage après plantage", "Server startup waits for a responsive console before reporting success": "Le démarrage du serveur attend une console réactive avant d’annoncer sa réussite", - "Restore the existing Manager window when launching it again": "Un nouveau lancement restaure la fenêtre existante du gestionnaire" + "Restore the existing Manager window when launching it again": "Un nouveau lancement restaure la fenêtre existante du gestionnaire", + "Show available update notes even when release history is cached": "Afficher les changements des mises à jour disponibles même si l’historique est en cache" }, "es": { "Choose Parallel or Global level scaling in module settings": "Elige escalado de nivel paralelo o global en los ajustes del módulo", @@ -126,7 +129,8 @@ "Disable public server browsing in Settings": "Desactiva la consulta de servidores públicos en los ajustes", "Keep deliberate stops and update shutdowns from triggering crash restart": "Las paradas voluntarias y las de actualización no activan el reinicio por fallo", "Server startup waits for a responsive console before reporting success": "El arranque del servidor espera a que responda la consola antes de indicar que ha terminado", - "Restore the existing Manager window when launching it again": "Al volver a iniciar el gestor se restaura su ventana existente" + "Restore the existing Manager window when launching it again": "Al volver a iniciar el gestor se restaura su ventana existente", + "Show available update notes even when release history is cached": "Mostrar los cambios de las actualizaciones disponibles aunque el historial esté en caché" }, "zh": { "Choose Parallel or Global level scaling in module settings": "在模块设置中选择并行或全局等级缩放", @@ -158,7 +162,8 @@ "Disable public server browsing in Settings": "可在设置中禁用公共服务器浏览", "Keep deliberate stops and update shutdowns from triggering crash restart": "主动停止及更新时关闭不会触发崩溃自动重启", "Server startup waits for a responsive console before reporting success": "服务器启动会等待控制台响应后再报告成功", - "Restore the existing Manager window when launching it again": "再次启动时恢复已有的管理器窗口" + "Restore the existing Manager window when launching it again": "再次启动时恢复已有的管理器窗口", + "Show available update notes even when release history is cached": "即使历史记录已缓存,也显示可用更新的说明" } }, "server": { diff --git a/src/screens/ReleaseHistory.tsx b/src/screens/ReleaseHistory.tsx index cd45c0e..8abc1e9 100644 --- a/src/screens/ReleaseHistory.tsx +++ b/src/screens/ReleaseHistory.tsx @@ -31,14 +31,14 @@ export function ReleaseHistory({ selection, onSelect }: { selection: HistorySele }, [selection, component, updater.version]); useEffect(() => { let live = true; - void invoke("release_history", { component }).then(feed => { + void invoke("release_history", { component, target }).then(feed => { if (live && feed.component === component && Array.isArray(feed.releases)) { setFeeds(previous => ({ ...previous, [component]: feed })); setFresh(previous => ({ ...previous, [component]: feed.source === "remote" })); } }).catch(() => {}); // Bundled reviewed notes remain visible offline. return () => { live = false; }; - }, [component]); + }, [component, target]); const releases = [...(mode === "range" ? releasesForUpdate(feeds[component].releases, from, target) : feeds[component].releases)] .sort((a, b) => compareVersions(b.version.split("-")[0], a.version.split("-")[0]) || b.date.localeCompare(a.date)); const record = releases.find(r => r.version === selected) ?? releases[0];