fix
This commit is contained in:
@@ -2427,11 +2427,24 @@ impl Launcher {
|
||||
// The commit the LIVE pack was cut from. Read from this PC's own copy of that version,
|
||||
// which costs nothing; when this PC is on an older version we simply do not know, and
|
||||
// the panel says so rather than guessing.
|
||||
let pack_commit = match (&installed, &pack_live) {
|
||||
(Some(have), Some(live)) if have == live => std::fs::read(Layout::new(&install_dir, have).version_dir().join("manifest.json"))
|
||||
// One read, two answers. The commit is only trusted when this PC is ON the live version
|
||||
// (an older copy says where IT came from, not where the live pack did), but the data
|
||||
// payload is worth showing either way - it is carried forward unchanged, so an older
|
||||
// manifest describes the same seed and the same tiles.
|
||||
let manifest = installed.as_ref().and_then(|have| {
|
||||
std::fs::read(Layout::new(&install_dir, have).version_dir().join("manifest.json"))
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_slice::<serde_json::Value>(&raw).ok())
|
||||
.and_then(|m| m.get("source_commit").and_then(|c| c.as_str().map(str::to_string))),
|
||||
});
|
||||
let is_live = matches!((&installed, &pack_live), (Some(have), Some(live)) if have == live);
|
||||
let pack_commit = match (is_live, &manifest) {
|
||||
(true, Some(m)) => m.get("source_commit").and_then(|c| c.as_str().map(str::to_string)),
|
||||
_ => None,
|
||||
};
|
||||
let data = match (&manifest, &installed) {
|
||||
(Some(m), Some(have)) => {
|
||||
Some(crate::release::data_from_manifest(m, have, is_live, now_secs() as i64))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// The launcher's own feeds are public, so this needs no token.
|
||||
@@ -2477,6 +2490,7 @@ impl Launcher {
|
||||
launcher_live,
|
||||
launcher_commit,
|
||||
None,
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+151
-1
@@ -23,6 +23,9 @@ pub struct ReleasePlan {
|
||||
pub items: Vec<ReleaseItem>,
|
||||
/// Why the whole thing cannot run, in one line; None when it can.
|
||||
pub blocked: Option<String>,
|
||||
/// What the pack carries but a release does not rebuild. None when nothing is installed
|
||||
/// here, so there is no manifest to read it from.
|
||||
pub data: Option<ReleaseData>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -115,6 +118,107 @@ fn item(
|
||||
ReleaseItem { key, title, live, commit: commit.map(|c| c[..c.len().min(7)].to_string()), behind, dirty, note }
|
||||
}
|
||||
|
||||
/// What a pack carries that a code release does NOT rebuild.
|
||||
///
|
||||
/// `cut_pack.py` copies the database seed, the source chunks and the tile sets forward from
|
||||
/// the previous version every time, and asserts they did not change - which is what keeps a
|
||||
/// code release from quietly altering data. The cost is that they age invisibly: the seed and
|
||||
/// the tiles are whatever they were the day someone last built them. This puts a date on it.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ReleaseData {
|
||||
/// The version whose manifest these numbers were read from.
|
||||
pub version: String,
|
||||
/// Whether that is also what the DHP serves, so the numbers describe the live pack.
|
||||
pub is_live: bool,
|
||||
/// The seed's snapshot stamp, e.g. `20260921T125618Z`.
|
||||
pub db_snapshot: Option<String>,
|
||||
pub db_age_days: Option<i64>,
|
||||
pub db_bytes: u64,
|
||||
pub tile_sets: usize,
|
||||
pub tile_bytes: u64,
|
||||
/// Every distinct dataset the tile sets name.
|
||||
pub tile_datasets: Vec<String>,
|
||||
/// The age of the OLDEST dated dataset, which is what decides how stale the maps are. A
|
||||
/// name carrying no date (`planet_heightmap`) is not counted: that one does not move.
|
||||
pub tiles_age_days: Option<i64>,
|
||||
}
|
||||
|
||||
/// Days from 1970-01-01 to y-m-d, proleptic Gregorian. Hinnant's algorithm, which is exact
|
||||
/// and needs no date crate - the alternative was a dependency to subtract two dates.
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = (m + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// How many days ago `YYYYMMDD` was, or None when those are not eight sane digits.
|
||||
fn age_days(stamp: &str, now_secs: i64) -> Option<i64> {
|
||||
let digits: String = stamp.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if digits.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let y: i64 = digits[0..4].parse().ok()?;
|
||||
let m: i64 = digits[4..6].parse().ok()?;
|
||||
let d: i64 = digits[6..8].parse().ok()?;
|
||||
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || !(2000..=2999).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
Some(now_secs / 86_400 - days_from_civil(y, m, d))
|
||||
}
|
||||
|
||||
/// The date a dataset name carries, if any: `protomaps_20260423` -> `20260423`.
|
||||
fn dataset_stamp(dataset: &str) -> Option<&str> {
|
||||
let tail = dataset.rsplit('_').next()?;
|
||||
(tail.len() == 8 && tail.chars().all(|c| c.is_ascii_digit())).then_some(tail)
|
||||
}
|
||||
|
||||
/// Read the data payload out of an installed version's manifest.
|
||||
pub fn data_from_manifest(m: &serde_json::Value, version: &str, is_live: bool, now_secs: i64) -> ReleaseData {
|
||||
let db = m.get("db");
|
||||
let db_snapshot = db.and_then(|d| d.get("snapshot")).and_then(|s| s.as_str()).map(str::to_string);
|
||||
let db_bytes = db
|
||||
.and_then(|d| d.get("files"))
|
||||
.and_then(|f| f.as_array())
|
||||
.map(|files| files.iter().filter_map(|f| f.get("size").and_then(serde_json::Value::as_u64)).sum())
|
||||
.unwrap_or(0);
|
||||
let sets = m.get("tiles").and_then(|t| t.get("sets")).and_then(|s| s.as_array());
|
||||
let tile_bytes = sets
|
||||
.map(|s| {
|
||||
s.iter()
|
||||
.filter_map(|set| set.get("bytes").or_else(|| set.get("size")).and_then(serde_json::Value::as_u64))
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let mut tile_datasets: Vec<String> = Vec::new();
|
||||
for set in sets.map(Vec::as_slice).unwrap_or(&[]) {
|
||||
if let Some(d) = set.get("dataset").and_then(|d| d.as_str()) {
|
||||
if !tile_datasets.iter().any(|have| have == d) {
|
||||
tile_datasets.push(d.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let tiles_age_days = tile_datasets
|
||||
.iter()
|
||||
.filter_map(|d| dataset_stamp(d))
|
||||
.filter_map(|s| age_days(s, now_secs))
|
||||
.max();
|
||||
ReleaseData {
|
||||
version: version.to_string(),
|
||||
is_live,
|
||||
db_age_days: db_snapshot.as_deref().and_then(|s| age_days(s, now_secs)),
|
||||
db_snapshot,
|
||||
db_bytes,
|
||||
tile_sets: sets.map(|s| s.len()).unwrap_or(0),
|
||||
tile_bytes,
|
||||
tile_datasets,
|
||||
tiles_age_days,
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the Release panel shows.
|
||||
pub fn plan(
|
||||
dev_root: &Path,
|
||||
@@ -125,6 +229,7 @@ pub fn plan(
|
||||
launcher_live: Option<String>,
|
||||
launcher_commit: Option<String>,
|
||||
api_live: Option<String>,
|
||||
data: Option<ReleaseData>,
|
||||
) -> ReleasePlan {
|
||||
let docker = docker_running();
|
||||
let scripts = mcp_repo.join("scripts").join("release_pack.ps1").is_file()
|
||||
@@ -157,7 +262,7 @@ pub fn plan(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
ReleasePlan { docker, online, scripts, items, blocked }
|
||||
ReleasePlan { docker, online, scripts, items, blocked, data }
|
||||
}
|
||||
|
||||
/// The Release console's buffer: filled by the running release, read by the window.
|
||||
@@ -446,6 +551,51 @@ pub fn next_launcher(live: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_pack_says_how_old_the_data_it_carries_is() {
|
||||
// 2026-10-01, the day this was written, as unix seconds.
|
||||
let now = days_from_civil(2026, 10, 1) * 86_400;
|
||||
assert_eq!(age_days("20261001T000000Z", now), Some(0), "today");
|
||||
assert_eq!(age_days("20260921T125618Z", now), Some(10), "the real 0.72.0 seed");
|
||||
assert_eq!(age_days("20260423", now), Some(161), "the real basemap extract");
|
||||
// Leap day, and a year boundary: the arithmetic is the whole point of this test.
|
||||
assert_eq!(age_days("20260228", now), Some(215));
|
||||
assert_eq!(age_days("20251231", now), Some(274));
|
||||
// Nonsense must not become a date.
|
||||
assert_eq!(age_days("", now), None);
|
||||
assert_eq!(age_days("2026", now), None);
|
||||
assert_eq!(age_days("20261301", now), None, "month 13");
|
||||
assert_eq!(age_days("latest", now), None);
|
||||
|
||||
assert_eq!(dataset_stamp("protomaps_20260423"), Some("20260423"));
|
||||
assert_eq!(dataset_stamp("planet_heightmap"), None, "undated: it does not move");
|
||||
|
||||
// A manifest shaped like the real one.
|
||||
let m: serde_json::Value = serde_json::json!({
|
||||
"db": { "snapshot": "20260921T125618Z",
|
||||
"files": [ {"size": 1000}, {"size": 2000} ] },
|
||||
"tiles": { "sets": [
|
||||
{"style": "dark", "dataset": "protomaps_20260423", "bytes": 10},
|
||||
{"style": "dark_v2", "dataset": "protomaps_20260423", "bytes": 20},
|
||||
{"style": "heightmap", "dataset": "planet_heightmap", "bytes": 70}
|
||||
]}
|
||||
});
|
||||
let d = data_from_manifest(&m, "0.72.0", true, now);
|
||||
assert_eq!(d.db_bytes, 3000);
|
||||
assert_eq!(d.db_age_days, Some(10));
|
||||
assert_eq!(d.tile_sets, 3);
|
||||
assert_eq!(d.tile_bytes, 100);
|
||||
// Deduplicated, and the undated set does not drag the age to None.
|
||||
assert_eq!(d.tile_datasets, vec!["protomaps_20260423".to_string(), "planet_heightmap".to_string()]);
|
||||
assert_eq!(d.tiles_age_days, Some(161));
|
||||
|
||||
// A manifest with no tiles at all must not panic or invent numbers.
|
||||
let empty = data_from_manifest(&serde_json::json!({}), "0.1.0", false, now);
|
||||
assert_eq!(empty.tile_sets, 0);
|
||||
assert_eq!(empty.db_bytes, 0);
|
||||
assert_eq!(empty.db_age_days, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_version_moves_the_right_place() {
|
||||
// A pack is a minor (0.71.0 -> 0.72.0); the launcher is a patch (0.1.38 -> 0.1.39).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "ODIAN Launcher",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.43",
|
||||
"identifier": "fr.odian.launcher",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
Reference in New Issue
Block a user