From c1a3bf03b889d685ea5e07fc1683868ee4881472 Mon Sep 17 00:00:00 2001 From: Kelorastus <108483685+Kelorastus@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:56:40 +0200 Subject: [PATCH] fix --- src-tauri/src/launcher.rs | 20 +++- src-tauri/src/release.rs | 152 ++++++++++++++++++++++++++++- src-tauri/tauri.conf.json | 2 +- src/components/ReleasePanel.svelte | 87 +++++++++++++++++ src/lib/mock.ts | 12 +++ src/lib/types.ts | 17 ++++ 6 files changed, 285 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index ae7f42d..6f626ca 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -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::(&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, ) } diff --git a/src-tauri/src/release.rs b/src-tauri/src/release.rs index 3c3fe78..99edb6a 100644 --- a/src-tauri/src/release.rs +++ b/src-tauri/src/release.rs @@ -23,6 +23,9 @@ pub struct ReleasePlan { pub items: Vec, /// Why the whole thing cannot run, in one line; None when it can. pub blocked: Option, + /// 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, } #[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, + pub db_age_days: Option, + pub db_bytes: u64, + pub tile_sets: usize, + pub tile_bytes: u64, + /// Every distinct dataset the tile sets name. + pub tile_datasets: Vec, + /// 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, +} + +/// 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 { + 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 = 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, launcher_commit: Option, api_live: Option, + data: Option, ) -> 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 { 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). diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 69b3566..c487618 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/components/ReleasePanel.svelte b/src/components/ReleasePanel.svelte index 22e192f..39dd1a7 100644 --- a/src/components/ReleasePanel.svelte +++ b/src/components/ReleasePanel.svelte @@ -3,6 +3,7 @@ import { prefersReducedMotion } from "svelte/motion"; import Icon from "./Icon.svelte"; import { launcher } from "../lib/launcher.svelte"; + import { formatBytes } from "../lib/format"; // Its own panel rather than a block inside the action card: the card is anchored to the // bottom of the window and cannot grow, so three rows plus a password field pushed the @@ -24,6 +25,24 @@ /** The short version of a row's state, for the column that has room for three words. NOT called `state`: that shadows the `$state` rune in this file, and everything declared with it silently stops being reactive. */ + /** `20260921T125618Z` -> `21 Sep 2026`; anything else is shown as it came. */ + function stamped(snapshot: string | null): string { + if (!snapshot || snapshot.length < 8) return snapshot ?? "unknown"; + const y = Number(snapshot.slice(0, 4)); + const m = Number(snapshot.slice(4, 6)); + const d = Number(snapshot.slice(6, 8)); + if (!y || !m || !d) return snapshot; + const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + return `${d} ${MONTHS[m - 1] ?? "?"} ${y}`; + } + + function aged(days: number | null): string { + if (days === null) return ""; + if (days <= 0) return "today"; + if (days === 1) return "1 day old"; + return `${days} days old`; + } + function rowState(row: { behind: boolean | null; dirty: boolean }): { text: string; tone: string } { if (row.dirty) return { text: "uncommitted", tone: "warn" }; if (row.behind === true) return { text: "behind", tone: "go" }; @@ -62,6 +81,32 @@ {/each} + + {#if plan.data} + {@const d = plan.data} +
+ Carried forward — a release does not rebuild these +
+ Database seed + {stamped(d.db_snapshot)} + 30}>{aged(d.db_age_days)} + {formatBytes(d.db_bytes)} +
+
+ Map tiles + {d.tile_sets} sets + 90}>{aged(d.tiles_age_days)} + {formatBytes(d.tile_bytes)} +
+ {d.tile_datasets.join(" · ")} + {#if !d.is_live} + Read from this PC's {d.version}, which is not what the DHP serves. + {/if} +
+ {/if} +
DHP {plan.online ? "✓" : "✗"} Docker {plan.docker ? "✓" : "✗"} @@ -108,6 +153,48 @@