This commit is contained in:
Kelorastus
2026-10-01 11:56:40 +02:00
parent 064c1b9098
commit c1a3bf03b8
6 changed files with 285 additions and 5 deletions
+17 -3
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
+87
View File
@@ -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}
</ul>
<!-- The payload a release does NOT rebuild. It is copied forward from the previous
version every time, so it ages quietly: this is the only place that says by how
much, which is what turns "the tiles are old" into a decision. -->
{#if plan.data}
{@const d = plan.data}
<div class="payload">
<span class="payload-head">Carried forward — a release does not rebuild these</span>
<div class="payload-row">
<span class="payload-name">Database seed</span>
<span class="payload-val mono">{stamped(d.db_snapshot)}</span>
<span class="payload-age" class:stale={(d.db_age_days ?? 0) > 30}>{aged(d.db_age_days)}</span>
<span class="payload-size mono">{formatBytes(d.db_bytes)}</span>
</div>
<div class="payload-row">
<span class="payload-name">Map tiles</span>
<span class="payload-val mono">{d.tile_sets} sets</span>
<span class="payload-age" class:stale={(d.tiles_age_days ?? 0) > 90}>{aged(d.tiles_age_days)}</span>
<span class="payload-size mono">{formatBytes(d.tile_bytes)}</span>
</div>
<span class="payload-foot mono">{d.tile_datasets.join(" · ")}</span>
{#if !d.is_live}
<span class="payload-foot">Read from this PC's {d.version}, which is not what the DHP serves.</span>
{/if}
</div>
{/if}
<div class="checks mono">
<span class:ok={plan.online}>DHP {plan.online ? "✓" : "✗"}</span>
<span class:ok={plan.docker}>Docker {plan.docker ? "✓" : "✗"}</span>
@@ -108,6 +153,48 @@
</section>
<style>
/* The data payload. Quiet by default - it is context, not a control - but the age goes
amber once it is old enough to matter, which is the whole point of showing it. */
.payload {
display: flex;
flex-direction: column;
gap: 2px;
margin: 10px 0 2px;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 8px;
}
.payload-head {
color: var(--muted);
font-size: var(--text-xs);
margin-bottom: 4px;
}
.payload-row {
display: grid;
grid-template-columns: 7.5rem 7rem minmax(0, 1fr) auto;
gap: 8px;
align-items: baseline;
font-size: var(--text-xs);
}
.payload-name {
color: var(--ink-2);
}
.payload-val,
.payload-size {
color: var(--muted);
}
.payload-age {
color: var(--muted);
}
.payload-age.stale {
color: var(--warn);
}
.payload-foot {
margin-top: 4px;
color: var(--muted);
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
.release {
display: flex;
flex-direction: column;
+12
View File
@@ -815,6 +815,18 @@ class MockBackend implements Backend {
{ key: "mcp", title: "MCP server", live: null, commit: "c7d8e9f", behind: null,
dirty: false, note: "the script asks the node whether it is already running this commit" },
],
// The real numbers from pack 0.72.0, so the panel can be judged on what it will show.
data: {
version: this.status.installed ?? "0.72.0",
is_live: true,
db_snapshot: "20260921T125618Z",
db_age_days: 10,
db_bytes: 15_354_000_000,
tile_sets: 8,
tile_bytes: 102_650_000_000,
tile_datasets: ["protomaps_20260423", "planet_heightmap"],
tiles_age_days: 161,
},
};
}
+17
View File
@@ -303,6 +303,21 @@ export interface ReleaseItem {
note: string;
}
/** What a pack carries but a code release does not rebuild: the DB seed and the tile sets. */
export interface ReleaseData {
version: string;
/** The numbers describe the live pack, not just this PC's older copy. */
is_live: boolean;
db_snapshot: string | null;
db_age_days: number | null;
db_bytes: number;
tile_sets: number;
tile_bytes: number;
tile_datasets: string[];
/** Age of the oldest dated dataset; undated sets (heightmap) are not counted. */
tiles_age_days: number | null;
}
export interface ReleasePlan {
docker: boolean;
online: boolean;
@@ -310,6 +325,8 @@ export interface ReleasePlan {
items: ReleaseItem[];
/** Why a release cannot run at all; null when it can. */
blocked: string | null;
/** Null when nothing is installed here, so there is no manifest to read it from. */
data: ReleaseData | null;
}
/** A slice of the running release's output: what the window has not shown yet. */