Merge pull request #190 from Corfirean/fix/update-release-history-target-20261030

Show notes for the available update despite a fresh older history cache
This commit is contained in:
Corfirean authored and GitHub committed 2026-10-30 15:29:35 +05:00
commit 35084dbce8
10 files changed
+115 -44

No files matched your search

Generated
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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"
@@ -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.
+86 -27
View File
@@ -63,7 +63,46 @@ fn validate(bytes: &[u8], component: &str) -> Result<Value, String> {
}
pub fn load(component: &str, cache_dir: &Path) -> Result<Value, String> {
load_for_target(component, cache_dir, None)
}
pub fn load_for_target(
component: &str,
cache_dir: &Path,
target: Option<&str>,
) -> Result<Value, String> {
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<Vec<u8>, String>,
) -> Result<Value, String> {
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<Value, String> {
.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<Value, String> {
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()));
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+2 -2
View File
@@ -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<Value, String> {
async fn release_history(component: String, target: Option<String>) -> std::result::Result<Value, String> {
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())?
+1 -1
View File
@@ -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",
+10 -5
View File
@@ -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": {
+2 -2
View File
@@ -31,14 +31,14 @@ export function ReleaseHistory({ selection, onSelect }: { selection: HistorySele
}, [selection, component, updater.version]);
useEffect(() => {
let live = true;
void invoke<History>("release_history", { component }).then(feed => {
void invoke<History>("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];