ODIAN Launcher: hub, installer, local stack and remembered sign-in (M2)

- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and
  update progress, local stack status, settings with DHP and MCP addresses.
- crates/odian-packs: Rust installer for pack format v1 (signed manifests,
  resumable verified downloads from a DHP or an ODIAN drive), byte-compatible
  with ODIAN_MCP_Server's Python odian_packs.
- Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe,
  one Job Object, clean shutdown; database snapshot restore on a fresh PC only.
- Keycloak sign-in through the DHP's MCP, remembered until Sign out with a
  launcher session that renews the Engine token silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kelorastus
2026-09-15 15:23:17 +02:00
commit b7f3061be3
102 changed files with 47611 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
src-tauri/target/
src-tauri/gen/schemas/
target/
*.log
.DS_Store
# Local Claude Code preview config
.claude/
Generated
+5836
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
[workspace]
resolver = "2"
members = ["src-tauri", "crates/odian-packs"]
[profile.release]
# The launcher downloads and hashes hundreds of GB; keep the hot paths optimised
# while the binary stays small.
lto = "thin"
codegen-units = 1
strip = true
+82
View File
@@ -0,0 +1,82 @@
# ODIAN Launcher
The desktop launcher for ODIAN. It signs an operator in with their site's
Keycloak account, installs ODIAN from the site (or from an ODIAN drive),
forces an update before launch while the site is reachable, and runs ODIAN
fully offline otherwise.
| Part | Where | Stack |
|---|---|---|
| Hub UI | `src/` | Svelte 5 + Vite, bundled fonts, no network requests of its own |
| Backend | `src-tauri/` | Tauri 2 (Rust): commands and events in [docs/CONTRACT.md](docs/CONTRACT.md) |
| Installer | `crates/odian-packs/` | Rust port of the pack format v1 (`ODIAN_MCP_Server/src/odian_packs`) |
## Develop
```bash
npm install
npm run dev # the hub in a normal browser, with a mock backend (?scenario=…)
npm run tauri dev # the real launcher window
npm run check # svelte-check
cargo test --workspace # Rust tests (needs ~/.cargo/bin on PATH)
```
Build the Windows setup (NSIS):
```bash
npm run tauri build
```
## How it finds the site
On first run the launcher reads `site.json` next to its executable and copies it
into its settings (`%APPDATA%\fr.odian.launcher\settings.json`). Operators can
change the addresses later in Settings. See [site.example.json](site.example.json):
```json
{ "name": "ODIAN test site", "site_url": "http://192.168.1.41:30097", "mcp_url": "http://192.168.1.41:30091" }
```
- `site_url`: the site's update service (`/v1/health`, versions, packs).
- `mcp_url`: the site's MCP, which runs the Keycloak desktop sign-in (`/auth/desktop/start`, `/auth/desktop/exchange`).
## On an operator PC
```
<install_dir>\ D:\ODIAN when a D: drive exists, else C:\ODIAN
versions\<v>\ app, API, tile server and runtime files for one version
data\db\<snapshot>\ database dumps restored into the local Postgres
data\tiles\<style>\<dataset>\ tiles
data\src\ source files
state\launcher.json install id, installed version, last operator
state\notes\<v>.md patch notes, for offline reading
```
The site token lives in Windows Credential Manager under "ODIAN Launcher", one
entry per site URL. The install id is generated on the PC the first time it saves
its state, so a cloned drive never carries one.
## The local stack
`launch` starts four processes, all on 127.0.0.1, in this order, each waiting for the previous one:
| Process | Port | Working dir | Ready when |
|---|---|---|---|
| Postgres (`versions\<v>\runtime\pgsql`) | 55432 | `state\pg` | `pg_ctl start -w` returns |
| Engine API (`versions\<v>\api\odian_api.exe`) | 18080 | `state\api` | `GET /health` answers |
| Tile server (`versions\<v>\tile_server`, `runtime\python`) | 18081 | `state\tiles` | `GET /health` answers |
| ODIAN (`versions\<v>\Odian\x64\MinGW-Release\Odian.exe`) | — | `state\client` | still running after 3 s |
- Every process starts from a clean environment (Windows basics plus the ODIAN
variables set in `src-tauri/src/stack.rs`), without a console window, inside
one Job Object, so nothing outlives the launcher.
- Odian.exe and the API read and write relative to their working directory.
`state\client` therefore holds junctions `assets` and `shaders` into the version
folder, and `versions\<v>\` is never written.
- The tile server runs cache-only (`ODIAN_TILE_CACHE_ONLY=1`) from a generated
`state\tiles\datasets.json`, and the client runs with `ODIAN_OFFLINE=1`.
- ODIAN signs in as the last operator through the local API's `/auth/mint`. The
mint secret and the Postgres password are generated on the PC and kept in
`state\launcher.json`.
- When Odian.exe closes, the rest stops in reverse order; Postgres stops with
`pg_ctl stop -m fast`.
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "odian-packs"
version = "0.1.0"
description = "ODIAN pack format v1: verify signed manifests and install a version from a site or an ODIAN drive"
edition = "2021"
rust-version = "1.81"
publish = false
[lib]
name = "odian_packs"
[dependencies]
# Same HTTP/TLS stack as src-tauri: reqwest 0.12, rustls (ring), no OpenSSL.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
tokio = { version = "1", features = ["rt", "fs", "sync", "time", "macros"] }
tokio-util = "0.7"
futures-util = "0.3"
bytes = "1"
sha2 = "0.10"
ed25519-dalek = "2.2"
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tar = { version = "0.4", default-features = false }
rusqlite = { version = "0.40", features = ["bundled"] }
thiserror = "2"
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tempfile = "3"
@@ -0,0 +1 @@
ed25519:DS7z/nhxZE9ffn0PKVtw5fMUBRLdQ424OE236A0tbts=
+116
View File
@@ -0,0 +1,116 @@
//! Canonical JSON: the one serialisation that gets hashed or signed.
//!
//! Byte-for-byte equal to Python's
//! `json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")`.
//!
//! Object keys are sorted HERE, by code point (UTF-8 byte order is the same order).
//! Nothing relies on `serde_json::Map` iteration order, which changes when any crate in
//! the build enables serde_json's `preserve_order` feature.
//!
//! Numbers must be integers in the i64/u64 range. Floats (`1.0`, `1e3`, `-0`) and integers
//! outside that range are refused: Python would serialise them, but no pack document holds
//! one, and refusing them keeps float formatting out of the signed bytes.
use serde_json::Value;
use crate::error::{Error, Result};
/// Canonical bytes of `value`, or [`Error::Manifest`] if it holds a non-integer number.
pub fn canonical_bytes(value: &Value) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(1024);
write_value(value, &mut out)?;
Ok(out)
}
fn write_value(value: &Value, out: &mut Vec<u8>) -> Result<()> {
match value {
Value::Null => out.extend_from_slice(b"null"),
Value::Bool(true) => out.extend_from_slice(b"true"),
Value::Bool(false) => out.extend_from_slice(b"false"),
Value::Number(n) => {
if let Some(u) = n.as_u64() {
out.extend_from_slice(u.to_string().as_bytes());
} else if let Some(i) = n.as_i64() {
out.extend_from_slice(i.to_string().as_bytes());
} else {
return Err(Error::manifest(format!(
"number {n} is not an integer in the 64-bit range; pack documents hold integers only"
)));
}
}
Value::String(s) => write_str(s, out),
Value::Array(items) => {
out.push(b'[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(b',');
}
write_value(item, out)?;
}
out.push(b']');
}
Value::Object(map) => {
let mut entries: Vec<(&String, &Value)> = map.iter().collect();
entries.sort_unstable_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
out.push(b'{');
for (i, (key, item)) in entries.into_iter().enumerate() {
if i > 0 {
out.push(b',');
}
write_str(key, out);
out.push(b':');
write_value(item, out)?;
}
out.push(b'}');
}
}
Ok(())
}
/// Python's `encode_basestring` (the `ensure_ascii=False` escaper).
fn write_str(s: &str, out: &mut Vec<u8>) {
const HEX: &[u8; 16] = b"0123456789abcdef";
out.push(b'"');
// Escapes only ever replace ASCII bytes, and bytes of multi-byte UTF-8 sequences are
// all >= 0x80, so a byte loop copies every other character through untouched.
for &b in s.as_bytes() {
match b {
b'"' => out.extend_from_slice(b"\\\""),
b'\\' => out.extend_from_slice(b"\\\\"),
b'\n' => out.extend_from_slice(b"\\n"),
b'\r' => out.extend_from_slice(b"\\r"),
b'\t' => out.extend_from_slice(b"\\t"),
0x08 => out.extend_from_slice(b"\\b"),
0x0c => out.extend_from_slice(b"\\f"),
0x00..=0x1f => {
out.extend_from_slice(b"\\u00");
out.push(HEX[(b >> 4) as usize]);
out.push(HEX[(b & 0x0f) as usize]);
}
_ => out.push(b),
}
}
out.push(b'"');
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn sorts_and_escapes_like_python() {
let v = json!({"b": 1, "a": [1, {"d": 2, "c": 3}]});
assert_eq!(canonical_bytes(&v).unwrap(), br#"{"a":[1,{"c":3,"d":2}],"b":1}"#);
let v = json!({"s": "q\"b\\n\n\u{1}\u{7f}é/"});
assert_eq!(canonical_bytes(&v).unwrap(), "{\"s\":\"q\\\"b\\\\n\\n\\u0001\u{7f}é/\"}".as_bytes());
}
#[test]
fn refuses_floats() {
for text in ["1.0", "1e3", "-0", "[0.5]", "18446744073709551616"] {
let v: Value = serde_json::from_str(text).unwrap();
assert!(canonical_bytes(&v).is_err(), "{text}");
}
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Copy a body into a file while hashing it, off the async threads, with cancellation.
use std::io::{BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use bytes::Bytes;
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
use crate::error::{Error, Result};
use crate::hashing::{hex, BUF};
use crate::progress::Reporter;
use crate::source::{blocking, Body};
pub(crate) struct Copied {
/// Bytes written by this copy.
pub written: u64,
pub hasher: Sha256,
/// The body held more than `limit` bytes; the excess was not written.
pub overflow: bool,
}
/// Copy `body` into `file` (opened and positioned by the caller), feeding `hasher`.
/// At most `limit` bytes are written. On a cut-short body or cancellation, whatever was
/// written stays on disk (a `.part` file resumes from it).
pub(crate) async fn copy_body(
body: Body,
file: std::fs::File,
file_path: PathBuf,
hasher: Sha256,
limit: u64,
reporter: Arc<Reporter>,
cancel: CancellationToken,
) -> Result<Copied> {
match body {
Body::File { mut reader, path } => {
blocking(move || {
let mut out = BufWriter::with_capacity(BUF, file);
let mut hasher = hasher;
let mut buf = vec![0u8; BUF];
let (mut written, mut overflow) = (0u64, false);
let result = (|| {
loop {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let n = reader.read(&mut buf).map_err(Error::io(&path))?;
if n == 0 {
return Ok(());
}
let room = (limit - written).min(n as u64) as usize;
overflow = room < n;
out.write_all(&buf[..room]).map_err(Error::io(&file_path))?;
hasher.update(&buf[..room]);
written += room as u64;
reporter.add_transferred(room as u64);
if overflow {
return Ok(());
}
}
})();
let flushed = out.flush().map_err(Error::io(&file_path));
result?;
flushed?;
Ok(Copied { written, hasher, overflow })
})
.await
}
Body::Http { response, url } => {
let (tx, mut rx) = tokio::sync::mpsc::channel::<Bytes>(16);
let writer = tokio::task::spawn_blocking(move || -> Result<(u64, Sha256)> {
let mut out = BufWriter::with_capacity(BUF, file);
let mut hasher = hasher;
let mut written = 0u64;
while let Some(chunk) = rx.blocking_recv() {
out.write_all(&chunk).map_err(Error::io(&file_path))?;
hasher.update(&chunk);
written += chunk.len() as u64;
}
out.flush().map_err(Error::io(&file_path))?;
Ok((written, hasher))
});
let mut stream = response.bytes_stream();
let (mut sent, mut overflow) = (0u64, false);
let mut outcome: Result<()> = Ok(());
loop {
let next = tokio::select! {
_ = cancel.cancelled() => { outcome = Err(Error::Cancelled); break; }
next = stream.next() => next,
};
match next {
Some(Ok(mut chunk)) => {
let room = limit - sent;
if chunk.len() as u64 > room {
chunk.truncate(room as usize);
overflow = true;
}
let len = chunk.len() as u64;
if len > 0 {
if tx.send(chunk).await.is_err() {
break; // the writer failed; its error is reported below
}
sent += len;
reporter.add_transferred(len);
}
if overflow {
break;
}
}
Some(Err(e)) => {
outcome = Err(Error::Http { url: url.clone(), status: None, message: format!("body cut short: {e}") });
break;
}
None => break,
}
}
drop(tx);
let (written, hasher) = match writer.await {
Ok(result) => result?,
Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
Err(_) => return Err(Error::Cancelled),
};
outcome?;
Ok(Copied { written, hasher, overflow })
}
}
}
/// Blocking: copy a reader into a new file, hashing. Returns (bytes, sha256 hex).
pub(crate) fn copy_hashing<R: Read>(
reader: &mut R,
target: &Path,
buf: &mut [u8],
reporter: &Reporter,
cancel: &CancellationToken,
read_error: &dyn Fn(std::io::Error) -> Error,
) -> Result<(u64, String)> {
let file = std::fs::File::create(target).map_err(Error::io(target))?;
let mut out = BufWriter::with_capacity(256 * 1024, file);
let mut hasher = Sha256::new();
let mut written = 0u64;
loop {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let n = reader.read(buf).map_err(read_error)?;
if n == 0 {
break;
}
out.write_all(&buf[..n]).map_err(Error::io(target))?;
hasher.update(&buf[..n]);
written += n as u64;
reporter.add_transferred(n as u64);
}
out.flush().map_err(Error::io(target))?;
Ok((written, hex(&hasher.finalize())))
}
/// 32 hex characters, unique within this process and very likely across processes
/// (the role `uuid.uuid4().hex` plays in fetch.py).
pub(crate) fn unique_hex() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
let mut hasher = Sha256::new();
hasher.update(nanos.to_le_bytes());
hasher.update(std::process::id().to_le_bytes());
hasher.update(n.to_le_bytes());
hex(&hasher.finalize()[..16])
}
/// Write `data` to `path` through a temp file and a rename (fetch.py's `_atomic_write`).
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
}
let mut temp = path.as_os_str().to_owned();
temp.push(format!(".{}.tmp", unique_hex()));
let temp = PathBuf::from(temp);
let result = std::fs::write(&temp, data).map_err(Error::io(&temp)).and_then(|()| {
std::fs::rename(&temp, path).map_err(Error::io(path))
});
if result.is_err() {
let _ = std::fs::remove_file(&temp);
}
result
}
/// `path` + `suffix` (e.g. `Odian.exe` -> `Odian.exe.part`).
pub(crate) fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(suffix);
PathBuf::from(s)
}
+92
View File
@@ -0,0 +1,92 @@
//! One error type for the whole crate, with the variants the launcher UI branches on.
use std::fmt;
use std::path::PathBuf;
pub type Result<T, E = Error> = std::result::Result<T, E>;
/// Which check failed when [`Error::Integrity`] is returned.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IntegrityKind {
/// The manifest is unsigned, altered after signing, or signed by another key.
Signature,
/// The site's signed manifest names another version than the one asked for.
Version,
/// A whole file (app or database dump) does not match its size and sha256.
FileHash,
/// A source-file chunk does not match its sha256.
ChunkHash,
/// A tile index does not match the manifest's `index_sha256`.
IndexHash,
/// A tile block's tiles do not add up to the signed block digest.
BlockDigest,
/// A tile block holds an entry that is not a tile of that block.
TarEntry,
}
impl fmt::Display for IntegrityKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
IntegrityKind::Signature => "signature",
IntegrityKind::Version => "version",
IntegrityKind::FileHash => "file hash",
IntegrityKind::ChunkHash => "chunk hash",
IntegrityKind::IndexHash => "tile index hash",
IntegrityKind::BlockDigest => "tile block digest",
IntegrityKind::TarEntry => "tile block entry",
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The site refused the token (HTTP 401 or 403).
#[error("the DHP refused the sign-in (HTTP {status}) for {url}")]
Unauthorized { url: String, status: u16 },
/// A version, file or tile set does not exist on the site (404) or the drive.
#[error("not found: {what}")]
NotFound { what: String },
/// The site kept answering 503 (stream limit, or its Engine is briefly unreachable).
#[error("the DHP is busy: {url} answered 503 {attempts} times")]
SiteBusy { url: String, attempts: u32, retry_after_secs: Option<f64> },
/// Bytes do not match what the signed manifest (or the signed tile index) pins.
#[error("integrity check failed ({kind}): {what}: {detail}")]
Integrity { kind: IntegrityKind, what: String, detail: String },
/// Any other HTTP failure: connection error, unexpected status, body cut short.
#[error("HTTP error for {url}: {message}")]
Http { url: String, status: Option<u16>, message: String },
#[error("I/O error on {}: {source}", path.display())]
Io { path: PathBuf, #[source] source: std::io::Error },
/// The manifest (or a document it pins) is malformed.
#[error("manifest error: {0}")]
Manifest(String),
#[error("cancelled")]
Cancelled,
}
impl Error {
pub(crate) fn io(path: impl Into<PathBuf>) -> impl FnOnce(std::io::Error) -> Error {
let path = path.into();
move |source| Error::Io { path, source }
}
pub(crate) fn manifest(message: impl Into<String>) -> Error {
Error::Manifest(message.into())
}
pub(crate) fn integrity(kind: IntegrityKind, what: impl Into<String>, detail: impl Into<String>) -> Error {
Error::Integrity { kind, what: what.into(), detail: detail.into() }
}
pub fn is_cancelled(&self) -> bool {
matches!(self, Error::Cancelled)
}
}
+85
View File
@@ -0,0 +1,85 @@
//! sha256 helpers (the Python module `odian_packs.hashing`).
use std::io::Read;
use std::path::Path;
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
use crate::error::{Error, Result};
pub(crate) const BUF: usize = 1024 * 1024;
/// Lowercase hex of any byte slice.
pub fn hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0x0f) as usize] as char);
}
s
}
/// sha256 hex of a byte slice.
pub fn sha256_hex(data: &[u8]) -> String {
hex(&Sha256::digest(data))
}
/// Inclusive byte range of chunk `index` of a `size`-byte file: the form an HTTP Range takes.
pub fn chunk_range(index: u64, size: u64, chunk_bytes: u64) -> Option<(u64, u64)> {
let start = index.checked_mul(chunk_bytes)?;
if start >= size {
return None;
}
Some((start, size.min(start.saturating_add(chunk_bytes)) - 1))
}
/// Feed the first `limit` bytes (or all, if `None`) of a file into `hasher`. Blocking.
/// Returns how many bytes were read.
pub(crate) fn hash_file_prefix(
path: &Path,
hasher: &mut Sha256,
limit: Option<u64>,
cancel: &CancellationToken,
) -> Result<u64> {
let file = std::fs::File::open(path).map_err(Error::io(path))?;
let mut reader: Box<dyn Read> = match limit {
Some(n) => Box::new(file.take(n)),
None => Box::new(file),
};
let mut buf = vec![0u8; BUF];
let mut total = 0u64;
loop {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let n = reader.read(&mut buf).map_err(Error::io(path))?;
if n == 0 {
return Ok(total);
}
hasher.update(&buf[..n]);
total += n as u64;
}
}
/// (size, sha256 hex) of a whole file. Blocking.
pub(crate) fn sha256_file(path: &Path, cancel: &CancellationToken) -> Result<(u64, String)> {
let mut hasher = Sha256::new();
let size = hash_file_prefix(path, &mut hasher, None, cancel)?;
Ok((size, hex(&hasher.finalize())))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ranges_match_python() {
assert_eq!(chunk_range(2, 25, 10), Some((20, 24)));
assert_eq!(chunk_range(0, 25, 10), Some((0, 9)));
assert_eq!(chunk_range(3, 25, 10), None);
assert_eq!(chunk_range(0, 0, 10), None);
assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
}
}
+620
View File
@@ -0,0 +1,620 @@
//! The installer (fetch.py's `fetch`): verified manifest in, verified install out.
use std::collections::BTreeSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use futures_util::StreamExt;
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
use crate::copy::{atomic_write, copy_body, with_suffix};
use crate::error::{Error, IntegrityKind, Result};
use crate::hashing::{chunk_range, hash_file_prefix, hex, sha256_file};
use crate::manifest::{safe_relpath, AppFile, DbFile, SrcFile, SrcSection, TilesSection, VerifiedManifest};
use crate::progress::{Progress, Reporter, Stage};
use crate::source::{blocking, Backend, Pack, Source};
use crate::tileblocks::{copy_drive_block, fetch_site_block, DriveBlock};
use crate::tiles::{verify_index, IndexBlock};
/// How to install.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallOptions {
/// Parallel downloads (db uses at most 4, source chunks at most 3, like fetch.py).
pub workers: usize,
/// Stages to run; `App`, `Db`, `Src` and `Tiles` are meaningful, others are ignored.
pub only: Vec<Stage>,
}
impl Default for InstallOptions {
fn default() -> Self {
InstallOptions { workers: 6, only: vec![Stage::App, Stage::Db, Stage::Src, Stage::Tiles] }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AppSummary {
pub files: usize,
pub downloaded: usize,
pub skipped: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DbSummary {
pub snapshot: String,
pub files: usize,
pub downloaded: usize,
pub skipped: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SrcFileSummary {
pub path: String,
/// Chunks fetched by this run.
pub chunks: usize,
pub skipped: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TileSetSummary {
pub set: String,
pub blocks: usize,
/// Blocks fetched by this run.
pub downloaded: usize,
pub files: u64,
pub bytes: u64,
}
/// What an install did (fetch.py's summary dict, plus the bytes that were transferred).
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Summary {
pub version: String,
pub app: Option<AppSummary>,
pub db: Option<DbSummary>,
pub src: Option<Vec<SrcFileSummary>>,
pub tiles: Option<Vec<TileSetSummary>>,
/// Bytes received from the site (or copied from the drive) by this run.
pub transferred_bytes: u64,
pub seconds: f64,
}
struct Ctx<'a> {
backend: &'a Backend,
reporter: Arc<Reporter>,
cancel: CancellationToken,
workers: usize,
}
fn join_rel(root: &Path, rel: &str) -> Result<PathBuf> {
let mut path = root.to_path_buf();
for part in safe_relpath(rel)?.split('/') {
path.push(part);
}
Ok(path)
}
/// Install `manifest` from `source` into `dest`. Resumable: re-running skips everything
/// already verified. `versions/<v>/manifest.json` is written last, only on success.
pub async fn install<F>(
source: &Source,
dest: &Path,
manifest: &VerifiedManifest,
opts: InstallOptions,
progress: F,
cancel: CancellationToken,
) -> Result<Summary>
where
F: Fn(Progress) + Send + Sync + 'static,
{
let started = Instant::now();
let backend = Backend::new(source)?;
let m = manifest.manifest();
let version = m.version.clone();
let wants = |stage: Stage| opts.only.contains(&stage);
let do_app = wants(Stage::App);
let do_db = wants(Stage::Db) && m.db.is_some();
let do_src = wants(Stage::Src) && m.src.as_ref().is_some_and(|s| s.enabled);
let do_tiles = wants(Stage::Tiles) && m.tiles.is_some();
let stage_count = 1 + [do_app, do_db, do_src, do_tiles].iter().filter(|b| **b).count() as u32;
let reporter = Arc::new(Reporter::new(Arc::new(progress), stage_count));
reporter.begin_stage(Stage::Manifest, 1, 0, 1);
reporter.begin_sub(format!("manifest {version}: signature OK"), "", 0);
reporter.items_done(1);
reporter.finish_stage();
let ctx = Ctx { backend: &backend, reporter: reporter.clone(), cancel: cancel.clone(), workers: opts.workers.max(1) };
let version_dir = dest.join("versions").join(&version);
let data_dir = dest.join("data");
let mut summary = Summary {
version: version.clone(),
app: None,
db: None,
src: None,
tiles: None,
transferred_bytes: 0,
seconds: 0.0,
};
let mut stage_index = 1;
let check_cancel = || if cancel.is_cancelled() { Err(Error::Cancelled) } else { Ok(()) };
if do_app {
check_cancel()?;
stage_index += 1;
summary.app = Some(install_app(&ctx, stage_index, &m.app.files, &version_dir).await?);
}
if let (true, Some(db)) = (do_db, &m.db) {
check_cancel()?;
stage_index += 1;
let (files, downloaded, skipped) = install_db(&ctx, stage_index, &db.files, &data_dir).await?;
summary.db = Some(DbSummary { snapshot: db.snapshot.clone(), files, downloaded, skipped });
}
if let (true, Some(src)) = (do_src, &m.src) {
check_cancel()?;
stage_index += 1;
summary.src = Some(install_src(&ctx, stage_index, src, &data_dir).await?);
}
if let (true, Some(tiles)) = (do_tiles, &m.tiles) {
check_cancel()?;
stage_index += 1;
summary.tiles = Some(install_tiles(&ctx, stage_index, &version, tiles, &data_dir).await?);
}
check_cancel()?;
// Written last: its presence means this version is fully installed.
let manifest_path = version_dir.join("manifest.json");
let raw = manifest.raw().to_vec();
blocking(move || atomic_write(&manifest_path, &raw)).await?;
summary.transferred_bytes = reporter.transferred();
summary.seconds = (started.elapsed().as_secs_f64() * 10.0).round() / 10.0;
Ok(summary)
}
/// Run `f` over `items` with at most `workers` in flight. On the first error nothing new
/// starts, running items are told to stop (their verified work is kept) and drained, and
/// that first error is returned (a real error wins over the `Cancelled` it caused).
async fn run_pool<I, T, Fut, F, G>(items: Vec<I>, workers: usize, cancel: &CancellationToken, f: F, mut on_ok: G) -> Result<()>
where
F: Fn(I, CancellationToken) -> Fut,
Fut: Future<Output = Result<T>>,
G: FnMut(T) -> Result<()>,
{
let stop = cancel.child_token();
let futures = items.into_iter().map(|item| {
let token = stop.clone();
let fut = f(item, token.clone());
async move {
if token.is_cancelled() {
return Err(Error::Cancelled);
}
fut.await
}
});
let mut stream = futures_util::stream::iter(futures).buffer_unordered(workers.max(1));
let mut first: Option<Error> = None;
while let Some(result) = stream.next().await {
if let Err(e) = result.and_then(&mut on_ok) {
stop.cancel();
first = match first {
None => Some(e),
Some(Error::Cancelled) if !e.is_cancelled() => Some(e),
keep => keep,
};
}
}
match first {
Some(e) => Err(e),
None if cancel.is_cancelled() => Err(Error::Cancelled),
None => Ok(()),
}
}
// ---- whole files (app, db) -------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Outcome {
Downloaded,
Skipped,
}
/// One whole file, resuming `<dest>.part` with a Range request; verified before the rename.
async fn download_file(ctx: &Ctx<'_>, pack: Pack<'_>, dest: &Path, size: u64, sha256: &str, cancel: &CancellationToken) -> Result<Outcome> {
let existing = {
let (dest, cancel) = (dest.to_path_buf(), cancel.clone());
blocking(move || match std::fs::metadata(&dest) {
Ok(md) if md.is_file() && md.len() == size => sha256_file(&dest, &cancel).map(|(_, sha)| Some(sha)),
_ => Ok(None),
})
.await?
};
if existing.as_deref() == Some(sha256) {
ctx.reporter.add_done_bytes(size);
return Ok(Outcome::Skipped);
}
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await.map_err(Error::io(parent))?;
}
let part = with_suffix(dest, ".part");
let mut got = (0u64, String::new());
for _ in 0..2 {
let mut counted = 0u64;
let mut have = match tokio::fs::metadata(&part).await {
Ok(md) if md.is_file() => md.len(),
_ => 0,
};
if have > size {
tokio::fs::remove_file(&part).await.map_err(Error::io(&part))?;
have = 0;
}
let (got_size, got_sha) = if have < size {
let mut hasher = Sha256::new();
if have > 0 {
let (path, cancel) = (part.clone(), cancel.clone());
hasher = blocking(move || {
let mut hasher = Sha256::new();
hash_file_prefix(&path, &mut hasher, Some(have), &cancel)?;
Ok(hasher)
})
.await?;
}
let (body, starts_at_offset) = ctx.backend.open_pack(pack, have, cancel).await?;
if have > 0 && !starts_at_offset {
have = 0; // the server ignored the Range; start over
hasher = Sha256::new();
}
ctx.reporter.add_done_bytes(have);
counted += have;
let file = {
let path = part.clone();
blocking(move || {
let mut options = std::fs::OpenOptions::new();
if have > 0 { options.append(true) } else { options.write(true).create(true).truncate(true) };
options.open(&path).map_err(Error::io(&path))
})
.await?
};
let copied = copy_body(body, file, part.clone(), hasher, size - have, ctx.reporter.clone(), cancel.clone()).await?;
counted += copied.written;
(have + copied.written + u64::from(copied.overflow), hex(&copied.hasher.finalize()))
} else {
let (path, cancel) = (part.clone(), cancel.clone());
let (s, h) = blocking(move || {
if !path.is_file() {
std::fs::File::create(&path).map_err(Error::io(&path))?; // an empty file: nothing to fetch
}
sha256_file(&path, &cancel)
})
.await?;
ctx.reporter.add_done_bytes(s);
counted += s;
(s, h)
};
if got_size == size && got_sha == sha256 {
let (from, to) = (part.clone(), dest.to_path_buf());
blocking(move || std::fs::rename(&from, &to).map_err(Error::io(&to))).await?;
return Ok(Outcome::Downloaded);
}
// Corrupt or stale partial: one clean retry, then give up.
ctx.reporter.retract_bytes(counted);
let _ = tokio::fs::remove_file(&part).await;
got = (got_size, got_sha);
}
Err(Error::integrity(
IntegrityKind::FileHash,
ctx.backend.pack_label(pack),
format!("expected {size} bytes sha256 {sha256}, got {} bytes sha256 {}", got.0, got.1),
))
}
async fn install_app(ctx: &Ctx<'_>, stage_index: u32, files: &[AppFile], version_dir: &Path) -> Result<AppSummary> {
let total: u64 = files.iter().map(|f| f.size).sum();
ctx.reporter.begin_stage(Stage::App, stage_index, total, files.len() as u64);
ctx.reporter.begin_sub("app".into(), "files", files.len() as u64);
let (mut downloaded, mut skipped) = (0, 0);
run_pool(
files.iter().collect(),
ctx.workers,
&ctx.cancel,
|entry: &AppFile, stop| async move {
let target = join_rel(version_dir, &entry.path)?;
download_file(ctx, Pack::App(&entry.sha256), &target, entry.size, &entry.sha256, &stop).await
},
|outcome| {
if outcome == Outcome::Downloaded { downloaded += 1 } else { skipped += 1 }
ctx.reporter.items_done(1);
Ok(())
},
)
.await?;
ctx.reporter.finish_stage();
Ok(AppSummary { files: files.len(), downloaded, skipped })
}
async fn install_db(ctx: &Ctx<'_>, stage_index: u32, files: &[DbFile], data_dir: &Path) -> Result<(usize, usize, usize)> {
let total: u64 = files.iter().map(|f| f.size).sum();
ctx.reporter.begin_stage(Stage::Db, stage_index, total, files.len() as u64);
ctx.reporter.begin_sub("db".into(), "files", files.len() as u64);
let (mut downloaded, mut skipped) = (0, 0);
run_pool(
files.iter().collect(),
ctx.workers.min(4),
&ctx.cancel,
|entry: &DbFile, stop| async move {
let target = join_rel(data_dir, &entry.path)?;
download_file(ctx, Pack::Db(&entry.path), &target, entry.size, &entry.sha256, &stop).await
},
|outcome| {
if outcome == Outcome::Downloaded { downloaded += 1 } else { skipped += 1 }
ctx.reporter.items_done(1);
Ok(())
},
)
.await?;
ctx.reporter.finish_stage();
Ok((files.len(), downloaded, skipped))
}
// ---- source files -----------------------------------------------------------------
async fn install_src(ctx: &Ctx<'_>, stage_index: u32, src: &SrcSection, data_dir: &Path) -> Result<Vec<SrcFileSummary>> {
let total: u64 = src.files.iter().map(|f| f.size).sum();
let chunks: u64 = src.files.iter().map(|f| f.chunks.len() as u64).sum();
ctx.reporter.begin_stage(Stage::Src, stage_index, total, chunks);
let mut out = Vec::with_capacity(src.files.len());
for entry in &src.files {
if ctx.cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let target = join_rel(&data_dir.join("src"), &entry.path)?;
ctx.reporter.begin_sub(format!("src {}", entry.path), "chunks", entry.chunks.len() as u64);
out.push(fetch_source_file(ctx, entry, src.chunk_bytes, &target).await?);
}
ctx.reporter.finish_stage();
Ok(out)
}
/// `{"chunks_sha256": "...", "verified": [0, 1]}`, exactly as Python's `json.dumps` writes it.
fn chunks_progress_json(chunks_sha256: &str, verified: &BTreeSet<u64>) -> String {
let list: Vec<String> = verified.iter().map(u64::to_string).collect();
format!("{{\"chunks_sha256\": \"{chunks_sha256}\", \"verified\": [{}]}}", list.join(", "))
}
/// One source file, chunk by chunk from byte ranges, written in place.
async fn fetch_source_file(ctx: &Ctx<'_>, entry: &SrcFile, chunk_bytes: u64, dest: &Path) -> Result<SrcFileSummary> {
let n = entry.chunks.len() as u64;
let marker = with_suffix(dest, ".ok");
let progress_path = with_suffix(dest, ".chunks.json");
let (done, verified) = {
let (dest, marker, progress_path) = (dest.to_path_buf(), marker.clone(), progress_path.clone());
let (size, digest) = (entry.size, entry.chunks_sha256.clone());
blocking(move || -> Result<(bool, BTreeSet<u64>)> {
let dest_ok = std::fs::metadata(&dest).is_ok_and(|md| md.is_file() && md.len() == size);
if dest_ok && marker.is_file() {
if let Ok(text) = std::fs::read_to_string(&marker) {
if text.trim() == digest {
return Ok((true, BTreeSet::new()));
}
}
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
}
let mut verified = BTreeSet::new();
if dest_ok && progress_path.is_file() {
let saved = std::fs::read(&progress_path).ok().and_then(|raw| serde_json::from_slice::<Value>(&raw).ok());
if let Some(saved) = saved.filter(|s| s.get("chunks_sha256").and_then(Value::as_str) == Some(&digest)) {
let list = saved.get("verified").and_then(Value::as_array).cloned().unwrap_or_default();
verified.extend(list.iter().filter_map(Value::as_u64).filter(|i| *i < n));
}
}
if !dest_ok {
let file = std::fs::File::create(&dest).map_err(Error::io(&dest))?;
file.set_len(size).map_err(Error::io(&dest))?;
verified.clear();
}
Ok((false, verified))
})
.await?
};
if done {
ctx.reporter.add_done_bytes(entry.size);
ctx.reporter.items_done(n);
return Ok(SrcFileSummary { path: entry.path.clone(), chunks: 0, skipped: n as usize });
}
for index in &verified {
let (start, end) = chunk_range(*index, entry.size, chunk_bytes).expect("validated chunk index");
ctx.reporter.add_done_bytes(end - start + 1);
}
ctx.reporter.items_done(verified.len() as u64);
let pending: Vec<u64> = (0..n).filter(|i| !verified.contains(i)).collect();
let fetched = pending.len();
let mut verified = verified;
run_pool(
pending,
ctx.workers.min(3),
&ctx.cancel,
|index, stop| fetch_chunk(ctx, entry, chunk_bytes, dest, index, stop),
|index| {
verified.insert(index);
atomic_write(&progress_path, chunks_progress_json(&entry.chunks_sha256, &verified).as_bytes())?;
ctx.reporter.items_done(1);
Ok(())
},
)
.await?;
let (marker_path, progress_file, digest) = (marker, progress_path, entry.chunks_sha256.clone());
blocking(move || {
atomic_write(&marker_path, digest.as_bytes())?;
match std::fs::remove_file(&progress_file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(Error::Io { path: progress_file, source: e }),
_ => Ok(()),
}
})
.await?;
Ok(SrcFileSummary { path: entry.path.clone(), chunks: fetched, skipped: n as usize - fetched })
}
async fn fetch_chunk(ctx: &Ctx<'_>, entry: &SrcFile, chunk_bytes: u64, dest: &Path, index: u64, cancel: CancellationToken) -> Result<u64> {
let (start, end) = chunk_range(index, entry.size, chunk_bytes).expect("validated chunk index");
let len = end - start + 1;
let expected = &entry.chunks[index as usize];
let mut got = String::new();
for _ in 0..2 {
let body = ctx.backend.open_src_range(&entry.path, start, end, &cancel).await?;
let file = {
let path = dest.to_path_buf();
blocking(move || {
use std::io::{Seek, SeekFrom};
let mut file = std::fs::OpenOptions::new().write(true).open(&path).map_err(Error::io(&path))?;
file.seek(SeekFrom::Start(start)).map_err(Error::io(&path))?;
Ok(file)
})
.await?
};
let copied = copy_body(body, file, dest.to_path_buf(), Sha256::new(), len, ctx.reporter.clone(), cancel.clone()).await?;
got = hex(&copied.hasher.finalize());
if copied.written == len && !copied.overflow && got == *expected {
return Ok(index);
}
ctx.reporter.retract_bytes(copied.written);
}
Err(Error::integrity(
IntegrityKind::ChunkHash,
format!("/src/{} chunk {index}", entry.path),
format!("sha256 mismatch after retry: expected {expected}, got {got}"),
))
}
// ---- tiles --------------------------------------------------------------------------
/// `{"index_sha256": "...", "blocks": ["z/bx/by:digest", ...]}`, as Python's `json.dumps` writes it.
fn blocks_done_json(index_sha256: &str, done: &BTreeSet<String>) -> String {
let list: Vec<String> = done.iter().map(|k| format!("\"{k}\"")).collect();
format!("{{\"index_sha256\": \"{index_sha256}\", \"blocks\": [{}]}}", list.join(", "))
}
async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &TilesSection, data_dir: &Path) -> Result<Vec<TileSetSummary>> {
let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum();
let blocks: u64 = tiles.sets.iter().map(|s| s.blocks).sum();
ctx.reporter.begin_stage(Stage::Tiles, stage_index, total, blocks);
let journal = match ctx.backend.drive_root() {
Some(root) if !tiles.sets.is_empty() => {
let path = root.join("tiles").join("tiles.db");
Some(Arc::new(blocking(move || crate::source::Journal::open(&path)).await?))
}
_ => None,
};
let mut out = Vec::with_capacity(tiles.sets.len());
for set in &tiles.sets {
if ctx.cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let (style, dataset) = (set.style.as_str(), set.dataset.as_str());
let what = format!("tile index {style}/{dataset}");
let raw = ctx.backend.tile_index(version, style, dataset, &ctx.cancel).await?;
let index = verify_index(&raw, &set.index_sha256, &what)?;
let set_dir = data_dir.join("tiles").join(style).join(dataset);
let done_path = set_dir.join(".blocks-done.json");
let mut done: BTreeSet<String> = BTreeSet::new();
if let Some(saved) = std::fs::read(&done_path).ok().and_then(|raw| serde_json::from_slice::<Value>(&raw).ok()) {
if saved.get("index_sha256").and_then(Value::as_str) == Some(set.index_sha256.as_str()) {
let list = saved.get("blocks").and_then(Value::as_array).cloned().unwrap_or_default();
done.extend(list.iter().filter_map(Value::as_str).map(str::to_string));
}
}
let pending: Vec<IndexBlock> = index.blocks.iter().filter(|b| !done.contains(&b.done_key())).cloned().collect();
ctx.reporter.begin_sub(format!("tiles {style}/{dataset}"), "blocks", index.blocks.len() as u64);
let skipped_bytes: u64 = index.blocks.iter().filter(|b| done.contains(&b.done_key())).map(|b| b.bytes).sum();
ctx.reporter.add_done_bytes(skipped_bytes);
ctx.reporter.items_done((index.blocks.len() - pending.len()) as u64);
tokio::fs::create_dir_all(&set_dir).await.map_err(Error::io(&set_dir))?;
let fetched = pending.len();
let mut count = 0usize;
let set_root = ctx.backend.drive_root().map(|root| root.join("tiles").join(style).join(dataset));
let result = run_pool(
pending,
ctx.workers,
&ctx.cancel,
|block: IndexBlock, stop| {
let (set_dir, reporter, journal, set_root) = (set_dir.clone(), ctx.reporter.clone(), journal.clone(), set_root.clone());
let (style, dataset, journal_seq) = (style.to_string(), dataset.to_string(), index.journal_seq);
async move {
let key = block.done_key();
let url = format!("/v1/versions/{version}/tiles/{style}/{dataset}/block/{}/{}/{}.tar", block.z, block.bx, block.by);
match (ctx.backend.site(), journal, set_root) {
(Some(site), _, _) => fetch_site_block(site, url, block, set_dir, reporter, stop).await?,
(None, Some(journal), Some(set_root)) => {
blocking(move || {
let drive = DriveBlock { journal: &journal, set_root: &set_root, style: &style, dataset: &dataset, journal_seq };
let what = format!("tiles/{style}/{dataset} block {}/{}/{}", block.z, block.bx, block.by);
copy_drive_block(&drive, &what, &block, &set_dir, &reporter, &stop)
})
.await?
}
_ => unreachable!("a drive install always opens the journal"),
}
Ok(key)
}
},
|key| {
done.insert(key);
count += 1;
ctx.reporter.items_done(1);
if count % 50 == 0 || count == fetched {
atomic_write(&done_path, blocks_done_json(&set.index_sha256, &done).as_bytes())?;
}
Ok(())
},
)
.await;
if result.is_err() && count % 50 != 0 {
// Keep the blocks that did verify, so the next run does not fetch them again.
let _ = atomic_write(&done_path, blocks_done_json(&set.index_sha256, &done).as_bytes());
}
result?;
out.push(TileSetSummary {
set: format!("{style}/{dataset}"),
blocks: index.blocks.len(),
downloaded: fetched,
files: index.files,
bytes: index.bytes,
});
}
ctx.reporter.finish_stage();
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn progress_files_match_python_json_dumps() {
let verified: BTreeSet<u64> = [2, 0, 1].into_iter().collect();
assert_eq!(chunks_progress_json("ab", &verified), r#"{"chunks_sha256": "ab", "verified": [0, 1, 2]}"#);
let done: BTreeSet<String> = ["7/1/0:bb".to_string(), "0/0/0:aa".to_string()].into_iter().collect();
assert_eq!(blocks_done_json("cd", &done), r#"{"index_sha256": "cd", "blocks": ["0/0/0:aa", "7/1/0:bb"]}"#);
assert_eq!(blocks_done_json("cd", &BTreeSet::new()), r#"{"index_sha256": "cd", "blocks": []}"#);
}
#[test]
fn install_future_is_send() {
fn assert_send<T: Send>(_: &T) {}
let source = Source::Drive { root: PathBuf::from(".") };
let raw = b"{}".to_vec();
if let Ok(manifest) = VerifiedManifest::from_bytes(raw, crate::bundled_public_key()) {
let fut = install(&source, Path::new("."), &manifest, InstallOptions::default(), |_| {}, CancellationToken::new());
assert_send(&fut);
}
}
}
+73
View File
@@ -0,0 +1,73 @@
//! ODIAN packs, format v1: verify a signed version manifest and install that version from a
//! site's update service or from an ODIAN drive, checking every byte.
//!
//! A byte-for-byte compatible port of the Python installer (`odian_packs.fetch` in
//! ODIAN_MCP_Server; the format is its `src/odian_packs/README.md`). An install made by
//! either one is resumed and re-verified by the other: same layout, same progress files.
//!
//! ```text
//! dest/versions/<v>/manifest.json written LAST: its presence means "installed"
//! dest/versions/<v>/<app paths>
//! dest/data/db/<snapshot>/...
//! dest/data/src/<paths> + <path>.chunks.json while partial, <path>.ok when done
//! dest/data/tiles/<style>/<dataset>/<z>/<x>/<y><ext> + .blocks-done.json
//! ```
pub mod canonical;
pub mod error;
pub mod hashing;
pub mod manifest;
pub mod signing;
pub mod tiles;
mod copy;
mod install;
mod progress;
mod source;
mod tileblocks;
pub use canonical::canonical_bytes;
pub use error::{Error, IntegrityKind, Result};
pub use install::{
install, AppSummary, DbSummary, InstallOptions, SrcFileSummary, Summary, TileSetSummary,
};
pub use manifest::{safe_relpath, validate, Manifest, VerifiedManifest};
pub use progress::{Progress, Stage};
pub use source::Source;
use tokio_util::sync::CancellationToken;
/// The ODIAN release public key that ships with the launcher (`ed25519:<base64>`).
pub fn bundled_public_key() -> &'static str {
include_str!("../keys/odian-release.pub").trim()
}
/// The source's CURRENT version manifest: fetched, validated and signature-checked.
/// The raw bytes are kept ([`VerifiedManifest::raw`]) because the install writes them verbatim.
pub async fn current_manifest(source: &Source, public_key: &str) -> Result<VerifiedManifest> {
let backend = source::Backend::new(source)?;
let raw = backend.current_manifest(&CancellationToken::new()).await?;
VerifiedManifest::from_bytes(raw, public_key)
}
/// A specific version's manifest, verified, and checked to really be that version.
pub async fn version_manifest(source: &Source, version: &str, public_key: &str) -> Result<VerifiedManifest> {
let backend = source::Backend::new(source)?;
let raw = backend.version_manifest(version, &CancellationToken::new()).await?;
let verified = VerifiedManifest::from_bytes(raw, public_key)?;
if verified.version() != version {
return Err(Error::integrity(
IntegrityKind::Version,
format!("manifest for {version}"),
format!("asked for {version}, the signed manifest says {}", verified.version()),
));
}
Ok(verified)
}
/// A version's patch notes (Markdown). **Untrusted text**: not covered by the signature.
pub async fn fetch_notes(source: &Source, version: &str) -> Result<String> {
let backend = source::Backend::new(source)?;
let raw = backend.notes(version, &CancellationToken::new()).await?;
Ok(String::from_utf8_lossy(&raw).into_owned())
}
+454
View File
@@ -0,0 +1,454 @@
//! Version manifest: schema v1, validation, and the verified form the installer uses
//! (the Python module `odian_packs.manifest`).
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use crate::canonical::canonical_bytes;
use crate::error::{Error, Result};
use crate::hashing::hex;
pub const SCHEMA: u64 = 1;
pub const CHUNK_BYTES: u64 = 256 * 1024 * 1024;
pub const BLOCK_SHIFT: u32 = 6;
pub const DB_SECTIONS: [&str; 4] = ["pre-data", "data", "sequences", "post-data"];
const WINDOWS_FORBIDDEN: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
/// `MAJOR.MINOR.PATCH` with no leading zeros (ASCII digits only).
pub fn parse_version(text: &str) -> Result<(u64, u64, u64)> {
let bad = || Error::manifest(format!("version must be MAJOR.MINOR.PATCH, got {text:?}"));
let parts: Vec<&str> = text.split('.').collect();
if parts.len() != 3 {
return Err(bad());
}
let mut out = [0u64; 3];
for (slot, part) in out.iter_mut().zip(&parts) {
let ok = !part.is_empty()
&& part.bytes().all(|b| b.is_ascii_digit())
&& (*part == "0" || !part.starts_with('0'));
if !ok {
return Err(bad());
}
*slot = part.parse().map_err(|_| bad())?;
}
Ok((out[0], out[1], out[2]))
}
/// A path that stays inside the directory it is joined to, and that Windows can create as
/// a plain file. Same rules and order of checks as Python's `safe_relpath`.
pub fn safe_relpath(path: &str) -> Result<&str> {
if path.is_empty() {
return Err(Error::manifest("empty path"));
}
if path.contains('\\') || path.starts_with('/') {
return Err(Error::manifest(format!("path must be relative with '/' separators: {path:?}")));
}
let parts: Vec<&str> = path.split('/').collect();
if parts.iter().any(|p| matches!(*p, "" | "." | "..")) {
return Err(Error::manifest(format!("path must not contain empty, '.' or '..' parts: {path:?}")));
}
for part in parts {
if part.chars().any(|c| (c as u32) < 32 || WINDOWS_FORBIDDEN.contains(&c)) {
return Err(Error::manifest(format!(
"path part {part:?} holds a control character or one of <>:\"|?*: {path:?}"
)));
}
if part.ends_with('.') || part.ends_with(' ') {
return Err(Error::manifest(format!("path part {part:?} ends with a dot or space: {path:?}")));
}
if is_windows_reserved(part) {
return Err(Error::manifest(format!("path part {part:?} is a reserved Windows device name: {path:?}")));
}
}
Ok(path)
}
/// `^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(\..*)?$`, case-insensitive.
fn is_windows_reserved(part: &str) -> bool {
let base = match part.find('.') {
Some(i) => &part[..i],
None => part,
};
let lower = base.to_ascii_lowercase();
if matches!(lower.as_str(), "con" | "prn" | "aux" | "nul") {
return true;
}
for prefix in ["com", "lpt"] {
if let Some(rest) = lower.strip_prefix(prefix) {
let mut chars = rest.chars();
if let (Some(c), None) = (chars.next(), chars.next()) {
if c.is_ascii_digit() || matches!(c, '¹' | '²' | '³') {
return true;
}
}
}
}
false
}
pub fn chunk_count(size: u64, chunk_bytes: u64) -> u64 {
size.div_ceil(chunk_bytes)
}
/// sha256 over the concatenated chunk hashes (hex text): a source file's identity.
pub fn chunks_digest<S: AsRef<str>>(chunks: &[S]) -> String {
let mut digest = Sha256::new();
for chunk in chunks {
digest.update(chunk.as_ref().as_bytes());
}
hex(&digest.finalize())
}
/// Where a version keeps a tile set's frozen index, relative to `versions/<v>/`.
pub fn tile_index_name(style: &str, dataset: &str) -> String {
format!("tiles/{style}.{dataset}.index.json")
}
/// What the Ed25519 signature covers: canonical JSON of the manifest minus `signature`.
pub fn signing_payload(manifest: &Value) -> Result<Vec<u8>> {
let object = manifest.as_object().ok_or_else(|| Error::manifest("manifest must be a JSON object"))?;
let mut copy = Map::new();
for (k, v) in object {
if k != "signature" {
copy.insert(k.clone(), v.clone());
}
}
canonical_bytes(&Value::Object(copy))
}
// ---- validation -------------------------------------------------------------
fn require(cond: bool, message: impl FnOnce() -> String) -> Result<()> {
if cond { Ok(()) } else { Err(Error::Manifest(message())) }
}
fn is_count(value: Option<&Value>) -> bool {
value.and_then(Value::as_u64).is_some()
}
fn is_sha256(value: Option<&Value>) -> bool {
matches!(value.and_then(Value::as_str), Some(s) if s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')))
}
fn check_sha(value: Option<&Value>, place: &str) -> Result<()> {
require(is_sha256(value), || format!("{place}: sha256 must be 64 lowercase hex chars"))
}
fn is_name(s: &str) -> bool {
(1..=128).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
}
fn is_snapshot(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 16
&& b[..8].iter().all(u8::is_ascii_digit)
&& b[8] == b'T'
&& b[9..15].iter().all(u8::is_ascii_digit)
&& b[15] == b'Z'
}
fn relpath_value(value: Option<&Value>) -> Result<&str> {
match value.and_then(Value::as_str) {
Some(s) => safe_relpath(s),
None => Err(Error::manifest("empty path")),
}
}
fn version_value(value: Option<&Value>) -> Result<()> {
match value {
None => parse_version("").map(|_| ()),
Some(Value::String(s)) => parse_version(s).map(|_| ()),
Some(other) => Err(Error::manifest(format!("version must be MAJOR.MINOR.PATCH, got {other}"))),
}
}
fn entries<'a>(value: Option<&'a Value>, place: &str) -> Result<Vec<&'a Map<String, Value>>> {
let list = value.and_then(Value::as_array).ok_or_else(|| Error::manifest(format!("{place} must be a list")))?;
list.iter()
.enumerate()
.map(|(i, entry)| entry.as_object().ok_or_else(|| Error::manifest(format!("{place}[{i}] must be an object"))))
.collect()
}
fn object<'a>(value: &'a Value, message: &str) -> Result<&'a Map<String, Value>> {
value.as_object().ok_or_else(|| Error::manifest(message))
}
/// Error unless `manifest` is a well-formed schema-1 manifest. Structure only; the
/// signature is checked by [`crate::signing::verify`].
pub fn validate(manifest: &Value) -> Result<()> {
let m = object(manifest, "manifest must be a JSON object")?;
require(m.get("schema").and_then(Value::as_u64) == Some(SCHEMA), || format!("schema must be {SCHEMA}"))?;
require(m.get("product").and_then(Value::as_str) == Some("odian"), || "product must be 'odian'".into())?;
version_value(m.get("version"))?;
require(m.get("released_at").is_some_and(Value::is_string), || "released_at must be an ISO-8601 string".into())?;
if let Some(v) = m.get("min_launcher") {
version_value(Some(v))?;
}
if let Some(v) = m.get("notes") {
relpath_value(Some(v))?;
}
let app_files = match m.get("app") {
None => Vec::new(),
Some(app) => entries(object(app, "app must be an object")?.get("files"), "app.files")?,
};
let mut seen: HashSet<String> = HashSet::new();
for (i, entry) in app_files.iter().enumerate() {
let place = format!("app.files[{i}]");
let path = relpath_value(entry.get("path"))?;
require(seen.insert(path.to_lowercase()), || format!("{place}: duplicate path {path:?}"))?;
require(is_count(entry.get("size")), || format!("{place}: size must be a non-negative integer"))?;
check_sha(entry.get("sha256"), &place)?;
}
if let Some(db) = m.get("db") {
let db = object(db, "db must be an object")?;
require(is_count(db.get("schema_version")), || "db.schema_version must be a non-negative integer".into())?;
let snapshot = db
.get("snapshot")
.and_then(Value::as_str)
.filter(|s| is_snapshot(s))
.ok_or_else(|| Error::manifest("db.snapshot must look like 20260915T101500Z"))?;
let files = entries(db.get("files"), "db.files")?;
require(!files.is_empty(), || "db.files must not be empty".into())?;
let prefix = format!("db/{snapshot}/");
let mut seen: HashSet<String> = HashSet::new();
for (i, entry) in files.iter().enumerate() {
let place = format!("db.files[{i}]");
let section = entry.get("section").and_then(Value::as_str).filter(|s| DB_SECTIONS.contains(s));
let section = section.ok_or_else(|| Error::manifest(format!("{place}: section must be one of {DB_SECTIONS:?}")))?;
if section == "data" {
require(entry.get("table").and_then(Value::as_str).is_some_and(|t| !t.is_empty()), || {
format!("{place}: data dumps name their table")
})?;
}
let path = relpath_value(entry.get("path"))?;
require(path.starts_with(&prefix), || format!("{place}: path must live under {prefix}"))?;
require(seen.insert(path.to_lowercase()), || format!("{place}: duplicate path {path:?}"))?;
require(is_count(entry.get("size")), || format!("{place}: size must be a non-negative integer"))?;
check_sha(entry.get("sha256"), &place)?;
}
}
if let Some(tiles) = m.get("tiles") {
let tiles = object(tiles, "tiles must be an object")?;
require(tiles.get("block_shift").and_then(Value::as_u64) == Some(BLOCK_SHIFT as u64), || {
format!("tiles.block_shift must be {BLOCK_SHIFT}")
})?;
let mut sets_seen: HashSet<(String, String)> = HashSet::new();
for (i, entry) in entries(tiles.get("sets"), "tiles.sets")?.iter().enumerate() {
let place = format!("tiles.sets[{i}]");
for key in ["style", "dataset"] {
require(entry.get(key).and_then(Value::as_str).is_some_and(is_name), || format!("{place}: bad {key}"))?;
}
let style = entry["style"].as_str().unwrap_or_default().to_string();
let dataset = entry["dataset"].as_str().unwrap_or_default().to_string();
require(!sets_seen.contains(&(style.clone(), dataset.clone())), || {
format!("{place}: duplicate tile set {style}/{dataset}")
})?;
for count in ["journal_seq", "blocks", "files", "bytes"] {
require(is_count(entry.get(count)), || format!("{place}: {count} must be a non-negative integer"))?;
}
check_sha(entry.get("index_sha256"), &place)?;
let expected = tile_index_name(&style, &dataset);
require(entry.get("index").and_then(Value::as_str) == Some(expected.as_str()), || {
format!("{place}: index must be {expected:?}")
})?;
sets_seen.insert((style, dataset));
}
}
if let Some(src) = m.get("src") {
let src = object(src, "src must be an object")?;
require(src.get("enabled").is_some_and(Value::is_boolean), || "src.enabled must be true or false".into())?;
let chunk_bytes = src.get("chunk_bytes").and_then(Value::as_u64).filter(|n| *n > 0);
let chunk_bytes = chunk_bytes.ok_or_else(|| Error::manifest("src.chunk_bytes must be a positive integer"))?;
let mut seen: HashSet<String> = HashSet::new();
for (i, entry) in entries(src.get("files"), "src.files")?.iter().enumerate() {
let place = format!("src.files[{i}]");
let path = relpath_value(entry.get("path"))?;
require(seen.insert(path.to_lowercase()), || format!("{place}: duplicate path {path:?}"))?;
let size = entry.get("size").and_then(Value::as_u64);
let size = size.ok_or_else(|| Error::manifest(format!("{place}: size must be a non-negative integer")))?;
let chunks = entry.get("chunks").and_then(Value::as_array);
let chunks = chunks.ok_or_else(|| Error::manifest(format!("{place}: chunks must be a list")))?;
require(chunks.len() as u64 == chunk_count(size, chunk_bytes), || {
format!("{place}: {} chunks for {size} bytes at {chunk_bytes} per chunk", chunks.len())
})?;
let mut hashes = Vec::with_capacity(chunks.len());
for chunk in chunks {
check_sha(Some(chunk), &place)?;
hashes.push(chunk.as_str().unwrap_or_default());
}
require(entry.get("chunks_sha256").and_then(Value::as_str) == Some(chunks_digest(&hashes).as_str()), || {
format!("{place}: chunks_sha256 does not match chunks")
})?;
}
}
if let Some(signature) = m.get("signature") {
require(signature.as_str().is_some_and(|s| s.starts_with("ed25519:")), || {
"signature must look like 'ed25519:<base64>'".into()
})?;
}
Ok(())
}
// ---- typed view -------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppFile {
pub path: String,
pub size: u64,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AppSection {
pub files: Vec<AppFile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DbFile {
pub section: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table: Option<String>,
pub path: String,
pub size: u64,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DbSection {
pub schema_version: u64,
pub snapshot: String,
pub files: Vec<DbFile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileSet {
pub style: String,
pub dataset: String,
pub journal_seq: u64,
pub blocks: u64,
pub files: u64,
pub bytes: u64,
pub index: String,
pub index_sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TilesSection {
pub block_shift: u64,
pub sets: Vec<TileSet>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SrcFile {
pub path: String,
pub size: u64,
pub chunks: Vec<String>,
pub chunks_sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SrcSection {
pub enabled: bool,
pub chunk_bytes: u64,
pub files: Vec<SrcFile>,
}
/// A validated manifest's fields (unknown keys are kept only in the raw bytes).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
pub schema: u64,
pub product: String,
pub version: String,
pub released_at: String,
#[serde(default)]
pub min_launcher: Option<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub app: AppSection,
#[serde(default)]
pub db: Option<DbSection>,
#[serde(default)]
pub tiles: Option<TilesSection>,
#[serde(default)]
pub src: Option<SrcSection>,
#[serde(default)]
pub signature: Option<String>,
}
/// A manifest that parsed, validated and carries a good signature by the given key.
/// Only [`VerifiedManifest::from_bytes`] builds one.
#[derive(Debug, Clone)]
pub struct VerifiedManifest {
raw: Vec<u8>,
value: Value,
manifest: Manifest,
}
impl VerifiedManifest {
/// Parse, validate and signature-check manifest bytes (e.g. an installed
/// `versions/<v>/manifest.json`, re-checked offline).
pub fn from_bytes(raw: Vec<u8>, public_key: &str) -> Result<Self> {
let value: Value = serde_json::from_slice(&raw)
.map_err(|e| Error::manifest(format!("manifest is not valid UTF-8 JSON: {e}")))?;
// Refuse non-integer numbers anywhere, before anything is hashed.
canonical_bytes(&value)?;
validate(&value)?;
crate::signing::verify(&value, public_key)?;
let manifest: Manifest = serde_json::from_value(value.clone())
.map_err(|e| Error::manifest(format!("validated manifest does not map to its fields: {e}")))?;
Ok(VerifiedManifest { raw, value, manifest })
}
/// The bytes exactly as served; written verbatim to `versions/<v>/manifest.json`.
pub fn raw(&self) -> &[u8] {
&self.raw
}
pub fn value(&self) -> &Value {
&self.value
}
pub fn manifest(&self) -> &Manifest {
&self.manifest
}
pub fn version(&self) -> &str {
&self.manifest.version
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relpaths_like_python_tests() {
assert!(safe_relpath("Odian/x.exe").is_ok());
for bad in ["../a", "a/../b", "/a", "C:/a", "a\\b", "a//b", "", "./a", "Odian/Odian.exe:hidden", "a/b.", "a/b ",
"CON", "a/aux.txt", "a/Lpt1.log", "a/b\u{0}c", "a/b\nc", "a/b?c", "a/b*c", "a/b\"c", "a/b|c", "a/com¹"] {
assert!(safe_relpath(bad).is_err(), "{bad:?}");
}
for fine in ["a/console.txt", "a/auxiliary", "a/com10", "a/.hidden", "a/b c.txt", "a/comA"] {
assert!(safe_relpath(fine).is_ok(), "{fine:?}");
}
}
#[test]
fn versions() {
assert_eq!(parse_version("0.51.0").unwrap(), (0, 51, 0));
for bad in ["1.0", "01.2.3", "0.51.0-rc1", "0.51.0\n", "", "1..2", "a.b.c"] {
assert!(parse_version(bad).is_err(), "{bad:?}");
}
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Install progress, shaped like `InstallProgress` in the launcher's docs/CONTRACT.md,
//! throttled to about ten reports per second.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
/// Install stages. This crate reports `manifest`, `app`, `db`, `src` and `tiles`;
/// `restore` and `finalize` exist so the launcher can reuse the type for its own steps.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Stage {
Manifest,
App,
Db,
Src,
Tiles,
Restore,
Finalize,
}
/// One progress report (`InstallProgress` on the wire).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Progress {
pub stage: Stage,
/// 1-based.
pub stage_index: u32,
pub stage_count: u32,
pub done_bytes: u64,
/// 0 when unknown.
pub total_bytes: u64,
pub done_items: u64,
pub total_items: u64,
pub bytes_per_sec: f64,
pub message: Option<String>,
}
const INTERVAL: Duration = Duration::from_millis(100);
/// Time constant of the smoothed transfer rate.
const RATE_WINDOW_SECS: f64 = 2.0;
pub(crate) type ProgressFn = Arc<dyn Fn(Progress) + Send + Sync>;
struct State {
stage: Stage,
stage_index: u32,
stage_count: u32,
total_bytes: u64,
total_items: u64,
label: String,
unit: &'static str,
sub_total: u64,
last_emit: Option<Instant>,
rate_bytes: u64,
rate_at: Instant,
rate: f64,
}
/// Shared by every worker of one install. Counters are atomics; a report is built and
/// sent at most every [`INTERVAL`], except stage starts and ends, which always go out.
pub(crate) struct Reporter {
callback: ProgressFn,
done_bytes: AtomicU64,
done_items: AtomicU64,
sub_done: AtomicU64,
transferred: AtomicU64,
state: Mutex<State>,
}
impl Reporter {
pub fn new(callback: ProgressFn, stage_count: u32) -> Self {
Reporter {
callback,
done_bytes: AtomicU64::new(0),
done_items: AtomicU64::new(0),
sub_done: AtomicU64::new(0),
transferred: AtomicU64::new(0),
state: Mutex::new(State {
stage: Stage::Manifest,
stage_index: 1,
stage_count,
total_bytes: 0,
total_items: 0,
label: String::new(),
unit: "",
sub_total: 0,
last_emit: None,
rate_bytes: 0,
rate_at: Instant::now(),
rate: 0.0,
}),
}
}
pub fn begin_stage(&self, stage: Stage, stage_index: u32, total_bytes: u64, total_items: u64) {
{
let mut st = self.state.lock().unwrap_or_else(|p| p.into_inner());
st.stage = stage;
st.stage_index = stage_index;
st.total_bytes = total_bytes;
st.total_items = total_items;
st.label.clear();
st.unit = "";
st.sub_total = 0;
self.done_bytes.store(0, Ordering::Relaxed);
self.done_items.store(0, Ordering::Relaxed);
self.sub_done.store(0, Ordering::Relaxed);
}
self.emit(true);
}
/// The part of the stage the message describes ("tiles dark/ds", 1402 "blocks").
pub fn begin_sub(&self, label: String, unit: &'static str, sub_total: u64) {
{
let mut st = self.state.lock().unwrap_or_else(|p| p.into_inner());
st.label = label;
st.unit = unit;
st.sub_total = sub_total;
self.sub_done.store(0, Ordering::Relaxed);
}
self.emit(true);
}
/// Bytes that just crossed the network (or were copied from a drive).
pub fn add_transferred(&self, n: u64) {
self.transferred.fetch_add(n, Ordering::Relaxed);
self.done_bytes.fetch_add(n, Ordering::Relaxed);
self.emit(false);
}
/// Bytes found already in place (skipped files, resumed prefixes, verified chunks).
pub fn add_done_bytes(&self, n: u64) {
self.done_bytes.fetch_add(n, Ordering::Relaxed);
self.emit(false);
}
/// Take back bytes counted for a download that failed verification and restarts.
pub fn retract_bytes(&self, n: u64) {
let _ = self.done_bytes.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| Some(v.saturating_sub(n)));
}
pub fn items_done(&self, n: u64) {
self.done_items.fetch_add(n, Ordering::Relaxed);
self.sub_done.fetch_add(n, Ordering::Relaxed);
self.emit(false);
}
pub fn finish_stage(&self) {
self.emit(true);
}
pub fn transferred(&self) -> u64 {
self.transferred.load(Ordering::Relaxed)
}
fn emit(&self, force: bool) {
let now = Instant::now();
let progress = {
let mut st = if force {
self.state.lock().unwrap_or_else(|p| p.into_inner())
} else {
match self.state.try_lock() {
Ok(guard) => guard,
Err(_) => return, // another worker is reporting right now
}
};
if !force && st.last_emit.is_some_and(|last| now.duration_since(last) < INTERVAL) {
return;
}
st.last_emit = Some(now);
let transferred = self.transferred.load(Ordering::Relaxed);
let dt = now.duration_since(st.rate_at).as_secs_f64();
if dt >= 0.05 {
let instant = transferred.saturating_sub(st.rate_bytes) as f64 / dt;
let alpha = (dt / RATE_WINDOW_SECS).min(1.0);
st.rate += alpha * (instant - st.rate);
st.rate_bytes = transferred;
st.rate_at = now;
}
let mut done_bytes = self.done_bytes.load(Ordering::Relaxed);
if st.total_bytes > 0 {
done_bytes = done_bytes.min(st.total_bytes);
}
let message = (!st.label.is_empty()).then(|| {
if st.sub_total == 0 {
st.label.clone()
} else {
format!("{}: {}/{} {}", st.label, self.sub_done.load(Ordering::Relaxed), st.sub_total, st.unit)
}
});
Progress {
stage: st.stage,
stage_index: st.stage_index,
stage_count: st.stage_count,
done_bytes,
total_bytes: st.total_bytes,
done_items: self.done_items.load(Ordering::Relaxed),
total_items: st.total_items,
bytes_per_sec: st.rate.max(0.0).round(),
message,
}
};
(self.callback)(progress);
}
}
+52
View File
@@ -0,0 +1,52 @@
//! Ed25519 release signatures (verification only; the Python module `odian_packs.signing`).
//!
//! Key text: `ed25519:<base64 of the raw 32 bytes>`. Signature: Ed25519 over
//! [`crate::manifest::signing_payload`], written as `ed25519:<base64 of 64 bytes>`.
use base64::alphabet;
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
use base64::Engine;
use ed25519_dalek::{Signature, VerifyingKey};
use serde_json::Value;
use crate::error::{Error, IntegrityKind, Result};
use crate::manifest::signing_payload;
/// Python's `base64.b64decode(text, validate=True)`: standard alphabet, correct padding,
/// no whitespace, and (like CPython) non-zero trailing bits tolerated.
const B64: GeneralPurpose = GeneralPurpose::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new()
.with_decode_allow_trailing_bits(true)
.with_decode_padding_mode(DecodePaddingMode::RequireCanonical),
);
fn signature_error(detail: &str) -> Error {
Error::integrity(IntegrityKind::Signature, "manifest", detail)
}
/// Parse `ed25519:<base64>` (surrounding whitespace ignored).
pub fn load_public_key(text: &str) -> Result<VerifyingKey> {
let body = text
.trim()
.strip_prefix("ed25519:")
.ok_or_else(|| signature_error("public key must look like 'ed25519:<base64>'"))?;
let raw = B64.decode(body).map_err(|_| signature_error("public key is not valid base64"))?;
let bytes: [u8; 32] = raw.try_into().map_err(|_| signature_error("an Ed25519 public key is 32 bytes"))?;
VerifyingKey::from_bytes(&bytes).map_err(|_| signature_error("public key is not a valid Ed25519 point"))
}
/// Error ([`IntegrityKind::Signature`]) unless `manifest` carries a valid signature by that key.
pub fn verify(manifest: &Value, public_key: &str) -> Result<()> {
let body = manifest
.get("signature")
.and_then(Value::as_str)
.and_then(|s| s.strip_prefix("ed25519:"))
.ok_or_else(|| signature_error("manifest is not signed"))?;
let raw = B64.decode(body).map_err(|_| signature_error("signature is not valid base64"))?;
let key = load_public_key(public_key)?;
let payload = signing_payload(manifest)?;
let mismatch = || signature_error("signature does not match: manifest altered, or signed by another key");
let signature = Signature::from_slice(&raw).map_err(|_| mismatch())?;
key.verify_strict(&payload, &signature).map_err(|_| mismatch())
}
+425
View File
@@ -0,0 +1,425 @@
//! Where a version comes from: a site's update service over HTTP, or an ODIAN drive that
//! holds the site store layout (`packs/`, `tiles/`, `src/`).
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
use futures_util::StreamExt;
use reqwest::header::{HeaderValue, RANGE, RETRY_AFTER};
use rusqlite::{Connection, OpenFlags};
use tokio_util::sync::CancellationToken;
use crate::error::{Error, Result};
use crate::manifest::{parse_version, tile_index_name};
/// Where to install from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
/// A site's update service, e.g. `http://192.168.1.41:30097`, with an Engine token.
Site { base_url: String, token: String },
/// An ODIAN drive (SSD) whose root holds `packs/`, `tiles/` (with `tiles.db`) and `src/`.
Drive { root: PathBuf },
}
const RETRIES: u32 = 4;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const READ_TIMEOUT: Duration = Duration::from_secs(120);
/// A Retry-After beyond this is treated as this (Python sleeps whatever the site says).
const MAX_RETRY_AFTER_SECS: f64 = 120.0;
const ERROR_BODY_BYTES: usize = 300;
/// Run blocking work off the async threads.
pub(crate) async fn blocking<T, F>(f: F) -> Result<T>
where
T: Send + 'static,
F: FnOnce() -> Result<T> + Send + 'static,
{
match tokio::task::spawn_blocking(f).await {
Ok(result) => result,
Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
Err(_) => Err(Error::Cancelled),
}
}
pub(crate) async fn sleep_or_cancel(secs: f64, cancel: &CancellationToken) -> Result<()> {
tokio::select! {
_ = cancel.cancelled() => Err(Error::Cancelled),
_ = tokio::time::sleep(Duration::from_secs_f64(secs.max(0.0))) => Ok(()),
}
}
/// Percent-encode each segment of a relative path (manifest paths may hold spaces,
/// `%`, `#` or non-ASCII; nginx decodes them back before looking the file up).
pub(crate) fn encode_path(rel: &str) -> String {
let mut out = String::with_capacity(rel.len());
for (i, segment) in rel.split('/').enumerate() {
if i > 0 {
out.push('/');
}
for b in segment.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
}
out
}
fn join_rel(root: &Path, rel: &str) -> PathBuf {
let mut path = root.to_path_buf();
for part in rel.split('/') {
path.push(part);
}
path
}
fn open_error(path: &Path) -> impl FnOnce(std::io::Error) -> Error + '_ {
move |e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::NotFound { what: path.display().to_string() }
} else {
Error::Io { path: path.to_path_buf(), source: e }
}
}
}
/// A body to copy: an HTTP response, or a drive file already positioned and limited.
pub(crate) enum Body {
Http { response: reqwest::Response, url: String },
File { reader: std::io::Take<std::fs::File>, path: PathBuf },
}
/// A pack-store object.
#[derive(Clone, Copy)]
pub(crate) enum Pack<'a> {
/// `app/<sha256>`.
App(&'a str),
/// A manifest `db.files[].path` (`db/<snapshot>/...`).
Db(&'a str),
}
pub(crate) struct Site {
base: String,
token: String,
client: reqwest::Client,
}
pub(crate) struct Drive {
root: PathBuf,
}
pub(crate) enum Backend {
Site(Site),
Drive(Drive),
}
impl Backend {
pub fn new(source: &Source) -> Result<Self> {
match source {
Source::Site { base_url, token } => {
let client = reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.read_timeout(READ_TIMEOUT)
.user_agent(concat!("odian-packs/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| Error::Http { url: base_url.clone(), status: None, message: e.to_string() })?;
Ok(Backend::Site(Site { base: base_url.trim_end_matches('/').to_string(), token: token.clone(), client }))
}
Source::Drive { root } => Ok(Backend::Drive(Drive { root: root.clone() })),
}
}
pub async fn current_manifest(&self, cancel: &CancellationToken) -> Result<Vec<u8>> {
match self {
Backend::Site(site) => site.get_bytes("/v1/versions/current", cancel).await,
Backend::Drive(drive) => {
let pointer = drive.root.join("packs").join("versions").join("CURRENT");
let raw = blocking({
let pointer = pointer.clone();
move || std::fs::read(&pointer).map_err(open_error(&pointer))
})
.await?;
let text = String::from_utf8_lossy(&raw[..raw.len().min(256)]).trim().to_string();
if text.is_empty() {
return Err(Error::NotFound { what: format!("{}: no current version", pointer.display()) });
}
parse_version(&text)
.map_err(|_| Error::manifest(format!("{} does not hold a valid version", pointer.display())))?;
drive.read(join_rel(&drive.root, &format!("packs/versions/{text}/manifest.json"))).await
}
}
}
pub async fn version_manifest(&self, version: &str, cancel: &CancellationToken) -> Result<Vec<u8>> {
parse_version(version)?;
match self {
Backend::Site(site) => site.get_bytes(&format!("/v1/versions/{version}/manifest"), cancel).await,
Backend::Drive(drive) => drive.read(join_rel(&drive.root, &format!("packs/versions/{version}/manifest.json"))).await,
}
}
pub async fn notes(&self, version: &str, cancel: &CancellationToken) -> Result<Vec<u8>> {
parse_version(version)?;
match self {
Backend::Site(site) => site.get_bytes(&format!("/v1/versions/{version}/notes"), cancel).await,
Backend::Drive(drive) => drive.read(join_rel(&drive.root, &format!("packs/versions/{version}/notes.md"))).await,
}
}
/// `style` and `dataset` come from a validated manifest (name rule already applied).
pub async fn tile_index(&self, version: &str, style: &str, dataset: &str, cancel: &CancellationToken) -> Result<Vec<u8>> {
match self {
Backend::Site(site) => {
site.get_bytes(&format!("/v1/versions/{version}/tiles/{style}/{dataset}/index"), cancel).await
}
Backend::Drive(drive) => {
let rel = format!("packs/versions/{version}/{}", tile_index_name(style, dataset));
drive.read(join_rel(&drive.root, &rel)).await
}
}
}
pub fn pack_label(&self, pack: Pack<'_>) -> String {
match pack {
Pack::App(sha) => format!("/packs/app/{sha}"),
Pack::Db(path) => format!("/packs/{path}"),
}
}
/// Open a pack object from `offset`. The flag says whether the body really starts at
/// `offset` (a site may ignore the Range and send the whole file).
pub async fn open_pack(&self, pack: Pack<'_>, offset: u64, cancel: &CancellationToken) -> Result<(Body, bool)> {
match self {
Backend::Site(site) => {
let path = match pack {
Pack::App(sha) => format!("/packs/app/{}", encode_path(sha)),
Pack::Db(rel) => format!("/packs/{}", encode_path(rel)),
};
let range = (offset > 0).then(|| format!("bytes={offset}-"));
let (response, url) = site.get(&path, range, cancel).await?;
let partial = offset > 0 && response.status() == reqwest::StatusCode::PARTIAL_CONTENT;
Ok((Body::Http { response, url }, offset == 0 || partial))
}
Backend::Drive(drive) => {
let path = match pack {
Pack::App(sha) => drive.root.join("packs").join("app").join(sha),
Pack::Db(rel) => join_rel(&drive.root.join("packs"), rel),
};
let body = drive.open_range(path, offset, u64::MAX).await?;
Ok((body, true))
}
}
}
/// Inclusive byte range `[start, end]` of a source file.
pub async fn open_src_range(&self, rel: &str, start: u64, end: u64, cancel: &CancellationToken) -> Result<Body> {
match self {
Backend::Site(site) => {
let path = format!("/src/{}", encode_path(rel));
let (response, url) = site.get(&path, Some(format!("bytes={start}-{end}")), cancel).await?;
if response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
return Err(Error::Http {
url,
status: Some(response.status().as_u16()),
message: "the DHP did not honour the byte range".into(),
});
}
Ok(Body::Http { response, url })
}
Backend::Drive(drive) => drive.open_range(join_rel(&drive.root.join("src"), rel), start, end - start + 1).await,
}
}
pub fn drive_root(&self) -> Option<&Path> {
match self {
Backend::Drive(drive) => Some(&drive.root),
Backend::Site(_) => None,
}
}
pub fn site(&self) -> Option<&Site> {
match self {
Backend::Site(site) => Some(site),
Backend::Drive(_) => None,
}
}
}
impl Site {
/// GET with the Bearer token. Retries 502/503/504 (honouring Retry-After) and
/// connection errors with a doubling delay, like fetch.py's `Site.open`.
pub async fn get(&self, path: &str, range: Option<String>, cancel: &CancellationToken) -> Result<(reqwest::Response, String)> {
let url = format!("{}{}", self.base, path);
let mut delay = 1.0f64;
for attempt in 1..=RETRIES {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let mut request = self.client.get(&url).bearer_auth(&self.token);
if let Some(range) = &range {
request = request.header(RANGE, HeaderValue::from_str(range).expect("ascii range"));
}
let sent = tokio::select! {
_ = cancel.cancelled() => return Err(Error::Cancelled),
sent = request.send() => sent,
};
let last = attempt == RETRIES;
match sent {
Ok(response) if response.status().is_success() => return Ok((response, url)),
Ok(response) => {
let status = response.status().as_u16();
let retry_after = response
.headers()
.get(RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<f64>().ok())
.filter(|v| v.is_finite() && *v >= 0.0)
.map(|v| v.min(MAX_RETRY_AFTER_SECS));
if matches!(status, 502..=504) && !last {
sleep_or_cancel(retry_after.unwrap_or(delay), cancel).await?;
delay *= 2.0;
continue;
}
let body = response.bytes().await.map(|b| b.to_vec()).unwrap_or_default();
let body = String::from_utf8_lossy(&body[..body.len().min(ERROR_BODY_BYTES)]).into_owned();
return Err(match status {
401 | 403 => Error::Unauthorized { url, status },
404 => Error::NotFound { what: format!("{url}: {body}") },
503 => Error::SiteBusy { url, attempts: attempt, retry_after_secs: retry_after },
_ => Error::Http { url, status: Some(status), message: format!("HTTP {status}: {body}") },
});
}
Err(e) => {
if !last {
sleep_or_cancel(delay, cancel).await?;
delay *= 2.0;
continue;
}
return Err(Error::Http { url, status: None, message: format!("request failed after {RETRIES} attempts: {e}") });
}
}
}
unreachable!("the loop returns on its last attempt")
}
pub async fn get_bytes(&self, path: &str, cancel: &CancellationToken) -> Result<Vec<u8>> {
let (response, url) = self.get(path, None, cancel).await?;
let mut stream = response.bytes_stream();
let mut out = Vec::new();
loop {
let next = tokio::select! {
_ = cancel.cancelled() => return Err(Error::Cancelled),
next = stream.next() => next,
};
match next {
Some(Ok(chunk)) => out.extend_from_slice(&chunk),
Some(Err(e)) => return Err(Error::Http { url, status: None, message: format!("body cut short: {e}") }),
None => return Ok(out),
}
}
}
}
impl Drive {
async fn read(&self, path: PathBuf) -> Result<Vec<u8>> {
blocking(move || std::fs::read(&path).map_err(open_error(&path))).await
}
async fn open_range(&self, path: PathBuf, start: u64, len: u64) -> Result<Body> {
blocking(move || {
let mut file = std::fs::File::open(&path).map_err(open_error(&path))?;
if start > 0 {
file.seek(SeekFrom::Start(start)).map_err(Error::io(&path))?;
}
Ok(Body::File { reader: file.take(len), path })
})
.await
}
}
/// One tile row straight from the journal: `(z, x, y, ext, size, sha256)`.
pub(crate) type JournalRow = (i64, i64, i64, String, i64, String);
/// A drive's tile journal (`tiles/tiles.db`), opened read-only.
pub(crate) struct Journal {
conn: Mutex<Connection>,
path: PathBuf,
}
fn sqlite_uri(path: &Path, immutable: bool) -> String {
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let text = absolute.to_string_lossy().replace('\\', "/");
let mut uri = String::from(if text.starts_with('/') { "file://" } else { "file:///" });
for b in text.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/' | b':') {
uri.push(b as char);
} else {
uri.push_str(&format!("%{b:02X}"));
}
}
uri.push_str("?mode=ro");
if immutable {
uri.push_str("&immutable=1");
}
uri
}
impl Journal {
/// Blocking. `mode=ro` first; on "unable to open"/"readonly" (a WAL journal whose
/// -wal/-shm cannot be created) retry once with `immutable=1`, like the update service.
pub fn open(path: &Path) -> Result<Journal> {
if !path.is_file() {
return Err(Error::NotFound { what: format!("tile journal {}", path.display()) });
}
let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX;
let mut last = None;
for immutable in [false, true] {
let attempt = Connection::open_with_flags(sqlite_uri(path, immutable), flags).and_then(|conn| {
conn.busy_timeout(Duration::from_secs(30))?;
conn.query_row("SELECT COUNT(*) FROM tile WHERE seq < 0", [], |_| Ok(()))?;
Ok(conn)
});
match attempt {
Ok(conn) => return Ok(Journal { conn: Mutex::new(conn), path: path.to_path_buf() }),
Err(e) => {
let message = e.to_string().to_lowercase();
let retryable = message.contains("unable to open") || message.contains("readonly");
last = Some(e);
if !retryable {
break;
}
}
}
}
Err(Error::Io { path: path.to_path_buf(), source: std::io::Error::other(last.expect("an attempt ran")) })
}
/// Blocking. The tiles of one block as of `max_seq`, in digest order (`tiles.block_rows`).
pub fn block_rows(&self, style: &str, dataset: &str, z: u64, bx: u64, by: u64, max_seq: u64) -> Result<Vec<JournalRow>> {
let shift = crate::manifest::BLOCK_SHIFT;
let (z, bx, by, max_seq) = (z as i64, bx as i64, by as i64, max_seq as i64);
let conn = self.conn.lock().unwrap_or_else(|p| p.into_inner());
let run = || -> rusqlite::Result<Vec<JournalRow>> {
let mut stmt = conn.prepare_cached(
"SELECT z, x, y, ext, size, sha256 FROM tile \
WHERE style = ? AND dataset = ? AND z = ? AND x BETWEEN ? AND ? AND y BETWEEN ? AND ? AND seq <= ? \
ORDER BY z, x, y, ext",
)?;
let rows = stmt.query_map(
rusqlite::params![
style, dataset, z,
bx << shift, ((bx + 1) << shift) - 1,
by << shift, ((by + 1) << shift) - 1,
max_seq
],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)),
)?;
rows.collect()
};
run().map_err(|e| Error::Io { path: self.path.clone(), source: std::io::Error::other(e) })
}
}
+272
View File
@@ -0,0 +1,272 @@
//! One tile block: fetch it (site tar stream or drive journal copy) into a staging
//! directory, check every name and the block digest from the bytes, then move the tiles in.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use bytes::{Buf, Bytes};
use futures_util::StreamExt;
use tokio_util::sync::CancellationToken;
use crate::copy::{copy_hashing, unique_hex};
use crate::error::{Error, IntegrityKind, Result};
use crate::progress::Reporter;
use crate::source::{Journal, Site};
use crate::tiles::{block_digest, block_of, parse_tile_name, tile_relpath, IndexBlock, TileRow};
const TILE_BUF: usize = 256 * 1024;
/// `<set_dir>/../.incoming/<hex>`, removed however the block ends.
struct Staging {
path: PathBuf,
}
impl Staging {
fn create(set_dir: &Path) -> Result<Staging> {
let root = set_dir.parent().unwrap_or(set_dir).join(".incoming");
std::fs::create_dir_all(&root).map_err(Error::io(&root))?;
loop {
let path = root.join(unique_hex());
match std::fs::create_dir(&path) {
Ok(()) => return Ok(Staging { path }),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(Error::Io { path, source: e }),
}
}
}
}
impl Drop for Staging {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn tile_path(root: &Path, z: u64, x: u64, y: u64, ext: &str) -> PathBuf {
root.join(z.to_string()).join(x.to_string()).join(format!("{y}{ext}"))
}
/// Digest and count check, then move every tile from staging into the set.
fn finish_block(what: &str, block: &IndexBlock, rows: &[TileRow], staging: &Path, set_dir: &Path) -> Result<()> {
let got = block_digest(rows);
if got != block.digest || rows.len() as u64 != block.files {
return Err(Error::integrity(
IntegrityKind::BlockDigest,
what,
format!(
"block digest {got} with {} tiles, signed index says {} with {}",
rows.len(),
block.digest,
block.files
),
));
}
for (z, x, y, ext, _, _) in rows {
let target = tile_path(set_dir, *z, *x, *y, ext);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
}
let staged = tile_path(staging, *z, *x, *y, ext);
std::fs::rename(&staged, &target).map_err(Error::io(&target))?;
}
Ok(())
}
// ---- site: tar stream ---------------------------------------------------------
/// Blocking `Read` over body chunks sent from the async side.
struct ChannelReader {
rx: tokio::sync::mpsc::Receiver<std::result::Result<Bytes, String>>,
current: Bytes,
}
impl Read for ChannelReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
while self.current.is_empty() {
match self.rx.blocking_recv() {
Some(Ok(chunk)) => self.current = chunk,
Some(Err(message)) => return Err(std::io::Error::other(message)),
None => return Ok(0),
}
}
let n = buf.len().min(self.current.len());
buf[..n].copy_from_slice(&self.current[..n]);
self.current.advance(n);
Ok(n)
}
}
/// Download `url_path` (an uncompressed tar of `z/x/y.ext`) and install it as `block`.
pub(crate) async fn fetch_site_block(
site: &Site,
url_path: String,
block: IndexBlock,
set_dir: PathBuf,
reporter: Arc<Reporter>,
cancel: CancellationToken,
) -> Result<()> {
let (response, url) = site.get(&url_path, None, &cancel).await?;
let (tx, rx) = tokio::sync::mpsc::channel::<std::result::Result<Bytes, String>>(16);
let extractor = tokio::task::spawn_blocking({
let (url, cancel) = (url.clone(), cancel.clone());
move || extract_tar(ChannelReader { rx, current: Bytes::new() }, &url, &block, &set_dir, &reporter, &cancel)
});
let feeder = async {
let mut stream = response.bytes_stream();
loop {
let next = tokio::select! {
_ = cancel.cancelled() => {
let _ = tx.send(Err("cancelled".into())).await;
return Some(Error::Cancelled);
}
next = stream.next() => next,
};
match next {
Some(Ok(chunk)) => {
if tx.send(Ok(chunk)).await.is_err() {
return None; // the extractor is done (tar end reached, or it failed)
}
}
Some(Err(e)) => {
let message = format!("body cut short: {e}");
let _ = tx.send(Err(message.clone())).await;
return Some(Error::Http { url: url.clone(), status: None, message });
}
None => return None,
}
}
};
let feed_error = feeder.await;
drop(tx);
let extracted = match extractor.await {
Ok(result) => result,
Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
Err(_) => Err(Error::Cancelled),
};
match extracted {
Ok(()) => Ok(()),
// A network error or a cancel explains a broken tar better than the tar error does.
Err(e) => Err(feed_error.unwrap_or(e)),
}
}
fn extract_tar<R: Read>(
reader: R,
what: &str,
block: &IndexBlock,
set_dir: &Path,
reporter: &Reporter,
cancel: &CancellationToken,
) -> Result<()> {
let staging = Staging::create(set_dir)?;
let tar_error = |e: std::io::Error| Error::integrity(IntegrityKind::TarEntry, what, format!("malformed tar stream: {e}"));
let mut archive = tar::Archive::new(reader);
let mut rows: Vec<TileRow> = Vec::new();
let mut buf = vec![0u8; TILE_BUF];
for entry in archive.entries().map_err(tar_error)? {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let mut entry = entry.map_err(tar_error)?;
let name = String::from_utf8_lossy(&entry.path_bytes()).into_owned();
let kind = entry.header().entry_type();
let parsed = if kind.is_file() || kind == tar::EntryType::Continuous { parse_tile_name(&name) } else { None };
let (z, x, y, ext) = parsed.ok_or_else(|| {
Error::integrity(IntegrityKind::TarEntry, what, format!("unexpected tar entry {name:?}"))
})?;
if block_of(z, x, y) != (block.z, block.bx, block.by) {
return Err(Error::integrity(IntegrityKind::TarEntry, what, format!("tile {name} is outside this block")));
}
let target = tile_path(&staging.path, z, x, y, &ext);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
}
let (size, sha) = copy_hashing(&mut entry, &target, &mut buf, reporter, cancel, &tar_error)?;
rows.push((z, x, y, ext, size, sha));
}
finish_block(what, block, &rows, &staging.path, set_dir)
}
// ---- drive: journal + tile store --------------------------------------------------
pub(crate) struct DriveBlock<'a> {
pub journal: &'a Journal,
/// `<drive>/tiles/<style>/<dataset>`.
pub set_root: &'a Path,
pub style: &'a str,
pub dataset: &'a str,
pub journal_seq: u64,
}
/// Blocking: copy one block's tiles (journal rows at `seq <= journal_seq`) from the drive.
pub(crate) fn copy_drive_block(
drive: &DriveBlock<'_>,
what: &str,
block: &IndexBlock,
set_dir: &Path,
reporter: &Reporter,
cancel: &CancellationToken,
) -> Result<()> {
let journal_rows = drive.journal.block_rows(drive.style, drive.dataset, block.z, block.bx, block.by, drive.journal_seq)?;
let mut expected: Vec<TileRow> = Vec::with_capacity(journal_rows.len());
for (z, x, y, ext, size, sha) in journal_rows {
let unsafe_row = || {
Error::integrity(IntegrityKind::TarEntry, what, format!("the drive's tile journal holds an unsafe tile {z}/{x}/{y}{ext:?}"))
};
if z < 0 || x < 0 || y < 0 || size < 0 {
return Err(unsafe_row());
}
let (z, x, y, size) = (z as u64, x as u64, y as u64, size as u64);
// The same name rule the site path applies to tar entries.
if parse_tile_name(&tile_relpath(z, x, y, &ext)) != Some((z, x, y, ext.clone())) {
return Err(unsafe_row());
}
expected.push((z, x, y, ext, size, sha));
}
// The update service's pre-stream check (its 409): the journal must match the index.
let total: u64 = expected.iter().map(|row| row.4).sum();
let journal_digest = block_digest(&expected);
if expected.len() as u64 != block.files || total != block.bytes || journal_digest != block.digest {
return Err(Error::integrity(
IntegrityKind::BlockDigest,
what,
format!(
"the drive's tile journal does not match the signed index: {} tiles, {total} bytes, digest {journal_digest}; \
index says {} tiles, {} bytes, digest {}",
expected.len(),
block.files,
block.bytes,
block.digest
),
));
}
let staging = Staging::create(set_dir)?;
let mut rows: Vec<TileRow> = Vec::with_capacity(expected.len());
let mut buf = vec![0u8; TILE_BUF];
for (z, x, y, ext, _, _) in &expected {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let source = tile_path(drive.set_root, *z, *x, *y, ext);
let mut file = std::fs::File::open(&source).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::NotFound { what: source.display().to_string() }
} else {
Error::Io { path: source.clone(), source: e }
}
})?;
let target = tile_path(&staging.path, *z, *x, *y, ext);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
}
let read_error = |e: std::io::Error| Error::Io { path: source.clone(), source: e };
let (size, sha) = copy_hashing(&mut file, &target, &mut buf, reporter, cancel, &read_error)?;
rows.push((*z, *x, *y, ext.clone(), size, sha));
}
finish_block(what, block, &rows, &staging.path, set_dir)
}
+136
View File
@@ -0,0 +1,136 @@
//! Tile blocks, block digests and frozen tile indexes (the Python module `odian_packs.tiles`).
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::canonical::canonical_bytes;
use crate::error::{Error, Result};
use crate::hashing::hex;
use crate::manifest::BLOCK_SHIFT;
/// One tile as the digest sees it: `(z, x, y, ext, size, sha256)`.
pub type TileRow = (u64, u64, u64, String, u64, String);
pub fn block_of(z: u64, x: u64, y: u64) -> (u64, u64, u64) {
(z, x >> BLOCK_SHIFT, y >> BLOCK_SHIFT)
}
/// `"{z}/{x}/{y}{ext} {size} {sha256}\n"`.
pub fn tile_line(z: u64, x: u64, y: u64, ext: &str, size: u64, sha256: &str) -> String {
format!("{z}/{x}/{y}{ext} {size} {sha256}\n")
}
pub fn tile_relpath(z: u64, x: u64, y: u64, ext: &str) -> String {
format!("{z}/{x}/{y}{ext}")
}
/// Digest of tile rows; sorts them by `(z, x, y, ext)` first, like Python.
pub fn block_digest(rows: &[TileRow]) -> String {
let mut sorted: Vec<&TileRow> = rows.iter().collect();
sorted.sort_by(|a, b| (a.0, a.1, a.2, a.3.as_bytes()).cmp(&(b.0, b.1, b.2, b.3.as_bytes())));
let mut digest = Sha256::new();
for (z, x, y, ext, size, sha) in sorted {
digest.update(tile_line(*z, *x, *y, ext, *size, sha).as_bytes());
}
hex(&digest.finalize())
}
/// sha256 of an index document's canonical JSON.
pub fn index_sha256(index: &Value) -> Result<String> {
Ok(hex(&Sha256::digest(canonical_bytes(index)?)))
}
/// `(z, x, y, ext)` of a tile name `z/x/y.ext` (the installer's tar-entry rule:
/// ASCII digits, and an extension of 1 to 8 ASCII letters or digits).
pub fn parse_tile_name(name: &str) -> Option<(u64, u64, u64, String)> {
let mut parts = name.split('/');
let (zs, xs, last) = (parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() {
return None;
}
let dot = last.find('.')?;
let (ys, ext) = last.split_at(dot);
let ext_body = &ext[1..];
if ext_body.is_empty() || ext_body.len() > 8 || !ext_body.bytes().all(|b| b.is_ascii_alphanumeric()) {
return None;
}
Some((digits(zs)?, digits(xs)?, digits(ys)?, ext.to_string()))
}
fn digits(s: &str) -> Option<u64> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
// Leading zeros are allowed (Python's int() takes them); the value is what counts.
let trimmed = s.trim_start_matches('0');
if trimmed.is_empty() {
return Some(0);
}
trimmed.parse().ok()
}
/// One block of a frozen tile index.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IndexBlock {
pub z: u64,
pub bx: u64,
pub by: u64,
pub files: u64,
pub bytes: u64,
pub digest: String,
}
impl IndexBlock {
/// The key recorded in `.blocks-done.json`: `"{z}/{bx}/{by}:{digest}"`.
pub fn done_key(&self) -> String {
format!("{}/{}/{}:{}", self.z, self.bx, self.by, self.digest)
}
}
/// A frozen tile index document (format 1).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileIndex {
pub format: u64,
pub style: String,
pub dataset: String,
pub block_shift: u64,
pub journal_seq: u64,
pub files: u64,
pub bytes: u64,
pub blocks: Vec<IndexBlock>,
}
/// Parse index bytes and check them against the manifest's `index_sha256`.
pub fn verify_index(raw: &[u8], expected_sha256: &str, what: &str) -> Result<TileIndex> {
let value: Value = serde_json::from_slice(raw)
.map_err(|e| Error::manifest(format!("{what}: tile index is not valid JSON: {e}")))?;
let got = index_sha256(&value)?;
if got != expected_sha256 {
return Err(Error::integrity(
crate::error::IntegrityKind::IndexHash,
what,
format!("index sha256 {got}, the signed manifest says {expected_sha256}"),
));
}
let index: TileIndex = serde_json::from_value(value)
.map_err(|e| Error::manifest(format!("{what}: signed tile index is malformed: {e}")))?;
if index.format != 1 || index.block_shift != BLOCK_SHIFT as u64 {
return Err(Error::manifest(format!("{what}: tile index must be format 1 with block_shift {BLOCK_SHIFT}")));
}
Ok(index)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tile_names() {
assert_eq!(parse_tile_name("7/63/5.png"), Some((7, 63, 5, ".png".into())));
assert_eq!(parse_tile_name("07/1/2.pbf"), Some((7, 1, 2, ".pbf".into())));
for bad in ["7/1/2", "7/1/2.", "7/1/2.tar.gz", "7/1/x.png", "7/-1/2.png", "7/1/2/3.png", "../1/2.png", "7/1/2.abcdefghi"] {
assert_eq!(parse_tile_name(bad), None, "{bad}");
}
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Cross-language fixtures: every expected value was computed by the Python reference
//! implementation (`tests/fixtures/gen_fixtures.py`); Rust must reproduce or verify it.
use std::path::PathBuf;
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
use odian_packs::hashing::{hex, sha256_hex};
use odian_packs::manifest::{chunk_count, chunks_digest, signing_payload};
use odian_packs::signing::load_public_key;
use odian_packs::tiles::{block_digest, block_of, index_sha256, tile_line, verify_index, TileRow};
use odian_packs::{bundled_public_key, canonical_bytes, safe_relpath, validate, Error, IntegrityKind, VerifiedManifest};
use serde_json::Value;
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("fixtures").join(name)
}
fn read(name: &str) -> Vec<u8> {
std::fs::read(fixture_path(name)).unwrap_or_else(|e| panic!("fixture {name}: {e}"))
}
fn load(name: &str) -> Value {
serde_json::from_slice(&read(name)).unwrap()
}
fn s<'a>(v: &'a Value, key: &str) -> &'a str {
v[key].as_str().unwrap_or_else(|| panic!("{key} is not a string in {v}"))
}
fn is_signature_error(result: &Result<VerifiedManifest, Error>) -> bool {
matches!(result, Err(Error::Integrity { kind: IntegrityKind::Signature, .. }))
}
#[test]
fn canonical_bytes_match_python() {
let doc = load("canonical.json");
let cases = doc["cases"].as_array().unwrap();
assert!(cases.len() >= 30);
for case in cases {
let value: Value = serde_json::from_str(s(case, "input")).unwrap();
let got = hex(&canonical_bytes(&value).unwrap());
assert_eq!(got, s(case, "canonical_hex"), "case {}", s(case, "name"));
}
}
#[test]
fn canonical_refuses_non_integer_numbers() {
let doc = load("canonical.json");
for case in doc["rust_rejects"].as_array().unwrap() {
let value: Value = serde_json::from_str(s(case, "input")).unwrap();
assert!(
matches!(canonical_bytes(&value), Err(Error::Manifest(_))),
"case {} should be refused",
s(case, "name")
);
}
}
#[test]
fn signed_manifests_verify_like_python() {
let doc = load("signing.json");
let public = s(&doc, "public_key");
let cases = doc["cases"].as_array().unwrap();
assert_eq!(cases.len(), 5);
for case in cases {
let file = s(case, "file");
let result = VerifiedManifest::from_bytes(read(file), public);
match s(case, "python") {
"ok" => {
let verified = result.unwrap_or_else(|e| panic!("{file}: {e}"));
assert_eq!(verified.raw(), read(file).as_slice(), "raw bytes are kept verbatim");
assert_eq!(verified.version(), "0.51.0");
}
_ => assert!(is_signature_error(&result), "{file}: expected a signature error, got {result:?}"),
}
}
// Signed by a different key.
assert_eq!(s(&doc, "signed_with_other_key_verdict"), "error");
let other = VerifiedManifest::from_bytes(read("signed_manifest.utf8.json"), s(&doc, "other_public_key"));
assert!(is_signature_error(&other));
}
#[test]
fn signing_payload_and_signature_match_python() {
let doc = load("signing.json");
let signed = load("signed_manifest.utf8.json");
let payload = signing_payload(&signed).unwrap();
assert_eq!(hex(&payload), s(&doc, "payload_hex"));
// Ed25519 is deterministic: the same key over the same payload gives Python's signature.
let raw: [u8; 32] = (0u8..32).collect::<Vec<_>>().try_into().unwrap();
assert_eq!(hex(&raw), s(&doc, "private_key_raw_hex"));
let key = SigningKey::from_bytes(&raw);
let signature = base64::engine::general_purpose::STANDARD.encode(key.sign(&payload).to_bytes());
assert_eq!(format!("ed25519:{signature}"), s(&doc, "signature"));
let public = base64::engine::general_purpose::STANDARD.encode(key.verifying_key().to_bytes());
assert_eq!(format!("ed25519:{public}"), s(&doc, "public_key"));
}
#[test]
fn public_key_text_rules() {
let doc = load("signing.json");
let public = s(&doc, "public_key");
assert!(load_public_key(&format!(" {public}\n")).is_ok());
for bad in ["", "rsa:AAAA", "ed25519:AAAA", "ed25519:not base64!", &public.replace("ed25519:", "")] {
assert!(load_public_key(bad).is_err(), "{bad:?}");
}
}
#[test]
fn relpaths_match_python() {
let doc = load("relpaths.json");
let cases = doc["cases"].as_array().unwrap();
assert!(cases.len() >= 60);
for case in cases {
let path = s(case, "path");
assert_eq!(safe_relpath(path).is_ok(), s(case, "python") == "ok", "path {path:?}: python {}", s(case, "python_error"));
}
}
#[test]
fn validate_matches_python_except_documented_quirks() {
let doc = load("validate.json");
let cases = doc["cases"].as_array().unwrap();
let mut quirks = 0;
for case in cases {
let name = s(case, "name");
let value: Value = serde_json::from_str(s(case, "manifest_json")).unwrap();
// The installer's parse path: canonical form (no floats) first, then validate.
let rust = canonical_bytes(&value).and_then(|_| validate(&value));
let expect_ok = s(case, "rust_expect") == "ok";
assert_eq!(rust.is_ok(), expect_ok, "case {name}: rust {rust:?}, python {}", s(case, "python_error"));
if !case["quirk"].is_null() {
quirks += 1;
assert_eq!(s(case, "python"), "ok", "a quirk case is one Python accepts: {name}");
}
}
// The Python library was brought to parity (anchors, ASCII digits, integers and floats),
// so a regenerated fixture must show no case where the two validators disagree.
assert!(cases.len() >= 50 && quirks == 0, "{} cases, {quirks} quirks", cases.len());
}
#[test]
fn hashes_match_python() {
let doc = load("hashes.json");
let row = |v: &Value| -> TileRow {
(v[0].as_u64().unwrap(), v[1].as_u64().unwrap(), v[2].as_u64().unwrap(), v[3].as_str().unwrap().into(),
v[4].as_u64().unwrap(), v[5].as_str().unwrap().into())
};
let rows: Vec<TileRow> = doc["block_digest"]["rows"].as_array().unwrap().iter().map(row).collect();
assert_eq!(block_digest(&rows), s(&doc["block_digest"], "digest"));
assert_eq!(block_digest(&[]), s(&doc, "block_digest_empty"));
for line in doc["tile_lines"].as_array().unwrap() {
let (z, x, y, ext, size, sha) = row(&line["row"]);
assert_eq!(tile_line(z, x, y, &ext, size, &sha), s(line, "line"));
}
for case in doc["block_of"].as_array().unwrap() {
let n: Vec<u64> = case.as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!(block_of(n[0], n[1], n[2]), (n[3], n[4], n[5]));
}
let chunks: Vec<String> = serde_json::from_value(doc["chunks_digest"]["chunks"].clone()).unwrap();
assert_eq!(chunks_digest(&chunks), s(&doc["chunks_digest"], "digest"));
assert_eq!(chunks_digest::<&str>(&[]), s(&doc, "chunks_digest_empty"));
for case in doc["chunk_count"].as_array().unwrap() {
let n: Vec<u64> = case.as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!(chunk_count(n[0], n[1]), n[2]);
}
let index = &doc["index"];
let text = s(index, "text");
assert_eq!(index_sha256(&serde_json::from_str(text).unwrap()).unwrap(), s(index, "index_sha256"));
let parsed = verify_index(text.as_bytes(), s(index, "index_sha256"), "fixture index").unwrap();
assert_eq!(parsed.blocks.len(), 2);
let wrong = verify_index(text.as_bytes(), &"0".repeat(64), "fixture index");
assert!(matches!(wrong, Err(Error::Integrity { kind: IntegrityKind::IndexHash, .. })));
}
#[test]
fn real_published_manifest_verifies_with_the_bundled_key() {
let hashes = load("real/0.51.0.hashes.json");
let raw = read("real/0.51.0.manifest.json");
assert_eq!(raw.len() as u64, hashes["raw_len"].as_u64().unwrap());
assert_eq!(sha256_hex(&raw), s(&hashes, "raw_sha256"));
assert_eq!(bundled_public_key(), s(&hashes, "release_public_key"));
let verified = VerifiedManifest::from_bytes(raw.clone(), bundled_public_key()).expect("the real release verifies");
assert_eq!(verified.version(), s(&hashes, "version"));
let canonical = canonical_bytes(verified.value()).unwrap();
assert_eq!(canonical.len() as u64, hashes["canonical_len"].as_u64().unwrap());
assert_eq!(sha256_hex(&canonical), s(&hashes, "canonical_sha256"));
assert_eq!(sha256_hex(&signing_payload(verified.value()).unwrap()), s(&hashes, "payload_sha256"));
let m = verified.manifest();
assert_eq!(m.app.files.len() as u64, hashes["app_files"].as_u64().unwrap());
assert!(m.db.is_some() && m.tiles.is_some() && m.src.as_ref().is_some_and(|s| s.enabled));
// One changed character anywhere breaks it.
let text = String::from_utf8(raw).unwrap();
let released = verified.manifest().released_at.clone();
let mut bumped = released.clone().into_bytes();
let last_digit = bumped.iter().rposition(u8::is_ascii_digit).unwrap();
bumped[last_digit] = if bumped[last_digit] == b'9' { b'0' } else { bumped[last_digit] + 1 };
let tampered = text.replacen(&released, std::str::from_utf8(&bumped).unwrap(), 1);
assert!(is_signature_error(&VerifiedManifest::from_bytes(tampered.into_bytes(), bundled_public_key())));
// And the test key does not vouch for it.
let test_key = load("signing.json");
assert!(is_signature_error(&VerifiedManifest::from_bytes(read("real/0.51.0.manifest.json"), s(&test_key, "public_key"))));
}
+557
View File
@@ -0,0 +1,557 @@
//! End to end against a local stub site: `tests/stub_site.py` builds a small signed pack
//! store with the Python reference code (odian_packs) and serves it with the real update
//! service (`odian_update.api.create_app`) plus nginx-like Range routes. The same store is
//! also installed as an ODIAN drive.
//!
//! These tests spawn Python, so they are `#[ignore]`d by default. They need Python 3 with
//! fastapi, uvicorn, httpx and cryptography, and the ODIAN_MCP_Server sources:
//!
//! ```text
//! ODIAN_PACKS_PYTHONPATH=C:/Users/JFEXP_D/Documents/Gitea/ODIAN_MCP_Server/src \
//! cargo test -p odian-packs --test e2e_site -- --ignored
//! ```
//!
//! `ODIAN_PACKS_PYTHON` picks the interpreter (default `python`).
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::{Arc, Mutex};
use odian_packs::hashing::sha256_hex;
use odian_packs::{
bundled_public_key, current_manifest, fetch_notes, install, version_manifest, Error, InstallOptions, IntegrityKind,
Progress, Source, Stage, Summary,
};
use serde_json::Value;
use tokio_util::sync::CancellationToken;
const CHUNK: usize = 1024 * 1024;
const NOTES: &str = "# ODIAN 0.51.0\n\nPremier pack signé.\n";
fn python() -> String {
std::env::var("ODIAN_PACKS_PYTHON").unwrap_or_else(|_| "python".into())
}
fn pythonpath() -> String {
std::env::var("ODIAN_PACKS_PYTHONPATH")
.expect("ODIAN_PACKS_PYTHONPATH must point at ODIAN_MCP_Server/src (see the header of tests/e2e_site.rs)")
}
struct Stub {
child: Child,
stdin: Option<ChildStdin>,
_stdout: BufReader<ChildStdout>,
info: Value,
dir: tempfile::TempDir,
}
impl Stub {
fn start(args: &[&str]) -> Stub {
let dir = tempfile::Builder::new().prefix("odian-packs-e2e-").tempdir().unwrap();
let log_path = dir.path().join("stub.log");
let log = std::fs::File::create(&log_path).unwrap();
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("stub_site.py");
let mut child = Command::new(python())
.arg(script)
.arg("--root")
.arg(dir.path().join("store"))
.args(args)
.env("PYTHONPATH", pythonpath())
.env("PYTHONDONTWRITEBYTECODE", "1")
.env("PYTHONIOENCODING", "utf-8")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(log)
.spawn()
.expect("spawn python");
let stdin = child.stdin.take();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let mut line = String::new();
let _ = stdout.read_line(&mut line);
if line.trim().is_empty() {
let _ = child.kill();
let _ = child.wait();
panic!("stub site did not start:\n{}", std::fs::read_to_string(&log_path).unwrap_or_default());
}
let info: Value = serde_json::from_str(&line).unwrap();
Stub { child, stdin, _stdout: stdout, info, dir }
}
fn base(&self) -> String {
self.info["base_url"].as_str().unwrap().to_string()
}
fn root(&self) -> PathBuf {
PathBuf::from(self.info["root"].as_str().unwrap())
}
fn key(&self) -> String {
self.info["public_key"].as_str().unwrap().to_string()
}
fn site(&self) -> Source {
Source::Site { base_url: self.base(), token: "good".into() }
}
fn drive(&self) -> Source {
Source::Drive { root: self.root() }
}
fn dest(&self, name: &str) -> PathBuf {
self.dir.path().join(name)
}
fn store_manifest_path(&self) -> PathBuf {
self.root().join("packs").join("versions").join("0.51.0").join("manifest.json")
}
fn store_manifest(&self) -> Value {
serde_json::from_slice(&std::fs::read(self.store_manifest_path()).unwrap()).unwrap()
}
fn app_sha(&self, install_path: &str) -> String {
let m = self.store_manifest();
let entry = m["app"]["files"].as_array().unwrap().iter().find(|f| f["path"] == install_path).unwrap();
entry["sha256"].as_str().unwrap().to_string()
}
fn app_blob(&self, install_path: &str) -> PathBuf {
self.root().join("packs").join("app").join(self.app_sha(install_path))
}
async fn ctl(&self, path: &str) -> Value {
let text = reqwest::get(format!("{}{}", self.base(), path)).await.unwrap().text().await.unwrap();
serde_json::from_str(&text).unwrap()
}
async fn requests(&self) -> Vec<(String, Option<String>)> {
let log = self.ctl("/_ctl/log").await;
log["requests"]
.as_array()
.unwrap()
.iter()
.map(|r| (r["path"].as_str().unwrap().to_string(), r["range"].as_str().map(str::to_string)))
.collect()
}
}
impl Drop for Stub {
fn drop(&mut self) {
drop(self.stdin.take()); // the stub exits when its stdin closes
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn only(stages: &[Stage]) -> InstallOptions {
InstallOptions { only: stages.to_vec(), ..InstallOptions::default() }
}
async fn install_from(source: &Source, dest: &Path, key: &str, opts: InstallOptions) -> Result<Summary, Error> {
let manifest = current_manifest(source, key).await?;
install(source, dest, &manifest, opts, |_| {}, CancellationToken::new()).await
}
/// Every file under `root`: relative path -> sha256.
fn files(root: &Path) -> BTreeMap<String, String> {
fn walk(dir: &Path, prefix: &str, out: &mut BTreeMap<String, String>) {
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name().to_string_lossy().into_owned();
let rel = if prefix.is_empty() { name } else { format!("{prefix}/{name}") };
if entry.file_type().unwrap().is_dir() {
walk(&entry.path(), &rel, out);
} else {
out.insert(rel, sha256_hex(&std::fs::read(entry.path()).unwrap()));
}
}
}
let mut out = BTreeMap::new();
walk(root, "", &mut out);
out
}
fn flip(path: &Path, offset: usize) {
let mut data = std::fs::read(path).unwrap();
data[offset] ^= 0xFF;
std::fs::write(path, data).unwrap();
}
fn integrity(result: Result<Summary, Error>, expected: IntegrityKind) -> String {
match result {
Err(Error::Integrity { kind, what, detail }) if kind == expected => format!("{what}: {detail}"),
other => panic!("expected an {expected:?} integrity error, got {other:?}"),
}
}
fn is_payload(path: &str) -> bool {
path.starts_with("/packs/") || path.starts_with("/src/") || path.contains("/block/")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn site_install_verifies_everything_then_rerun_downloads_nothing() {
let stub = Stub::start(&[]);
let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install"));
let manifest = current_manifest(&site, &key).await.unwrap();
assert_eq!(manifest.raw(), std::fs::read(stub.store_manifest_path()).unwrap().as_slice());
let events: Arc<Mutex<Vec<Progress>>> = Arc::default();
let sink = events.clone();
let summary = install(&site, &dest, &manifest, InstallOptions::default(), move |p| sink.lock().unwrap().push(p), CancellationToken::new())
.await
.unwrap();
let app = summary.app.clone().unwrap();
assert_eq!((app.files, app.downloaded, app.skipped), (5, 5, 0));
let db = summary.db.clone().unwrap();
assert_eq!((db.files, db.downloaded, db.skipped), (3, 3, 0));
let src: Vec<(String, usize)> = summary.src.clone().unwrap().into_iter().map(|s| (s.path, s.chunks)).collect();
assert_eq!(src, vec![
("osm_source/world_aeroway_v1.sqlite".to_string(), 3),
("osm_source/small.bin".to_string(), 1),
("osm_source/empty.bin".to_string(), 0),
]);
let tiles = summary.tiles.clone().unwrap();
assert_eq!((tiles[0].blocks, tiles[0].downloaded, tiles[0].files), (4, 4, 8));
assert!(summary.transferred_bytes > 3 * CHUNK as u64);
// Every byte in place, in the fetch.py layout.
let store = stub.root();
let m = stub.store_manifest();
let version_dir = dest.join("versions").join("0.51.0");
for f in m["app"]["files"].as_array().unwrap() {
let path = version_dir.join(f["path"].as_str().unwrap());
assert_eq!(sha256_hex(&std::fs::read(&path).unwrap()), f["sha256"].as_str().unwrap(), "{}", path.display());
}
assert_eq!(std::fs::metadata(version_dir.join("Odian/assets/empty.conf")).unwrap().len(), 0);
for f in m["db"]["files"].as_array().unwrap() {
let path = dest.join("data").join(f["path"].as_str().unwrap());
assert_eq!(sha256_hex(&std::fs::read(&path).unwrap()), f["sha256"].as_str().unwrap());
}
for f in m["src"]["files"].as_array().unwrap() {
let rel = f["path"].as_str().unwrap();
let path = dest.join("data").join("src").join(rel);
assert_eq!(std::fs::read(&path).unwrap(), std::fs::read(store.join("src").join(rel)).unwrap());
let marker = std::fs::read_to_string(format!("{}.ok", path.display())).unwrap();
assert_eq!(marker, f["chunks_sha256"].as_str().unwrap());
assert!(!Path::new(&format!("{}.chunks.json", path.display())).exists());
}
assert_eq!(std::fs::read(version_dir.join("manifest.json")).unwrap(), manifest.raw());
let set_dir = dest.join("data/tiles/dark/test_ds");
let mut tile_files = files(&set_dir);
let done = tile_files.remove(".blocks-done.json").map(|_| std::fs::read_to_string(set_dir.join(".blocks-done.json")).unwrap()).unwrap();
let expected_tiles = ["0/0/0.png", "3/1/2.png", "7/1/2.png", "7/127/63.png", "7/3/2.jpg", "7/3/2.png", "7/63/63.pbf", "7/64/5.png"];
assert_eq!(tile_files.keys().map(String::as_str).collect::<Vec<_>>(), expected_tiles, "the late tile 7/5/5.png must not be installed");
for rel in expected_tiles {
assert_eq!(tile_files[rel], sha256_hex(&std::fs::read(store.join("tiles/dark/test_ds").join(rel)).unwrap()));
}
assert!(done.starts_with("{\"index_sha256\": \""), "Python's json.dumps layout: {done}");
let done: Value = serde_json::from_str(&done).unwrap();
assert_eq!(done["index_sha256"], m["tiles"]["sets"][0]["index_sha256"]);
assert_eq!(done["blocks"].as_array().unwrap().len(), 4);
// No request for the zero-byte file: it never needs the network.
let log = stub.requests().await;
assert!(!log.iter().any(|(p, _)| p.ends_with(&stub.app_sha("Odian/assets/empty.conf"))));
// Progress: stages in order, ending at their totals, contract-shaped JSON.
let events = events.lock().unwrap().clone();
let mut stages: Vec<Stage> = events.iter().map(|p| p.stage).collect();
stages.dedup();
assert_eq!(stages, vec![Stage::Manifest, Stage::App, Stage::Db, Stage::Src, Stage::Tiles]);
assert!(events.iter().all(|p| p.stage_count == 5));
for stage in [Stage::App, Stage::Db, Stage::Src, Stage::Tiles] {
let last = events.iter().rev().find(|p| p.stage == stage).unwrap();
assert_eq!((last.done_bytes, last.done_items), (last.total_bytes, last.total_items), "{stage:?}");
}
assert!(events.iter().any(|p| p.message.as_deref() == Some("tiles dark/test_ds: 4/4 blocks")));
let json = serde_json::to_value(&events[0]).unwrap();
assert_eq!(json["stage"], "manifest");
for field in ["stage_index", "stage_count", "done_bytes", "total_bytes", "done_items", "total_items", "bytes_per_sec", "message"] {
assert!(json.get(field).is_some(), "{field}");
}
// Re-run: nothing is downloaded again.
stub.ctl("/_ctl/reset").await;
let again = install(&site, &dest, &manifest, InstallOptions::default(), |_| {}, CancellationToken::new()).await.unwrap();
assert_eq!(again.transferred_bytes, 0);
assert_eq!(again.app.unwrap().downloaded, 0);
assert_eq!(again.db.unwrap().downloaded, 0);
assert!(again.src.unwrap().iter().all(|s| s.chunks == 0));
assert_eq!(again.tiles.unwrap()[0].downloaded, 0);
let log = stub.requests().await;
assert!(log.iter().all(|(p, _)| !is_payload(p)), "{log:?}");
assert_eq!(fetch_notes(&site, "0.51.0").await.unwrap(), NOTES);
assert_eq!(version_manifest(&site, "0.51.0", &key).await.unwrap().raw(), manifest.raw());
assert!(matches!(version_manifest(&site, "9.9.9", &key).await, Err(Error::NotFound { .. })));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn wrong_token_is_unauthorized_and_edited_manifest_is_a_signature_error() {
let stub = Stub::start(&[]);
let key = stub.key();
let bad = Source::Site { base_url: stub.base(), token: "bad".into() };
assert!(matches!(current_manifest(&bad, &key).await, Err(Error::Unauthorized { status: 401, .. })));
let manifest = current_manifest(&stub.site(), &key).await.unwrap();
let dest = stub.dest("install");
let result = install(&bad, &dest, &manifest, only(&[Stage::App]), |_| {}, CancellationToken::new()).await;
assert!(matches!(result, Err(Error::Unauthorized { .. })), "{result:?}");
assert!(!dest.join("versions/0.51.0/manifest.json").exists());
// The stub's manifest is signed by the test key, not the release key.
let release = current_manifest(&stub.site(), bundled_public_key()).await;
assert!(matches!(release, Err(Error::Integrity { kind: IntegrityKind::Signature, .. })));
let path = stub.store_manifest_path();
let text = std::fs::read_to_string(&path).unwrap();
let edited = text.replacen("\"released_at\": \"2026-09-15T12:00:00Z\"", "\"released_at\": \"2026-09-15T12:00:01Z\"", 1);
assert_ne!(text, edited);
std::fs::write(&path, edited).unwrap();
let result = current_manifest(&stub.site(), &key).await;
assert!(matches!(result, Err(Error::Integrity { kind: IntegrityKind::Signature, .. })), "{result:?}");
std::fs::remove_file(stub.root().join("packs/versions/CURRENT")).unwrap();
assert!(matches!(current_manifest(&stub.site(), &key).await, Err(Error::NotFound { .. })));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn flipped_app_byte_is_an_integrity_error() {
let stub = Stub::start(&[]);
let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install"));
let manifest = current_manifest(&site, &key).await.unwrap();
let sha = stub.app_sha("Odian/Odian.exe");
flip(&stub.app_blob("Odian/Odian.exe"), 1234);
stub.ctl("/_ctl/reset").await;
let detail = integrity(install(&site, &dest, &manifest, only(&[Stage::App]), |_| {}, CancellationToken::new()).await, IntegrityKind::FileHash);
assert!(detail.contains(&sha), "{detail}");
let gets = stub.requests().await.iter().filter(|(p, _)| p.ends_with(&sha)).count();
assert_eq!(gets, 2, "one clean retry, then give up");
let target = dest.join("versions/0.51.0/Odian/Odian.exe");
assert!(!target.exists() && !Path::new(&format!("{}.part", target.display())).exists());
assert!(!dest.join("versions/0.51.0/manifest.json").exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn flipped_tile_byte_is_an_integrity_error() {
let stub = Stub::start(&[]);
let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install"));
flip(&stub.root().join("tiles/dark/test_ds/7/63/63.pbf"), 100);
let detail = integrity(install_from(&site, &dest, &key, only(&[Stage::Tiles])).await, IntegrityKind::BlockDigest);
assert!(detail.contains("block/7/0/0.tar"), "{detail}");
let set_dir = dest.join("data/tiles/dark/test_ds");
assert!(!set_dir.join("7/63/63.pbf").exists() && !set_dir.join("7/1/2.png").exists(), "nothing of a bad block moves in");
if let Ok(done) = std::fs::read_to_string(set_dir.join(".blocks-done.json")) {
assert!(!done.contains("\"7/0/0:"), "{done}");
}
assert!(!dest.join("versions/0.51.0/manifest.json").exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn flipped_source_chunk_byte_is_an_integrity_error_and_the_rest_resumes() {
let stub = Stub::start(&[]);
let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install"));
let original = stub.root().join("src/osm_source/world_aeroway_v1.sqlite");
flip(&original, CHUNK + 5);
let detail = integrity(install_from(&site, &dest, &key, only(&[Stage::Src])).await, IntegrityKind::ChunkHash);
assert!(detail.contains("chunk 1"), "{detail}");
let target = dest.join("data/src/osm_source/world_aeroway_v1.sqlite");
assert!(!Path::new(&format!("{}.ok", target.display())).exists());
let progress: Value = serde_json::from_str(&std::fs::read_to_string(format!("{}.chunks.json", target.display())).unwrap()).unwrap();
let verified: Vec<u64> = progress["verified"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert!(!verified.contains(&1), "{verified:?}");
flip(&original, CHUNK + 5); // the site is fixed
stub.ctl("/_ctl/reset").await;
let summary = install_from(&site, &dest, &key, only(&[Stage::Src])).await.unwrap();
let world = &summary.src.unwrap()[0];
assert_eq!(world.chunks + world.skipped, 3);
assert_eq!(world.skipped, verified.len());
let ranges: Vec<String> = stub
.requests()
.await
.into_iter()
.filter(|(p, _)| p.ends_with("world_aeroway_v1.sqlite"))
.map(|(_, r)| r.unwrap())
.collect();
assert_eq!(ranges.len(), world.chunks);
assert!(ranges.contains(&format!("bytes={}-{}", CHUNK, 2 * CHUNK - 1)), "{ranges:?}");
assert_eq!(std::fs::read(&target).unwrap(), std::fs::read(&original).unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn interrupted_part_resumes_with_a_range_request() {
let stub = Stub::start(&[]);
let (site, key) = (stub.site(), stub.key());
let manifest = current_manifest(&site, &key).await.unwrap();
let sha = stub.app_sha("Odian/data/big.bin");
let big = std::fs::read(stub.app_blob("Odian/data/big.bin")).unwrap();
let app_total: u64 = manifest.manifest().app.files.iter().map(|f| f.size).sum();
// A .part left by an earlier run (Python or Rust).
let dest = stub.dest("seeded");
let target = dest.join("versions/0.51.0/Odian/data/big.bin");
let part = PathBuf::from(format!("{}.part", target.display()));
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
let have = CHUNK + 1000;
std::fs::write(&part, &big[..have]).unwrap();
stub.ctl("/_ctl/reset").await;
let summary = install(&site, &dest, &manifest, only(&[Stage::App]), |_| {}, CancellationToken::new()).await.unwrap();
assert_eq!(summary.app.unwrap().downloaded, 5);
assert_eq!(summary.transferred_bytes, app_total - have as u64);
let log = stub.requests().await;
let big_requests: Vec<_> = log.iter().filter(|(p, _)| p.ends_with(&sha)).collect();
assert_eq!(big_requests.len(), 1);
assert_eq!(big_requests[0].1.as_deref(), Some(format!("bytes={have}-").as_str()));
assert_eq!(std::fs::read(&target).unwrap(), big);
assert!(!part.exists());
// A connection that really drops mid-file.
let dest = stub.dest("cut");
let target = dest.join("versions/0.51.0/Odian/data/big.bin");
let part = PathBuf::from(format!("{}.part", target.display()));
stub.ctl(&format!("/_ctl/cut?prefix=/packs/app/{sha}&after=700000&count=1")).await;
let result = install(&site, &dest, &manifest, only(&[Stage::App]), |_| {}, CancellationToken::new()).await;
assert!(matches!(result, Err(Error::Http { .. })), "{result:?}");
let have = std::fs::metadata(&part).unwrap().len();
assert!(have > 0 && have <= 700_000, "{have}");
stub.ctl("/_ctl/reset").await;
install(&site, &dest, &manifest, only(&[Stage::App]), |_| {}, CancellationToken::new()).await.unwrap();
let log = stub.requests().await;
let big_requests: Vec<_> = log.iter().filter(|(p, _)| p.ends_with(&sha)).collect();
assert_eq!(big_requests.len(), 1);
assert_eq!(big_requests[0].1.as_deref(), Some(format!("bytes={have}-").as_str()));
assert_eq!(std::fs::read(&target).unwrap(), big);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn busy_site_is_retried_with_retry_after() {
let stub = Stub::start(&[]);
let (site, key) = (stub.site(), stub.key());
stub.ctl("/_ctl/reset").await;
stub.ctl("/_ctl/fail?prefix=/v1/versions/current&status=503&count=2&retry_after=0").await;
current_manifest(&site, &key).await.unwrap();
let tries = stub.requests().await.iter().filter(|(p, _)| p == "/v1/versions/current").count();
assert_eq!(tries, 3);
stub.ctl("/_ctl/fail?prefix=/v1/versions/current&status=503&count=4&retry_after=0").await;
let result = current_manifest(&site, &key).await;
assert!(matches!(result, Err(Error::SiteBusy { attempts: 4, .. })), "{result:?}");
// A 502 on a tile block and a 504 on an app file are retried too.
stub.ctl("/_ctl/fail?prefix=/v1/versions/0.51.0/tiles/dark/test_ds/block/&status=502&count=1&retry_after=0").await;
stub.ctl("/_ctl/fail?prefix=/packs/app/&status=504&count=1&retry_after=0").await;
let summary = install_from(&site, &stub.dest("install"), &key, only(&[Stage::App, Stage::Tiles])).await.unwrap();
assert_eq!(summary.tiles.unwrap()[0].downloaded, 4);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn cancel_stops_and_keeps_verified_work() {
let stub = Stub::start(&[]);
let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install"));
let manifest = current_manifest(&site, &key).await.unwrap();
let cancel = CancellationToken::new();
let trigger = cancel.clone();
let result = install(&site, &dest, &manifest, InstallOptions::default(), move |p| {
if p.stage == Stage::App && p.done_items >= 1 {
trigger.cancel();
}
}, cancel)
.await;
assert!(matches!(result, Err(Error::Cancelled)), "{result:?}");
assert!(!dest.join("versions/0.51.0/manifest.json").exists());
let summary = install(&site, &dest, &manifest, InstallOptions::default(), |_| {}, CancellationToken::new()).await.unwrap();
assert!(summary.app.unwrap().skipped >= 1);
assert!(dest.join("versions/0.51.0/manifest.json").exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn drive_install_equals_site_install() {
let stub = Stub::start(&[]);
let (site, drive, key) = (stub.site(), stub.drive(), stub.key());
let from_site = stub.dest("from-site");
install_from(&site, &from_site, &key, InstallOptions::default()).await.unwrap();
let manifest = current_manifest(&drive, &key).await.unwrap();
assert_eq!(manifest.raw(), std::fs::read(stub.store_manifest_path()).unwrap().as_slice());
let from_drive = stub.dest("from-drive");
let summary = install(&drive, &from_drive, &manifest, InstallOptions::default(), |_| {}, CancellationToken::new()).await.unwrap();
assert_eq!(summary.app.unwrap().downloaded, 5);
assert_eq!(summary.tiles.unwrap()[0].downloaded, 4);
assert_eq!(files(&from_site), files(&from_drive), "site and drive installs must be identical, file by file");
let again = install(&drive, &from_drive, &manifest, InstallOptions::default(), |_| {}, CancellationToken::new()).await.unwrap();
assert_eq!(again.transferred_bytes, 0);
assert_eq!(fetch_notes(&drive, "0.51.0").await.unwrap(), NOTES);
assert_eq!(version_manifest(&drive, "0.51.0", &key).await.unwrap().raw(), manifest.raw());
// The same checks guard a drive.
let tile = stub.root().join("tiles/dark/test_ds/7/64/5.png");
flip(&tile, 3);
let detail = integrity(install_from(&drive, &stub.dest("drive-tile"), &key, only(&[Stage::Tiles])).await, IntegrityKind::BlockDigest);
assert!(detail.contains("7/1/0"), "{detail}");
flip(&tile, 3);
flip(&stub.app_blob("api/odian_api.exe"), 7);
integrity(install_from(&drive, &stub.dest("drive-app"), &key, only(&[Stage::App])).await, IntegrityKind::FileHash);
}
fn python_fetch(stub: &Stub, dest: &Path) -> Value {
let code = "import json, sys\n\
from odian_packs.fetch import fetch\n\
summary = fetch(sys.argv[1], 'good', sys.argv[2], public_key=sys.argv[3], log=lambda m: None)\n\
print(json.dumps(summary))\n";
let output = Command::new(python())
.arg("-c")
.arg(code)
.arg(stub.base())
.arg(dest)
.arg(stub.key())
.env("PYTHONPATH", pythonpath())
.env("PYTHONDONTWRITEBYTECODE", "1")
.output()
.unwrap();
assert!(output.status.success(), "python fetch failed:\n{}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8(output.stdout).unwrap();
serde_json::from_str(stdout.lines().last().unwrap()).unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn python_and_rust_installs_are_interchangeable() {
// fetch.py cannot install a zero-byte source file (see the report), so leave it out here.
let stub = Stub::start(&["--no-empty-src"]);
let (site, key) = (stub.site(), stub.key());
let python_first = stub.dest("python-first");
let py = tokio::task::block_in_place(|| python_fetch(&stub, &python_first));
assert_eq!(py["app"]["downloaded"], 5);
let rust = install_from(&site, &python_first, &key, InstallOptions::default()).await.unwrap();
assert_eq!(rust.transferred_bytes, 0, "Rust re-verifies a Python install and downloads nothing: {rust:?}");
assert_eq!(rust.app.unwrap().skipped, 5);
let rust_first = stub.dest("rust-first");
install_from(&site, &rust_first, &key, InstallOptions::default()).await.unwrap();
let py = tokio::task::block_in_place(|| python_fetch(&stub, &rust_first));
assert_eq!(py["app"]["downloaded"], 0, "{py}");
assert_eq!(py["db"]["downloaded"], 0, "{py}");
assert!(py["src"].as_array().unwrap().iter().all(|s| s["chunks"] == 0), "{py}");
assert_eq!(py["tiles"][0]["downloaded"], 0, "{py}");
assert_eq!(files(&python_first), files(&rust_first));
}
@@ -0,0 +1,2 @@
# Fixtures are compared byte for byte with what Python produced: never convert line endings.
* -text
File diff suppressed because one or more lines are too long
+373
View File
@@ -0,0 +1,373 @@
"""Generate the cross-language fixtures the odian-packs Rust tests read.
PYTHONPATH=C:/Users/JFEXP_D/Documents/Gitea/ODIAN_MCP_Server/src \
python crates/odian-packs/tests/fixtures/gen_fixtures.py [--real-manifest PATH]
Every expected value below is computed by the reference implementation (odian_packs);
the Rust tests must reproduce or verify each one. Outputs sit next to this script:
canonical.json canonical bytes of tricky JSON inputs (+ inputs Rust refuses)
signing.json test key, verdicts; signed/tampered manifests as separate files
relpaths.json safe_relpath verdicts
validate.json validate() verdicts, with the Python quirks Rust deliberately rejects
hashes.json block_of, block_digest, chunks_digest, chunk_count, index_sha256
real/0.51.0.* the real published 0.51.0 manifest and its Python hashes
"""
import argparse
import base64
import copy
import hashlib
import json
import os
import shutil
import sys
sys.dont_write_bytecode = True
from odian_packs import manifest as mf # noqa: E402
from odian_packs import signing, tiles # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_REAL = (r"C:\Users\JFEXP_D\AppData\Local\Temp\claude\C--Users-JFEXP-D-Documents-Gitea"
r"\d92cce4d-3852-472e-a7e3-10137a35c218\scratchpad\m1\packs\versions\0.51.0\manifest.json")
def out_path(name):
path = os.path.join(HERE, *name.split("/"))
os.makedirs(os.path.dirname(path), exist_ok=True)
return path
def write_bytes(name, data):
with open(out_path(name), "wb") as f:
f.write(data)
def write_json(name, obj):
# ASCII-only fixture files: no editor or checkout can change their bytes.
write_bytes(name, (json.dumps(obj, indent=1, ensure_ascii=True) + "\n").encode("ascii"))
def sha(data):
return hashlib.sha256(data).hexdigest()
def reversed_keys(obj):
if isinstance(obj, dict):
return {k: reversed_keys(obj[k]) for k in reversed(list(obj))}
if isinstance(obj, list):
return [reversed_keys(v) for v in obj]
return obj
def verdict(fn):
try:
fn()
return "ok", ""
except Exception as exc: # noqa: BLE001 - any exception is a refusal
return "error", f"{type(exc).__name__}: {exc}"
# ---- canonical JSON -------------------------------------------------------------
def minimal_manifest():
return {"schema": 1, "product": "odian", "version": "0.51.0", "released_at": "2026-09-15T12:00:00Z",
"app": {"files": [{"path": "Odian/Odian.exe", "size": 3, "sha256": sha(b"exe")}]}}
def canonical_fixture():
control = "".join(chr(i) for i in range(0x20)) + "\x7f\x80\x9f\u2028\u2029"
nested = "leaf"
for i in range(40):
nested = {f"k{i % 3}": [nested, i]} if i % 2 else [nested, {"i": i}]
values = [
("empty_object", {}),
("empty_array", []),
("empty_string", ""),
("null", None),
("true", True),
("false", False),
("empty_containers_nested", {"b": [], "a": {}, "c": [[], {}], "d": ""}),
("non_ascii", {"fr": "données été à Noël", "de": "Straße", "zh": "中文路径", "ar": "مرحبا", "ru": "Привет"}),
("emoji", {"emoji": "😀🚀 and 👨\u200d👩\u200d👧 zwj", "flag": "🇫🇷"}),
("control_chars", {"all": control}),
("quotes_backslashes", {"q": 'say "hi"', "b": "C:\\Odian\\x", "mixed": "\\\"\\n/<>&'"}),
("key_order_code_points", {"\ufb01": 1, "😀": 2, "a": 3, "B": 4, "": 5, "é": 6, "aa": 7, "a\u0000": 8,
"\uffff": 9, "Z": 10, "\u00ff": 11}),
("nested_sorting", {"z": {"y": [{"b": [], "a": {}}, [], {}], "x": None}, "a": [True, False, None, "", 0],
"m": {"k2": {"b": 1, "a": 2}, "k1": 3}}),
("big_ints", {"n": [0, -1, 1, 2**31, 2**32, 2**53, 2**53 + 1, 2**63 - 1, -2**63, 2**64 - 1, 2437523419]}),
("deep_nesting", nested),
("minimal_manifest", minimal_manifest()),
]
cases = []
for name, value in values:
for style, text in (
("ascii_escaped_reversed", json.dumps(reversed_keys(value), indent=2, ensure_ascii=True)),
("raw_utf8", json.dumps(value, ensure_ascii=False)),
):
assert json.loads(text) == value
cases.append({"name": f"{name}/{style}", "input": text,
"canonical_hex": mf.canonical_bytes(json.loads(text)).hex()})
rejects = []
for name, text in (("float", "1.5"), ("float_integral", "1.0"), ("exponent", "1e3"), ("negative_zero", "-0"),
("negative_zero_float", "-0.0"), ("above_u64", "18446744073709551616"),
("below_i64", "-9223372036854775809"), ("float_in_object", '{"a": 2.5}'),
("float_in_array", "[1, 0.1]")):
rejects.append({"name": name, "input": text,
"python_canonical_hex": mf.canonical_bytes(json.loads(text)).hex()})
write_json("canonical.json", {"cases": cases, "rust_rejects": rejects})
# ---- signing ---------------------------------------------------------------------
def full_manifest():
chunks = [sha(b"c0"), sha(b"c1")]
return {
"schema": 1, "product": "odian", "version": "0.51.0", "released_at": "2026-09-15T12:00:00Z",
"min_launcher": "0.1.0", "notes": "notes.md",
"app": {"files": [
{"path": "Odian/Odian.exe", "size": 3, "sha256": sha(b"exe")},
{"path": "Odian/assets/empty.conf", "size": 0, "sha256": sha(b"")},
{"path": "Odian/données/carte été.png", "size": 5, "sha256": sha(b"carte")},
]},
"db": {"schema_version": 0, "snapshot": "20260915T101500Z", "files": [
{"section": "pre-data", "path": "db/20260915T101500Z/pre-data.dump", "size": 1, "sha256": sha(b"p")},
{"section": "data", "table": "public.t1", "path": "db/20260915T101500Z/data/public.t1.dump",
"size": 1, "sha256": sha(b"d")},
]},
"tiles": {"block_shift": 6, "sets": [
{"style": "dark", "dataset": "ds", "journal_seq": 9, "blocks": 2, "files": 3, "bytes": 2437523419,
"index": "tiles/dark.ds.index.json", "index_sha256": "a" * 64},
]},
"src": {"enabled": True, "chunk_bytes": 10, "files": [
{"path": "osm/x.sqlite", "size": 15, "chunks": chunks, "chunks_sha256": mf.chunks_digest(chunks)},
]},
"extra_note": "unknown keys are signed too: \"quoted\" \\ \u0001 😀",
}
def signing_fixture():
raw_key = bytes(range(32))
key = signing.private_key_from_raw(raw_key)
public = signing.public_key_text(key.public_key())
other = signing.public_key_text(signing.private_key_from_raw(bytes(range(1, 33))).public_key())
signed = signing.sign(mf.validate(full_manifest()), key)
tampered = copy.deepcopy(signed)
tampered["app"]["files"][0]["size"] = 4
flipped = copy.deepcopy(signed)
sig = bytearray(base64.b64decode(signed["signature"][len("ed25519:"):]))
sig[0] ^= 0xFF
flipped["signature"] = "ed25519:" + base64.b64encode(bytes(sig)).decode("ascii")
unsigned = {k: v for k, v in signed.items() if k != "signature"}
files = {
"signed_manifest.utf8.json": (json.dumps(signed, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode("utf-8"),
"signed_manifest.ascii.json": (json.dumps(signed, indent=2, sort_keys=True) + "\n").encode("ascii"),
"tampered_manifest.json": (json.dumps(tampered, indent=2, sort_keys=True) + "\n").encode("ascii"),
"signature_flipped_manifest.json": (json.dumps(flipped, indent=2, sort_keys=True) + "\n").encode("ascii"),
"unsigned_manifest.json": (json.dumps(unsigned, indent=2, sort_keys=True) + "\n").encode("ascii"),
}
cases = []
for name, data in files.items():
write_bytes(name, data)
doc = json.loads(data.decode("utf-8"))
ok, error = verdict(lambda: (mf.validate(doc), signing.verify(doc, public)))
cases.append({"file": name, "python": ok, "python_error": error})
other_ok, _ = verdict(lambda: signing.verify(signed, other))
write_json("signing.json", {
"private_key_raw_hex": raw_key.hex(),
"public_key": public,
"other_public_key": other,
"signed_with_other_key_verdict": other_ok,
"signature": signed["signature"],
"payload_hex": mf.signing_payload(signed).hex(),
"cases": cases,
})
# ---- safe_relpath -----------------------------------------------------------------
RELPATHS = [
"Odian/x.exe", "db/20260915T082529Z/data/public.odb_air_timeline_2026_02.dump",
"../a", "a/../b", "/a", "C:/a", "a\\b", "a//b", "", "./a", "a/", "a/.", ".", "..", "a/..",
"Odian/Odian.exe:hidden", "a/b.", "a/b ", "CON", "con", "a/CoN.TxT", "a/aux.txt", "a/Lpt1.log", "NUL.tar.gz",
"a/nul..x", "a/prn", "a/prn_x", "a/com¹", "a/COM².txt", "a/lpt³", "a/lpt0", "a/com0", "a/comA", "a/com10",
"a/con .txt", "a/b\x00c", "a/b\nc", "a/b\x1fc", "a/b\x7fc", "a/b\u0085c", "a/b\u2028c", "a/b?c", "a/b*c",
'a/b"c', "a/b|c", "a/b<c", "a/b>c", "a/console.txt", "a/auxiliary", "a/.hidden", "a/b c.txt", " a/b",
"a/ b", "a/b.c.", "données/été.png", "中文/路径.txt", "a/😀.png", "a/b#c%20d", "a/aux\u00a0",
"a/com\u0660", "a/\u212aon",
]
def relpath_fixture():
cases = []
for path in RELPATHS:
ok, error = verdict(lambda: mf.safe_relpath(path))
cases.append({"path": path, "python": ok, "python_error": error})
write_json("relpaths.json", {"cases": cases})
# ---- validate ---------------------------------------------------------------------
def validate_fixture():
def app0(m):
return m["app"]["files"][0]
def setp(fn):
def mutate(m):
fn(m)
return m
return mutate
empty_src = {"path": "e.bin", "size": 0, "chunks": [], "chunks_sha256": mf.chunks_digest([])}
# (name, mutation, reason Rust deliberately differs from Python, or None)
mutations = [
("valid_full", lambda m: m, None),
("valid_minimal", lambda m: minimal_manifest(), None),
("no_app_section", setp(lambda m: m.pop("app")), None),
("app_null", setp(lambda m: m.update(app=None)), None),
("app_files_missing", setp(lambda m: m.update(app={})), None),
("app_entry_not_object", setp(lambda m: m.update(app={"files": [None]})), None),
("db_entry_not_object", setp(lambda m: m["db"].update(files=[1])), None),
("tiles_set_not_object", setp(lambda m: m["tiles"].update(sets=[1])), None),
("src_file_not_object", setp(lambda m: m["src"].update(files=["x"])), None),
("manifest_not_object", lambda m: [1], None),
("schema_2", setp(lambda m: m.update(schema=2)), None),
("schema_true", setp(lambda m: m.update(schema=True)),
"Python's `== 1` accepts True; Rust wants the integer 1"),
("schema_float", setp(lambda m: m.update(schema=1.0)),
"Python's `== 1` accepts 1.0; Rust refuses floats anywhere"),
("product_wrong", setp(lambda m: m.update(product="other")), None),
("version_two_parts", setp(lambda m: m.update(version="1.0")), None),
("version_leading_zero", setp(lambda m: m.update(version="01.2.3")), None),
("version_int", setp(lambda m: m.update(version=5)), None),
("version_missing", setp(lambda m: m.pop("version")), None),
("version_trailing_newline", setp(lambda m: m.update(version="0.51.0\n")),
"Python's regex `$` matches before a final newline; Rust refuses it"),
("version_unicode_digit", setp(lambda m: m.update(version="0.51.\u0663")),
"Python's `\\d` matches any Unicode digit; Rust takes ASCII digits only"),
("released_at_int", setp(lambda m: m.update(released_at=5)), None),
("min_launcher_bad", setp(lambda m: m.update(min_launcher="0.1")), None),
("notes_traversal", setp(lambda m: m.update(notes="../notes.md")), None),
("notes_int", setp(lambda m: m.update(notes=5)), None),
("sha_upper", setp(lambda m: app0(m).update(sha256="A" * 64)), None),
("sha_short", setp(lambda m: app0(m).update(sha256="a" * 63)), None),
("sha_trailing_newline", setp(lambda m: app0(m).update(sha256="a" * 64 + "\n")),
"Python's regex `$` matches before a final newline; Rust refuses it"),
("dup_app_path_case", setp(lambda m: m["app"]["files"].append({**app0(m), "path": "ODIAN/odian.EXE"})), None),
("dup_app_path_unicode_case", setp(lambda m: m["app"]["files"].extend([
{"path": "Odian/Ä.txt", "size": 1, "sha256": "b" * 64},
{"path": "odian/ä.txt", "size": 1, "sha256": "c" * 64}])), None),
("app_path_traversal", setp(lambda m: app0(m).update(path="../evil.exe")), None),
("size_negative", setp(lambda m: app0(m).update(size=-1)), None),
("size_bool", setp(lambda m: app0(m).update(size=True)), None),
("size_float", setp(lambda m: app0(m).update(size=3.0)), None),
("size_string", setp(lambda m: app0(m).update(size="3")), None),
("size_above_u64", setp(lambda m: app0(m).update(size=2**64)),
"Rust numbers are 64-bit; a size above u64::MAX is refused"),
("db_empty_files", setp(lambda m: m["db"].update(files=[])), None),
("db_bad_section", setp(lambda m: m["db"]["files"][0].update(section="schema")), None),
("db_data_without_table", setp(lambda m: m["db"]["files"][1].pop("table")), None),
("db_data_empty_table", setp(lambda m: m["db"]["files"][1].update(table="")), None),
("db_path_outside_snapshot", setp(lambda m: m["db"]["files"][0].update(path="db/20260101T000000Z/x.dump")), None),
("db_snapshot_bad", setp(lambda m: m["db"].update(snapshot="2026-09-15")), None),
("db_schema_version_bool", setp(lambda m: m["db"].update(schema_version=True)), None),
("tiles_block_shift_7", setp(lambda m: m["tiles"].update(block_shift=7)), None),
("tiles_block_shift_float", setp(lambda m: m["tiles"].update(block_shift=6.0)),
"Python's `== 6` accepts 6.0; Rust refuses floats anywhere"),
("tiles_bad_style", setp(lambda m: m["tiles"]["sets"][0].update(style="da rk")), None),
("tiles_style_trailing_newline", setp(lambda m: m["tiles"]["sets"][0].update(
style="dark\n", index="tiles/dark\n.ds.index.json")),
"Python's regex `$` matches before a final newline; Rust refuses it"),
("tiles_dup_set", setp(lambda m: m["tiles"]["sets"].append(dict(m["tiles"]["sets"][0]))), None),
("tiles_wrong_index_name", setp(lambda m: m["tiles"]["sets"][0].update(index="tiles/other.json")), None),
("tiles_count_negative", setp(lambda m: m["tiles"]["sets"][0].update(journal_seq=-1)), None),
("src_enabled_int", setp(lambda m: m["src"].update(enabled=1)), None),
("src_chunk_bytes_zero", setp(lambda m: m["src"].update(chunk_bytes=0)), None),
("src_chunk_count_wrong", setp(lambda m: m["src"]["files"][0].update(size=25)), None),
("src_chunks_sha_wrong", setp(lambda m: m["src"]["files"][0].update(chunks_sha256="c" * 64)), None),
("src_empty_file", setp(lambda m: m["src"]["files"].append(empty_src)), None),
("signature_not_ed25519", setp(lambda m: m.update(signature="rsa:xx")), None),
("signature_int", setp(lambda m: m.update(signature=5)), None),
("unknown_keys", setp(lambda m: m.update(future={"x": [1, "y"]})), None),
("unknown_float_key", setp(lambda m: m.update(ratio=0.5)),
"Rust refuses floats anywhere in a manifest, even under keys validate() ignores"),
]
cases = []
for name, mutate, quirk in mutations:
doc = mutate(copy.deepcopy(full_manifest()))
ok, error = verdict(lambda: mf.validate(doc))
if ok == "error":
quirk = None # Python refuses it as well: not a difference
rust = "error" if quirk else ok
cases.append({"name": name, "manifest_json": json.dumps(doc, ensure_ascii=True), "python": ok,
"python_error": error, "rust_expect": rust, "quirk": quirk})
write_json("validate.json", {"cases": cases})
# ---- hashes -----------------------------------------------------------------------
def hashes_fixture():
rows = [(7, 63, 5, ".png", 13, sha(b"b")), (7, 0, 0, ".png", 13, sha(b"a")), (7, 0, 0, ".jpg", 9, sha(b"c")),
(7, 10, 2, ".png", 700, sha(b"d")), (7, 2, 10, ".png", 1, sha(b"e"))]
chunks = [sha(b"x"), sha(b"y"), sha(b"z")]
index = {"format": 1, "style": "dark", "dataset": "ds", "block_shift": 6, "journal_seq": 376157, "files": 3,
"bytes": 2437523419, "blocks": [
{"z": 0, "bx": 0, "by": 0, "files": 1, "bytes": 5321, "digest": sha(b"b0")},
{"z": 7, "bx": 1, "by": 0, "files": 2, "bytes": 2437518098, "digest": sha(b"b1")}]}
write_json("hashes.json", {
"block_digest": {"rows": [list(r) for r in rows], "digest": tiles.block_digest(rows)},
"block_digest_empty": tiles.block_digest([]),
"tile_lines": [{"row": list(r), "line": tiles.tile_line(*r).decode("ascii")} for r in rows],
"block_of": [[z, x, y, *tiles.block_of(z, x, y)] for z, x, y in
((0, 0, 0), (7, 63, 63), (7, 64, 0), (20, 1048575, 65), (3, 7, 7))],
"chunks_digest": {"chunks": chunks, "digest": mf.chunks_digest(chunks)},
"chunks_digest_empty": mf.chunks_digest([]),
"chunk_count": [[size, cb, mf.chunk_count(size, cb)] for size, cb in
((0, 10), (1, 10), (10, 10), (11, 10), (884244480, mf.CHUNK_BYTES), (2**40, mf.CHUNK_BYTES))],
"index": {"text": json.dumps(index, indent=2, sort_keys=True) + "\n", "index_sha256": tiles.index_sha256(index)},
})
# ---- the real published manifest ---------------------------------------------------
def real_fixture(source):
target = out_path("real/0.51.0.manifest.json")
if source and os.path.isfile(source):
shutil.copyfile(source, target)
with open(target, "rb") as f:
raw = f.read()
doc = json.loads(raw.decode("utf-8"))
mf.validate(doc)
signing.verify(doc, signing.bundled_public_key())
canonical = mf.canonical_bytes(doc)
write_json("real/0.51.0.hashes.json", {
"version": doc["version"],
"raw_len": len(raw),
"raw_sha256": sha(raw),
"canonical_len": len(canonical),
"canonical_sha256": sha(canonical),
"payload_sha256": sha(mf.signing_payload(doc)),
"release_public_key": signing.bundled_public_key(),
"app_files": len(doc["app"]["files"]),
})
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--real-manifest", default=DEFAULT_REAL)
args = parser.parse_args()
canonical_fixture()
signing_fixture()
relpath_fixture()
validate_fixture()
hashes_fixture()
real_fixture(args.real_manifest)
print(f"fixtures written to {HERE}")
if __name__ == "__main__":
main()
+192
View File
@@ -0,0 +1,192 @@
{
"block_digest": {
"rows": [
[
7,
63,
5,
".png",
13,
"3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d"
],
[
7,
0,
0,
".png",
13,
"ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb"
],
[
7,
0,
0,
".jpg",
9,
"2e7d2c03a9507ae265ecf5b5356885a53393a2029d241394997265a1a25aefc6"
],
[
7,
10,
2,
".png",
700,
"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4"
],
[
7,
2,
10,
".png",
1,
"3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea"
]
],
"digest": "57db4d13b815dbc73ae79fd3ed093fe6c735abafd1898526db086332592f3838"
},
"block_digest_empty": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"tile_lines": [
{
"row": [
7,
63,
5,
".png",
13,
"3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d"
],
"line": "7/63/5.png 13 3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d\n"
},
{
"row": [
7,
0,
0,
".png",
13,
"ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb"
],
"line": "7/0/0.png 13 ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb\n"
},
{
"row": [
7,
0,
0,
".jpg",
9,
"2e7d2c03a9507ae265ecf5b5356885a53393a2029d241394997265a1a25aefc6"
],
"line": "7/0/0.jpg 9 2e7d2c03a9507ae265ecf5b5356885a53393a2029d241394997265a1a25aefc6\n"
},
{
"row": [
7,
10,
2,
".png",
700,
"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4"
],
"line": "7/10/2.png 700 18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\n"
},
{
"row": [
7,
2,
10,
".png",
1,
"3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea"
],
"line": "7/2/10.png 1 3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea\n"
}
],
"block_of": [
[
0,
0,
0,
0,
0,
0
],
[
7,
63,
63,
7,
0,
0
],
[
7,
64,
0,
7,
1,
0
],
[
20,
1048575,
65,
20,
16383,
1
],
[
3,
7,
7,
3,
0,
0
]
],
"chunks_digest": {
"chunks": [
"2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
"a1fce4363854ff888cff4b8e7875d600c2682390412a8cf79b37d0b11148b0fa",
"594e519ae499312b29433b7dd8a97ff068defcba9755b6d5d00e84c524d67b06"
],
"digest": "71d8fb1b613dcdc24ac90f01cab91b1c9cb56fd4b40a73862cf87f7636b63c31"
},
"chunks_digest_empty": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"chunk_count": [
[
0,
10,
0
],
[
1,
10,
1
],
[
10,
10,
1
],
[
11,
10,
2
],
[
884244480,
268435456,
4
],
[
1099511627776,
268435456,
4096
]
],
"index": {
"text": "{\n \"block_shift\": 6,\n \"blocks\": [\n {\n \"bx\": 0,\n \"by\": 0,\n \"bytes\": 5321,\n \"digest\": \"c02c0b965e023abee808f2b548d8d5193a8b5229be6f3121a6f16e2d41a449b3\",\n \"files\": 1,\n \"z\": 0\n },\n {\n \"bx\": 1,\n \"by\": 0,\n \"bytes\": 2437518098,\n \"digest\": \"7dc96f776c8423e57a2785489a3f9c43fb6e756876d6ad9a9cac4aa4e72ec193\",\n \"files\": 2,\n \"z\": 7\n }\n ],\n \"bytes\": 2437523419,\n \"dataset\": \"ds\",\n \"files\": 3,\n \"format\": 1,\n \"journal_seq\": 376157,\n \"style\": \"dark\"\n}\n",
"index_sha256": "02bfedeafc1aec68814eeafa4e116d0c7b94d8bbb164c8cab17a58a1241473fa"
}
}
@@ -0,0 +1,10 @@
{
"version": "0.51.0",
"raw_len": 993204,
"raw_sha256": "a0e86b662cd228497a697d22096e0d888bea40484802a4bf393e5283cc883971",
"canonical_len": 756473,
"canonical_sha256": "901818050d56e309cedbc5adfbfafa63a30c6d9ac59d85450d9fa0681d1bbc3d",
"payload_sha256": "1be8d279322c6d16b7cd7a4e942cfc15207fca795be45bbdbe55f94c42de629f",
"release_public_key": "ed25519:DS7z/nhxZE9ffn0PKVtw5fMUBRLdQ424OE236A0tbts=",
"app_files": 4950
}
File diff suppressed because it is too large Load Diff
+309
View File
@@ -0,0 +1,309 @@
{
"cases": [
{
"path": "Odian/x.exe",
"python": "ok",
"python_error": ""
},
{
"path": "db/20260915T082529Z/data/public.odb_air_timeline_2026_02.dump",
"python": "ok",
"python_error": ""
},
{
"path": "../a",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: '../a'"
},
{
"path": "a/../b",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: 'a/../b'"
},
{
"path": "/a",
"python": "error",
"python_error": "ManifestError: path must be relative with '/' separators: '/a'"
},
{
"path": "C:/a",
"python": "error",
"python_error": "ManifestError: path part 'C:' holds a control character or one of <>:\"|?*: 'C:/a'"
},
{
"path": "a\\b",
"python": "error",
"python_error": "ManifestError: path must be relative with '/' separators: 'a\\\\b'"
},
{
"path": "a//b",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: 'a//b'"
},
{
"path": "",
"python": "error",
"python_error": "ManifestError: empty path"
},
{
"path": "./a",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: './a'"
},
{
"path": "a/",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: 'a/'"
},
{
"path": "a/.",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: 'a/.'"
},
{
"path": ".",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: '.'"
},
{
"path": "..",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: '..'"
},
{
"path": "a/..",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: 'a/..'"
},
{
"path": "Odian/Odian.exe:hidden",
"python": "error",
"python_error": "ManifestError: path part 'Odian.exe:hidden' holds a control character or one of <>:\"|?*: 'Odian/Odian.exe:hidden'"
},
{
"path": "a/b.",
"python": "error",
"python_error": "ManifestError: path part 'b.' ends with a dot or space: 'a/b.'"
},
{
"path": "a/b ",
"python": "error",
"python_error": "ManifestError: path part 'b ' ends with a dot or space: 'a/b '"
},
{
"path": "CON",
"python": "error",
"python_error": "ManifestError: path part 'CON' is a reserved Windows device name: 'CON'"
},
{
"path": "con",
"python": "error",
"python_error": "ManifestError: path part 'con' is a reserved Windows device name: 'con'"
},
{
"path": "a/CoN.TxT",
"python": "error",
"python_error": "ManifestError: path part 'CoN.TxT' is a reserved Windows device name: 'a/CoN.TxT'"
},
{
"path": "a/aux.txt",
"python": "error",
"python_error": "ManifestError: path part 'aux.txt' is a reserved Windows device name: 'a/aux.txt'"
},
{
"path": "a/Lpt1.log",
"python": "error",
"python_error": "ManifestError: path part 'Lpt1.log' is a reserved Windows device name: 'a/Lpt1.log'"
},
{
"path": "NUL.tar.gz",
"python": "error",
"python_error": "ManifestError: path part 'NUL.tar.gz' is a reserved Windows device name: 'NUL.tar.gz'"
},
{
"path": "a/nul..x",
"python": "error",
"python_error": "ManifestError: path part 'nul..x' is a reserved Windows device name: 'a/nul..x'"
},
{
"path": "a/prn",
"python": "error",
"python_error": "ManifestError: path part 'prn' is a reserved Windows device name: 'a/prn'"
},
{
"path": "a/prn_x",
"python": "ok",
"python_error": ""
},
{
"path": "a/com\u00b9",
"python": "error",
"python_error": "ManifestError: path part 'com\u00b9' is a reserved Windows device name: 'a/com\u00b9'"
},
{
"path": "a/COM\u00b2.txt",
"python": "error",
"python_error": "ManifestError: path part 'COM\u00b2.txt' is a reserved Windows device name: 'a/COM\u00b2.txt'"
},
{
"path": "a/lpt\u00b3",
"python": "error",
"python_error": "ManifestError: path part 'lpt\u00b3' is a reserved Windows device name: 'a/lpt\u00b3'"
},
{
"path": "a/lpt0",
"python": "error",
"python_error": "ManifestError: path part 'lpt0' is a reserved Windows device name: 'a/lpt0'"
},
{
"path": "a/com0",
"python": "error",
"python_error": "ManifestError: path part 'com0' is a reserved Windows device name: 'a/com0'"
},
{
"path": "a/comA",
"python": "ok",
"python_error": ""
},
{
"path": "a/com10",
"python": "ok",
"python_error": ""
},
{
"path": "a/con .txt",
"python": "ok",
"python_error": ""
},
{
"path": "a/b\u0000c",
"python": "error",
"python_error": "ManifestError: path part 'b\\x00c' holds a control character or one of <>:\"|?*: 'a/b\\x00c'"
},
{
"path": "a/b\nc",
"python": "error",
"python_error": "ManifestError: path part 'b\\nc' holds a control character or one of <>:\"|?*: 'a/b\\nc'"
},
{
"path": "a/b\u001fc",
"python": "error",
"python_error": "ManifestError: path part 'b\\x1fc' holds a control character or one of <>:\"|?*: 'a/b\\x1fc'"
},
{
"path": "a/b\u007fc",
"python": "ok",
"python_error": ""
},
{
"path": "a/b\u0085c",
"python": "ok",
"python_error": ""
},
{
"path": "a/b\u2028c",
"python": "ok",
"python_error": ""
},
{
"path": "a/b?c",
"python": "error",
"python_error": "ManifestError: path part 'b?c' holds a control character or one of <>:\"|?*: 'a/b?c'"
},
{
"path": "a/b*c",
"python": "error",
"python_error": "ManifestError: path part 'b*c' holds a control character or one of <>:\"|?*: 'a/b*c'"
},
{
"path": "a/b\"c",
"python": "error",
"python_error": "ManifestError: path part 'b\"c' holds a control character or one of <>:\"|?*: 'a/b\"c'"
},
{
"path": "a/b|c",
"python": "error",
"python_error": "ManifestError: path part 'b|c' holds a control character or one of <>:\"|?*: 'a/b|c'"
},
{
"path": "a/b<c",
"python": "error",
"python_error": "ManifestError: path part 'b<c' holds a control character or one of <>:\"|?*: 'a/b<c'"
},
{
"path": "a/b>c",
"python": "error",
"python_error": "ManifestError: path part 'b>c' holds a control character or one of <>:\"|?*: 'a/b>c'"
},
{
"path": "a/console.txt",
"python": "ok",
"python_error": ""
},
{
"path": "a/auxiliary",
"python": "ok",
"python_error": ""
},
{
"path": "a/.hidden",
"python": "ok",
"python_error": ""
},
{
"path": "a/b c.txt",
"python": "ok",
"python_error": ""
},
{
"path": " a/b",
"python": "ok",
"python_error": ""
},
{
"path": "a/ b",
"python": "ok",
"python_error": ""
},
{
"path": "a/b.c.",
"python": "error",
"python_error": "ManifestError: path part 'b.c.' ends with a dot or space: 'a/b.c.'"
},
{
"path": "donn\u00e9es/\u00e9t\u00e9.png",
"python": "ok",
"python_error": ""
},
{
"path": "\u4e2d\u6587/\u8def\u5f84.txt",
"python": "ok",
"python_error": ""
},
{
"path": "a/\ud83d\ude00.png",
"python": "ok",
"python_error": ""
},
{
"path": "a/b#c%20d",
"python": "ok",
"python_error": ""
},
{
"path": "a/aux\u00a0",
"python": "ok",
"python_error": ""
},
{
"path": "a/com\u0660",
"python": "ok",
"python_error": ""
},
{
"path": "a/\u212aon",
"python": "ok",
"python_error": ""
}
]
}
@@ -0,0 +1,78 @@
{
"app": {
"files": [
{
"path": "Odian/Odian.exe",
"sha256": "9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b",
"size": 3
},
{
"path": "Odian/assets/empty.conf",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
},
{
"path": "Odian/donn\u00e9es/carte \u00e9t\u00e9.png",
"sha256": "d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9",
"size": 5
}
]
},
"db": {
"files": [
{
"path": "db/20260915T101500Z/pre-data.dump",
"section": "pre-data",
"sha256": "148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940",
"size": 1
},
{
"path": "db/20260915T101500Z/data/public.t1.dump",
"section": "data",
"sha256": "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4",
"size": 1,
"table": "public.t1"
}
],
"schema_version": 0,
"snapshot": "20260915T101500Z"
},
"extra_note": "unknown keys are signed too: \"quoted\" \\ \u0001 \ud83d\ude00",
"min_launcher": "0.1.0",
"notes": "notes.md",
"product": "odian",
"released_at": "2026-09-15T12:00:00Z",
"schema": 1,
"signature": "ed25519:lgdqpswT6p5WOOy74T3v1JA7aWsJUMmRjc+MFDJSMw7W/GG2ec0BuWaeVL+YkMP+WtaF3tOS8Lh43TlD2Y7UDw==",
"src": {
"chunk_bytes": 10,
"enabled": true,
"files": [
{
"chunks": [
"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3",
"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982"
],
"chunks_sha256": "9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867",
"path": "osm/x.sqlite",
"size": 15
}
]
},
"tiles": {
"block_shift": 6,
"sets": [
{
"blocks": 2,
"bytes": 2437523419,
"dataset": "ds",
"files": 3,
"index": "tiles/dark.ds.index.json",
"index_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"journal_seq": 9,
"style": "dark"
}
]
},
"version": "0.51.0"
}
@@ -0,0 +1,78 @@
{
"app": {
"files": [
{
"path": "Odian/Odian.exe",
"sha256": "9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b",
"size": 3
},
{
"path": "Odian/assets/empty.conf",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
},
{
"path": "Odian/donn\u00e9es/carte \u00e9t\u00e9.png",
"sha256": "d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9",
"size": 5
}
]
},
"db": {
"files": [
{
"path": "db/20260915T101500Z/pre-data.dump",
"section": "pre-data",
"sha256": "148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940",
"size": 1
},
{
"path": "db/20260915T101500Z/data/public.t1.dump",
"section": "data",
"sha256": "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4",
"size": 1,
"table": "public.t1"
}
],
"schema_version": 0,
"snapshot": "20260915T101500Z"
},
"extra_note": "unknown keys are signed too: \"quoted\" \\ \u0001 \ud83d\ude00",
"min_launcher": "0.1.0",
"notes": "notes.md",
"product": "odian",
"released_at": "2026-09-15T12:00:00Z",
"schema": 1,
"signature": "ed25519:aQdqpswT6p5WOOy74T3v1JA7aWsJUMmRjc+MFDJSMw7W/GG2ec0BuWaeVL+YkMP+WtaF3tOS8Lh43TlD2Y7UDw==",
"src": {
"chunk_bytes": 10,
"enabled": true,
"files": [
{
"chunks": [
"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3",
"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982"
],
"chunks_sha256": "9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867",
"path": "osm/x.sqlite",
"size": 15
}
]
},
"tiles": {
"block_shift": 6,
"sets": [
{
"blocks": 2,
"bytes": 2437523419,
"dataset": "ds",
"files": 3,
"index": "tiles/dark.ds.index.json",
"index_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"journal_seq": 9,
"style": "dark"
}
]
},
"version": "0.51.0"
}
@@ -0,0 +1,78 @@
{
"app": {
"files": [
{
"path": "Odian/Odian.exe",
"sha256": "9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b",
"size": 3
},
{
"path": "Odian/assets/empty.conf",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
},
{
"path": "Odian/données/carte été.png",
"sha256": "d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9",
"size": 5
}
]
},
"db": {
"files": [
{
"path": "db/20260915T101500Z/pre-data.dump",
"section": "pre-data",
"sha256": "148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940",
"size": 1
},
{
"path": "db/20260915T101500Z/data/public.t1.dump",
"section": "data",
"sha256": "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4",
"size": 1,
"table": "public.t1"
}
],
"schema_version": 0,
"snapshot": "20260915T101500Z"
},
"extra_note": "unknown keys are signed too: \"quoted\" \\ \u0001 😀",
"min_launcher": "0.1.0",
"notes": "notes.md",
"product": "odian",
"released_at": "2026-09-15T12:00:00Z",
"schema": 1,
"signature": "ed25519:aQdqpswT6p5WOOy74T3v1JA7aWsJUMmRjc+MFDJSMw7W/GG2ec0BuWaeVL+YkMP+WtaF3tOS8Lh43TlD2Y7UDw==",
"src": {
"chunk_bytes": 10,
"enabled": true,
"files": [
{
"chunks": [
"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3",
"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982"
],
"chunks_sha256": "9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867",
"path": "osm/x.sqlite",
"size": 15
}
]
},
"tiles": {
"block_shift": 6,
"sets": [
{
"blocks": 2,
"bytes": 2437523419,
"dataset": "ds",
"files": 3,
"index": "tiles/dark.ds.index.json",
"index_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"journal_seq": 9,
"style": "dark"
}
]
},
"version": "0.51.0"
}
+35
View File
@@ -0,0 +1,35 @@
{
"private_key_raw_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"public_key": "ed25519:A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=",
"other_public_key": "ed25519:ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=",
"signed_with_other_key_verdict": "error",
"signature": "ed25519:aQdqpswT6p5WOOy74T3v1JA7aWsJUMmRjc+MFDJSMw7W/GG2ec0BuWaeVL+YkMP+WtaF3tOS8Lh43TlD2Y7UDw==",
"payload_hex": "7b22617070223a7b2266696c6573223a5b7b2270617468223a224f6469616e2f4f6469616e2e657865222c22736861323536223a2239303935626462383539333038623632616366303430333666666434616466653336366437663733376432373665623663343661653433346633383136633962222c2273697a65223a337d2c7b2270617468223a224f6469616e2f6173736574732f656d7074792e636f6e66222c22736861323536223a2265336230633434323938666331633134396166626634633839393666623932343237616534316534363439623933346361343935393931623738353262383535222c2273697a65223a307d2c7b2270617468223a224f6469616e2f646f6e6ec3a965732f636172746520c3a974c3a92e706e67222c22736861323536223a2264326331323432306239383135653037623239623132353666646436653339613934623639656431393230303966306335643761666666633939646534346539222c2273697a65223a357d5d7d2c226462223a7b2266696c6573223a5b7b2270617468223a2264622f3230323630393135543130313530305a2f7072652d646174612e64756d70222c2273656374696f6e223a227072652d64617461222c22736861323536223a2231343864653963356137613434643139653536636439616531613535346266363738343761666230633538663665313266613239616337646466636139393430222c2273697a65223a317d2c7b2270617468223a2264622f3230323630393135543130313530305a2f646174612f7075626c69632e74312e64756d70222c2273656374696f6e223a2264617461222c22736861323536223a2231386163336537333433663031363839306335313065393366393335323631313639643965336635363534333634323938333066616630393334663466386534222c2273697a65223a312c227461626c65223a227075626c69632e7431227d5d2c22736368656d615f76657273696f6e223a302c22736e617073686f74223a223230323630393135543130313530305a227d2c2265787472615f6e6f7465223a22756e6b6e6f776e206b65797320617265207369676e656420746f6f3a205c2271756f7465645c22205c5c205c753030303120f09f9880222c226d696e5f6c61756e63686572223a22302e312e30222c226e6f746573223a226e6f7465732e6d64222c2270726f64756374223a226f6469616e222c2272656c65617365645f6174223a22323032362d30392d31355431323a30303a30305a222c22736368656d61223a312c22737263223a7b226368756e6b5f6279746573223a31302c22656e61626c6564223a747275652c2266696c6573223a5b7b226368756e6b73223a5b2231323263353937303833626434333862376636643732616637356430323539343838393936343737313162383036626464326364383266613639373133646233222c2264306636333163613164646261386462336263666362396530353763646339386430333739663162656530306537356135343531343761323764616464393832225d2c226368756e6b735f736861323536223a2239636132623534313738616238653732613635303666313135633137633737623338636464356333323366363463653336653061316661343061613635383637222c2270617468223a226f736d2f782e73716c697465222c2273697a65223a31357d5d7d2c2274696c6573223a7b22626c6f636b5f7368696674223a362c2273657473223a5b7b22626c6f636b73223a322c226279746573223a323433373532333431392c2264617461736574223a226473222c2266696c6573223a332c22696e646578223a2274696c65732f6461726b2e64732e696e6465782e6a736f6e222c22696e6465785f736861323536223a2261616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161222c226a6f75726e616c5f736571223a392c227374796c65223a226461726b227d5d7d2c2276657273696f6e223a22302e35312e30227d",
"cases": [
{
"file": "signed_manifest.utf8.json",
"python": "ok",
"python_error": ""
},
{
"file": "signed_manifest.ascii.json",
"python": "ok",
"python_error": ""
},
{
"file": "tampered_manifest.json",
"python": "error",
"python_error": "SignatureError: signature does not match: manifest altered, or signed by another key"
},
{
"file": "signature_flipped_manifest.json",
"python": "error",
"python_error": "SignatureError: signature does not match: manifest altered, or signed by another key"
},
{
"file": "unsigned_manifest.json",
"python": "error",
"python_error": "SignatureError: manifest is not signed"
}
]
}
@@ -0,0 +1,78 @@
{
"app": {
"files": [
{
"path": "Odian/Odian.exe",
"sha256": "9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b",
"size": 4
},
{
"path": "Odian/assets/empty.conf",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
},
{
"path": "Odian/donn\u00e9es/carte \u00e9t\u00e9.png",
"sha256": "d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9",
"size": 5
}
]
},
"db": {
"files": [
{
"path": "db/20260915T101500Z/pre-data.dump",
"section": "pre-data",
"sha256": "148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940",
"size": 1
},
{
"path": "db/20260915T101500Z/data/public.t1.dump",
"section": "data",
"sha256": "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4",
"size": 1,
"table": "public.t1"
}
],
"schema_version": 0,
"snapshot": "20260915T101500Z"
},
"extra_note": "unknown keys are signed too: \"quoted\" \\ \u0001 \ud83d\ude00",
"min_launcher": "0.1.0",
"notes": "notes.md",
"product": "odian",
"released_at": "2026-09-15T12:00:00Z",
"schema": 1,
"signature": "ed25519:aQdqpswT6p5WOOy74T3v1JA7aWsJUMmRjc+MFDJSMw7W/GG2ec0BuWaeVL+YkMP+WtaF3tOS8Lh43TlD2Y7UDw==",
"src": {
"chunk_bytes": 10,
"enabled": true,
"files": [
{
"chunks": [
"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3",
"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982"
],
"chunks_sha256": "9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867",
"path": "osm/x.sqlite",
"size": 15
}
]
},
"tiles": {
"block_shift": 6,
"sets": [
{
"blocks": 2,
"bytes": 2437523419,
"dataset": "ds",
"files": 3,
"index": "tiles/dark.ds.index.json",
"index_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"journal_seq": 9,
"style": "dark"
}
]
},
"version": "0.51.0"
}
@@ -0,0 +1,77 @@
{
"app": {
"files": [
{
"path": "Odian/Odian.exe",
"sha256": "9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b",
"size": 3
},
{
"path": "Odian/assets/empty.conf",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
},
{
"path": "Odian/donn\u00e9es/carte \u00e9t\u00e9.png",
"sha256": "d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9",
"size": 5
}
]
},
"db": {
"files": [
{
"path": "db/20260915T101500Z/pre-data.dump",
"section": "pre-data",
"sha256": "148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940",
"size": 1
},
{
"path": "db/20260915T101500Z/data/public.t1.dump",
"section": "data",
"sha256": "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4",
"size": 1,
"table": "public.t1"
}
],
"schema_version": 0,
"snapshot": "20260915T101500Z"
},
"extra_note": "unknown keys are signed too: \"quoted\" \\ \u0001 \ud83d\ude00",
"min_launcher": "0.1.0",
"notes": "notes.md",
"product": "odian",
"released_at": "2026-09-15T12:00:00Z",
"schema": 1,
"src": {
"chunk_bytes": 10,
"enabled": true,
"files": [
{
"chunks": [
"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3",
"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982"
],
"chunks_sha256": "9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867",
"path": "osm/x.sqlite",
"size": 15
}
]
},
"tiles": {
"block_shift": 6,
"sets": [
{
"blocks": 2,
"bytes": 2437523419,
"dataset": "ds",
"files": 3,
"index": "tiles/dark.ds.index.json",
"index_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"journal_seq": 9,
"style": "dark"
}
]
},
"version": "0.51.0"
}
+468
View File
@@ -0,0 +1,468 @@
{
"cases": [
{
"name": "valid_full",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "ok",
"python_error": "",
"rust_expect": "ok",
"quirk": null
},
{
"name": "valid_minimal",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}]}}",
"python": "ok",
"python_error": "",
"rust_expect": "ok",
"quirk": null
},
{
"name": "no_app_section",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "ok",
"python_error": "",
"rust_expect": "ok",
"quirk": null
},
{
"name": "app_null",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": null, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app must be an object",
"rust_expect": "error",
"quirk": null
},
{
"name": "app_files_missing",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files must be a list",
"rust_expect": "error",
"quirk": null
},
{
"name": "app_entry_not_object",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [null]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0] must be an object",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_entry_not_object",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [1]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.files[0] must be an object",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_set_not_object",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [1]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.sets[0] must be an object",
"rust_expect": "error",
"quirk": null
},
{
"name": "src_file_not_object",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [\"x\"]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: src.files[0] must be an object",
"rust_expect": "error",
"quirk": null
},
{
"name": "manifest_not_object",
"manifest_json": "[1]",
"python": "error",
"python_error": "ManifestError: manifest must be a JSON object",
"rust_expect": "error",
"quirk": null
},
{
"name": "schema_2",
"manifest_json": "{\"schema\": 2, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: schema must be 1",
"rust_expect": "error",
"quirk": null
},
{
"name": "schema_true",
"manifest_json": "{\"schema\": true, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: schema must be 1",
"rust_expect": "error",
"quirk": null
},
{
"name": "schema_float",
"manifest_json": "{\"schema\": 1.0, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: manifest.schema: numbers must be integers, got 1.0",
"rust_expect": "error",
"quirk": null
},
{
"name": "product_wrong",
"manifest_json": "{\"schema\": 1, \"product\": \"other\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: product must be 'odian'",
"rust_expect": "error",
"quirk": null
},
{
"name": "version_two_parts",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"1.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be MAJOR.MINOR.PATCH, got '1.0'",
"rust_expect": "error",
"quirk": null
},
{
"name": "version_leading_zero",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"01.2.3\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be MAJOR.MINOR.PATCH, got '01.2.3'",
"rust_expect": "error",
"quirk": null
},
{
"name": "version_int",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": 5, \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be a MAJOR.MINOR.PATCH string, got 5",
"rust_expect": "error",
"quirk": null
},
{
"name": "version_missing",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be MAJOR.MINOR.PATCH, got ''",
"rust_expect": "error",
"quirk": null
},
{
"name": "version_trailing_newline",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\\n\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be MAJOR.MINOR.PATCH, got '0.51.0\\n'",
"rust_expect": "error",
"quirk": null
},
{
"name": "version_unicode_digit",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.\\u0663\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be MAJOR.MINOR.PATCH, got '0.51.\u0663'",
"rust_expect": "error",
"quirk": null
},
{
"name": "released_at_int",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": 5, \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: released_at must be an ISO-8601 string",
"rust_expect": "error",
"quirk": null
},
{
"name": "min_launcher_bad",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be MAJOR.MINOR.PATCH, got '0.1'",
"rust_expect": "error",
"quirk": null
},
{
"name": "notes_traversal",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"../notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: '../notes.md'",
"rust_expect": "error",
"quirk": null
},
{
"name": "notes_int",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": 5, \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: empty path",
"rust_expect": "error",
"quirk": null
},
{
"name": "sha_upper",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0]: sha256 must be 64 lowercase hex chars",
"rust_expect": "error",
"quirk": null
},
{
"name": "sha_short",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0]: sha256 must be 64 lowercase hex chars",
"rust_expect": "error",
"quirk": null
},
{
"name": "sha_trailing_newline",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\\n\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0]: sha256 must be 64 lowercase hex chars",
"rust_expect": "error",
"quirk": null
},
{
"name": "dup_app_path_case",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}, {\"path\": \"ODIAN/odian.EXE\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[3]: duplicate path 'ODIAN/odian.EXE'",
"rust_expect": "error",
"quirk": null
},
{
"name": "dup_app_path_unicode_case",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}, {\"path\": \"Odian/\\u00c4.txt\", \"size\": 1, \"sha256\": \"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"}, {\"path\": \"odian/\\u00e4.txt\", \"size\": 1, \"sha256\": \"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[4]: duplicate path 'odian/\u00e4.txt'",
"rust_expect": "error",
"quirk": null
},
{
"name": "app_path_traversal",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"../evil.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: '../evil.exe'",
"rust_expect": "error",
"quirk": null
},
{
"name": "size_negative",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": -1, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0]: size must be a non-negative integer",
"rust_expect": "error",
"quirk": null
},
{
"name": "size_bool",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": true, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0]: size must be a non-negative integer",
"rust_expect": "error",
"quirk": null
},
{
"name": "size_float",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3.0, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: manifest.app.files[0].size: numbers must be integers, got 3.0",
"rust_expect": "error",
"quirk": null
},
{
"name": "size_string",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": \"3\", \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0]: size must be a non-negative integer",
"rust_expect": "error",
"quirk": null
},
{
"name": "size_above_u64",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 18446744073709551616, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: manifest.app.files[0].size: 18446744073709551616 does not fit in 64 bits",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_empty_files",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": []}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.files must not be empty",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_bad_section",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"schema\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.files[0]: section must be one of ('pre-data', 'data', 'sequences', 'post-data')",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_data_without_table",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.files[1]: data dumps name their table",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_data_empty_table",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.files[1]: data dumps name their table",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_path_outside_snapshot",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260101T000000Z/x.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.files[0]: path must live under db/20260915T101500Z/",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_snapshot_bad",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"2026-09-15\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.snapshot must look like 20260915T101500Z",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_schema_version_bool",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": true, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.schema_version must be a non-negative integer",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_block_shift_7",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 7, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.block_shift must be 6",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_block_shift_float",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6.0, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: manifest.tiles.block_shift: numbers must be integers, got 6.0",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_bad_style",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"da rk\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.sets[0]: bad style",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_style_trailing_newline",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\\n\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark\\n.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.sets[0]: bad style",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_dup_set",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}, {\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.sets[1]: duplicate tile set dark/ds",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_wrong_index_name",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/other.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.sets[0]: index must be 'tiles/dark.ds.index.json'",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_count_negative",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": -1, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.sets[0]: journal_seq must be a non-negative integer",
"rust_expect": "error",
"quirk": null
},
{
"name": "src_enabled_int",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": 1, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: src.enabled must be true or false",
"rust_expect": "error",
"quirk": null
},
{
"name": "src_chunk_bytes_zero",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 0, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: src.chunk_bytes must be a positive integer",
"rust_expect": "error",
"quirk": null
},
{
"name": "src_chunk_count_wrong",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 25, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: src.files[0]: 2 chunks for 25 bytes at 10 per chunk",
"rust_expect": "error",
"quirk": null
},
{
"name": "src_chunks_sha_wrong",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: src.files[0]: chunks_sha256 does not match chunks",
"rust_expect": "error",
"quirk": null
},
{
"name": "src_empty_file",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}, {\"path\": \"e.bin\", \"size\": 0, \"chunks\": [], \"chunks_sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "ok",
"python_error": "",
"rust_expect": "ok",
"quirk": null
},
{
"name": "signature_not_ed25519",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\", \"signature\": \"rsa:xx\"}",
"python": "error",
"python_error": "ManifestError: signature must look like 'ed25519:<base64>'",
"rust_expect": "error",
"quirk": null
},
{
"name": "signature_int",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\", \"signature\": 5}",
"python": "error",
"python_error": "ManifestError: signature must look like 'ed25519:<base64>'",
"rust_expect": "error",
"quirk": null
},
{
"name": "unknown_keys",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\", \"future\": {\"x\": [1, \"y\"]}}",
"python": "ok",
"python_error": "",
"rust_expect": "ok",
"quirk": null
},
{
"name": "unknown_float_key",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\", \"ratio\": 0.5}",
"python": "error",
"python_error": "ManifestError: manifest.ratio: numbers must be integers, got 0.5",
"rust_expect": "error",
"quirk": null
}
]
}
+339
View File
@@ -0,0 +1,339 @@
"""Local stub of an ODIAN site for the odian-packs Rust end-to-end tests.
PYTHONPATH=<ODIAN_MCP_Server>/src python stub_site.py --root DIR [--no-empty-src]
Builds a small signed pack store under DIR with odian_packs itself. The layout is the
site store layout, which is also the ODIAN drive layout:
DIR/packs/versions/CURRENT
DIR/packs/versions/0.51.0/{manifest.json, notes.md, tiles/dark.test_ds.index.json}
DIR/packs/app/<sha256> DIR/packs/db/<snapshot>/...
DIR/tiles/dark/test_ds/<z>/<x>/<y><ext>, DIR/tiles/tiles.db
DIR/src/...
It then serves DIR on 127.0.0.1 (a free port) with ``odian_update.api.create_app`` and a
stub token validator (token ``good``), plus nginx-like Range routes for /packs/app,
/packs/db and /src that check the same Bearer token. It prints one JSON line (port, test
public key, token, layout) and serves until its stdin closes.
Test-only control routes (no token):
GET /_ctl/log every request seen: path, range
POST /_ctl/reset clear the log and all rules
POST /_ctl/fail?prefix=P&status=503&count=N&retry_after=0
POST /_ctl/cut?prefix=P&after=BYTES&count=N send BYTES of the body, then drop the connection
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import socket
import sys
import threading
import urllib.parse
sys.dont_write_bytecode = True
VERSION = "0.51.0"
STYLE = "dark"
DATASET = "test_ds"
SNAPSHOT = "20260915T101500Z"
TOKEN = "good"
CHUNK = 1024 * 1024
TEST_KEY_RAW = bytes(range(32))
APP_FILES = {
"Odian/Odian.exe": 200 * 1024,
"Odian/assets/empty.conf": 0,
"Odian/data/big.bin": 3 * CHUNK + 17,
"Odian/données/carte été.png": 1000,
"api/odian_api.exe": 50 * 1024,
}
DB_FILES = [
("pre-data", None, "pre-data.dump", 5000),
("data", "public.t1", "data/public.t1.dump", 300 * 1024),
("post-data", None, "post-data.dump", 2048),
]
# (z, x, y, ext, size). Blocks (0,0,0), (3,0,0), (7,0,0), (7,1,0). The 8-byte tile is
# below MIN_TILE_BYTES and never journaled.
TILES = [
(0, 0, 0, ".png", 5000),
(3, 1, 2, ".png", 40),
(7, 1, 2, ".png", 700),
(7, 3, 2, ".jpg", 512),
(7, 3, 2, ".png", 9),
(7, 63, 63, ".pbf", 300 * 1024),
(7, 2, 2, ".png", 8),
(7, 64, 5, ".png", 20),
(7, 127, 63, ".png", 1000),
]
# Journaled AFTER the index is frozen, inside block (7,0,0): no install may contain it.
LATE_TILE = (7, 5, 5, ".png", 64)
SRC_FILES = {"osm_source/world_aeroway_v1.sqlite": 2 * CHUNK + 123, "osm_source/small.bin": 100}
EMPTY_SRC = "osm_source/empty.bin"
def blob(label, size):
return hashlib.shake_256(label.encode("utf-8")).digest(size) if size else b""
def write(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(data)
def write_json(path, obj):
"""Like odian_packs.cli._write_json: pretty, sorted, ASCII, trailing newline."""
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(obj, f, indent=2, sort_keys=True)
f.write("\n")
def join(root, rel):
return os.path.join(root, *rel.split("/"))
def build(root, empty_src):
from odian_packs import appstore, hashing, signing, tiles
from odian_packs import manifest as mf
packs = os.path.join(root, "packs")
tiles_root = os.path.join(root, "tiles")
src_root = os.path.join(root, "src")
stage = os.path.join(root, "stage")
for rel, size in APP_FILES.items():
write(join(os.path.join(stage, "app"), rel), blob("app:" + rel, size))
app = appstore.store_tree(os.path.join(stage, "app"), packs, workers=2)
shutil.rmtree(stage)
db_files = []
for section, table, rel, size in DB_FILES:
path_rel = f"db/{SNAPSHOT}/{rel}"
full = join(packs, path_rel)
write(full, blob("db:" + rel, size))
got_size, got_sha = hashing.sha256_file(full)
entry = {"section": section, "path": path_rel, "size": got_size, "sha256": got_sha}
if table:
entry["table"] = table
db_files.append(entry)
set_root = os.path.join(tiles_root, STYLE, DATASET)
journal = os.path.join(tiles_root, "tiles.db")
for z, x, y, ext, size in TILES:
write(os.path.join(set_root, str(z), str(x), f"{y}{ext}"), blob(f"tile:{z}/{x}/{y}{ext}", size))
tiles.import_tree(set_root, STYLE, DATASET, journal, workers=2)
index = tiles.build_index(journal, STYLE, DATASET)
version_dir = os.path.join(packs, "versions", VERSION)
index_name = mf.tile_index_name(STYLE, DATASET)
write_json(join(version_dir, index_name), index)
z, x, y, ext, size = LATE_TILE
write(os.path.join(set_root, str(z), str(x), f"{y}{ext}"), blob(f"tile:{z}/{x}/{y}{ext}", size))
tiles.import_tree(set_root, STYLE, DATASET, journal, workers=2)
src_items = dict(SRC_FILES)
if empty_src:
src_items[EMPTY_SRC] = 0
src_files = []
for rel, size in src_items.items():
full = join(src_root, rel)
write(full, blob("src:" + rel, size))
hashed = hashing.hash_chunks(full, CHUNK)
src_files.append({"path": rel, "size": hashed["size"], "chunks": hashed["chunks"],
"chunks_sha256": hashed["chunks_sha256"]})
manifest = {
"schema": 1, "product": "odian", "version": VERSION, "released_at": "2026-09-15T12:00:00Z",
"min_launcher": "0.1.0", "notes": "notes.md",
"app": app,
"db": {"schema_version": 0, "snapshot": SNAPSHOT, "files": db_files},
"tiles": {"block_shift": 6, "sets": [{
"style": STYLE, "dataset": DATASET, "journal_seq": index["journal_seq"],
"blocks": len(index["blocks"]), "files": index["files"], "bytes": index["bytes"],
"index": index_name, "index_sha256": tiles.index_sha256(index)}]},
"src": {"enabled": True, "chunk_bytes": CHUNK, "files": src_files},
}
signed = signing.sign(mf.validate(manifest), signing.private_key_from_raw(TEST_KEY_RAW))
write_json(os.path.join(version_dir, "manifest.json"), signed)
write(os.path.join(version_dir, "notes.md"), "# ODIAN 0.51.0\n\nPremier pack signé.\n".encode("utf-8"))
write(os.path.join(packs, "versions", "CURRENT"), f"{VERSION}\n".encode("ascii"))
def serve(root, info):
import uvicorn
from fastapi import Request
from fastapi.responses import Response
from odian_packs.manifest import ManifestError, safe_relpath
from odian_update.api import create_app
from odian_update.auth import AuthInvalid, Caller, bearer_token
from odian_update.config import Settings
class StubValidator:
async def validate(self, token):
if token != TOKEN:
raise AuthInvalid("invalid token")
return Caller(username="stub-operator", expires_at=0)
packs = os.path.join(root, "packs")
settings = Settings(packs_dir=packs, tiles_dir=os.path.join(root, "tiles"), max_streams=8)
app = create_app(settings, validator=StubValidator())
def serve_file(request: Request, base: str, rel: str) -> Response:
"""nginx `alias` + `auth_request`: token check, then the file, honouring one Range."""
if bearer_token(request.headers.get("authorization")) != TOKEN:
return Response(status_code=401, headers={"WWW-Authenticate": "Bearer"})
try:
safe_relpath(rel)
except ManifestError:
return Response(status_code=404)
path = join(base, rel)
if not os.path.isfile(path):
return Response(status_code=404)
with open(path, "rb") as f:
data = f.read()
size = len(data)
header = request.headers.get("range")
if not header:
return Response(data, media_type="application/octet-stream", headers={"Accept-Ranges": "bytes"})
match = re.fullmatch(r"bytes=(\d*)-(\d*)", header.strip())
unsatisfiable = Response(status_code=416, headers={"Content-Range": f"bytes */{size}"})
if not match or (not match.group(1) and not match.group(2)):
return unsatisfiable
if not match.group(1):
start, end = max(0, size - int(match.group(2))), size - 1
else:
start = int(match.group(1))
end = min(int(match.group(2)), size - 1) if match.group(2) else size - 1
if start >= size or start > end:
return unsatisfiable
return Response(data[start:end + 1], status_code=206, media_type="application/octet-stream",
headers={"Content-Range": f"bytes {start}-{end}/{size}", "Accept-Ranges": "bytes"})
@app.get("/packs/app/{sha}")
def app_file(sha: str, request: Request):
return serve_file(request, os.path.join(packs, "app"), sha)
@app.get("/packs/db/{rel:path}")
def db_file(rel: str, request: Request):
return serve_file(request, os.path.join(packs, "db"), rel)
@app.get("/src/{rel:path}")
def src_file(rel: str, request: Request):
return serve_file(request, os.path.join(root, "src"), rel)
lock = threading.Lock()
state = {"log": [], "fail": [], "cut": []}
async def reply(send, status, obj, headers=()):
body = json.dumps(obj).encode("utf-8")
await send({"type": "http.response.start", "status": status,
"headers": [(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode("ascii")), *headers]})
await send({"type": "http.response.body", "body": body})
async def control(scope, receive, send):
path = scope["path"]
query = {k: v[0] for k, v in urllib.parse.parse_qs(scope["query_string"].decode("latin-1")).items()}
with lock:
if path == "/_ctl/log":
return await reply(send, 200, {"requests": list(state["log"])})
if path == "/_ctl/reset":
state["log"].clear()
state["fail"].clear()
state["cut"].clear()
elif path == "/_ctl/fail":
state["fail"].append({"prefix": query["prefix"], "status": int(query.get("status", "503")),
"count": int(query.get("count", "1")), "retry_after": query.get("retry_after")})
elif path == "/_ctl/cut":
state["cut"].append({"prefix": query["prefix"], "after": int(query["after"]),
"count": int(query.get("count", "1"))})
else:
return await reply(send, 404, {"error": "unknown control"})
await reply(send, 200, {"ok": True})
def take_rule(kind, path):
for rule in state[kind]:
if rule["count"] > 0 and path.startswith(rule["prefix"]):
rule["count"] -= 1
return rule
return None
async def asgi(scope, receive, send):
if scope["type"] != "http":
return await app(scope, receive, send)
path = scope["path"]
if path.startswith("/_ctl/"):
return await control(scope, receive, send)
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
with lock:
state["log"].append({"path": path, "range": headers.get("range")})
fail = take_rule("fail", path)
cut = None if fail else take_rule("cut", path)
if fail:
extra = [(b"retry-after", fail["retry_after"].encode("ascii"))] if fail["retry_after"] is not None else []
return await reply(send, fail["status"], {"error": "stub failure"}, extra)
if not cut:
return await app(scope, receive, send)
sent = 0
async def cutting_send(message):
nonlocal sent
if message["type"] != "http.response.body":
return await send(message)
body = message.get("body", b"")
room = cut["after"] - sent
if len(body) < room:
sent += len(body)
return await send(message)
await send({"type": "http.response.body", "body": body[:room], "more_body": True})
raise ConnectionAbortedError("stub cut the connection")
return await app(scope, receive, cutting_send)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
info.update(port=port, base_url=f"http://127.0.0.1:{port}")
print(json.dumps(info), flush=True)
def exit_when_stdin_closes():
try:
sys.stdin.buffer.read()
finally:
os._exit(0)
threading.Thread(target=exit_when_stdin_closes, daemon=True).start()
server = uvicorn.Server(uvicorn.Config(asgi, log_level="warning", lifespan="off", access_log=False))
server.run(sockets=[sock])
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True)
parser.add_argument("--no-empty-src", action="store_true", help="leave out the zero-byte source file")
parser.add_argument("--build-only", action="store_true")
args = parser.parse_args()
root = os.path.abspath(args.root)
if not os.path.isfile(os.path.join(root, "packs", "versions", "CURRENT")):
build(root, empty_src=not args.no_empty_src)
from odian_packs import signing
info = {
"root": root, "token": TOKEN, "version": VERSION, "style": STYLE, "dataset": DATASET, "snapshot": SNAPSHOT,
"public_key": signing.public_key_text(signing.private_key_from_raw(TEST_KEY_RAW).public_key()),
}
if args.build_only:
print(json.dumps(info), flush=True)
return
serve(root, info)
if __name__ == "__main__":
main()
+99
View File
@@ -0,0 +1,99 @@
# UI ↔ backend contract
The Svelte UI (`src/`) talks to the Rust backend (`src-tauri/`) only through the
Tauri commands and events below. `src/lib/backend.ts` is the single place that
calls them. When the page runs outside Tauri (a plain browser, for design work),
it falls back to a mock with the same shapes.
All names are snake_case on the wire (serde defaults), and timestamps are Unix seconds.
## Types
```ts
type Phase =
| "idle" // nothing running
| "checking" // probing the site
| "signing_in" // browser sign-in in progress
| "installing" // downloading + verifying a version
| "restoring" // loading the database snapshot into local Postgres
| "launching" // starting Postgres → API → tile server → Odian.exe
| "running" // Odian.exe is open
| "error"; // the last long operation failed; see `error`
interface Status {
online: boolean; // the site's update service answered /v1/health
site: { url: string; name: string | null };
operator: string | null; // last signed-in ODIAN username (cached; valid offline)
signed_in: boolean; // this PC remembers the operator's site sign-in until Sign out: the
// launcher renews the short Engine token by itself (launcher session)
installed: string | null; // installed version, e.g. "0.51.0"
available: string | null; // the site's CURRENT version (online only)
update_required: boolean; // online && installed !== null && available !== null && installed !== available
// (never true when nothing is installed: that is a first install)
can_launch: boolean; // installed && !update_required && phase is idle or error
phase: Phase;
error: string | null; // set with phase "error"; cleared by the next command or `refresh`
install_dir: string; // e.g. "D:\\ODIAN"
}
interface Settings {
site_url: string; // e.g. "http://192.168.1.41:30097"
mcp_url: string; // site MCP, for Keycloak sign-in
install_dir: string;
}
interface InstallProgress {
stage: "manifest" | "app" | "db" | "src" | "tiles" | "restore" | "finalize";
stage_index: number; // 1-based
stage_count: number;
done_bytes: number; // within the current stage
total_bytes: number; // of the current stage; 0 when unknown
done_items: number; // within the current stage
total_items: number;
bytes_per_sec: number;
message: string | null; // e.g. "tiles dark/protomaps_20260423: 412/1402 blocks"
}
interface StackEvent {
component: "postgres" | "api" | "tiles" | "odian";
state: "starting" | "ready" | "stopped" | "failed";
message: string | null;
}
interface LogEvent { level: "info" | "warn" | "error"; message: string; at: number }
```
## Commands (`invoke`)
| Command | Args | Returns | Notes |
|---|---|---|---|
| `get_status` | — | `Status` | cheap; no network |
| `refresh` | — | `Status` | re-probes the site (2 s timeout); also clears an `error` phase |
| `sign_in` | — | `{ username: string }` | opens the system browser (MCP desktop flow) and waits for the loopback callback. Rejects with "Sign-in was cancelled." after `cancel_sign_in` (phase returns to idle, not error). |
| `cancel_sign_in` | — | `void` | abandons a sign-in that is waiting for the browser |
| `sign_out` | — | `Status` | forgets the operator on this PC: the site sign-in, its launcher session (ended on the site when reachable) and the cached operator. **Offline launch needs a new sign-in afterwards**, so the UI confirms first |
| `get_notes` | `{ version?: string }` | `string` | Markdown. From the site when online; the cached copy of the installed version offline. **Untrusted: the UI must sanitize.** |
| `open_external` | `{ url: string }` | `void` | opens an `http`/`https` link in the system browser (links in patch notes); anything else is refused |
| `start_install` | — | `void` | installs or updates to `available`; needs `signed_in` (the site checks the token); progress arrives as events |
| `start_install_from_drive` | `{ path: string }` | `void` | same, reading an ODIAN drive (SSD) instead of the site; no sign-in needed |
| `cancel_install` | — | `void` | stops at the next file boundary; everything already verified is kept |
| `launch` | — | `void` | starts the local stack; `stack` events follow |
| `stop` | — | `void` | stops Odian.exe's stack cleanly |
| `get_stack` | — | `StackEvent[]` | the latest event per component of the current or last launch (for a reloaded UI) |
| `get_settings` | — | `Settings` | |
| `set_settings` | `{ settings: Settings }` | `Status` | |
| `pick_folder` | — | `string \| null` | native folder picker |
Errors reject the promise with a string message.
Planned, not yet available: the size of `available` for the Install button, and
inspecting a drive (its version and size) before installing from it.
## Events (`listen`)
| Event | Payload | When |
|---|---|---|
| `status` | `Status` | whenever any field changes |
| `install-progress` | `InstallProgress` | at most ~10 per second while installing |
| `stack` | `StackEvent` | on each component state change |
| `log` | `LogEvent` | notable backend messages, for the activity panel |
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" style="background: #09090b">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>ODIAN Launcher</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1485
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "odian-launcher",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"check": "svelte-check --tsconfig ./tsconfig.json",
"tauri": "tauri"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tauri-apps/cli": "^2.11.4",
"@tsconfig/svelte": "^5.0.8",
"svelte": "^5.57.0",
"svelte-check": "^4.7.6",
"typescript": "^6.0.3",
"vite": "^8.3.0"
},
"dependencies": {
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/space-grotesk": "^5.3.0",
"@tauri-apps/api": "^2.11.1",
"dompurify": "^3.4.15",
"marked": "^18.0.13"
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "ODIAN test DHP",
"site_url": "http://192.168.1.41:30097",
"mcp_url": "http://192.168.1.41:30091"
}
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "odian-launcher"
version = "0.1.0"
description = "ODIAN Launcher: installs, updates and runs ODIAN on an operator PC"
authors = ["ODIAN"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.81"
[lib]
name = "odian_launcher_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
odian-packs = { path = "../crates/odian-packs" }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.3", features = [] }
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tokio = { version = "1", features = ["net", "io-util", "time", "sync", "macros", "rt-multi-thread", "fs"] }
tokio-util = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
keyring = { version = "3", features = ["windows-native"] }
url = "2"
uuid = { version = "1", features = ["v4"] }
junction = "1"
[target.'cfg(windows)'.dependencies]
# Win32_System_Threading gates JOBOBJECT_EXTENDED_LIMIT_INFORMATION (it embeds IO_COUNTERS).
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Main window: core IPC plus the custom title bar (the window is frameless)",
"windows": [
"main"
],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+195
View File
@@ -0,0 +1,195 @@
//! Keycloak sign-in through the site MCP's desktop flow.
//!
//! The launcher listens on a random 127.0.0.1 port and opens the system browser
//! at `{mcp}/auth/desktop/start?cb=http://127.0.0.1:<port>/cb`. The MCP runs
//! Keycloak (Auth Code + PKCE), mints an Engine token, and redirects the browser
//! back to the loopback with a one-time `code` (or `error`). The launcher then
//! swaps that code at `POST {mcp}/auth/desktop/exchange` for the token, so the
//! token itself never appears in a browser URL.
use std::collections::HashMap;
use std::time::Duration;
use serde::Deserialize;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Notify;
use crate::tokens::SiteToken;
/// The MCP forgets a pending sign-in after 300 s.
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Deserialize)]
struct ExchangePayload {
#[serde(default)]
access_token: String,
#[serde(default)]
expires_at: i64,
#[serde(default)]
username: String,
#[serde(default)]
dataless: bool,
/// Only issued for `client=launcher`; an MCP without launcher sessions leaves it out.
#[serde(default)]
launcher_token: Option<String>,
}
enum Callback {
Code(String),
Error(String),
}
pub enum SignInError {
/// The operator cancelled from the launcher; not an error to show.
Cancelled,
Failed(String),
}
impl From<String> for SignInError {
fn from(message: String) -> Self {
SignInError::Failed(message)
}
}
pub async fn sign_in(
app: &AppHandle,
http: &reqwest::Client,
mcp_url: &str,
cancel: &Notify,
) -> Result<SiteToken, SignInError> {
if mcp_url.is_empty() {
return Err("Set the DHP's sign-in address (MCP URL) in Settings first.".to_string().into());
}
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("cannot open the sign-in callback port: {e}"))?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let callback = format!("http://127.0.0.1:{port}/cb");
// client=launcher asks the MCP for a launcher session too, so the operator is remembered until Sign out.
let start = url::Url::parse_with_params(
&format!("{mcp_url}/auth/desktop/start"),
&[("cb", callback.as_str()), ("client", "launcher")],
)
.map_err(|e| format!("the MCP URL is not valid: {e}"))?;
app
.opener()
.open_url(start.as_str(), None::<&str>)
.map_err(|e| format!("cannot open the browser: {e}"))?;
let waited = tokio::select! {
waited = tokio::time::timeout(SIGN_IN_TIMEOUT, wait_for_callback(listener)) => waited,
_ = cancel.notified() => return Err(SignInError::Cancelled),
};
let code = match waited {
Err(_) => return Err("Sign-in timed out. Try again.".to_string().into()),
Ok(Err(e)) => return Err(e.into()),
Ok(Ok(Callback::Error(error))) => return Err(describe_error(&error).into()),
Ok(Ok(Callback::Code(code))) => code,
};
let response = http
.post(format!("{mcp_url}/auth/desktop/exchange"))
.json(&serde_json::json!({ "code": code }))
.timeout(Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("the sign-in service did not answer: {e}"))?;
if !response.status().is_success() {
return Err(format!("sign-in failed: the MCP answered HTTP {}", response.status()).into());
}
let payload: ExchangePayload = response.json().await.map_err(|e| format!("unexpected sign-in answer: {e}"))?;
if payload.access_token.is_empty() || payload.username.is_empty() {
return Err("sign-in failed: the MCP returned no token".to_string().into());
}
if payload.dataless {
// A dataless session means the site's Engine has no database bound: nothing to install from.
return Err("The DHP's Engine has no database connected. Ask an administrator.".to_string().into());
}
Ok(SiteToken {
access_token: payload.access_token,
expires_at: payload.expires_at,
username: payload.username,
launcher_token: payload.launcher_token.filter(|t| !t.is_empty()),
})
}
fn describe_error(error: &str) -> String {
match error {
"access_denied" => "Sign-in was cancelled.".to_string(),
"auth_failed" => "Keycloak did not accept the sign-in.".to_string(),
"mint_failed" => "You signed in, but this DHP has no ODIAN account for you. Ask an administrator.".to_string(),
"keycloak_unavailable" => "The DHP's Keycloak is unreachable.".to_string(),
other => format!("Sign-in failed ({other})."),
}
}
async fn wait_for_callback(listener: TcpListener) -> Result<Callback, String> {
loop {
let (mut stream, _) = listener.accept().await.map_err(|e| format!("sign-in callback failed: {e}"))?;
let Some(target) = read_request_target(&mut stream).await else {
continue;
};
if !target.starts_with("/cb") {
respond(&mut stream, "404 Not Found", "").await;
continue;
}
let query: HashMap<String, String> = url::Url::parse(&format!("http://127.0.0.1{target}"))
.map(|u| u.query_pairs().into_owned().collect())
.unwrap_or_default();
if let Some(code) = query.get("code").filter(|c| !c.is_empty()) {
respond(&mut stream, "200 OK", &page("Signed in", "You can close this tab and return to the ODIAN Launcher.")).await;
return Ok(Callback::Code(code.clone()));
}
let error = query.get("error").cloned().unwrap_or_else(|| "no_code".to_string());
respond(&mut stream, "200 OK", &page("Sign-in failed", "Return to the ODIAN Launcher for details.")).await;
return Ok(Callback::Error(error));
}
}
/// The path of a `GET` request line, or None for anything else (or a client that stalls).
async fn read_request_target(stream: &mut TcpStream) -> Option<String> {
let mut buf = vec![0u8; 8192];
let mut len = 0;
let read = async {
while len < buf.len() {
let n = stream.read(&mut buf[len..]).await.ok()?;
if n == 0 {
break;
}
len += n;
if buf[..len].windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
Some(())
};
tokio::time::timeout(Duration::from_secs(5), read).await.ok()??;
let head = String::from_utf8_lossy(&buf[..len]);
let mut parts = head.lines().next()?.split_whitespace();
if parts.next()? != "GET" {
return None;
}
parts.next().map(str::to_string)
}
async fn respond(stream: &mut TcpStream, status: &str, body: &str) {
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
}
fn page(title: &str, message: &str) -> String {
format!(
"<!doctype html><meta charset=utf-8><title>ODIAN — {title}</title>\
<body style=\"margin:0;min-height:100vh;display:grid;place-content:center;background:#09090b;color:#f2f1ee;\
font-family:'Segoe UI',system-ui,sans-serif;text-align:center\">\
<div><div style=\"letter-spacing:.28em;font-weight:600;font-size:15px;color:#9a999e\">ODIAN</div>\
<h1 style=\"font-weight:600;margin:18px 0 8px\">{title}</h1><p style=\"color:#d4d3d0\">{message}</p></div>"
)
}
+115
View File
@@ -0,0 +1,115 @@
//! Tauri commands: thin wrappers over `Launcher` (see docs/CONTRACT.md).
use tauri::{AppHandle, State};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
use crate::launcher::{Launcher, Status};
use crate::settings::Settings;
use crate::stack::StackEvent;
type CmdResult<T> = Result<T, String>;
#[derive(serde::Serialize)]
pub struct SignedIn {
username: String,
}
#[tauri::command]
pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult<Status> {
Ok(launcher.status().await)
}
#[tauri::command]
pub async fn refresh(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<Status> {
Ok(launcher.refresh(&app).await)
}
#[tauri::command]
pub async fn sign_in(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<SignedIn> {
launcher.sign_in(&app).await.map(|username| SignedIn { username })
}
#[tauri::command]
pub async fn cancel_sign_in(launcher: State<'_, Launcher>) -> CmdResult<()> {
launcher.cancel_sign_in();
Ok(())
}
#[tauri::command]
pub async fn sign_out(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<Status> {
Ok(launcher.sign_out(&app).await)
}
#[tauri::command]
pub async fn get_notes(launcher: State<'_, Launcher>, version: Option<String>) -> CmdResult<String> {
launcher.notes(version).await
}
/// Links in patch notes open in the system browser; the launcher window never navigates.
#[tauri::command]
pub async fn open_external(app: AppHandle, url: String) -> CmdResult<()> {
let parsed = url::Url::parse(&url).map_err(|_| "That link is not a valid address.".to_string())?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err("Only web links can be opened.".to_string());
}
app
.opener()
.open_url(parsed.as_str(), None::<&str>)
.map_err(|e| format!("cannot open the browser: {e}"))
}
#[tauri::command]
pub async fn start_install(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<()> {
launcher.start_install(&app, None).await
}
#[tauri::command]
pub async fn start_install_from_drive(app: AppHandle, launcher: State<'_, Launcher>, path: String) -> CmdResult<()> {
launcher.start_install(&app, Some(std::path::PathBuf::from(path))).await
}
#[tauri::command]
pub async fn cancel_install(launcher: State<'_, Launcher>) -> CmdResult<()> {
launcher.cancel_install()
}
#[tauri::command]
pub async fn launch(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<()> {
launcher.launch(&app).await
}
#[tauri::command]
pub async fn stop(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<()> {
launcher.stop(&app).await
}
#[tauri::command]
pub async fn get_stack(launcher: State<'_, Launcher>) -> CmdResult<Vec<StackEvent>> {
Ok(launcher.stack_states())
}
#[tauri::command]
pub async fn get_settings(launcher: State<'_, Launcher>) -> CmdResult<Settings> {
Ok(launcher.settings().await)
}
#[tauri::command]
pub async fn set_settings(app: AppHandle, launcher: State<'_, Launcher>, settings: Settings) -> CmdResult<Status> {
launcher.set_settings(&app, settings).await
}
#[tauri::command]
pub async fn pick_folder(app: AppHandle) -> CmdResult<Option<String>> {
// The native dialog blocks its thread, so keep it off the async runtime's workers.
tauri::async_runtime::spawn_blocking(move || {
app
.dialog()
.file()
.blocking_pick_folder()
.and_then(|picked| picked.into_path().ok())
.map(|path| path.display().to_string())
})
.await
.map_err(|e| e.to_string())
}
+23
View File
@@ -0,0 +1,23 @@
//! Small file helpers shared by the settings and state stores.
use std::path::Path;
/// Replace `path` with `bytes` so a reader sees the old file or the new one, never half of either.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
let temp = path.with_extension(format!("tmp-{}", std::process::id()));
std::fs::write(&temp, bytes).map_err(|e| format!("cannot write {}: {e}", temp.display()))?;
// std::fs::rename replaces an existing target on Windows (MoveFileEx with REPLACE_EXISTING).
std::fs::rename(&temp, path).map_err(|e| {
let _ = std::fs::remove_file(&temp);
format!("cannot replace {}: {e}", path.display())
})
}
pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), String> {
let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
bytes.push(b'\n');
write_atomic(path, &bytes)
}
+80
View File
@@ -0,0 +1,80 @@
//! Install glue around the `odian-packs` crate: what the launcher adds to a verified install.
use odian_packs::manifest::Manifest;
use crate::stack::{API_EXE, ODIAN_EXE, PG_CTL_EXE, PG_RESTORE_EXE, PYTHON_EXE, TILE_SERVER_APP};
/// Refuse, before downloading anything, a version that can't run offline on this PC.
pub fn check_runnable(manifest: &Manifest) -> Result<(), String> {
let required = [
(ODIAN_EXE, "ODIAN"),
(API_EXE, "the Engine API"),
(TILE_SERVER_APP, "the tile server"),
(PYTHON_EXE, "a Python runtime"),
(PG_CTL_EXE, "Postgres"),
(PG_RESTORE_EXE, "Postgres restore tools"),
];
let missing: Vec<&str> = required
.iter()
.filter(|(path, _)| !manifest.app.files.iter().any(|file| file.path.eq_ignore_ascii_case(path)))
.map(|(_, what)| *what)
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(format!(
"ODIAN {} can't run on its own on this PC: the version doesn't include {}. Ask for a launcher-ready version.",
manifest.version,
missing.join(", ")
))
}
}
/// The launcher's steps after the crate's own stages: restore (fresh database only), then finalize.
pub fn extra_stages(restores_db: bool) -> u32 {
if restores_db {
2
} else {
1
}
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest(paths: &[&str]) -> Manifest {
let files: Vec<serde_json::Value> = paths
.iter()
.map(|path| serde_json::json!({ "path": path, "size": 1, "sha256": "a".repeat(64) }))
.collect();
serde_json::from_value(serde_json::json!({
"schema": 1, "product": "odian", "version": "0.52.0", "released_at": "2026-09-15T12:00:00Z",
"app": { "files": files }
}))
.expect("a manifest")
}
#[test]
fn a_launcher_ready_version_passes() {
let all = [ODIAN_EXE, API_EXE, TILE_SERVER_APP, PYTHON_EXE, PG_CTL_EXE, PG_RESTORE_EXE];
assert_eq!(check_runnable(&manifest(&all)), Ok(()));
}
#[test]
fn the_first_packed_version_is_refused_before_any_download() {
// 0.51.0 shipped only the client and the API.
let message = check_runnable(&manifest(&[ODIAN_EXE, API_EXE])).unwrap_err();
assert!(message.contains("0.52.0"), "{message}");
for part in ["the tile server", "a Python runtime", "Postgres"] {
assert!(message.contains(part), "{message} should name {part}");
}
assert!(!message.contains("the Engine API"), "{message}");
}
#[test]
fn stage_count_adds_restore_only_for_a_fresh_database() {
assert_eq!(extra_stages(true), 2);
assert_eq!(extra_stages(false), 1);
}
}
+744
View File
@@ -0,0 +1,744 @@
//! The launcher's live state behind every command, and the `status` event.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use odian_packs::{InstallOptions, Progress, Source, Stage};
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::{Mutex, Notify};
use tokio_util::sync::CancellationToken;
use crate::install;
use crate::localdb::{self, Pg};
use crate::settings::{normalize_url, Settings};
use crate::site::{self, SessionRefresh, TokenCheck};
use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent};
use crate::state::{self, LauncherState};
use crate::tokens::{self, SiteToken};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Idle,
Checking,
SigningIn,
Installing,
Restoring,
Launching,
Running,
Error,
}
#[derive(Clone, Serialize)]
pub struct SiteInfo {
pub url: String,
pub name: Option<String>,
}
#[derive(Clone, Serialize)]
pub struct Status {
pub online: bool,
pub site: SiteInfo,
pub operator: Option<String>,
pub signed_in: bool,
pub installed: Option<String>,
pub available: Option<String>,
pub update_required: bool,
pub can_launch: bool,
pub phase: Phase,
pub error: Option<String>,
pub install_dir: String,
}
#[derive(Clone, Serialize)]
struct LogEvent {
level: &'static str,
message: String,
at: u64,
}
enum InstallFailure {
Cancelled,
/// The site refused the Engine token (expired, or the site's API restarted).
Unauthorized,
Failed(String),
}
impl From<String> for InstallFailure {
fn from(message: String) -> Self {
InstallFailure::Failed(message)
}
}
impl From<odian_packs::Error> for InstallFailure {
fn from(error: odian_packs::Error) -> Self {
match error {
odian_packs::Error::Cancelled => InstallFailure::Cancelled,
odian_packs::Error::Unauthorized { .. } => InstallFailure::Unauthorized,
other => InstallFailure::Failed(other.to_string()),
}
}
}
struct Inner {
settings: Settings,
state: LauncherState,
online: bool,
available: Option<String>,
token: Option<SiteToken>,
phase: Phase,
error: Option<String>,
}
impl Inner {
fn status(&self) -> Status {
let installed = self.state.installed_version.clone();
// Online means "update before launch"; offline launches whatever is installed.
// Nothing installed is never "update required": that is a first install.
let update_required = self.online && installed.is_some() && self.available.is_some() && installed != self.available;
Status {
online: self.online,
site: SiteInfo { url: self.settings.site_url.clone(), name: self.settings.site_name.clone() },
operator: self.state.operator.clone(),
// Remembered until Sign out: a launcher session renews the Engine token on its own.
signed_in: self.token.as_ref().is_some_and(|t| t.launcher_token.is_some() || !t.expired()),
can_launch: installed.is_some() && !update_required && matches!(self.phase, Phase::Idle | Phase::Error),
installed,
available: self.available.clone(),
update_required,
phase: self.phase,
error: self.error.clone(),
install_dir: self.settings.install_dir.clone(),
}
}
}
pub struct Launcher {
pub http: reqwest::Client,
inner: Mutex<Inner>,
stack: Mutex<Option<RunningStack>>,
/// The latest event per stack component, so a reloaded UI can redraw the stack.
stack_states: std::sync::Mutex<Vec<StackEvent>>,
sign_in_cancel: Notify,
install_cancel: std::sync::Mutex<Option<CancellationToken>>,
}
fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
impl Launcher {
pub fn load(app: &AppHandle) -> Result<Launcher, String> {
let settings = Settings::load(app)?;
let state = LauncherState::load(&settings.install_path())?;
let token = tokens::load(&settings.site_url);
let http = reqwest::Client::builder()
.user_agent(concat!("ODIAN-Launcher/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| format!("cannot start the HTTP client: {e}"))?;
Ok(Launcher {
http,
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None }),
stack: Mutex::new(None),
stack_states: std::sync::Mutex::new(Vec::new()),
sign_in_cancel: Notify::new(),
install_cancel: std::sync::Mutex::new(None),
})
}
pub async fn status(&self) -> Status {
self.inner.lock().await.status()
}
async fn emit(&self, app: &AppHandle) -> Status {
let status = self.status().await;
let _ = app.emit("status", &status);
status
}
fn log(&self, app: &AppHandle, level: &'static str, message: impl Into<String>) {
let _ = app.emit("log", &LogEvent { level, message: message.into(), at: now_secs() });
}
async fn finish(&self, app: &AppHandle, error: Option<String>) {
{
let mut inner = self.inner.lock().await;
inner.phase = if error.is_some() { Phase::Error } else { Phase::Idle };
inner.error = error;
}
self.emit(app).await;
}
async fn set_phase(&self, app: &AppHandle, phase: Phase) {
{
self.inner.lock().await.phase = phase;
}
self.emit(app).await;
}
pub async fn settings(&self) -> Settings {
self.inner.lock().await.settings.clone()
}
// ── DHP sign-in ─────────────────────────────────────────────────────────
/// Swap the launcher session for a fresh Engine token. Never asks the operator.
/// Ok(token) when renewed; Err when the session ended (the token is forgotten) or the
/// sign-in service couldn't answer (the token is kept for a later try).
async fn renew(&self, app: &AppHandle) -> Result<String, String> {
let (mcp_url, site_url, token) = {
let inner = self.inner.lock().await;
(inner.settings.mcp_url.clone(), inner.settings.site_url.clone(), inner.token.clone())
};
let Some(mut token) = token else {
return Err("Sign in to download from the DHP.".to_string());
};
let Some(launcher_token) = token.launcher_token.clone() else {
// A sign-in from before launcher sessions: nothing to renew with.
let mut inner = self.inner.lock().await;
tokens::clear(&site_url);
inner.token = None;
return Err("Your DHP sign-in has expired. Sign in again.".to_string());
};
match site::refresh_session(&self.http, &mcp_url, &launcher_token).await {
SessionRefresh::Renewed { access_token, expires_at, username } => {
token.access_token = access_token.clone();
token.expires_at = expires_at;
if !username.is_empty() {
token.username = username;
}
let _ = tokens::save(&site_url, &token);
self.inner.lock().await.token = Some(token);
self.log(app, "info", "Renewed the DHP sign-in.");
Ok(access_token)
}
SessionRefresh::Revoked(detail) => {
tokens::clear(&site_url);
self.inner.lock().await.token = None;
self.log(app, "warn", format!("The DHP ended this PC's sign-in: {detail}"));
Err(format!("The DHP ended this PC's sign-in ({detail}). Sign in again."))
}
SessionRefresh::Unavailable(reason) => Err(format!("Couldn't renew the DHP sign-in: {reason}")),
}
}
/// An Engine token the site should accept: the stored one while it hasn't expired, else renewed.
async fn site_access_token(&self, app: &AppHandle) -> Result<String, String> {
let current = self.inner.lock().await.token.clone();
match current {
Some(token) if !token.expired() => Ok(token.access_token),
Some(_) => self.renew(app).await,
None => Err("Sign in to download from the DHP.".to_string()),
}
}
/// Re-probe the site and re-check the stored sign-in, renewing it quietly when the site
/// no longer takes it. Never fails: an unreachable site is "offline". Also how the UI
/// dismisses an error.
pub async fn refresh(&self, app: &AppHandle) -> Status {
let (site_url, token) = {
let mut inner = self.inner.lock().await;
if matches!(inner.phase, Phase::Idle | Phase::Error) {
inner.phase = Phase::Checking;
inner.error = None;
}
(inner.settings.site_url.clone(), inner.token.clone())
};
self.emit(app).await;
let probe = site::probe(&self.http, &site_url).await;
{
let mut inner = self.inner.lock().await;
inner.online = probe.online;
inner.available = probe.version;
}
// Offline nothing is checked or forgotten: the remembered operator still launches.
if let (true, Some(token)) = (probe.online, token) {
let check = if token.expired() {
TokenCheck::Invalid
} else {
site::check_token(&self.http, &site_url, &token.access_token).await
};
if matches!(check, TokenCheck::Invalid) {
// Renewal failures are already logged; an unavailable sign-in service keeps the token.
let _ = self.renew(app).await;
}
}
{
let mut inner = self.inner.lock().await;
if inner.phase == Phase::Checking {
inner.phase = Phase::Idle;
}
}
self.emit(app).await
}
pub async fn sign_in(&self, app: &AppHandle) -> Result<String, String> {
let (mcp_url, site_url) = {
let mut inner = self.inner.lock().await;
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
return Err("The launcher is busy.".to_string());
}
inner.phase = Phase::SigningIn;
inner.error = None;
(inner.settings.mcp_url.clone(), inner.settings.site_url.clone())
};
self.emit(app).await;
let result = crate::auth::sign_in(app, &self.http, &mcp_url, &self.sign_in_cancel).await;
let outcome = {
let mut inner = self.inner.lock().await;
match result {
Ok(token) => {
let username = token.username.clone();
let remembered = token.launcher_token.is_some();
let stored = tokens::save(&site_url, &token);
inner.token = Some(token);
inner.state.operator = Some(username.clone());
let install_dir = inner.settings.install_path();
let saved = inner.state.save(&install_dir);
inner.phase = Phase::Idle;
// Staying signed in for this session still works if Credential Manager refused.
inner.error = stored.and(saved).err();
if !remembered {
self.log(app, "warn", "This DHP's sign-in service can't remember you yet: you may be asked to sign in again after a day.");
}
Ok(username)
}
Err(crate::auth::SignInError::Cancelled) => {
inner.phase = Phase::Idle;
Err("Sign-in was cancelled.".to_string())
}
Err(crate::auth::SignInError::Failed(message)) => {
inner.phase = Phase::Error;
inner.error = Some(message.clone());
Err(message)
}
}
};
self.emit(app).await;
outcome
}
/// Abandon a browser sign-in that is waiting for its callback.
pub fn cancel_sign_in(&self) {
self.sign_in_cancel.notify_waiters();
}
/// Forget the operator on this PC: the DHP sign-in, its launcher session (ended on the site
/// when reachable) and the remembered operator. ODIAN won't launch offline until the next sign-in.
pub async fn sign_out(&self, app: &AppHandle) -> Status {
let (mcp_url, launcher_token) = {
let mut inner = self.inner.lock().await;
tokens::clear(&inner.settings.site_url);
let launcher_token = inner.token.take().and_then(|t| t.launcher_token);
inner.state.operator = None;
let install_dir = inner.settings.install_path();
if let Err(e) = inner.state.save(&install_dir) {
inner.error = Some(e);
}
(inner.settings.mcp_url.clone(), launcher_token)
};
if let Some(launcher_token) = launcher_token {
site::end_session(&self.http, &mcp_url, &launcher_token).await;
}
self.log(app, "info", "Signed out: this PC no longer remembers the operator.");
self.emit(app).await
}
pub async fn set_settings(&self, app: &AppHandle, incoming: Settings) -> Result<Status, String> {
{
let mut inner = self.inner.lock().await;
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
return Err("Settings can't change while the launcher is busy.".to_string());
}
let mut next = Settings {
site_url: normalize_url(&incoming.site_url),
mcp_url: normalize_url(&incoming.mcp_url),
install_dir: incoming.install_dir.trim().to_string(),
site_name: incoming.site_name.or_else(|| inner.settings.site_name.clone()),
};
if next.install_dir.is_empty() {
next.install_dir = inner.settings.install_dir.clone();
}
next.save(app)?;
if next.install_dir != inner.settings.install_dir {
inner.state = LauncherState::load(&next.install_path())?;
}
if next.site_url != inner.settings.site_url {
inner.token = tokens::load(&next.site_url);
inner.available = None;
}
inner.settings = next;
}
Ok(self.refresh(app).await)
}
/// Patch notes: from the DHP when possible, else the cached copy.
pub async fn notes(&self, version: Option<String>) -> Result<String, String> {
let (site_url, token, online, install_dir, fallback) = {
let inner = self.inner.lock().await;
let fallback = inner.available.clone().or_else(|| inner.state.installed_version.clone());
(inner.settings.site_url.clone(), inner.token.clone(), inner.online, inner.settings.install_path(), fallback)
};
let version = version.or(fallback).ok_or("No version is installed or available yet.")?;
if let (true, Some(token)) = (online, token.filter(|t| !t.expired())) {
if let Ok(markdown) = site::notes(&self.http, &site_url, &token.access_token, &version).await {
let _ = state::cache_notes(&install_dir, &version, &markdown);
return Ok(markdown);
}
}
state::cached_notes(&install_dir, &version).ok_or_else(|| {
if online {
"Sign in to read the patch notes.".to_string()
} else {
"Patch notes for this version aren't available offline yet.".to_string()
}
})
}
// ── install ──────────────────────────────────────────────────────────────
/// Install or update from the DHP (`drive: None`) or from an ODIAN drive.
/// Returns once installing has begun; `install-progress`, `log` and `status` events follow.
pub async fn start_install(&self, app: &AppHandle, drive: Option<PathBuf>) -> Result<(), String> {
{
let inner = self.inner.lock().await;
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
return Err("The launcher is busy.".to_string());
}
match &drive {
Some(root) => {
if !root.join("packs").join("versions").join("CURRENT").is_file() {
return Err(format!("{} is not an ODIAN drive: it has no packs\\versions\\CURRENT.", root.display()));
}
}
None => {
if !inner.online {
return Err("Your DHP is unreachable. Connect to install, or install from a drive.".to_string());
}
}
}
}
if drive.is_none() {
// Renew before starting rather than failing a minute in.
self.site_access_token(app).await?;
}
let install_dir = {
let mut inner = self.inner.lock().await;
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
return Err("The launcher is busy.".to_string());
}
inner.phase = Phase::Installing;
inner.error = None;
inner.settings.install_path()
};
let cancel = CancellationToken::new();
if let Ok(mut slot) = self.install_cancel.lock() {
*slot = Some(cancel.clone());
}
self.emit(app).await;
let handle = app.clone();
tauri::async_runtime::spawn(async move {
handle.state::<Launcher>().run_install(&handle, drive, install_dir, cancel).await;
});
Ok(())
}
/// Stop an install at the next file, chunk, block or table; everything verified is kept.
pub fn cancel_install(&self) -> Result<(), String> {
match self.install_cancel.lock().ok().and_then(|slot| slot.clone()) {
Some(token) => {
token.cancel();
Ok(())
}
None => Err("Nothing is installing.".to_string()),
}
}
async fn run_install(&self, app: &AppHandle, drive: Option<PathBuf>, install_dir: PathBuf, cancel: CancellationToken) {
let mut renewed = false;
let result = loop {
let source = match &drive {
Some(root) => Source::Drive { root: root.clone() },
None => match self.site_access_token(app).await {
Ok(token) => Source::Site { base_url: self.inner.lock().await.settings.site_url.clone(), token },
Err(message) => break Err(InstallFailure::Failed(message)),
},
};
match self.install_steps(app, &source, &install_dir, &cancel).await {
// The site's API restarted (or the token aged out) mid-way: renew once and resume.
// Everything already verified is skipped on the second pass.
Err(InstallFailure::Unauthorized) if drive.is_none() && !renewed => {
renewed = true;
self.log(app, "info", "The DHP no longer accepted the sign-in; renewing it and resuming.");
if let Err(message) = self.renew(app).await {
break Err(InstallFailure::Failed(message));
}
}
other => break other,
}
};
if let Ok(mut slot) = self.install_cancel.lock() {
*slot = None;
}
match result {
Ok(version) => {
self.log(app, "info", format!("ODIAN {version} is installed in {}", install_dir.display()));
self.finish(app, None).await;
if drive.is_none() {
self.refresh(app).await;
}
}
Err(InstallFailure::Cancelled) => {
self.log(app, "warn", "Install cancelled. Everything already verified was kept; installing again resumes from there.");
self.finish(app, None).await;
}
Err(InstallFailure::Unauthorized) => {
let message = "The DHP refused the sign-in even after renewing it. Sign in again.".to_string();
self.log(app, "error", message.clone());
self.finish(app, Some(message)).await;
}
Err(InstallFailure::Failed(message)) => {
self.log(app, "error", message.clone());
self.finish(app, Some(message)).await;
}
}
}
async fn install_steps(
&self,
app: &AppHandle,
source: &Source,
install_dir: &Path,
cancel: &CancellationToken,
) -> Result<String, InstallFailure> {
match source {
Source::Site { base_url, .. } => self.log(app, "info", format!("Reading the current version from {base_url}")),
Source::Drive { root } => self.log(app, "info", format!("Reading the ODIAN drive at {}", root.display())),
}
let verified = odian_packs::current_manifest(source, odian_packs::bundled_public_key()).await?;
let manifest = verified.manifest();
let version = verified.version().to_string();
install::check_runnable(manifest)?;
let database_restored = self.inner.lock().await.state.db_snapshot.is_some();
let restores_db = manifest.db.is_some() && !database_restored;
let extra = install::extra_stages(restores_db);
self.log(app, "info", format!("Installing ODIAN {version} (signature verified)"));
if manifest.db.is_some() && database_restored {
self.log(app, "info", "Keeping this PC's database: operator work stays; world data refreshes with sync.");
}
// The crate numbers only its own stages; the launcher appends restore and finalize.
let crate_stages = Arc::new(AtomicU32::new(0));
let progress = {
let app = app.clone();
let crate_stages = crate_stages.clone();
move |mut report: Progress| {
crate_stages.store(report.stage_count, Ordering::Relaxed);
report.stage_count += extra;
let _ = app.emit("install-progress", &report);
}
};
odian_packs::install(source, install_dir, &verified, InstallOptions::default(), progress, cancel.clone()).await?;
let base = match crate_stages.load(Ordering::Relaxed) {
0 => 5,
n => n,
};
let total = base + extra;
if let (true, Some(db)) = (restores_db, manifest.db.as_ref()) {
self.set_phase(app, Phase::Restoring).await;
self.log(app, "info", format!("Loading database snapshot {} into this PC's Postgres", db.snapshot));
let password = {
let mut inner = self.inner.lock().await;
if inner.state.pg_password.is_empty() {
inner.state.pg_password = state::new_secret();
inner.state.save(install_dir)?;
}
inner.state.pg_password.clone()
};
let pg = Pg::new(&Layout::new(install_dir, &version), &password);
let files: Vec<localdb::DbFile> = db
.files
.iter()
.map(|f| localdb::DbFile { section: f.section.clone(), path: f.path.clone(), size: f.size })
.collect();
let data_root = install_dir.join("data");
let reporter = app.clone();
let stop = cancel.clone();
let restored = tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
let report = |done_bytes: u64, total_bytes: u64, done: usize, count: usize, path: &str| {
let _ = reporter.emit(
"install-progress",
&Progress {
stage: Stage::Restore,
stage_index: base + 1,
stage_count: total,
done_bytes,
total_bytes,
done_items: done as u64,
total_items: count as u64,
bytes_per_sec: 0.0,
message: Some(format!("restored {path}")),
},
);
};
if !pg.is_initialized() {
pg.init()?;
}
pg.start()?;
let result = pg.restore(&data_root, &files, 4, &report, &|| stop.is_cancelled());
let stopped = pg.stop();
result.and(stopped)
})
.await
.map_err(|e| e.to_string())?;
if cancel.is_cancelled() {
return Err(InstallFailure::Cancelled);
}
restored.map_err(|e| format!("The database restore failed: {e}"))?;
let mut inner = self.inner.lock().await;
inner.state.db_snapshot = Some(db.snapshot.clone());
inner.state.save(install_dir)?;
}
let _ = app.emit(
"install-progress",
&Progress {
stage: Stage::Finalize,
stage_index: total,
stage_count: total,
done_bytes: 0,
total_bytes: 0,
done_items: 0,
total_items: 0,
bytes_per_sec: 0.0,
message: Some(format!("ODIAN {version}")),
},
);
if let Ok(markdown) = odian_packs::fetch_notes(source, &version).await {
let _ = state::cache_notes(install_dir, &version, &markdown);
}
{
let mut inner = self.inner.lock().await;
inner.state.installed_version = Some(version.clone());
inner.state.save(install_dir)?;
}
Ok(version)
}
// ── local stack ───────────────────────────────────────────────────────────
pub fn stack_states(&self) -> Vec<StackEvent> {
self.stack_states.lock().map(|states| states.clone()).unwrap_or_default()
}
fn record_stack(&self, app: &AppHandle, event: StackEvent) {
if let Ok(mut states) = self.stack_states.lock() {
states.retain(|known| known.component != event.component);
states.push(event.clone());
}
let _ = app.emit("stack", &event);
}
/// Start the local stack. Returns once launching has begun; `stack` and `status` events follow.
pub async fn launch(&self, app: &AppHandle) -> Result<(), String> {
let config = {
let mut inner = self.inner.lock().await;
let status = inner.status();
if status.update_required {
return Err("An update is available: update before launching while connected to the DHP.".to_string());
}
if !status.can_launch {
return Err("ODIAN isn't ready to launch.".to_string());
}
let operator = inner
.state
.operator
.clone()
.ok_or("Sign in once while connected, so the launcher knows which operator to start ODIAN as.")?;
let version = inner.state.installed_version.clone().ok_or("ODIAN is not installed.")?;
if inner.state.pg_password.is_empty() || inner.state.db_snapshot.is_none() {
return Err("The local database was never set up. Reinstall from the DHP or a drive.".to_string());
}
let install_dir = inner.settings.install_path();
if inner.state.mint_secret.is_empty() {
inner.state.mint_secret = state::new_secret();
inner.state.save(&install_dir)?;
}
let layout = Layout::new(&install_dir, &version);
let tile_sets = stack::installed_tile_sets(&layout)?;
inner.phase = Phase::Launching;
inner.error = None;
StackConfig {
layout,
operator,
mint_secret: inner.state.mint_secret.clone(),
pg_password: inner.state.pg_password.clone(),
install_id: inner.state.install_id.clone(),
tile_sets,
}
};
if let Ok(mut states) = self.stack_states.lock() {
states.clear();
}
self.emit(app).await;
let handle = app.clone();
tauri::async_runtime::spawn(async move {
handle.state::<Launcher>().run_stack(&handle, config).await;
});
Ok(())
}
async fn run_stack(&self, app: &AppHandle, config: StackConfig) {
let emit = |event: StackEvent| self.record_stack(app, event);
let running = match stack::start(&self.http, &config, &emit).await {
Ok(running) => running,
Err(message) => {
self.log(app, "error", message.clone());
return self.finish(app, Some(message)).await;
}
};
let odian = running.odian();
*self.stack.lock().await = Some(running);
self.set_phase(app, Phase::Running).await;
if let Some(odian) = odian {
stack::wait_exit(&odian).await;
}
// Odian.exe closed. If Stop already took the stack, it stopped the rest too.
let taken = self.stack.lock().await.take();
if let Some(mut running) = taken {
running.stop(&emit).await;
}
self.finish(app, None).await;
}
pub async fn stop(&self, app: &AppHandle) -> Result<(), String> {
let taken = self.stack.lock().await.take();
let Some(mut running) = taken else {
return Err("ODIAN isn't running.".to_string());
};
running.stop(&|event: StackEvent| self.record_stack(app, event)).await;
Ok(())
}
/// The launcher is exiting: stop the stack cleanly rather than leave Postgres to crash recovery.
pub async fn shutdown(&self) {
if let Ok(slot) = self.install_cancel.lock() {
if let Some(token) = slot.as_ref() {
token.cancel();
}
}
let taken = self.stack.lock().await.take();
if let Some(mut running) = taken {
running.stop(&|_: StackEvent| {}).await;
}
}
}
+64
View File
@@ -0,0 +1,64 @@
//! ODIAN Launcher backend: the Tauri commands and events in docs/CONTRACT.md.
mod auth;
mod commands;
mod fsutil;
mod install;
mod launcher;
mod localdb;
mod settings;
mod site;
mod stack;
mod state;
mod tokens;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
app.manage(launcher::Launcher::load(app.handle())?);
// First look at the site in the background; the window shows "checking" meanwhile.
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
handle.state::<launcher::Launcher>().refresh(&handle).await;
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::get_status,
commands::refresh,
commands::sign_in,
commands::cancel_sign_in,
commands::sign_out,
commands::get_notes,
commands::open_external,
commands::start_install,
commands::start_install_from_drive,
commands::cancel_install,
commands::launch,
commands::stop,
commands::get_stack,
commands::get_settings,
commands::set_settings,
commands::pick_folder,
])
.build(tauri::generate_context!())
.expect("error while building the launcher")
.run(|app, event| {
if let tauri::RunEvent::Exit = event {
tauri::async_runtime::block_on(app.state::<launcher::Launcher>().shutdown());
}
});
}
+249
View File
@@ -0,0 +1,249 @@
//! The PC's own Postgres cluster (`data\pg`): create it, start and stop it, load a snapshot.
//!
//! It listens on 127.0.0.1 only, with a password generated on this PC. The
//! snapshot is restored in the order proven on the Data PC's dumps: pre-data,
//! every table's data (in parallel), sequences, post-data.
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::Mutex;
use crate::stack::{command, log_output, Layout, PG_PORT};
pub const PG_USER: &str = "odian";
pub const PG_DATABASE: &str = "odian";
/// One dump file from the manifest's `db.files`.
#[derive(Clone, Debug)]
pub struct DbFile {
pub section: String,
/// Relative to `data\`, e.g. `db/20260915T082529Z/data/public.db_ground_poi.dump`.
pub path: String,
pub size: u64,
}
/// Restore progress: (done bytes, total bytes, done files, total files, current file).
pub type RestoreProgress<'a> = &'a (dyn Fn(u64, u64, usize, usize, &str) + Sync);
#[derive(Clone)]
pub struct Pg {
bin: PathBuf,
data: PathBuf,
logs: PathBuf,
password: String,
}
impl Pg {
pub fn new(layout: &Layout, password: &str) -> Pg {
Pg {
bin: layout.pg_bin(),
data: layout.pg_data(),
logs: layout.state_dir().join("pg"),
password: password.to_string(),
}
}
fn tool(&self, name: &str) -> Command {
let mut cmd = command(&self.bin.join(format!("{name}.exe")), &self.logs, &[self.bin.clone()]);
cmd.env("PGPASSWORD", &self.password).env("PGCONNECT_TIMEOUT", "5").env("PGAPPNAME", "odian-launcher");
cmd
}
fn run(mut cmd: Command, what: &str) -> Result<String, String> {
let out = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("cannot run {what}: {e}"))?;
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let tail: String = stderr.chars().rev().take(1500).collect::<Vec<_>>().into_iter().rev().collect();
Err(format!("{what} failed ({}): {}", out.status, tail.trim()))
}
fn ensure_logs(&self) -> Result<(), String> {
std::fs::create_dir_all(&self.logs).map_err(|e| format!("cannot create {}: {e}", self.logs.display()))
}
pub fn is_initialized(&self) -> bool {
self.data.join("PG_VERSION").is_file()
}
/// Create the cluster. A `data\pg` without PG_VERSION is a failed earlier attempt and is replaced.
pub fn init(&self) -> Result<(), String> {
self.ensure_logs()?;
if self.data.exists() && !self.is_initialized() {
std::fs::remove_dir_all(&self.data).map_err(|e| format!("cannot clear {}: {e}", self.data.display()))?;
}
let pwfile = self.logs.join("initdb.pw");
std::fs::write(&pwfile, &self.password).map_err(|e| format!("cannot write {}: {e}", pwfile.display()))?;
let mut cmd = self.tool("initdb");
cmd
.arg("-D")
.arg(&self.data)
.args(["-U", PG_USER, "-A", "scram-sha-256", "-E", "UTF8", "--locale=C"])
.args(["--locale-provider=builtin", "--builtin-locale=C.UTF-8"])
.arg(format!("--pwfile={}", pwfile.display()));
let result = Self::run(cmd, "initdb");
let _ = std::fs::remove_file(&pwfile);
result.map(|_| ())
}
pub fn running(&self) -> bool {
let mut cmd = self.tool("pg_ctl");
cmd.arg("status").arg("-D").arg(&self.data).stdout(Stdio::null()).stderr(Stdio::null());
cmd.status().map(|status| status.success()).unwrap_or(false)
}
pub fn start(&self) -> Result<(), String> {
if !self.is_initialized() {
return Err("The local database isn't set up. Reinstall from the DHP or a drive.".to_string());
}
self.ensure_logs()?;
if self.running() {
return Ok(());
}
let mut cmd = self.tool("pg_ctl");
cmd
.arg("start")
.arg("-D")
.arg(&self.data)
.arg("-l")
.arg(self.logs.join("postgres.log"))
.args(["-w", "-t", "120", "-o"])
.arg(format!("-p {PG_PORT} -c listen_addresses=127.0.0.1"));
// The postmaster inherits pg_ctl's handles and outlives it, so a pipe would never
// reach EOF: its output goes to a file.
log_output(&mut cmd, &self.logs.join("pg_ctl.log"))?;
let status = cmd.status().map_err(|e| format!("cannot run pg_ctl: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("Postgres did not start ({status}). See state\\pg\\postgres.log."))
}
}
pub fn stop(&self) -> Result<(), String> {
if !self.is_initialized() || !self.running() {
return Ok(());
}
let mut cmd = self.tool("pg_ctl");
cmd.arg("stop").arg("-D").arg(&self.data).args(["-m", "fast", "-w", "-t", "60"]);
log_output(&mut cmd, &self.logs.join("pg_ctl.log"))?;
let status = cmd.status().map_err(|e| format!("cannot run pg_ctl: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("Postgres did not stop cleanly ({status})."))
}
}
fn psql(&self, database: &str, sql: &str) -> Result<String, String> {
let port = PG_PORT.to_string();
let mut cmd = self.tool("psql");
cmd
.args(["-X", "-A", "-t", "-v", "ON_ERROR_STOP=1", "-h", "127.0.0.1", "-p", port.as_str()])
.args(["-U", PG_USER, "-d", database, "-c", sql]);
Self::run(cmd, "psql")
}
/// Replace the `odian` database with a snapshot. Only for a PC whose restore never
/// completed: anything an operator did in the old database is dropped.
pub fn restore(
&self,
data_root: &Path,
files: &[DbFile],
workers: usize,
progress: RestoreProgress<'_>,
cancelled: &(dyn Fn() -> bool + Sync),
) -> Result<(), String> {
self.ensure_logs()?;
self.psql("postgres", &format!("DROP DATABASE IF EXISTS {PG_DATABASE}"))?;
self.psql("postgres", &format!("CREATE DATABASE {PG_DATABASE} TEMPLATE template0 ENCODING 'UTF8'"))?;
let total_bytes: u64 = files.iter().map(|f| f.size).sum();
let done_bytes = AtomicU64::new(0);
let done_files = AtomicUsize::new(0);
let finished = |file: &DbFile| {
let bytes = done_bytes.fetch_add(file.size, Ordering::Relaxed) + file.size;
let count = done_files.fetch_add(1, Ordering::Relaxed) + 1;
progress(bytes, total_bytes, count, files.len(), &file.path);
};
let section = |name: &'static str| files.iter().filter(move |f| f.section == name);
for file in section("pre-data") {
self.restore_one(data_root, file, true)?;
finished(file);
}
let queue = Mutex::new(section("data").collect::<VecDeque<_>>());
let failure: Mutex<Option<String>> = Mutex::new(None);
std::thread::scope(|scope| {
for _ in 0..workers.max(1) {
scope.spawn(|| loop {
if cancelled() || failure.lock().map(|f| f.is_some()).unwrap_or(true) {
return;
}
let Some(file) = queue.lock().ok().and_then(|mut q| q.pop_front()) else {
return;
};
match self.restore_one(data_root, file, false) {
Ok(()) => finished(file),
Err(message) => {
if let Ok(mut slot) = failure.lock() {
slot.get_or_insert(message);
}
return;
}
}
});
}
});
if let Some(message) = failure.into_inner().unwrap_or(None) {
return Err(message);
}
if cancelled() {
return Err("cancelled".to_string());
}
for file in section("sequences").chain(section("post-data")) {
self.restore_one(data_root, file, false)?;
finished(file);
}
Ok(())
}
fn restore_one(&self, data_root: &Path, file: &DbFile, pre_data: bool) -> Result<(), String> {
let dump = file.path.split('/').fold(data_root.to_path_buf(), |path, part| path.join(part));
let port = PG_PORT.to_string();
let mut cmd = self.tool("pg_restore");
cmd
.args(["-h", "127.0.0.1", "-p", port.as_str(), "-U", PG_USER, "-d", PG_DATABASE])
.args(["--no-owner", "--no-privileges", "--exit-on-error"]);
if pre_data {
cmd.arg("-L").arg(self.pre_data_list(&dump)?);
}
cmd.arg(&dump);
Self::run(cmd, &format!("pg_restore {}", file.path)).map(|_| ())
}
/// The pre-data table of contents without `CREATE SCHEMA public`: a fresh database
/// already has that schema, and --exit-on-error would stop on the duplicate.
fn pre_data_list(&self, dump: &Path) -> Result<PathBuf, String> {
let mut cmd = self.tool("pg_restore");
cmd.arg("-l").arg(dump);
let listing = Self::run(cmd, "pg_restore -l")?;
let kept: String = listing
.lines()
.filter(|line| !(line.contains(" SCHEMA - public ") || line.contains(" COMMENT - SCHEMA public ")))
.map(|line| format!("{line}\n"))
.collect();
let path = self.logs.join("pre-data.list");
std::fs::write(&path, kept).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
Ok(path)
}
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
odian_launcher_lib::run();
}
+98
View File
@@ -0,0 +1,98 @@
//! Launcher settings: which site to use and where ODIAN is installed.
//!
//! Stored per Windows user in the launcher's config dir. On first run they are
//! seeded from a `site.json` next to the launcher executable (shipped with the
//! setup or copied from an ODIAN drive), so operators never type addresses.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use crate::fsutil::write_json_atomic;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Settings {
/// The site's update service, e.g. `http://192.168.1.41:30097`.
#[serde(default)]
pub site_url: String,
/// The site's MCP, which runs the Keycloak sign-in.
#[serde(default)]
pub mcp_url: String,
#[serde(default)]
pub install_dir: String,
/// Display name from site.json; the UI's settings form does not edit it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub site_name: Option<String>,
}
#[derive(Deserialize, Default)]
struct SiteFile {
#[serde(default)]
name: Option<String>,
#[serde(default)]
site_url: String,
#[serde(default)]
mcp_url: String,
}
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_config_dir().map_err(|e| format!("no config dir: {e}"))?;
Ok(dir.join("settings.json"))
}
fn default_install_dir() -> String {
// Operator PCs keep the ~1 TB of data off the system drive when a D: exists.
// Not %LOCALAPPDATA%\ODIAN: on the release PC that folder holds the release
// signing key, and deleting a test install must never be able to take it along.
if Path::new("D:\\").is_dir() {
"D:\\ODIAN".to_string()
} else {
"C:\\ODIAN".to_string()
}
}
fn read_site_file() -> SiteFile {
let Some(dir) = std::env::current_exe().ok().and_then(|exe| exe.parent().map(Path::to_path_buf)) else {
return SiteFile::default();
};
std::fs::read(dir.join("site.json"))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
pub fn normalize_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_string()
}
impl Settings {
pub fn load(app: &AppHandle) -> Result<Settings, String> {
let path = settings_path(app)?;
if let Ok(bytes) = std::fs::read(&path) {
let mut settings: Settings =
serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid settings JSON: {e}", path.display()))?;
if settings.install_dir.trim().is_empty() {
settings.install_dir = default_install_dir();
}
return Ok(settings);
}
let site = read_site_file();
let settings = Settings {
site_url: normalize_url(&site.site_url),
mcp_url: normalize_url(&site.mcp_url),
install_dir: default_install_dir(),
site_name: site.name,
};
settings.save(app)?;
Ok(settings)
}
pub fn save(&self, app: &AppHandle) -> Result<(), String> {
write_json_atomic(&settings_path(app)?, self)
}
pub fn install_path(&self) -> PathBuf {
PathBuf::from(&self.install_dir)
}
}
+137
View File
@@ -0,0 +1,137 @@
//! Talking to the site's update service: reachability, token check, patch notes.
use std::time::Duration;
use reqwest::StatusCode;
/// How long a launch waits for the site before treating the PC as offline.
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
pub struct Probe {
pub online: bool,
/// The site's CURRENT version.
pub version: Option<String>,
}
pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
let offline = Probe { online: false, version: None };
if site_url.is_empty() {
return offline;
}
let Ok(response) = http.get(format!("{site_url}/v1/health")).timeout(PROBE_TIMEOUT).send().await else {
return offline;
};
if !response.status().is_success() {
return offline;
}
let body: serde_json::Value = response.json().await.unwrap_or_default();
Probe {
online: true,
version: body.get("version").and_then(|v| v.as_str()).map(str::to_string),
}
}
pub enum TokenCheck {
Valid,
Invalid,
/// The site could not say (unreachable, Engine down): keep the token.
Unknown,
}
pub async fn check_token(http: &reqwest::Client, site_url: &str, token: &str) -> TokenCheck {
let request = http
.get(format!("{site_url}/v1/versions/current"))
.bearer_auth(token)
.timeout(Duration::from_secs(5));
match request.send().await {
// 404 means "no current version yet", which is only answered after the token passed.
Ok(r) if r.status().is_success() || r.status() == StatusCode::NOT_FOUND => TokenCheck::Valid,
Ok(r) if r.status() == StatusCode::UNAUTHORIZED => TokenCheck::Invalid,
_ => TokenCheck::Unknown,
}
}
pub enum SessionRefresh {
Renewed { access_token: String, expires_at: i64, username: String },
/// The site no longer honours the launcher session: signed out elsewhere, revoked, or the user is gone.
Revoked(String),
/// The sign-in service could not answer (unreachable, Engine down, an MCP without launcher
/// sessions): keep the session and try again later.
Unavailable(String),
}
#[derive(serde::Deserialize)]
struct RenewedBody {
#[serde(default)]
access_token: String,
#[serde(default)]
expires_at: i64,
#[serde(default)]
username: String,
}
/// A fresh Engine token from the site MCP's launcher session, without asking the operator.
pub async fn refresh_session(http: &reqwest::Client, mcp_url: &str, launcher_token: &str) -> SessionRefresh {
if mcp_url.is_empty() {
return SessionRefresh::Unavailable("no sign-in address (MCP URL) is set".to_string());
}
let sent = http
.post(format!("{mcp_url}/auth/launcher/refresh"))
.json(&serde_json::json!({ "launcher_token": launcher_token }))
.timeout(Duration::from_secs(10))
.send()
.await;
let response = match sent {
Ok(response) => response,
Err(e) => return SessionRefresh::Unavailable(format!("the sign-in service did not answer: {e}")),
};
match response.status() {
s if s.is_success() => match response.json::<RenewedBody>().await {
Ok(body) if !body.access_token.is_empty() => SessionRefresh::Renewed {
access_token: body.access_token,
expires_at: body.expires_at,
username: body.username,
},
_ => SessionRefresh::Unavailable("the sign-in service gave an unexpected answer".to_string()),
},
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
let detail = response
.json::<serde_json::Value>()
.await
.ok()
.and_then(|body| body.get("detail").and_then(|d| d.as_str()).map(str::to_string))
.unwrap_or_else(|| "the DHP ended this sign-in".to_string());
SessionRefresh::Revoked(detail)
}
s => SessionRefresh::Unavailable(format!("the sign-in service answered HTTP {s}")),
}
}
/// Best effort: tell the site MCP to end the launcher session. Signing out works offline too.
pub async fn end_session(http: &reqwest::Client, mcp_url: &str, launcher_token: &str) {
if mcp_url.is_empty() {
return;
}
let _ = http
.post(format!("{mcp_url}/auth/launcher/logout"))
.json(&serde_json::json!({ "launcher_token": launcher_token }))
.timeout(Duration::from_secs(5))
.send()
.await;
}
pub async fn notes(http: &reqwest::Client, site_url: &str, token: &str, version: &str) -> Result<String, String> {
let response = http
.get(format!("{site_url}/v1/versions/{version}/notes"))
.bearer_auth(token)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("the DHP did not answer: {e}"))?;
match response.status() {
s if s.is_success() => response.text().await.map_err(|e| e.to_string()),
StatusCode::UNAUTHORIZED => Err("Sign in to read the patch notes.".to_string()),
StatusCode::NOT_FOUND => Err(format!("Version {version} has no patch notes.")),
s => Err(format!("the DHP answered HTTP {s}")),
}
}
+662
View File
@@ -0,0 +1,662 @@
//! The local ODIAN stack on this PC: Postgres → Engine API → tile server → Odian.exe.
//!
//! Everything listens on 127.0.0.1 only. Each process starts from a clean
//! environment (the Windows basics plus what is set here), without a console
//! window, inside one Job Object so nothing outlives the launcher.
//! `versions\<v>\` is only ever read: logs, configs and caches go under
//! `state\`, data under `data\`.
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::fsutil::{write_atomic, write_json_atomic};
use crate::localdb::Pg;
pub const PG_PORT: u16 = 55432;
pub const API_PORT: u16 = 18080;
pub const TILES_PORT: u16 = 18081;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Windows basics a child still needs once its environment is cleared. Nothing
/// from a developer's shell (ODIAN_*, PYTHONPATH, PG*) may leak into the stack.
const INHERITED_ENV: &[&str] = &[
"SystemRoot", "SystemDrive", "windir", "ComSpec", "PATHEXT", "OS",
"TEMP", "TMP", "USERPROFILE", "USERNAME", "USERDOMAIN", "HOMEDRIVE", "HOMEPATH",
"APPDATA", "LOCALAPPDATA", "ProgramData", "ProgramFiles", "ProgramFiles(x86)", "CommonProgramFiles",
"COMPUTERNAME", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "PROCESSOR_IDENTIFIER",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Component {
Postgres,
Api,
Tiles,
Odian,
}
impl Component {
fn label(self) -> &'static str {
match self {
Component::Postgres => "Postgres",
Component::Api => "the Engine API",
Component::Tiles => "the tile server",
Component::Odian => "ODIAN",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentState {
Starting,
Ready,
Stopped,
Failed,
}
#[derive(Clone, Debug, Serialize)]
pub struct StackEvent {
pub component: Component,
pub state: ComponentState,
pub message: Option<String>,
}
impl StackEvent {
fn new(component: Component, state: ComponentState) -> StackEvent {
StackEvent { component, state, message: None }
}
}
pub type Emit<'a> = &'a (dyn Fn(StackEvent) + Send + Sync);
/// Where a launcher-ready version keeps each runnable part, as paths in its
/// manifest's `app.files`. A version missing any of them cannot run on its own.
pub const ODIAN_EXE: &str = "Odian/x64/MinGW-Release/Odian.exe";
pub const API_EXE: &str = "api/odian_api.exe";
pub const TILE_SERVER_APP: &str = "tile_server/app.py";
pub const PYTHON_EXE: &str = "runtime/python/python.exe";
pub const PG_CTL_EXE: &str = "runtime/pgsql/bin/pg_ctl.exe";
pub const PG_RESTORE_EXE: &str = "runtime/pgsql/bin/pg_restore.exe";
/// Where one install keeps each part of one version.
#[derive(Clone, Debug)]
pub struct Layout {
root: PathBuf,
version: String,
}
impl Layout {
pub fn new(root: &Path, version: &str) -> Layout {
Layout { root: root.to_path_buf(), version: version.to_string() }
}
pub fn version_dir(&self) -> PathBuf {
self.root.join("versions").join(&self.version)
}
/// A manifest app path (`a/b/c`) inside this version.
fn app_file(&self, rel: &str) -> PathBuf {
rel.split('/').fold(self.version_dir(), |path, part| path.join(part))
}
pub fn data_dir(&self) -> PathBuf {
self.root.join("data")
}
pub fn state_dir(&self) -> PathBuf {
self.root.join("state")
}
pub fn pg_bin(&self) -> PathBuf {
parent(&self.app_file(PG_CTL_EXE))
}
pub fn pg_data(&self) -> PathBuf {
self.data_dir().join("pg")
}
fn python(&self) -> PathBuf {
self.app_file(PYTHON_EXE)
}
fn tile_server_dir(&self) -> PathBuf {
parent(&self.app_file(TILE_SERVER_APP))
}
fn api_exe(&self) -> PathBuf {
self.app_file(API_EXE)
}
fn odian_dir(&self) -> PathBuf {
self.version_dir().join("Odian")
}
fn odian_exe(&self) -> PathBuf {
self.app_file(ODIAN_EXE)
}
}
pub struct StackConfig {
pub layout: Layout,
pub operator: String,
pub mint_secret: String,
pub pg_password: String,
pub install_id: String,
/// (style, dataset) of every tile set the installed version carries.
pub tile_sets: Vec<(String, String)>,
}
/// A process started from a clean environment, in `cwd`, with `search_path` first on PATH.
pub fn command(program: &Path, cwd: &Path, search_path: &[PathBuf]) -> Command {
let mut cmd = Command::new(program);
cmd.env_clear();
for key in INHERITED_ENV {
if let Some(value) = std::env::var_os(key) {
cmd.env(key, value);
}
}
let windows = std::env::var_os("SystemRoot").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
let mut path = search_path.to_vec();
path.extend([windows.join("System32"), windows.clone(), windows.join("System32").join("Wbem")]);
if let Ok(joined) = std::env::join_paths(path) {
cmd.env("PATH", joined);
}
cmd.current_dir(cwd).stdin(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
/// Append the process's stdout and stderr to `log`.
pub fn log_output(cmd: &mut Command, log: &Path) -> Result<(), String> {
if let Some(dir) = log.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
}
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log)
.map_err(|e| format!("cannot open {}: {e}", log.display()))?;
let err = file.try_clone().map_err(|e| e.to_string())?;
cmd.stdout(file).stderr(err);
Ok(())
}
type Shared = Arc<Mutex<Child>>;
pub struct RunningStack {
job: job::Job,
pg: Pg,
children: Vec<(Component, Shared)>,
}
impl RunningStack {
fn spawn(&mut self, component: Component, cmd: &mut Command) -> Result<Shared, String> {
let child = cmd.spawn().map_err(|e| format!("cannot start {}: {e}", component.label()))?;
if let Err(e) = self.job.assign(&child) {
log::warn!("{} is not in the launcher's job object: {e}", component.label());
}
let shared = Arc::new(Mutex::new(child));
self.children.push((component, shared.clone()));
Ok(shared)
}
pub fn odian(&self) -> Option<Shared> {
self.children.iter().find(|(c, _)| *c == Component::Odian).map(|(_, child)| child.clone())
}
/// Stop in reverse start order; Postgres last, and cleanly.
pub async fn stop(&mut self, emit: Emit<'_>) {
for (component, child) in self.children.drain(..).rev() {
if let Ok(mut process) = child.lock() {
if process.try_wait().ok().flatten().is_none() {
let _ = process.kill();
let _ = process.wait();
}
}
emit(StackEvent::new(component, ComponentState::Stopped));
}
let pg = self.pg.clone();
match run_blocking(move || pg.stop()).await {
Ok(()) => emit(StackEvent::new(Component::Postgres, ComponentState::Stopped)),
Err(message) => emit(StackEvent { component: Component::Postgres, state: ComponentState::Failed, message: Some(message) }),
}
}
}
pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>) -> Result<RunningStack, String> {
prepare(config)?;
let mut running = RunningStack {
job: job::Job::new()?,
pg: Pg::new(&config.layout, &config.pg_password),
children: Vec::new(),
};
match start_all(http, config, emit, &mut running).await {
Ok(()) => Ok(running),
Err(message) => {
running.stop(emit).await;
Err(message)
}
}
}
async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>, running: &mut RunningStack) -> Result<(), String> {
let layout = &config.layout;
let state = layout.state_dir();
emit(StackEvent::new(Component::Postgres, ComponentState::Starting));
let pg = running.pg.clone();
run_blocking(move || pg.start()).await.map_err(|e| failed(emit, Component::Postgres, e))?;
emit(StackEvent::new(Component::Postgres, ComponentState::Ready));
emit(StackEvent::new(Component::Api, ComponentState::Starting));
let api_dir = state.join("api");
let api_exe = layout.api_exe();
let mut api = command(&api_exe, &api_dir, &[parent(&api_exe)]);
api.arg(API_PORT.to_string()).envs(api_env(config));
log_output(&mut api, &api_dir.join("logs").join("console.log"))?;
let child = running.spawn(Component::Api, &mut api).map_err(|e| failed(emit, Component::Api, e))?;
wait_http(http, &format!("http://127.0.0.1:{API_PORT}/health"), &child, Duration::from_secs(90))
.await
.map_err(|e| failed(emit, Component::Api, format!("The Engine API {e}. See state\\api\\logs.")))?;
emit(StackEvent::new(Component::Api, ComponentState::Ready));
emit(StackEvent::new(Component::Tiles, ComponentState::Starting));
let tiles_dir = state.join("tiles");
let python = layout.python();
let mut tiles = command(&python, &tiles_dir, &[parent(&python)]);
tiles
.args(["-m", "uvicorn", "--app-dir"])
.arg(layout.tile_server_dir())
.args(["app:app", "--host", "127.0.0.1", "--log-level", "warning", "--port"])
.arg(TILES_PORT.to_string())
.envs(tiles_env(config));
log_output(&mut tiles, &tiles_dir.join("tile_server.log"))?;
let child = running.spawn(Component::Tiles, &mut tiles).map_err(|e| failed(emit, Component::Tiles, e))?;
wait_http(http, &format!("http://127.0.0.1:{TILES_PORT}/health"), &child, Duration::from_secs(120))
.await
.map_err(|e| failed(emit, Component::Tiles, format!("The tile server {e}. See state\\tiles\\tile_server.log.")))?;
emit(StackEvent::new(Component::Tiles, ComponentState::Ready));
emit(StackEvent::new(Component::Odian, ComponentState::Starting));
let client_dir = state.join("client");
let odian_exe = layout.odian_exe();
let mut odian = command(&odian_exe, &client_dir, &[parent(&odian_exe)]);
odian.envs(client_env(config));
log_output(&mut odian, &client_dir.join("logs").join("console.log"))?;
let child = running.spawn(Component::Odian, &mut odian).map_err(|e| failed(emit, Component::Odian, e))?;
// Odian.exe has no health endpoint; a client that survives its first seconds has signed in.
tokio::time::sleep(Duration::from_secs(3)).await;
if let Some(status) = exit_status(&child) {
return Err(failed(
emit,
Component::Odian,
format!("ODIAN closed right away ({status}). See state\\client\\odian_engine.log."),
));
}
emit(StackEvent::new(Component::Odian, ComponentState::Ready));
Ok(())
}
fn failed(emit: Emit<'_>, component: Component, message: String) -> String {
emit(StackEvent { component, state: ComponentState::Failed, message: Some(message.clone()) });
message
}
fn parent(path: &Path) -> PathBuf {
path.parent().map(Path::to_path_buf).unwrap_or_default()
}
fn exit_status(child: &Shared) -> Option<ExitStatus> {
child.lock().ok()?.try_wait().ok().flatten()
}
/// Resolves once the process has exited.
pub async fn wait_exit(child: &Shared) {
loop {
let gone = match child.lock() {
Ok(mut process) => !matches!(process.try_wait(), Ok(None)),
Err(_) => true,
};
if gone {
return;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
async fn wait_http(http: &reqwest::Client, url: &str, child: &Shared, within: Duration) -> Result<(), String> {
let deadline = Instant::now() + within;
loop {
if let Ok(response) = http.get(url).timeout(Duration::from_secs(2)).send().await {
if response.status().is_success() {
return Ok(());
}
}
if let Some(status) = exit_status(child) {
return Err(format!("stopped while starting ({status})"));
}
if Instant::now() > deadline {
return Err(format!("did not answer within {} s", within.as_secs()));
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
}
async fn run_blocking<T: Send + 'static>(work: impl FnOnce() -> Result<T, String> + Send + 'static) -> Result<T, String> {
tauri::async_runtime::spawn_blocking(work).await.map_err(|e| e.to_string())?
}
fn prepare(config: &StackConfig) -> Result<(), String> {
let layout = &config.layout;
let state = layout.state_dir();
let data = layout.data_dir();
for dir in [
state.join("api").join("logs"),
state.join("api").join("keys"),
state.join("tiles"),
state.join("client").join("logs"),
state.join("pg"),
data.join("tiles"),
data.join("dem").join("terrarium"),
] {
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
}
// Odian.exe reads assets\ and shaders\ from its working directory and writes
// config\ and logs\ next to them. Junctions give it both at once: this
// version's read-only files, inside a folder of its own it may write to.
let client = state.join("client");
for name in ["assets", "shaders"] {
replace_junction(&client.join(name), &layout.odian_dir().join(name))?;
}
write_atomic(&state.join("api").join("pg.ini"), pg_ini(config).as_bytes())?;
write_json_atomic(&state.join("tiles").join("datasets.json"), &datasets(config))
}
fn replace_junction(link: &Path, target: &Path) -> Result<(), String> {
if !target.is_dir() {
return Err(format!("{} is missing from this version", target.display()));
}
if std::fs::symlink_metadata(link).is_ok() {
// On a junction this removes only the link; a real folder with files in it refuses.
std::fs::remove_dir(link).map_err(|e| format!("cannot replace {}: {e}", link.display()))?;
}
junction::create(target, link).map_err(|e| format!("cannot link {} to {}: {e}", link.display(), target.display()))
}
fn text(path: PathBuf) -> String {
path.display().to_string()
}
fn pg_ini(config: &StackConfig) -> String {
format!(
"hosts = 127.0.0.1\nport = {PG_PORT}\ndb = {}\nuser = {}\npassword = {}\nconnect_timeout_sec = 2\npoll_interval_ms = 1000\n",
crate::localdb::PG_DATABASE,
crate::localdb::PG_USER,
config.pg_password
)
}
fn datasets(config: &StackConfig) -> serde_json::Value {
let tiles = config.layout.data_dir().join("tiles");
let mut doc = serde_json::Map::new();
for (_, dataset) in &config.tile_sets {
let mut entry = serde_json::json!({ "cache_dir": text(tiles.clone()) });
let pmtiles = config.layout.data_dir().join("src").join(format!("{dataset}.pmtiles"));
if pmtiles.is_file() {
entry["pmtiles"] = serde_json::Value::String(text(pmtiles));
}
doc.insert(dataset.clone(), entry);
}
serde_json::Value::Object(doc)
}
fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> {
let api = config.layout.state_dir().join("api");
let logs = api.join("logs");
vec![
("ODIAN_API_BIND", "127.0.0.1".to_string()),
("ODIAN_API_PORT", API_PORT.to_string()),
("ODIAN_PG_INI", text(api.join("pg.ini"))),
("ODIAN_MINT_SECRET", config.mint_secret.clone()),
("ODIAN_DISCOVERY", "0".to_string()),
("ODIAN_DEM_OFFLINE", "1".to_string()),
("ODIAN_DEM_DIR", text(config.layout.data_dir().join("dem").join("terrarium"))),
("ODIAN_RAIL_SYNC", "0".to_string()),
("ODIAN_WEATHER_INI", text(api.join("weather.ini"))),
("ODIAN_OPENAIP_INI", text(api.join("openaip.ini"))),
("BDO_SIGNING_KEY_DIR", text(api.join("keys"))),
("ODIAN_API_LOGDIR", text(logs.clone())),
("ODIAN_API_LOG", text(logs.join("odian_api.log"))),
("ODIAN_API_THREAD_STATE", text(logs.join("thread_state.txt"))),
]
}
fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> {
let tiles = config.layout.data_dir().join("tiles");
vec![
("ODIAN_TILE_DATASETS", text(config.layout.state_dir().join("tiles").join("datasets.json"))),
("ODIAN_TILE_CACHE_ONLY", "1".to_string()),
("ODIAN_TILE_CACHE_DIR", text(tiles.clone())),
("ODIAN_TILE_JOURNAL", text(tiles.join("tiles.db"))),
("ODIAN_TILE_ORIGIN", config.install_id.clone()),
("ODIAN_TILE_BAKER_EXE", String::new()),
("PYTHONDONTWRITEBYTECODE", "1".to_string()),
("PYTHONNOUSERSITE", "1".to_string()),
("PYTHONUTF8", "1".to_string()),
]
}
fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
let client = config.layout.state_dir().join("client");
let tiles_url = format!("http://127.0.0.1:{TILES_PORT}");
let mut env = vec![
("ODIAN_ENGINE_API_URL", format!("http://127.0.0.1:{API_PORT}")),
("ODIAN_LAUNCH_USER", config.operator.clone()),
("ODIAN_MINT_SECRET", config.mint_secret.clone()),
("ODIAN_OFFLINE", "1".to_string()),
("ODIAN_TILE_SERVER_URL", tiles_url.clone()),
("ODIAN_TILE_PROVIDER", "odian".to_string()),
("ODIAN_TILE_REQUEST_MODE", "xyz".to_string()),
("ODIAN_TILE_Y_SCHEME", "xyz".to_string()),
("ODIAN_TILE_AUTH_MODE", "none".to_string()),
("ODIAN_TILE_CACHE_DIR", text(client.join("tile_cache"))),
("ODIAN_POP_SERVICE_URL", tiles_url.clone()),
("ODIAN_LIDAR_ENABLED", "0".to_string()),
("ODIAN_HEIGHT_CACHE_DIR", text(client.join("heightmap"))),
];
let basemap = config
.tile_sets
.iter()
.find(|(style, _)| style == "dark")
.or_else(|| config.tile_sets.iter().find(|(style, _)| style != "heightmap"));
if let Some((style, dataset)) = basemap {
env.push(("ODIAN_TILE_STYLE", style.clone()));
env.push(("ODIAN_TILE_DATASET", dataset.clone()));
}
match config.tile_sets.iter().find(|(style, _)| style == "heightmap") {
Some((style, dataset)) => {
env.push(("ODIAN_HEIGHT_SERVER_URL", tiles_url));
env.push(("ODIAN_HEIGHT_FORMAT", "lerc".to_string()));
env.push(("ODIAN_HEIGHT_STYLE", style.clone()));
env.push(("ODIAN_HEIGHT_DATASET", dataset.clone()));
}
None => env.push(("ODIAN_HEIGHT_ENABLED", "0".to_string())),
}
env
}
/// The tile sets the installed version's manifest names.
pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<(String, String)>, String> {
let path = layout.version_dir().join("manifest.json");
let bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?;
let manifest: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid JSON: {e}", path.display()))?;
let sets = manifest.pointer("/tiles/sets").and_then(|sets| sets.as_array());
Ok(sets
.map(|sets| {
sets
.iter()
.filter_map(|set| Some((set.get("style")?.as_str()?.to_string(), set.get("dataset")?.as_str()?.to_string())))
.collect()
})
.unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
fn config(tile_sets: &[(&str, &str)]) -> StackConfig {
StackConfig {
layout: Layout::new(Path::new(r"D:\ODIAN"), "0.52.0"),
operator: "m1op".to_string(),
mint_secret: "secret".to_string(),
pg_password: "pw".to_string(),
install_id: "install".to_string(),
tile_sets: tile_sets.iter().map(|(s, d)| (s.to_string(), d.to_string())).collect(),
}
}
fn value<'a>(env: &'a [(&'static str, String)], key: &str) -> Option<&'a str> {
env.iter().find(|(k, _)| *k == key).map(|(_, v)| v.as_str())
}
#[test]
fn layout_resolves_manifest_paths_inside_the_version() {
let layout = Layout::new(Path::new(r"D:\ODIAN"), "0.52.0");
assert_eq!(layout.odian_exe(), PathBuf::from(r"D:\ODIAN\versions\0.52.0\Odian\x64\MinGW-Release\Odian.exe"));
assert_eq!(layout.pg_bin(), PathBuf::from(r"D:\ODIAN\versions\0.52.0\runtime\pgsql\bin"));
assert_eq!(layout.tile_server_dir(), PathBuf::from(r"D:\ODIAN\versions\0.52.0\tile_server"));
}
#[test]
fn client_runs_offline_on_the_local_stack_only() {
let env = client_env(&config(&[("heightmap", "planet_heightmap"), ("dark", "protomaps_20260423")]));
assert_eq!(value(&env, "ODIAN_ENGINE_API_URL"), Some("http://127.0.0.1:18080"));
assert_eq!(value(&env, "ODIAN_TILE_SERVER_URL"), Some("http://127.0.0.1:18081"));
assert_eq!(value(&env, "ODIAN_OFFLINE"), Some("1"));
assert_eq!(value(&env, "ODIAN_LAUNCH_USER"), Some("m1op"));
assert_eq!(value(&env, "ODIAN_TILE_STYLE"), Some("dark"));
assert_eq!(value(&env, "ODIAN_TILE_DATASET"), Some("protomaps_20260423"));
assert_eq!(value(&env, "ODIAN_HEIGHT_DATASET"), Some("planet_heightmap"));
assert_eq!(value(&env, "ODIAN_HEIGHT_FORMAT"), Some("lerc"));
assert!(value(&env, "ODIAN_MCP_API_URL").is_none(), "an offline client must not know the MCP");
}
#[test]
fn heights_are_switched_off_when_the_version_has_none() {
let env = client_env(&config(&[("dark", "protomaps_20260423")]));
assert_eq!(value(&env, "ODIAN_HEIGHT_ENABLED"), Some("0"));
assert!(value(&env, "ODIAN_HEIGHT_SERVER_URL").is_none());
}
#[test]
fn api_and_tile_server_are_loopback_and_cache_only() {
let config = config(&[("dark", "protomaps_20260423")]);
let api = api_env(&config);
assert_eq!(value(&api, "ODIAN_API_BIND"), Some("127.0.0.1"));
assert_eq!(value(&api, "ODIAN_DISCOVERY"), Some("0"));
assert_eq!(value(&api, "ODIAN_RAIL_SYNC"), Some("0"));
let tiles = tiles_env(&config);
assert_eq!(value(&tiles, "ODIAN_TILE_CACHE_ONLY"), Some("1"));
assert_eq!(value(&tiles, "ODIAN_TILE_BAKER_EXE"), Some(""));
assert!(pg_ini(&config).contains("port = 55432\n") && pg_ini(&config).contains("password = pw\n"));
}
#[test]
fn datasets_point_every_set_at_the_shared_tile_store() {
let doc = datasets(&config(&[("dark", "protomaps_20260423"), ("heightmap", "planet_heightmap")]));
let object = doc.as_object().expect("an object");
assert_eq!(object.len(), 2);
assert_eq!(object["protomaps_20260423"]["cache_dir"], r"D:\ODIAN\data\tiles");
assert!(object["planet_heightmap"].get("pmtiles").is_none(), "no pmtiles on disk, so none is named");
}
#[test]
fn tile_sets_come_from_the_installed_manifest() {
let root = std::env::temp_dir().join(format!("odian-launcher-test-{}", uuid::Uuid::new_v4().simple()));
let layout = Layout::new(&root, "0.52.0");
std::fs::create_dir_all(layout.version_dir()).unwrap();
std::fs::write(
layout.version_dir().join("manifest.json"),
r#"{"tiles":{"block_shift":6,"sets":[{"style":"dark","dataset":"protomaps_20260423"},{"style":"heightmap","dataset":"planet_heightmap"}]}}"#,
)
.unwrap();
let sets = installed_tile_sets(&layout).unwrap();
std::fs::remove_dir_all(&root).unwrap();
assert_eq!(sets, vec![("dark".to_string(), "protomaps_20260423".to_string()), ("heightmap".to_string(), "planet_heightmap".to_string())]);
}
}
#[cfg(windows)]
mod job {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
/// Every stack process joins this job; when the launcher exits, however it exits, Windows ends them.
pub struct Job(HANDLE);
// A job handle may be used from any thread.
unsafe impl Send for Job {}
unsafe impl Sync for Job {}
impl Job {
pub fn new() -> Result<Job, String> {
unsafe {
let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null());
if handle.is_null() {
return Err(format!("cannot create a job object: {}", std::io::Error::last_os_error()));
}
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let ok = SetInformationJobObject(
handle,
JobObjectExtendedLimitInformation,
&info as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION as *const core::ffi::c_void,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
);
if ok == 0 {
let error = std::io::Error::last_os_error();
CloseHandle(handle);
return Err(format!("cannot configure the job object: {error}"));
}
Ok(Job(handle))
}
}
pub fn assign(&self, child: &std::process::Child) -> Result<(), String> {
let ok = unsafe { AssignProcessToJobObject(self.0, child.as_raw_handle() as HANDLE) };
if ok == 0 {
Err(std::io::Error::last_os_error().to_string())
} else {
Ok(())
}
}
}
impl Drop for Job {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0);
}
}
}
}
#[cfg(not(windows))]
mod job {
pub struct Job;
impl Job {
pub fn new() -> Result<Job, String> {
Ok(Job)
}
pub fn assign(&self, _child: &std::process::Child) -> Result<(), String> {
Ok(())
}
}
}
+69
View File
@@ -0,0 +1,69 @@
//! What this PC's install is: `<install_dir>\state\launcher.json`.
//!
//! Nothing here may be baked into an ODIAN drive: sites clone drives freely, so
//! the install id and the local secrets are generated on the PC itself.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::fsutil::{write_atomic, write_json_atomic};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct LauncherState {
#[serde(default)]
pub install_id: String,
/// The version whose files, database and tiles are fully in place.
#[serde(default)]
pub installed_version: Option<String>,
/// The last operator who signed in; offline launches run as them.
#[serde(default)]
pub operator: Option<String>,
/// Shared by the launcher and the local Engine API for `/auth/mint`. The API
/// only listens on 127.0.0.1, so it never leaves this PC.
#[serde(default)]
pub mint_secret: String,
/// Password of the local Postgres superuser `odian` (127.0.0.1 only).
#[serde(default)]
pub pg_password: String,
/// The database snapshot whose restore completed. None means the local
/// database holds no operator work yet and may be (re)created from a snapshot.
#[serde(default)]
pub db_snapshot: Option<String>,
}
fn state_dir(install_dir: &Path) -> PathBuf {
install_dir.join("state")
}
/// 256 random bits as hex.
pub fn new_secret() -> String {
format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple())
}
impl LauncherState {
pub fn load(install_dir: &Path) -> Result<LauncherState, String> {
let path = state_dir(install_dir).join("launcher.json");
match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid JSON: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LauncherState::default()),
Err(e) => Err(format!("cannot read {}: {e}", path.display())),
}
}
pub fn save(&mut self, install_dir: &Path) -> Result<(), String> {
if self.install_id.is_empty() {
self.install_id = uuid::Uuid::new_v4().simple().to_string();
}
write_json_atomic(&state_dir(install_dir).join("launcher.json"), self)
}
}
/// Patch notes are kept per version so the hub still has them offline.
pub fn cached_notes(install_dir: &Path, version: &str) -> Option<String> {
std::fs::read_to_string(state_dir(install_dir).join("notes").join(format!("{version}.md"))).ok()
}
pub fn cache_notes(install_dir: &Path, version: &str, markdown: &str) -> Result<(), String> {
write_atomic(&state_dir(install_dir).join("notes").join(format!("{version}.md")), markdown.as_bytes())
}
+52
View File
@@ -0,0 +1,52 @@
//! The site token, kept in Windows Credential Manager (one entry per site).
use serde::{Deserialize, Serialize};
const SERVICE: &str = "ODIAN Launcher";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SiteToken {
/// Engine session token: 24 h, and gone whenever the site's Engine API restarts.
pub access_token: String,
/// Unix seconds.
pub expires_at: i64,
pub username: String,
/// The site MCP's launcher session: no expiry, ended only by Sign out, an admin, or
/// the ODIAN user disappearing. It renews `access_token` without asking the operator.
#[serde(default)]
pub launcher_token: Option<String>,
}
impl SiteToken {
pub fn expired(&self) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
self.expires_at <= now
}
}
fn entry(site_url: &str) -> Option<keyring::Entry> {
if site_url.is_empty() {
return None;
}
keyring::Entry::new(SERVICE, site_url).ok()
}
pub fn load(site_url: &str) -> Option<SiteToken> {
let secret = entry(site_url)?.get_password().ok()?;
serde_json::from_str(&secret).ok()
}
pub fn save(site_url: &str, token: &SiteToken) -> Result<(), String> {
let entry = entry(site_url).ok_or("no DHP configured")?;
let secret = serde_json::to_string(token).map_err(|e| e.to_string())?;
entry.set_password(&secret).map_err(|e| format!("cannot store the sign-in in Credential Manager: {e}"))
}
pub fn clear(site_url: &str) {
if let Some(entry) = entry(site_url) {
let _ = entry.delete_credential();
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ODIAN Launcher",
"version": "0.1.0",
"identifier": "fr.odian.launcher",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"label": "main",
"title": "ODIAN",
"width": 1280,
"height": 760,
"minWidth": 1024,
"minHeight": 640,
"center": true,
"resizable": true,
"decorations": false,
"fullscreen": false
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost"
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import { onMount } from "svelte";
import ActionPanel from "./components/ActionPanel.svelte";
import ActivityPanel from "./components/ActivityPanel.svelte";
import Hero from "./components/Hero.svelte";
import KeyArt from "./components/KeyArt.svelte";
import NewsPanel from "./components/NewsPanel.svelte";
import SettingsDrawer from "./components/SettingsDrawer.svelte";
import StatusBar from "./components/StatusBar.svelte";
import TitleBar from "./components/TitleBar.svelte";
import { launcher } from "./lib/launcher.svelte";
let settingsOpen = $state(false);
onMount(() => launcher.start());
</script>
<div class="shell">
<KeyArt />
<TitleBar onOpenSettings={() => (settingsOpen = true)} />
<main class="hub">
<div class="news-slot"><NewsPanel /></div>
<div class="hero-slot"><Hero /></div>
<div class="action-slot"><ActionPanel /></div>
</main>
<StatusBar />
{#if launcher.activityOpen}
<div class="activity-slot"><ActivityPanel /></div>
{/if}
<SettingsDrawer bind:open={settingsOpen} />
</div>
<style>
.shell {
position: relative;
display: grid;
grid-template-rows: var(--titlebar-h) minmax(0, 1fr) var(--statusbar-h);
height: 100%;
overflow: hidden;
isolation: isolate;
}
.hub {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(320px, 400px) minmax(0, 1fr) minmax(360px, 404px);
grid-template-rows: minmax(0, 1fr) auto;
gap: 24px 40px;
min-height: 0;
padding: 28px 32px;
}
.news-slot {
display: flex;
grid-row: 1 / span 2;
grid-column: 1;
min-height: 0;
}
.hero-slot {
grid-row: 1;
grid-column: 2 / span 2;
align-self: start;
padding-top: 20px;
}
.action-slot {
grid-row: 2;
grid-column: 3;
align-self: end;
}
.activity-slot {
position: absolute;
z-index: 30;
bottom: calc(var(--statusbar-h) + 10px);
left: 32px;
width: min(640px, calc(100% - 64px));
}
@media (max-width: 1140px) {
.hub {
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr) minmax(340px, 380px);
gap: 20px 24px;
padding: 20px 24px;
}
.activity-slot {
left: 24px;
}
}
@media (max-height: 700px) {
.hero-slot {
padding-top: 4px;
}
}
</style>
+495
View File
@@ -0,0 +1,495 @@
<script lang="ts">
import Icon, { type IconName } from "./Icon.svelte";
import ProgressBar from "./ProgressBar.svelte";
import StackList from "./StackList.svelte";
import StateDot, { type DotTone } from "./StateDot.svelte";
import { launcher } from "../lib/launcher.svelte";
import { STAGE_LABEL, formatBytes, formatCount, formatDuration, formatSpeed } from "../lib/format";
const s = $derived(launcher.status);
const p = $derived(launcher.progress);
const action = $derived(launcher.action);
const busy = $derived(launcher.busy);
const checking = $derived(s?.phase === "checking");
const blocked = $derived(busy !== null || checking);
const site = $derived(launcher.siteName);
const fraction = $derived.by(() => {
if (!p) return null;
if (p.total_bytes > 0) return p.done_bytes / p.total_bytes;
if (p.total_items > 0) return p.done_items / p.total_items;
return null;
});
const transferred = $derived.by(() => {
if (!p) return "—";
if (p.total_bytes > 0) return `${formatBytes(p.done_bytes)} / ${formatBytes(p.total_bytes)}`;
if (p.total_items > 0) return `${formatCount(p.done_items)} / ${formatCount(p.total_items)}`;
return "—";
});
interface Head {
label: string;
tone: DotTone;
pulse: boolean;
meta: string | null;
}
const head: Head = $derived.by(() => {
const stage = p ? `Stage ${p.stage_index}/${p.stage_count}` : null;
const version = (v: string | null | undefined) => (v ? `v${v}` : null);
switch (action) {
case "loading":
return { label: "Connecting", tone: "idle", pulse: true, meta: null };
case "sign_in":
if (s?.installed && s.update_required) {
return { label: "Update required", tone: "warn", pulse: false, meta: `${s.installed} → ${s.available}` };
}
return s?.installed
? { label: "Signed out", tone: "idle", pulse: false, meta: version(s.installed) }
: { label: "Not installed", tone: "idle", pulse: false, meta: version(s?.available) };
case "signed_out_offline":
return { label: "Signed out", tone: "warn", pulse: false, meta: "offline" };
case "signing_in":
return { label: "Signing in", tone: "accent", pulse: true, meta: null };
case "install":
return { label: "Ready to install", tone: "accent", pulse: false, meta: version(s?.available) };
case "offline_unavailable":
return { label: "Not installed", tone: "warn", pulse: false, meta: "offline" };
case "installing":
return { label: s?.installed ? "Updating" : "Installing", tone: "accent", pulse: true, meta: stage };
case "restoring":
return { label: "Restoring database", tone: "accent", pulse: true, meta: stage };
case "update":
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
case "launch":
return { label: s?.online ? "Ready" : "Ready offline", tone: "ok", pulse: false, meta: version(s?.installed) };
case "stack":
if (launcher.stopping) return { label: "Stopping", tone: "warn", pulse: true, meta: version(s?.installed) };
return s?.phase === "running"
? { label: "Running", tone: "ok", pulse: false, meta: version(s?.installed) }
: { label: "Starting ODIAN", tone: "accent", pulse: true, meta: version(s?.installed) };
case "error":
return { label: "Needs attention", tone: "danger", pulse: false, meta: null };
}
});
const showNotice = $derived(launcher.notice !== null && !(action === "error" && launcher.notice === s?.error));
</script>
{#snippet primary(label: string, icon: IconName, onclick: () => void, disabled: boolean, pending = false)}
<div class="primary-wrap">
<button class="primary" {onclick} {disabled} aria-busy={pending}>
<span class="primary-icon">
{#if pending}
<span class="spinner" aria-hidden="true"></span>
{:else}
<Icon name={icon} size={18} strokeWidth={1.75} />
{/if}
</span>
<span class="primary-label">{label}</span>
</button>
<span class="corners" aria-hidden="true"></span>
</div>
{/snippet}
{#snippet driveLink(lead: string)}
<p class="secondary">
{lead}
<button class="btn-link" disabled={blocked} onclick={() => launcher.installFromDrive()}>Install from a drive…</button>
</p>
{/snippet}
<section class="action glass" aria-labelledby="action-title">
<header class="head">
<StateDot tone={head.tone} pulse={head.pulse} />
<h2 id="action-title" class="eyebrow title" aria-live="polite">{head.label}</h2>
{#if head.meta}<span class="meta mono">{head.meta}</span>{/if}
</header>
{#if action === "loading"}
{@render primary("Loading", "play", () => {}, true, true)}
{:else if action === "sign_in"}
{@render primary(
s?.update_required ? "Sign in to update" : s?.installed ? "Sign in" : "Sign in to install",
"key",
() => launcher.signIn(),
blocked,
busy === "sign_in",
)}
<p class="secondary">
{#if s?.update_required}
ODIAN <strong>{s.available}</strong> is required before launch while connected. Sign in with your {site}
account to download it.
{:else if s?.installed}
Sign in with your {site} account. This PC then remembers you, online and offline, until you sign out.
{:else}
Sign in with your {site} account to download ODIAN{#if s?.available}&nbsp;<strong>{s.available}</strong>{/if}.
{/if}
</p>
{#if !s?.installed}{@render driveLink("No access to the DHP?")}{/if}
{:else if action === "signing_in"}
{@render primary("Waiting for sign-in", "key", () => {}, true, true)}
<p class="secondary">
Complete the Keycloak sign-in in your browser, then come back here.
<button class="btn-link" onclick={() => launcher.cancelSignIn()}>Cancel sign-in</button>
</p>
{:else if action === "install"}
{@render primary("Install", "download", () => launcher.install(), blocked, busy === "install")}
<p class="secondary">
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
</p>
{@render driveLink("Have an ODIAN drive?")}
{:else if action === "offline_unavailable"}
{@render primary("Connect to your DHP to install", "globe", () => {}, true)}
{@render driveLink("Installing needs your DHP, or an ODIAN drive.")}
{:else if action === "signed_out_offline"}
{@render primary("Connect to your DHP to sign in", "globe", () => {}, true)}
<p class="secondary">
This PC doesn't remember an operator anymore. Sign in once while connected to {site}, and ODIAN launches offline
again after that.
</p>
{:else if action === "update"}
{@render primary(`Update to ${s?.available}`, "update", () => launcher.install(), blocked, busy === "install")}
<p class="secondary">
Updating is required before launch while connected to {site}. Everything already verified is reused.
</p>
{:else if action === "launch"}
{@render primary("Launch", "play", () => launcher.launch(), blocked || !s?.can_launch, busy === "launch")}
<p class="secondary">
{#if checking}
Checking {site}…
{:else if s?.online}
ODIAN <strong>{s.installed}</strong> is up to date.
{:else}
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
{/if}
</p>
{:else if action === "installing"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">{p ? STAGE_LABEL[p.stage] : "Preparing"}</span>
{#if fraction !== null}<span class="pct mono tabular">{Math.floor(fraction * 100)}%</span>{/if}
</div>
<ProgressBar
value={fraction}
label={p ? `${STAGE_LABEL[p.stage]}, stage ${p.stage_index} of ${p.stage_count}` : "Preparing install"}
/>
{#if p}
<div class="segments" style:--count={p.stage_count} aria-hidden="true">
{#each { length: p.stage_count } as _, i (i)}
<span class="seg" class:done={i < p.stage_index - 1} class:current={i === p.stage_index - 1}></span>
{/each}
</div>
<dl class="metrics">
<div>
<dt>Transferred</dt>
<dd class="mono tabular" title={transferred}>{transferred}</dd>
</div>
<div>
<dt>Speed</dt>
<dd class="mono tabular">{launcher.speed > 0 ? formatSpeed(launcher.speed) : "—"}</dd>
</div>
<div>
<dt>Stage ETA</dt>
<dd class="mono tabular">{launcher.eta !== null ? formatDuration(launcher.eta) : "—"}</dd>
</div>
</dl>
{#if p.message}<p class="message mono" title={p.message}>{p.message}</p>{/if}
{/if}
</div>
<button class="btn btn-danger wide" disabled={busy !== null} onclick={() => launcher.cancelInstall()}>
{busy === "cancel" ? "Cancelling…" : "Cancel"}
</button>
<p class="secondary small">Cancelling keeps every file already verified; the next install resumes from there.</p>
{:else if action === "restoring"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Loading the database snapshot</span>
</div>
<ProgressBar value={null} label="Restoring the database" />
{#if p?.stage === "restore" && p.message}<p class="message mono" title={p.message}>{p.message}</p>{/if}
</div>
<p class="secondary">This can take a few minutes. Keep the launcher open until it finishes.</p>
{:else if action === "stack"}
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
<Icon name="stop" size={12} />
{launcher.stopping ? "Stopping…" : s?.phase === "running" ? "Stop ODIAN" : "Stop"}
</button>
{:else if action === "error"}
<div class="error-box" role="alert">
<Icon name="alert" size={16} />
<p class="selectable">{s?.error ?? "Something went wrong."}</p>
</div>
{@render primary("Retry", "refresh", () => launcher.retry(), blocked, busy !== null)}
<p class="secondary">
<button class="btn-link" onclick={() => launcher.setActivityOpen(true)}>View activity</button> for the full log.
</p>
{/if}
{#if showNotice}
<div class="notice" role="alert">
<Icon name="alert" size={14} />
<p class="selectable">{launcher.notice}</p>
<button class="icon-btn dismiss" aria-label="Dismiss message" onclick={() => launcher.dismissNotice()}>
<Icon name="close" size={12} />
</button>
</div>
{/if}
</section>
<style>
.action {
display: grid;
gap: 14px;
width: 100%;
padding: 18px 22px 22px;
}
.head {
display: flex;
align-items: center;
gap: 10px;
min-height: 20px;
}
.title {
flex: 1;
color: var(--ink-2);
}
.meta {
color: var(--muted);
font-size: var(--text-xs);
}
/* Primary button */
.primary-wrap {
position: relative;
margin: 4px 0 2px;
}
.primary {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
height: 64px;
padding: 0 24px;
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-ink);
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 600;
letter-spacing: 0.02em;
box-shadow: 0 14px 40px -16px rgba(91, 140, 255, 0.7);
transition:
background-color var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
box-shadow var(--dur) var(--ease),
transform var(--dur-fast) var(--ease);
}
.primary:hover:not(:disabled) {
border-color: var(--accent-hover);
background: var(--accent-hover);
box-shadow: 0 18px 48px -14px rgba(91, 140, 255, 0.85);
}
.primary:active:not(:disabled) {
background: var(--accent-press);
transform: translateY(1px);
}
.primary:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
.primary:disabled {
border-color: var(--line-strong);
background: var(--surface-2);
color: var(--muted);
box-shadow: none;
font-size: var(--text-lg);
}
.primary-icon {
display: grid;
place-items: center;
width: 20px;
}
.primary-label {
flex: 1;
overflow: hidden;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
}
.corners {
--c: var(--accent-line);
position: absolute;
inset: -6px;
background:
linear-gradient(var(--c), var(--c)) 0 0 / 10px 1px,
linear-gradient(var(--c), var(--c)) 0 0 / 1px 10px,
linear-gradient(var(--c), var(--c)) 100% 0 / 10px 1px,
linear-gradient(var(--c), var(--c)) 100% 0 / 1px 10px,
linear-gradient(var(--c), var(--c)) 0 100% / 10px 1px,
linear-gradient(var(--c), var(--c)) 0 100% / 1px 10px,
linear-gradient(var(--c), var(--c)) 100% 100% / 10px 1px,
linear-gradient(var(--c), var(--c)) 100% 100% / 1px 10px;
background-repeat: no-repeat;
pointer-events: none;
transition:
inset var(--dur) var(--ease),
opacity var(--dur) var(--ease);
}
.primary-wrap:hover .corners {
inset: -8px;
}
.primary:disabled + .corners {
opacity: 0;
}
.spinner {
width: 16px;
height: 16px;
border: 1.5px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Text */
.secondary {
color: var(--muted);
font-size: var(--text-sm);
line-height: 1.55;
}
.secondary.small {
font-size: var(--text-xs);
}
.secondary strong {
color: var(--ink-2);
font-weight: 500;
}
.path {
color: var(--ink-2);
font-size: var(--text-xs);
}
.wide {
width: 100%;
height: 44px;
}
/* Progress */
.progress {
display: grid;
gap: 10px;
}
.stage-line {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.stage-name {
color: var(--ink);
font-family: var(--font-display);
font-size: var(--text-lg);
font-weight: 500;
}
.pct {
color: var(--ink-2);
font-size: var(--text-sm);
}
.segments {
display: grid;
grid-template-columns: repeat(var(--count), 1fr);
gap: 3px;
}
.seg {
height: 2px;
background: var(--line-strong);
transition: background-color var(--dur) var(--ease);
}
.seg.done {
background: var(--accent);
}
.seg.current {
background: var(--accent-line);
}
.metrics {
display: grid;
grid-template-columns: 1.45fr 1fr 1fr;
gap: 1px;
margin: 2px 0 0;
border: 1px solid var(--line);
background: var(--line);
}
.metrics > div {
min-width: 0;
padding: 7px 10px;
background: var(--surface);
}
.metrics dt {
color: var(--muted);
font-size: var(--text-xs);
}
.metrics dd {
margin: 0;
overflow: hidden;
color: var(--ink);
font-size: var(--text-xs);
white-space: nowrap;
text-overflow: ellipsis;
}
.message {
overflow: hidden;
color: var(--muted);
font-size: var(--text-xs);
white-space: nowrap;
text-overflow: ellipsis;
}
/* Errors */
.error-box,
.notice {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(248, 113, 113, 0.32);
border-radius: var(--radius);
background: var(--danger-soft);
color: var(--danger);
}
.error-box p,
.notice p {
flex: 1;
color: var(--ink-2);
font-size: var(--text-sm);
line-height: 1.5;
}
.error-box :global(.icon),
.notice :global(.icon) {
margin-top: 2px;
}
.notice {
align-items: center;
padding: 6px 6px 6px 12px;
}
.dismiss {
width: 24px;
height: 24px;
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { prefersReducedMotion } from "svelte/motion";
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
import { formatClock } from "../lib/format";
function close() {
launcher.setActivityOpen(false);
}
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && !event.defaultPrevented && !document.querySelector("dialog[open]")) close();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<section
id="activity-panel"
class="activity glass"
aria-label="Activity"
transition:fly={{ y: 8, duration: prefersReducedMotion.current ? 0 : 200 }}
>
<header class="head">
<h2 class="eyebrow">Activity</h2>
<span class="total mono">{launcher.logs.length}</span>
<span class="grow"></span>
<button class="btn clear" disabled={launcher.logs.length === 0} onclick={() => launcher.clearLogs()}>Clear</button>
<button class="icon-btn" aria-label="Close activity" onclick={close}>
<Icon name="close" size={14} />
</button>
</header>
{#if launcher.logs.length}
<ol class="log selectable" aria-label="Recent backend messages, newest first">
{#each launcher.logs as entry, i (i)}
<li class="entry {entry.level}">
<time class="mono tabular" datetime={new Date(entry.at * 1000).toISOString()}>{formatClock(entry.at)}</time>
<span class="level mono">{entry.level}</span>
<span class="message">{entry.message}</span>
</li>
{/each}
</ol>
{:else}
<p class="empty">No activity yet. Backend messages show up here as they happen.</p>
{/if}
</section>
<style>
.activity {
display: flex;
flex-direction: column;
max-height: min(340px, calc(100vh - 180px));
background: var(--glass-strong);
box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.8);
}
.head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--line);
}
.total {
color: var(--muted);
font-size: var(--text-xs);
}
.grow {
flex: 1;
}
.clear {
height: 28px;
padding: 0 10px;
font-size: var(--text-xs);
}
.log {
margin: 0;
padding: 4px 0;
overflow-y: auto;
list-style: none;
}
.entry {
display: grid;
grid-template-columns: 64px 48px minmax(0, 1fr);
gap: 10px;
padding: 5px 16px;
font-size: var(--text-xs);
line-height: 1.5;
}
.entry + .entry {
border-top: 1px solid rgba(35, 35, 38, 0.6);
}
time {
color: var(--muted);
}
.level {
color: var(--muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.message {
color: var(--ink-2);
overflow-wrap: anywhere;
}
.warn .level {
color: var(--warn);
}
.error .level,
.error .message {
color: var(--danger);
}
.empty {
padding: 16px;
color: var(--muted);
font-size: var(--text-sm);
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import { launcher } from "../lib/launcher.svelte";
const status = $derived(launcher.status);
</script>
<div class="hero">
<p class="eyebrow tagline">Tactical map console</p>
<h1 class="wordmark">ODIAN</h1>
{#if status}
<p class="meta mono">
<span>{status.installed ? `v${status.installed}` : "Not installed"}</span>
<span class="sep" aria-hidden="true">/</span>
<span>{launcher.siteName}</span>
{#if !status.online}
<span class="sep" aria-hidden="true">/</span>
<span>Offline mode</span>
{/if}
</p>
{/if}
</div>
<style>
.hero {
display: grid;
justify-items: start;
gap: 10px;
}
.tagline {
display: flex;
align-items: center;
gap: 12px;
color: var(--ink-2);
}
.tagline::before {
content: "";
width: 28px;
height: 1px;
background: var(--accent);
}
.wordmark {
margin: 0 calc(var(--wordmark-tracking) * -1) 0 0;
color: var(--ink);
font-family: var(--font-display);
font-size: clamp(52px, 5.4vw, 72px);
font-weight: 600;
line-height: 1;
letter-spacing: var(--wordmark-tracking);
text-shadow: 0 0 48px rgba(91, 140, 255, 0.28);
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
color: var(--muted);
font-size: var(--text-xs);
letter-spacing: 0.06em;
}
.sep {
color: var(--line-strong);
}
</style>
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts" module>
export type IconName =
| "minimize"
| "maximize"
| "restore"
| "close"
| "settings"
| "refresh"
| "play"
| "download"
| "update"
| "user"
| "stop"
| "folder"
| "drive"
| "activity"
| "alert"
| "check"
| "signout"
| "key"
| "globe";
</script>
<script lang="ts">
interface Props {
name: IconName;
size?: number;
strokeWidth?: number;
}
let { name, size = 16, strokeWidth = 1.5 }: Props = $props();
</script>
<!-- Original 16px line icons; decorative, so hidden from assistive tech. -->
<svg
class="icon"
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="square"
aria-hidden="true"
focusable="false"
>
{#if name === "minimize"}
<path d="M3.5 8.5h9" />
{:else if name === "maximize"}
<rect x="3.5" y="3.5" width="9" height="9" />
{:else if name === "restore"}
<path d="M6 5.5V3.5h6.5V10h-2" />
<rect x="3.5" y="6" width="7" height="6.5" />
{:else if name === "close"}
<path d="M4 4l8 8M12 4l-8 8" />
{:else if name === "settings"}
<path d="M2.5 4.5h6M12.5 4.5h1M2.5 11.5h1M7.5 11.5h6" />
<rect x="8.5" y="3" width="3" height="3" />
<rect x="4.5" y="10" width="3" height="3" />
{:else if name === "refresh"}
<path d="M13 8a5 5 0 1 1-1.5-3.55" />
<path d="M12 2v3h-3" />
{:else if name === "play"}
<path d="M5 3.25l7.5 4.75L5 12.75z" fill="currentColor" stroke="none" />
{:else if name === "download"}
<path d="M8 2.5v8M4.75 7.25L8 10.5l3.25-3.25M3 13.5h10" />
{:else if name === "update"}
<path d="M8 13.5v-8M4.75 8.75L8 5.5l3.25 3.25M3 2.5h10" />
{:else if name === "user"}
<circle cx="8" cy="5.5" r="2.5" />
<path d="M3 13.5c.6-2.3 2.6-3.75 5-3.75s4.4 1.45 5 3.75" />
{:else if name === "stop"}
<rect x="4" y="4" width="8" height="8" fill="currentColor" stroke="none" />
{:else if name === "folder"}
<path d="M2.5 4h4l1.5 1.5h5.5v7h-11z" />
{:else if name === "drive"}
<rect x="2.5" y="5" width="11" height="6.5" />
<path d="M5 8.25h.5M10 8.25h1.5" />
{:else if name === "activity"}
<path d="M1.5 8.5h3l2-5 3 9 2-4h3" />
{:else if name === "alert"}
<path d="M8 2.5l6 11H2z" />
<path d="M8 6.75v3M8 11.5v.25" />
{:else if name === "check"}
<path d="M3.5 8.5l3 3 6-7" />
{:else if name === "signout"}
<path d="M9.5 3h-6v10h6M7 8h6.5M11 5.5L13.5 8 11 10.5" />
{:else if name === "key"}
<circle cx="5.25" cy="10.75" r="2.5" />
<path d="M7 9l5.5-5.5M10.5 5.5L12 7" />
{:else if name === "globe"}
<circle cx="8" cy="8" r="5.5" />
<ellipse cx="8" cy="8" rx="2.4" ry="5.5" />
<path d="M2.5 8h11" />
{/if}
</svg>
<style>
.icon {
display: block;
flex: none;
}
</style>
+286
View File
@@ -0,0 +1,286 @@
<script lang="ts">
// Original key art, generated here: an orthographic wireframe globe with a
// bearing ring, a slow orbit, a few track markers and faint topographic
// contours. Static except for one orbiting point (off with reduced motion).
import { prefersReducedMotion } from "svelte/motion";
const W = 1280;
const H = 760;
const CX = 930;
const CY = 392;
const R = 288;
const TILT = rad(24); // view latitude
const SPIN = rad(-20); // view longitude
function rad(deg: number) {
return (deg * Math.PI) / 180;
}
interface Pt {
x: number;
y: number;
visible: boolean;
}
function project(latDeg: number, lonDeg: number): Pt {
const phi = rad(latDeg);
const lambda = rad(lonDeg) - SPIN;
const cosPhi = Math.cos(phi);
const x = R * cosPhi * Math.sin(lambda);
const y = R * (Math.cos(TILT) * Math.sin(phi) - Math.sin(TILT) * cosPhi * Math.cos(lambda));
const cosC = Math.sin(TILT) * Math.sin(phi) + Math.cos(TILT) * cosPhi * Math.cos(lambda);
return { x: CX + x, y: CY - y, visible: cosC >= 0 };
}
const fmt = (n: number) => n.toFixed(1);
/** Splits a projected polyline into its near-side and far-side runs. */
function trace(points: Pt[]) {
let front = "";
let back = "";
let prev: boolean | null = null;
for (const p of points) {
const seg = `${prev === p.visible ? "L" : "M"}${fmt(p.x)} ${fmt(p.y)}`;
if (p.visible) front += seg;
else back += seg;
prev = p.visible;
}
return { front, back };
}
function range(from: number, to: number, step: number) {
const out: number[] = [];
for (let v = from; v <= to + 1e-9; v += step) out.push(v);
return out;
}
const lines = [
...range(-180, 165, 15).map((lon) => trace(range(-90, 90, 2).map((lat) => project(lat, lon)))),
...range(-75, 75, 15).map((lat) => trace(range(-180, 180, 2).map((lon) => project(lat, lon)))),
];
const gridFront = lines.map((l) => l.front).join("");
const gridBack = lines.map((l) => l.back).join("");
const equator = trace(range(-180, 180, 2).map((lon) => project(0, lon))).front;
// Bearing ring around the globe.
const RING = R + 26;
const ticks = range(0, 355, 5)
.map((deg) => {
const a = rad(deg - 90);
const len = deg % 30 === 0 ? 9 : deg % 10 === 0 ? 5 : 2.5;
const c = Math.cos(a);
const s = Math.sin(a);
return `M${fmt(CX + c * RING)} ${fmt(CY + s * RING)}L${fmt(CX + c * (RING + len))} ${fmt(CY + s * (RING + len))}`;
})
.join("");
// Labels in the upper-left quadrant would sit under the hero wordmark.
const ringLabels = range(0, 330, 30)
.filter((deg) => deg < 285 || deg > 345)
.map((deg) => {
const a = rad(deg - 90);
return { x: CX + Math.cos(a) * (RING + 24), y: CY + Math.sin(a) * (RING + 24), text: String(deg).padStart(3, "0") };
});
// Track markers joined by great-circle legs.
type Vec = [number, number, number];
const toVec = (lat: number, lon: number): Vec => [
Math.cos(rad(lat)) * Math.cos(rad(lon)),
Math.cos(rad(lat)) * Math.sin(rad(lon)),
Math.sin(rad(lat)),
];
function leg(a: [number, number], b: [number, number]) {
const va = toVec(...a);
const vb = toVec(...b);
const omega = Math.acos(Math.min(1, va[0] * vb[0] + va[1] * vb[1] + va[2] * vb[2]));
const pts: Pt[] = [];
for (let i = 0; i <= 48; i++) {
const t = i / 48;
const ka = Math.sin((1 - t) * omega) / Math.sin(omega);
const kb = Math.sin(t * omega) / Math.sin(omega);
const v = [0, 1, 2].map((k) => ka * va[k] + kb * vb[k]);
const lat = (Math.asin(v[2]) * 180) / Math.PI;
const lon = (Math.atan2(v[1], v[0]) * 180) / Math.PI;
pts.push(project(lat, lon));
}
return trace(pts).front;
}
const waypoints: { lat: number; lon: number; label: string }[] = [
{ lat: 14, lon: -36, label: "WPT K7" },
{ lat: 33, lon: -12, label: "TRK 1187" },
{ lat: 47, lon: 5, label: "TRK 0412" },
];
const markers = waypoints.map((w) => ({ ...w, ...project(w.lat, w.lon) })).filter((m) => m.visible);
const route =
leg([waypoints[0].lat, waypoints[0].lon], [waypoints[1].lat, waypoints[1].lon]) +
leg([waypoints[1].lat, waypoints[1].lon], [waypoints[2].lat, waypoints[2].lon]);
// Orbit: an inclined ellipse; the far half is hidden behind the globe.
const ORBIT_TILT = -14;
const RX = R * 1.42;
const RY = R * 0.34;
const orbitBack = `M${fmt(CX - RX)} ${CY}A${fmt(RX)} ${fmt(RY)} 0 0 1 ${fmt(CX + RX)} ${CY}`;
const orbitFront = `M${fmt(CX + RX)} ${CY}A${fmt(RX)} ${fmt(RY)} 0 0 1 ${fmt(CX - RX)} ${CY}`;
const orbitPath = `${orbitBack}${orbitFront.replace(/^M[^A]+/, "")}`;
const staticSat = { x: CX + RX * Math.cos(rad(62)), y: CY + RY * Math.sin(rad(62)) };
// Topographic contours: nested wobbly rings.
function contours(cx: number, cy: number, count: number, seed: number, sx: number, sy: number) {
return range(0, count - 1, 1).map((i) => {
const pts = range(0, 120, 1).map((k) => {
const th = (k / 120) * Math.PI * 2;
const r =
28 +
i * 26 +
(10 + i * 1.5) * Math.sin(3 * th + seed + i * 0.35) +
(6 + i) * Math.sin(5 * th - seed * 1.7 + i * 0.2) +
4 * Math.sin(2 * th + i * 0.5);
return `${fmt(cx + r * Math.cos(th) * sx)} ${fmt(cy + r * Math.sin(th) * sy)}`;
});
return `M${pts.join("L")}Z`;
});
}
const topo = [...contours(520, 780, 12, 1.3, 1.35, 0.72), ...contours(1270, -10, 8, 4.1, 1.1, 0.9)];
</script>
<div class="keyart" aria-hidden="true">
<svg class="layer" viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="ka-glow" cx={CX} cy={CY} r={R * 1.9} gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#5b8cff" stop-opacity="0.2" />
<stop offset="0.42" stop-color="#5b8cff" stop-opacity="0.06" />
<stop offset="1" stop-color="#5b8cff" stop-opacity="0" />
</radialGradient>
<radialGradient id="ka-sphere" cx={CX - R * 0.38} cy={CY - R * 0.42} r={R * 1.55} gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#172241" />
<stop offset="0.55" stop-color="#0d1019" />
<stop offset="1" stop-color="#09090b" />
</radialGradient>
<radialGradient id="ka-fade" cx="0.72" cy="0.5" r="0.62">
<stop offset="0" stop-color="#fff" stop-opacity="1" />
<stop offset="1" stop-color="#fff" stop-opacity="0" />
</radialGradient>
<pattern id="ka-dots" width="24" height="24" patternUnits="userSpaceOnUse">
<rect width="1" height="1" fill="#4a4a52" />
</pattern>
<mask id="ka-dots-mask">
<rect width={W} height={H} fill="url(#ka-fade)" />
</mask>
<mask id="ka-behind" maskUnits="userSpaceOnUse" x="-600" y="-600" width={W + 1200} height={H + 1200}>
<rect x="-600" y="-600" width={W + 1200} height={H + 1200} fill="#fff" />
<circle cx={CX} cy={CY} r={R} fill="#000" />
</mask>
<path id="ka-orbit" d={orbitPath} />
</defs>
<rect width={W} height={H} fill="#09090b" />
<rect width={W} height={H} fill="url(#ka-dots)" mask="url(#ka-dots-mask)" />
<g fill="none" stroke="#9a999e" stroke-opacity="0.075">
{#each topo as d, i (i)}
<path {d} />
{/each}
</g>
<circle cx={CX} cy={CY} r={R * 1.9} fill="url(#ka-glow)" />
<g stroke="#9a999e" fill="none">
<circle cx={CX} cy={CY} r={RING} stroke-opacity="0.12" />
<path d={ticks} stroke-opacity="0.22" />
</g>
<g class="ring-labels" fill="#9a999e" fill-opacity="0.34">
{#each ringLabels as l (l.text)}
<text x={fmt(l.x)} y={fmt(l.y)} text-anchor="middle" dominant-baseline="central">{l.text}</text>
{/each}
</g>
<g transform="rotate({ORBIT_TILT} {CX} {CY})" fill="none" stroke="#7aa2ff">
<path d={orbitBack} stroke-opacity="0.22" stroke-dasharray="2 6" mask="url(#ka-behind)" />
</g>
<circle cx={CX} cy={CY} r={R} fill="url(#ka-sphere)" />
<path d={gridBack} fill="none" stroke="#5b8cff" stroke-opacity="0.06" />
<path d={gridFront} fill="none" stroke="#7aa2ff" stroke-opacity="0.2" stroke-width="0.9" />
<path d={equator} fill="none" stroke="#7aa2ff" stroke-opacity="0.38" />
<circle cx={CX} cy={CY} r={R} fill="none" stroke="#5b8cff" stroke-opacity="0.55" />
<circle cx={CX} cy={CY} r={R + 5} fill="none" stroke="#5b8cff" stroke-opacity="0.12" />
<path d={route} fill="none" stroke="#5b8cff" stroke-opacity="0.75" stroke-dasharray="5 4" />
<g class="markers">
{#each markers as m (m.label)}
<g transform="translate({fmt(m.x)} {fmt(m.y)})">
<rect x="-4.5" y="-4.5" width="9" height="9" fill="#09090b" stroke="#a9c1ff" stroke-opacity="0.9" />
<rect x="-1" y="-1" width="2" height="2" fill="#d6e2ff" />
<path d="M5 -5L16 -16H64" fill="none" stroke="#9a999e" stroke-opacity="0.4" />
<text x="20" y="-21" fill="#d4d3d0" fill-opacity="0.62">{m.label}</text>
</g>
{/each}
</g>
<g transform="rotate({ORBIT_TILT} {CX} {CY})" fill="none" stroke="#7aa2ff">
<path d={orbitFront} stroke-opacity="0.34" />
</g>
</svg>
<!-- Separate layer so the static art above never repaints. -->
<svg class="layer" viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid slice">
<g transform="rotate({ORBIT_TILT} {CX} {CY})">
{#if prefersReducedMotion.current}
<circle cx={fmt(staticSat.x)} cy={fmt(staticSat.y)} r="7" fill="#5b8cff" fill-opacity="0.18" />
<circle cx={fmt(staticSat.x)} cy={fmt(staticSat.y)} r="2.5" fill="#d6e2ff" />
{:else}
<g mask="url(#ka-behind)">
<g>
<circle r="7" fill="#5b8cff" fill-opacity="0.18" />
<circle r="2.5" fill="#d6e2ff" />
<animateMotion dur="96s" repeatCount="indefinite"><mpath href="#ka-orbit" /></animateMotion>
<animate attributeName="opacity" values="1;0" keyTimes="0;0.5" calcMode="discrete" dur="96s" repeatCount="indefinite" />
</g>
</g>
<g opacity="0">
<circle r="7" fill="#5b8cff" fill-opacity="0.18" />
<circle r="2.5" fill="#d6e2ff" />
<animateMotion dur="96s" repeatCount="indefinite"><mpath href="#ka-orbit" /></animateMotion>
<animate attributeName="opacity" values="0;1" keyTimes="0;0.5" calcMode="discrete" dur="96s" repeatCount="indefinite" />
</g>
{/if}
</g>
</svg>
<div class="vignette"></div>
</div>
<style>
.keyart {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
background: var(--bg);
pointer-events: none;
}
.layer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.ring-labels,
.markers {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.06em;
}
.vignette {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(9, 9, 11, 0.9) 0%, rgba(9, 9, 11, 0.5) 30%, rgba(9, 9, 11, 0) 52%),
linear-gradient(0deg, rgba(9, 9, 11, 0.85) 0%, rgba(9, 9, 11, 0) 32%),
linear-gradient(180deg, rgba(9, 9, 11, 0.55) 0%, rgba(9, 9, 11, 0) 18%),
radial-gradient(130% 100% at 72% 50%, transparent 55%, rgba(9, 9, 11, 0.65) 100%);
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
// Design-time switcher, only rendered on the browser mock.
import { MOCK_SCENARIOS, type MockControls } from "../lib/types";
interface Props {
mock: MockControls;
}
let { mock }: Props = $props();
let network = $state(false);
$effect.pre(() => {
network = mock.online;
});
function switchScenario(scenario: string) {
const params = new URLSearchParams(window.location.search);
params.set("scenario", scenario);
window.location.search = params.toString();
}
function setNetwork(online: boolean) {
network = online;
mock.setOnline(online);
}
</script>
<div class="mock" role="group" aria-label="Mock backend">
<span class="tag mono" title="Running in a browser: the backend is simulated">Mock</span>
<label class="sr-only" for="mock-scenario">Scenario</label>
<select
id="mock-scenario"
class="mono"
value={mock.scenario}
onchange={(e) => switchScenario(e.currentTarget.value)}
>
{#each MOCK_SCENARIOS as scenario (scenario)}
<option value={scenario}>{scenario}</option>
{/each}
</select>
<label class="net">
<input type="checkbox" checked={network} onchange={(e) => setNetwork(e.currentTarget.checked)} />
Network
</label>
</div>
<style>
.mock {
display: flex;
align-items: center;
gap: 10px;
}
.tag {
padding: 0 6px;
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
color: var(--warn);
font-size: var(--text-xs);
letter-spacing: 0.08em;
line-height: 18px;
text-transform: uppercase;
}
select {
height: 22px;
padding: 0 4px;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink-2);
font-size: var(--text-xs);
}
.net {
display: flex;
align-items: center;
gap: 6px;
color: var(--ink-2);
cursor: pointer;
}
input {
margin: 0;
accent-color: var(--accent);
}
</style>
+456
View File
@@ -0,0 +1,456 @@
<script lang="ts">
import { untrack } from "svelte";
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
import { renderNotes } from "../lib/markdown";
type Tab = "notes" | "roadmap";
const TABS: { id: Tab; label: string }[] = [
{ id: "notes", label: "Patch notes" },
{ id: "roadmap", label: "Roadmap" },
];
let tab = $state<Tab>("notes");
let scroller: HTMLElement | undefined = $state();
const tabButtons: HTMLButtonElement[] = $state([]);
const notes = $derived(launcher.notes);
const status = $derived(launcher.status);
const rendered = $derived(notes.markdown !== null ? renderNotes(notes.markdown) : null);
const versionTag = $derived.by(() => {
if (!status || !notes.version) return null;
if (notes.version === status.installed) return "Installed";
return status.installed ? "Update" : null;
});
// Fetch whenever the relevant version or connectivity changes.
$effect(() => {
if (launcher.notesKey !== null) untrack(() => void launcher.loadNotes());
});
// Notes may contain links: they open in the system browser and never navigate
// the launcher window. A middle click is swallowed.
$effect(() => {
const el = scroller;
if (!el) return;
const block = (e: MouseEvent) => {
const link = (e.target as Element | null)?.closest("a");
if (!link) return;
e.preventDefault();
const href = link.getAttribute("href");
if (e.type === "click" && href && /^https?:\/\//i.test(href)) void launcher.openExternal(href);
};
el.addEventListener("click", block);
el.addEventListener("auxclick", block);
return () => {
el.removeEventListener("click", block);
el.removeEventListener("auxclick", block);
};
});
function select(id: Tab) {
tab = id;
scroller?.scrollTo({ top: 0 });
}
function onTabKeydown(event: KeyboardEvent, index: number) {
let next = -1;
if (event.key === "ArrowRight") next = (index + 1) % TABS.length;
else if (event.key === "ArrowLeft") next = (index - 1 + TABS.length) % TABS.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = TABS.length - 1;
if (next < 0) return;
event.preventDefault();
select(TABS[next].id);
tabButtons[next]?.focus();
}
</script>
<section class="news glass" aria-label="News">
<header class="head">
<div class="tabs" role="tablist" aria-label="News">
{#each TABS as t, i (t.id)}
<button
bind:this={tabButtons[i]}
id="news-tab-{t.id}"
class="tab"
class:active={tab === t.id}
role="tab"
aria-selected={tab === t.id}
aria-controls="news-panel"
tabindex={tab === t.id ? 0 : -1}
onclick={() => select(t.id)}
onkeydown={(e) => onTabKeydown(e, i)}
>
{t.label}
</button>
{/each}
</div>
{#if notes.version}
<span class="version mono" title="Notes for ODIAN {notes.version}">
v{notes.version}
{#if versionTag}<span class="tag" class:update={versionTag === "Update"}>{versionTag}</span>{/if}
</span>
{/if}
</header>
<div
bind:this={scroller}
id="news-panel"
class="body"
role="tabpanel"
aria-labelledby="news-tab-{tab}"
aria-busy={notes.loading}
tabindex="0"
>
{#if notes.loading || (status === null && !notes.error)}
<div class="skeleton" aria-hidden="true">
<span style:width="62%" class="line title"></span>
<span style:width="38%" class="line"></span>
<span style:width="30%" class="line gap"></span>
<span style:width="92%" class="line"></span>
<span style:width="84%" class="line"></span>
<span style:width="88%" class="line"></span>
<span style:width="26%" class="line gap"></span>
<span style:width="90%" class="line"></span>
<span style:width="72%" class="line"></span>
</div>
<span class="sr-only">Loading notes…</span>
{:else if notes.error}
<div class="empty">
<Icon name={status?.online ? "alert" : "globe"} size={20} />
<p class="empty-title">{status?.online ? "Notes unavailable" : "No notes offline"}</p>
<p class="empty-body">
{status?.online
? "The DHP didn't return patch notes. Try again in a moment."
: "Patch notes are cached with each install. Connect to your DHP to read them."}
</p>
<p class="empty-detail mono selectable">{notes.error}</p>
</div>
{:else if rendered}
{#if tab === "notes"}
{#if rendered.notes.trim()}
<div class="md selectable">{@html rendered.notes}</div>
{:else}
<div class="empty">
<p class="empty-title">No patch notes</p>
<p class="empty-body">Nothing was published for this version.</p>
</div>
{/if}
{:else if rendered.roadmap}
<div class="md selectable">{@html rendered.roadmap}</div>
{:else}
<div class="empty">
<p class="empty-title">No roadmap yet</p>
<p class="empty-body">Your DHP hasn't published a roadmap with these notes.</p>
</div>
{/if}
{/if}
</div>
<footer class="foot">
{#if notes.fromCache && notes.markdown !== null}
<Icon name="drive" size={14} /> Cached copy, read offline
{:else if status && !status.online}
<Icon name="globe" size={14} /> Offline
{:else if status}
<Icon name="globe" size={14} /> From {launcher.siteName}
{/if}
</footer>
</section>
<style>
.news {
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
}
.head {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: var(--space-3);
padding: 0 var(--space-5);
border-bottom: 1px solid var(--line);
}
.tabs {
display: flex;
gap: var(--space-5);
}
.tab {
position: relative;
height: 46px;
padding: 0 2px;
border: 0;
background: none;
color: var(--muted);
font-family: var(--font-display);
font-size: var(--text-md);
font-weight: 500;
letter-spacing: 0.02em;
white-space: nowrap;
transition: color var(--dur-fast) var(--ease);
}
.tab:hover {
color: var(--ink-2);
}
.tab.active {
color: var(--ink);
}
.tab::after {
content: "";
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
background: var(--accent);
transform: scaleX(0);
transition: transform var(--dur) var(--ease);
}
.tab.active::after {
transform: scaleX(1);
}
.tab:focus-visible {
outline-offset: -2px;
}
.version {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--ink-2);
font-size: var(--text-xs);
}
.tag {
padding: 1px 6px;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
color: var(--muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tag.update {
border-color: var(--accent-line);
background: var(--accent-soft);
color: var(--accent-hover);
}
.body {
flex: 1;
min-height: 0;
padding: var(--space-5) var(--space-5) var(--space-6);
overflow-y: auto;
scrollbar-gutter: stable;
}
.body:focus-visible {
outline-offset: -2px;
}
.foot {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 34px;
padding: 0 var(--space-5);
border-top: 1px solid var(--line);
color: var(--muted);
font-size: var(--text-xs);
}
/* Loading */
.skeleton {
display: grid;
gap: 10px;
}
.line {
display: block;
height: 10px;
background: linear-gradient(90deg, var(--surface-2), var(--line), var(--surface-2));
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
}
.line.title {
height: 20px;
margin-bottom: 4px;
}
.line.gap {
margin-top: 14px;
}
@keyframes shimmer {
to {
background-position: -200% 0;
}
}
/* Empty / error */
.empty {
display: grid;
justify-items: start;
gap: 6px;
padding-top: var(--space-2);
color: var(--muted);
}
.empty-title {
margin-top: 6px;
color: var(--ink-2);
font-family: var(--font-display);
font-size: var(--text-lg);
font-weight: 500;
}
.empty-body {
max-width: 34ch;
font-size: var(--text-sm);
}
.empty-detail {
margin-top: var(--space-2);
padding: var(--space-2) 10px;
border-left: 1px solid var(--line-strong);
color: var(--muted);
font-size: var(--text-xs);
}
/* Rendered (sanitized) Markdown */
.md {
color: var(--ink-2);
font-size: var(--text-md);
line-height: 1.62;
overflow-wrap: anywhere;
}
.md > :global(:first-child) {
margin-top: 0;
}
.md :global(h1) {
margin: 0 0 4px;
color: var(--ink);
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
line-height: 1.25;
}
.md :global(h1 + p) {
color: var(--muted);
font-size: var(--text-sm);
}
.md :global(h2) {
display: flex;
align-items: center;
gap: 10px;
margin: 26px 0 10px;
color: var(--muted);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.md :global(h2)::after {
content: "";
flex: 1;
height: 1px;
background: var(--line);
}
.md :global(h3),
.md :global(h4) {
margin: 18px 0 6px;
color: var(--ink);
font-family: var(--font-display);
font-size: 15px;
font-weight: 600;
}
.md :global(p) {
margin: 0 0 10px;
}
.md :global(ul),
.md :global(ol) {
margin: 0 0 10px;
padding-left: 18px;
}
.md :global(ul) {
list-style: none;
padding-left: 16px;
}
.md :global(li) {
position: relative;
margin: 5px 0;
}
.md :global(ul > li)::before {
content: "";
position: absolute;
top: 0.78em;
left: -16px;
width: 6px;
height: 1px;
background: var(--accent);
}
.md :global(strong),
.md :global(b) {
color: var(--ink);
font-weight: 600;
}
.md :global(code) {
padding: 1px 5px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
color: var(--ink);
font-family: var(--font-mono);
font-size: 12.5px;
}
.md :global(pre) {
margin: 0 0 12px;
padding: 12px;
overflow-x: auto;
border: 1px solid var(--line);
background: var(--bg);
}
.md :global(pre code) {
padding: 0;
border: 0;
background: none;
}
.md :global(blockquote) {
margin: 14px 0;
padding: 8px 12px;
border-left: 2px solid var(--accent);
background: var(--accent-soft);
color: var(--ink-2);
font-size: var(--text-sm);
}
.md :global(blockquote p:last-child) {
margin-bottom: 0;
}
.md :global(a) {
color: var(--accent-hover);
text-decoration: underline;
text-decoration-color: var(--accent-line);
text-underline-offset: 3px;
cursor: pointer;
}
.md :global(hr) {
margin: 20px 0;
border: 0;
border-top: 1px solid var(--line);
}
.md :global(table) {
width: 100%;
margin: 0 0 12px;
border-collapse: collapse;
font-size: var(--text-sm);
}
.md :global(th),
.md :global(td) {
padding: 6px 8px;
border-bottom: 1px solid var(--line);
text-align: left;
}
.md :global(th) {
color: var(--muted);
font-weight: 500;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
interface Props {
/** 0..1, or null for indeterminate. */
value: number | null;
label: string;
valueText?: string;
}
let { value, label, valueText }: Props = $props();
const percent = $derived(value === null ? null : Math.round(Math.min(1, Math.max(0, value)) * 1000) / 10);
</script>
<div
class="bar"
class:indeterminate={percent === null}
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent ?? undefined}
aria-valuetext={valueText}
>
{#if percent === null}
<span class="sweep"></span>
{:else}
<span class="fill" style:width="{percent}%"></span>
{/if}
</div>
<style>
.bar {
position: relative;
height: 4px;
overflow: hidden;
background: var(--line);
}
.fill {
position: absolute;
inset: 0 auto 0 0;
background: var(--accent);
box-shadow: 0 0 14px rgba(91, 140, 255, 0.6);
transition: width var(--dur) linear;
}
.fill::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2px;
background: #d6e2ff;
}
.sweep {
position: absolute;
top: 0;
bottom: 0;
left: -35%;
width: 35%;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
animation: sweep 1.8s var(--ease) infinite;
}
@keyframes sweep {
to {
left: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.sweep {
left: 0;
width: 100%;
opacity: 0.5;
background: repeating-linear-gradient(90deg, var(--accent) 0 10px, transparent 10px 18px);
}
}
</style>
+443
View File
@@ -0,0 +1,443 @@
<script lang="ts">
import { untrack } from "svelte";
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
import type { Settings } from "../lib/types";
interface Props {
open: boolean;
}
let { open = $bindable(false) }: Props = $props();
const EMPTY: Settings = { site_url: "", mcp_url: "", install_dir: "" };
let dialog: HTMLDialogElement | undefined = $state();
let draft = $state<Settings>({ ...EMPTY });
let saved = $state(false);
let saveError = $state<string | null>(null);
/** Sign out forgets the operator on this PC, so it takes a second, explicit click. */
let confirmSignOut = $state(false);
const s = $derived(launcher.status);
const current = $derived(launcher.settings);
const locked = $derived(launcher.locked);
const saving = $derived(launcher.busy === "settings");
const busy = $derived(launcher.busy !== null);
const trimmed = $derived<Settings>({
site_url: draft.site_url.trim(),
mcp_url: draft.mcp_url.trim(),
install_dir: draft.install_dir.trim(),
});
const dirty = $derived(
current !== null &&
(trimmed.site_url !== current.site_url ||
trimmed.mcp_url !== current.mcp_url ||
trimmed.install_dir !== current.install_dir),
);
const siteError = $derived(urlProblem(trimmed.site_url));
const mcpError = $derived(urlProblem(trimmed.mcp_url));
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null);
function urlProblem(value: string): string | null {
if (!value) return "Required.";
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? null : "Use an http:// or https:// address.";
} catch {
return "Enter a full address, including http:// or https://.";
}
}
function resetDraft() {
draft = current ? { ...current } : { ...EMPTY };
saved = false;
saveError = null;
confirmSignOut = false;
}
// Keep the native modal in sync with `open`, returning focus to whatever opened it.
let opener: HTMLElement | null = null;
$effect(() => {
const el = dialog;
if (!el) return;
if (open && !el.open) {
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
untrack(resetDraft);
el.showModal();
} else if (!open) {
if (el.open) el.close();
if (opener?.isConnected) opener.focus();
opener = null;
}
});
// Settings may arrive after the drawer was opened.
$effect(() => {
if (open && current && untrack(() => !draft.site_url && !draft.mcp_url && !draft.install_dir)) {
untrack(resetDraft);
}
});
$effect(() => {
const el = dialog;
if (!el) return;
// Clicks on the ::backdrop target the dialog element itself.
const onClick = (e: MouseEvent) => {
if (e.target === el) open = false;
};
// The native cancel-on-Escape is skipped for some synthetic key events; be explicit.
const onKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
open = false;
}
};
el.addEventListener("click", onClick);
el.addEventListener("keydown", onKeydown);
return () => {
el.removeEventListener("click", onClick);
el.removeEventListener("keydown", onKeydown);
};
});
async function save(event: SubmitEvent) {
event.preventDefault();
if (!dirty || invalid || locked) return;
const ok = await launcher.saveSettings({ ...trimmed });
if (ok) {
resetDraft();
saved = true;
} else {
saveError = launcher.notice;
}
}
/** Keyboard focus for a control that replaces the one the operator just used. */
function focusOnMount(node: HTMLElement) {
node.focus();
}
async function signOutAndForget() {
confirmSignOut = false;
await launcher.signOut();
// The button that had focus is gone; put focus back inside the drawer so Escape still closes it.
dialog?.querySelector<HTMLButtonElement>("button:not([disabled])")?.focus();
}
async function browse() {
const path = await launcher.pickFolder();
if (path) draft.install_dir = path;
}
function installFromDrive() {
open = false;
void launcher.installFromDrive();
}
function signIn() {
open = false;
void launcher.signIn();
}
</script>
<dialog bind:this={dialog} class="drawer" aria-labelledby="settings-title" onclose={() => (open = false)}>
<form class="panel" onsubmit={save} novalidate>
<header class="head">
<h2 id="settings-title">Settings</h2>
<button type="button" class="icon-btn" aria-label="Close settings" onclick={() => (open = false)}>
<Icon name="close" />
</button>
</header>
<div class="content">
{#if locked}
<p class="lock" role="note">
<Icon name="alert" size={14} />
Settings are locked while ODIAN is signing in, installing or running.
</p>
{/if}
<fieldset class="group" disabled={locked || saving}>
<legend class="eyebrow">DHP</legend>
<div class="field">
<label class="field-label" for="set-site-url">DHP URL</label>
<input
id="set-site-url"
class="input"
type="url"
spellcheck="false"
autocomplete="off"
bind:value={draft.site_url}
aria-invalid={dirty && siteError !== null}
aria-describedby="set-site-url-help"
/>
<span id="set-site-url-help" class={dirty && siteError ? "field-error" : "field-help"}>
{dirty && siteError ? siteError : "Update service of your DHP."}
</span>
</div>
<div class="field">
<label class="field-label" for="set-mcp-url">MCP URL</label>
<input
id="set-mcp-url"
class="input"
type="url"
spellcheck="false"
autocomplete="off"
bind:value={draft.mcp_url}
aria-invalid={dirty && mcpError !== null}
aria-describedby="set-mcp-url-help"
/>
<span id="set-mcp-url-help" class={dirty && mcpError ? "field-error" : "field-help"}>
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in."}
</span>
</div>
</fieldset>
<fieldset class="group" disabled={locked || saving}>
<legend class="eyebrow">Installation</legend>
<div class="field">
<label class="field-label" for="set-install-dir">Install folder</label>
<div class="row">
<input
id="set-install-dir"
class="input"
type="text"
spellcheck="false"
autocomplete="off"
bind:value={draft.install_dir}
aria-invalid={dirty && dirError !== null}
aria-describedby="set-install-dir-help"
/>
<button type="button" class="btn" onclick={browse}>
<Icon name="folder" size={14} /> Browse
</button>
</div>
<span id="set-install-dir-help" class={dirty && dirError ? "field-error" : "field-help"}>
{dirty && dirError ? dirError : "Application, database snapshot and map tiles live here."}
</span>
</div>
</fieldset>
<section class="group" aria-labelledby="set-drive-title">
<h3 id="set-drive-title" class="eyebrow">Install from drive</h3>
<p class="text">Install or update from an ODIAN SSD without reaching the DHP. Pick the drive's root folder.</p>
<div>
<button type="button" class="btn" disabled={locked || busy} onclick={installFromDrive}>
<Icon name="drive" size={14} /> Install from drive…
</button>
</div>
</section>
<section class="group" aria-labelledby="set-account-title">
<h3 id="set-account-title" class="eyebrow">Account</h3>
{#if s?.signed_in && s.operator}
<p class="text">
Signed in as <strong>{s.operator}</strong> on {launcher.siteName}. This PC remembers you until you sign out.
</p>
{#if confirmSignOut}
<p class="text" role="alert" style="color: var(--warn)">
Signing out makes this PC forget <strong>{s.operator}</strong>: ODIAN won't launch offline until someone
signs in again while connected to the DHP.
</p>
<div style="display: flex; flex-wrap: wrap; gap: 8px">
<button type="button" class="btn btn-danger" disabled={locked || busy} onclick={signOutAndForget}>
<Icon name="signout" size={14} /> Sign out and forget me
</button>
<!-- The safe choice takes focus, so Enter right after "Sign out" keeps the operator signed in. -->
<button type="button" class="btn" use:focusOnMount onclick={() => (confirmSignOut = false)}>
Keep me signed in
</button>
</div>
{:else}
<div>
<button type="button" class="btn btn-danger" disabled={locked || busy} onclick={() => (confirmSignOut = true)}>
<Icon name="signout" size={14} /> Sign out
</button>
</div>
{/if}
{:else}
<p class="text">
{#if s?.operator}
Not signed in. Last operator: <strong>{s.operator}</strong>{s.online ? "" : " (offline)"}.
{:else}
Not signed in.
{/if}
{#if s && !s.online}Signing in needs a connection to your DHP.{/if}
</p>
{#if s?.online}
<div>
<button type="button" class="btn" disabled={locked || busy} onclick={signIn}>
<Icon name="key" size={14} /> Sign in
</button>
</div>
{/if}
{/if}
</section>
</div>
<footer class="foot">
<span class="result" role="status">
{#if saveError}
<span class="err">{saveError}</span>
{:else if saved}
<Icon name="check" size={14} /> Saved
{/if}
</span>
<button type="button" class="btn" disabled={!dirty || saving} onclick={resetDraft}>Revert</button>
<button type="submit" class="btn btn-accent" disabled={!dirty || invalid || locked || saving}>
{saving ? "Saving…" : "Save changes"}
</button>
</footer>
</form>
</dialog>
<style>
.drawer {
position: fixed;
inset: 0 0 0 auto;
width: min(440px, 100vw);
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0;
padding: 0;
border: 0;
border-left: 1px solid var(--line-strong);
background: var(--glass-strong);
color: var(--ink);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
opacity: 0;
transform: translateX(24px);
transition:
opacity var(--dur-slow) var(--ease),
transform var(--dur-slow) var(--ease),
overlay var(--dur-slow) allow-discrete,
display var(--dur-slow) allow-discrete;
}
.drawer[open] {
opacity: 1;
transform: none;
}
.drawer::backdrop {
background: rgba(5, 5, 7, 0);
transition:
background-color var(--dur-slow) var(--ease),
overlay var(--dur-slow) allow-discrete,
display var(--dur-slow) allow-discrete;
}
.drawer[open]::backdrop {
background: rgba(5, 5, 7, 0.6);
}
@starting-style {
.drawer[open] {
opacity: 0;
transform: translateX(24px);
}
.drawer[open]::backdrop {
background: rgba(5, 5, 7, 0);
}
}
.panel {
display: flex;
flex-direction: column;
height: 100%;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 12px 0 24px;
border-bottom: 1px solid var(--line);
}
.head h2 {
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
letter-spacing: 0.02em;
}
.content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.lock {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 16px 24px 0;
padding: 8px 12px;
border: 1px solid rgba(245, 180, 84, 0.3);
border-radius: var(--radius);
background: var(--warn-soft);
color: var(--warn);
font-size: var(--text-xs);
}
.group {
display: grid;
gap: 14px;
min-width: 0;
margin: 0;
padding: 22px 24px 24px;
border: 0;
border-bottom: 1px solid var(--line);
}
.group legend {
float: left;
width: 100%;
margin: 0 0 14px;
padding: 0;
}
.group legend + * {
clear: both;
}
.row {
display: flex;
gap: var(--space-2);
}
.text {
color: var(--muted);
font-size: var(--text-sm);
line-height: 1.55;
}
.text strong {
color: var(--ink);
font-weight: 500;
}
.foot {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 12px 24px;
border-top: 1px solid var(--line);
background: rgba(9, 9, 11, 0.6);
}
.result {
display: flex;
flex: 1;
align-items: center;
gap: 6px;
min-width: 0;
color: var(--ok);
font-size: var(--text-xs);
}
.err {
overflow: hidden;
color: var(--danger);
white-space: nowrap;
text-overflow: ellipsis;
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import StateDot, { type DotTone } from "./StateDot.svelte";
import { COMPONENT_DETAIL, COMPONENT_LABEL, STACK_STATE_LABEL } from "../lib/format";
import { STACK_ORDER, type StackMap } from "../lib/launcher.svelte";
import type { Phase, StackComponent, StackState } from "../lib/types";
interface Props {
stack: StackMap;
phase: Phase;
}
let { stack, phase }: Props = $props();
type Shown = StackState | "waiting";
const TONE: Record<Shown, DotTone> = {
waiting: "idle",
starting: "warn",
ready: "ok",
stopped: "idle",
failed: "danger",
};
function shownState(component: StackComponent): Shown {
const event = stack[component];
if (event) return event.state;
// No event seen yet (e.g. the UI reloaded while ODIAN was already open).
return phase === "running" ? "ready" : "waiting";
}
</script>
<ol class="stack" aria-label="Local stack">
{#each STACK_ORDER as component, index (component)}
{@const state = shownState(component)}
{@const message = stack[component]?.message}
<li class="row {state}">
<span class="index mono" aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
<span class="dot-cell"><StateDot tone={TONE[state]} pulse={state === "starting"} /></span>
<span class="name">
{COMPONENT_LABEL[component]}
<span class="detail mono">{COMPONENT_DETAIL[component]}</span>
</span>
<span class="state mono">{STACK_STATE_LABEL[state]}</span>
{#if message}
<span class="message mono selectable" title={message}>{message}</span>
{/if}
</li>
{/each}
</ol>
<style>
.stack {
display: grid;
margin: 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--line);
}
.row {
position: relative;
display: grid;
grid-template-columns: 22px 20px minmax(0, 1fr) auto;
align-items: center;
column-gap: 8px;
min-height: 44px;
padding: 8px 0;
border-bottom: 1px solid var(--line);
}
.index {
color: var(--muted);
font-size: var(--text-xs);
}
.dot-cell {
display: grid;
place-items: center;
}
.name {
overflow: hidden;
color: var(--ink-2);
font-size: var(--text-sm);
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
}
.detail {
margin-left: 6px;
color: var(--muted);
font-size: var(--text-xs);
font-weight: 400;
}
.state {
color: var(--muted);
font-size: var(--text-xs);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.message {
grid-column: 3 / 5;
overflow: hidden;
color: var(--muted);
font-size: var(--text-xs);
white-space: nowrap;
text-overflow: ellipsis;
}
.ready .state {
color: var(--ok);
}
.starting .state {
color: var(--warn);
}
.failed .state,
.failed .message {
color: var(--danger);
}
.ready .name,
.starting .name {
color: var(--ink);
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts" module>
export type DotTone = "ok" | "warn" | "danger" | "accent" | "idle";
</script>
<script lang="ts">
interface Props {
tone: DotTone;
pulse?: boolean;
}
let { tone, pulse = false }: Props = $props();
</script>
<span class="dot {tone}" class:pulse aria-hidden="true"></span>
<style>
.dot {
display: inline-block;
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--faint);
transition:
background-color var(--dur) var(--ease),
box-shadow var(--dur) var(--ease);
}
.ok {
background: var(--ok);
box-shadow: 0 0 0 3px var(--ok-soft);
}
.warn {
background: var(--warn);
box-shadow: 0 0 0 3px var(--warn-soft);
}
.danger {
background: var(--danger);
box-shadow: 0 0 0 3px var(--danger-soft);
}
.accent {
background: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.idle {
background: transparent;
box-shadow: inset 0 0 0 1px var(--muted);
}
.pulse {
animation: pulse 1.4s var(--ease) infinite;
}
</style>
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
import Icon from "./Icon.svelte";
import MockControls from "./MockControls.svelte";
import { launcher } from "../lib/launcher.svelte";
const s = $derived(launcher.status);
</script>
<footer class="statusbar">
<button
class="toggle"
class:open={launcher.activityOpen}
aria-expanded={launcher.activityOpen}
aria-controls="activity-panel"
onclick={() => launcher.setActivityOpen(!launcher.activityOpen)}
>
<Icon name="activity" size={14} />
Activity
{#if launcher.unseen > 0}
<span class="count mono" class:error={launcher.unseenError} aria-hidden="true">
{launcher.unseen > 99 ? "99+" : launcher.unseen}
</span>
<span class="sr-only">, {launcher.unseen} new</span>
{/if}
</button>
{#if s}
<span class="item">
<Icon name="user" size={14} />
{#if s.signed_in && s.operator}
<span>Signed in as <strong>{s.operator}</strong></span>
{:else if s.operator}
<span>Last operator: <strong>{s.operator}</strong>{s.online ? "" : " (offline)"}</span>
{:else}
<span>Not signed in</span>
{/if}
</span>
<span class="item">
<span>Installed</span>
<strong class="mono">{s.installed ?? "none"}</strong>
</span>
<span class="item dir" title={s.install_dir}>
<Icon name="folder" size={14} />
<span class="mono selectable path">{s.install_dir}</span>
</span>
{/if}
<span class="grow"></span>
{#if launcher.mock}
<MockControls mock={launcher.mock} />
{/if}
</footer>
<style>
.statusbar {
position: relative;
z-index: 10;
display: flex;
align-items: stretch;
min-width: 0;
height: var(--statusbar-h);
padding-right: var(--space-4);
border-top: 1px solid var(--line);
background: rgba(9, 9, 11, 0.88);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
color: var(--muted);
font-size: var(--text-xs);
}
.toggle {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-4);
border: 0;
border-right: 1px solid var(--line);
background: transparent;
color: var(--ink-2);
font-size: var(--text-xs);
font-weight: 500;
transition:
background-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.toggle:hover,
.toggle.open {
background: var(--surface-2);
color: var(--ink);
}
.toggle:focus-visible {
outline-offset: -2px;
}
.count {
min-width: 18px;
padding: 0 5px;
border-radius: var(--radius);
background: var(--accent-soft);
color: var(--accent-hover);
font-size: var(--text-xs);
line-height: 18px;
text-align: center;
}
.count.error {
background: var(--danger-soft);
color: var(--danger);
}
.item {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
padding: 0 var(--space-4);
border-right: 1px solid var(--line);
white-space: nowrap;
}
.item strong {
color: var(--ink-2);
font-weight: 500;
}
.dir {
flex-shrink: 1;
}
.path {
overflow: hidden;
color: var(--ink-2);
text-overflow: ellipsis;
}
.grow {
flex: 1;
}
</style>
+260
View File
@@ -0,0 +1,260 @@
<script lang="ts">
import { onMount } from "svelte";
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
interface Props {
onOpenSettings: () => void;
}
let { onOpenSettings }: Props = $props();
let maximized = $state(false);
const status = $derived(launcher.status);
const checking = $derived(launcher.refreshing || status?.phase === "checking");
function windowAction(task: () => Promise<void>) {
return () => {
task().catch((e) => console.error("window control failed", e));
};
}
onMount(() => {
let disposed = false;
let unlisten: (() => void) | undefined;
const sync = () => {
launcher.win
.isMaximized()
.then((value) => (maximized = value))
.catch(() => {});
};
sync();
launcher.win
.onResized(sync)
.then((u) => (disposed ? u() : (unlisten = u)))
.catch(() => {});
return () => {
disposed = true;
unlisten?.();
};
});
</script>
<header class="titlebar" data-tauri-drag-region>
<div class="brand" data-tauri-drag-region>
<svg class="mark" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" data-tauri-drag-region>
<rect x="0.5" y="0.5" width="17" height="17" fill="none" stroke="currentColor" stroke-opacity="0.45" />
<path d="M9 3.5L14.5 9 9 14.5 3.5 9z" fill="none" stroke="currentColor" stroke-width="1.5" />
<rect x="8" y="8" width="2" height="2" fill="currentColor" />
</svg>
<span class="wordmark" data-tauri-drag-region>ODIAN</span>
<span class="product" data-tauri-drag-region>Launcher</span>
</div>
{#if status}
<span class="divider" aria-hidden="true" data-tauri-drag-region></span>
<div class="site" data-tauri-drag-region>
<span class="site-name" title={status.site.url} data-tauri-drag-region>{launcher.siteName}</span>
<span
class="badge"
class:online={status.online}
class:checking
role="status"
aria-busy={checking}
data-tauri-drag-region
>
<span class="badge-dot" aria-hidden="true"></span>
{status.online ? "Online" : "Offline"}
</span>
<button
class="icon-btn refresh"
aria-label="Check the connection to your DHP"
title="Check connection"
disabled={checking}
onclick={() => launcher.refresh()}
>
<span class="refresh-icon" class:spinning={checking}><Icon name="refresh" size={14} /></span>
</button>
</div>
{/if}
<div class="drag" data-tauri-drag-region></div>
<button class="icon-btn settings" aria-label="Settings" title="Settings" aria-haspopup="dialog" onclick={onOpenSettings}>
<Icon name="settings" />
</button>
<div class="window-controls">
<button class="win" aria-label="Minimize" title="Minimize" onclick={windowAction(() => launcher.win.minimize())}>
<Icon name="minimize" size={14} strokeWidth={1.25} />
</button>
<button
class="win"
aria-label={maximized ? "Restore" : "Maximize"}
title={maximized ? "Restore" : "Maximize"}
onclick={windowAction(() => launcher.win.toggleMaximize())}
>
<Icon name={maximized ? "restore" : "maximize"} size={14} strokeWidth={1.25} />
</button>
<button class="win close" aria-label="Close" title="Close" onclick={windowAction(() => launcher.win.close())}>
<Icon name="close" size={14} strokeWidth={1.25} />
</button>
</div>
</header>
<style>
.titlebar {
position: relative;
z-index: 20;
display: flex;
align-items: center;
height: var(--titlebar-h);
padding-left: var(--space-4);
border-bottom: 1px solid rgba(35, 35, 38, 0.8);
background: linear-gradient(180deg, rgba(9, 9, 11, 0.9), rgba(9, 9, 11, 0.6));
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
height: 100%;
}
.mark {
color: var(--accent);
}
.wordmark {
margin-right: calc(var(--wordmark-tracking) * -1);
color: var(--ink);
font-family: var(--font-display);
font-size: 15px;
font-weight: 600;
letter-spacing: var(--wordmark-tracking);
}
.product {
margin-left: 6px;
color: var(--muted);
font-size: var(--text-xs);
letter-spacing: 0.04em;
}
.divider {
width: 1px;
height: 16px;
margin: 0 var(--space-4);
background: var(--line-strong);
}
.site {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
height: 100%;
font-size: var(--text-sm);
}
.site-name {
max-width: 280px;
overflow: hidden;
color: var(--ink-2);
white-space: nowrap;
text-overflow: ellipsis;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 22px;
padding: 0 8px;
border: 1px solid rgba(245, 180, 84, 0.3);
border-radius: var(--radius);
background: var(--warn-soft);
color: var(--warn);
font-family: var(--font-mono);
font-size: var(--text-xs);
letter-spacing: 0.08em;
text-transform: uppercase;
transition:
color var(--dur) var(--ease),
background-color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.badge.online {
border-color: rgba(134, 239, 172, 0.28);
background: var(--ok-soft);
color: var(--ok);
}
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.badge.checking .badge-dot {
animation: pulse 1.1s var(--ease) infinite;
}
.refresh {
width: 28px;
height: 28px;
}
.refresh:disabled {
opacity: 1;
}
.refresh-icon {
display: grid;
}
.spinning {
animation: spin 0.9s linear infinite;
}
.drag {
flex: 1;
align-self: stretch;
}
.settings {
margin-right: var(--space-2);
}
.window-controls {
display: flex;
align-self: stretch;
border-left: 1px solid var(--line);
}
.win {
display: grid;
place-items: center;
width: 46px;
height: 100%;
padding: 0;
border: 0;
background: transparent;
color: var(--muted);
transition:
background-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.win:hover {
background: var(--surface-2);
color: var(--ink);
}
.win.close:hover {
background: var(--danger);
color: var(--accent-ink);
}
.win:focus-visible {
outline-offset: -2px;
}
</style>
+65
View File
@@ -0,0 +1,65 @@
// The ONLY module that touches Tauri. Everything else talks to `backend`.
// Inside the Tauri webview this is a thin typed wrapper over invoke/listen
// (docs/CONTRACT.md); in a plain browser it is the timer-driven mock.
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { createMockBackend } from "./mock";
import type {
Backend,
InstallProgress,
LogEvent,
Settings,
SignInResult,
StackEvent,
Status,
Unlisten,
} from "./types";
export const IN_TAURI = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
function subscribe<T>(event: string, handler: (payload: T) => void): Promise<Unlisten> {
return listen<T>(event, (e) => handler(e.payload));
}
function createTauriBackend(): Backend {
const win = getCurrentWindow();
return {
kind: "tauri",
mock: null,
window: {
minimize: () => win.minimize(),
toggleMaximize: () => win.toggleMaximize(),
close: () => win.close(),
isMaximized: () => win.isMaximized(),
onResized: (handler) => win.onResized(() => handler()),
},
getStatus: () => invoke<Status>("get_status"),
refresh: () => invoke<Status>("refresh"),
signIn: () => invoke<SignInResult>("sign_in"),
cancelSignIn: () => invoke<void>("cancel_sign_in"),
signOut: () => invoke<Status>("sign_out"),
getNotes: (version) => invoke<string>("get_notes", version === undefined ? {} : { version }),
openExternal: (url) => invoke<void>("open_external", { url }),
getStack: () => invoke<StackEvent[]>("get_stack"),
startInstall: () => invoke<void>("start_install"),
startInstallFromDrive: (path) => invoke<void>("start_install_from_drive", { path }),
cancelInstall: () => invoke<void>("cancel_install"),
launch: () => invoke<void>("launch"),
stop: () => invoke<void>("stop"),
getSettings: () => invoke<Settings>("get_settings"),
setSettings: (settings) => invoke<Status>("set_settings", { settings }),
pickFolder: () => invoke<string | null>("pick_folder"),
onStatus: (handler) => subscribe<Status>("status", handler),
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
onStack: (handler) => subscribe<StackEvent>("stack", handler),
onLog: (handler) => subscribe<LogEvent>("log", handler),
};
}
export const backend: Backend = IN_TAURI
? createTauriBackend()
: createMockBackend(new URLSearchParams(window.location.search));
+94
View File
@@ -0,0 +1,94 @@
import type { InstallStage, StackComponent, StackState } from "./types";
const UNITS = ["B", "KB", "MB", "GB", "TB"];
/** Binary sizes with Windows-style labels: 412 MB, 18.6 GB. */
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < UNITS.length - 1) {
value /= 1024;
unit++;
}
const digits = unit === 0 || value >= 100 ? 0 : 1;
return `${value.toFixed(digits)} ${UNITS[unit]}`;
}
export function formatSpeed(bytesPerSec: number): string {
return `${formatBytes(bytesPerSec)}/s`;
}
/** 45 s · 4 min 12 s · 32 min · 1 h 03 min */
export function formatDuration(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return "—";
const s = Math.round(totalSeconds);
if (s < 60) return `${s} s`;
const minutes = Math.floor(s / 60);
if (minutes < 10) return `${minutes} min ${String(s % 60).padStart(2, "0")} s`;
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
return `${hours} h ${String(minutes % 60).padStart(2, "0")} min`;
}
export function formatCount(n: number): string {
return n.toLocaleString("en-US");
}
/** Unix seconds → 24 h wall clock. */
export function formatClock(unixSeconds: number): string {
const d = new Date(unixSeconds * 1000);
return [d.getHours(), d.getMinutes(), d.getSeconds()]
.map((n) => String(n).padStart(2, "0"))
.join(":");
}
export function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
/** Tauri rejects with a string; be forgiving about anything else. */
export function errorText(error: unknown): string {
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
if (error && typeof error === "object" && "message" in error) {
return String((error as { message: unknown }).message);
}
return "Unexpected error";
}
export const STAGE_LABEL: Record<InstallStage, string> = {
manifest: "Reading manifest",
app: "Application",
db: "Database snapshot",
src: "Source data",
tiles: "Map tiles",
restore: "Restoring database",
finalize: "Finalizing",
};
export const COMPONENT_LABEL: Record<StackComponent, string> = {
postgres: "Database",
api: "Engine API",
tiles: "Tile server",
odian: "ODIAN",
};
export const COMPONENT_DETAIL: Record<StackComponent, string> = {
postgres: "PostgreSQL",
api: "odian-engine-api",
tiles: "Local tiles",
odian: "Odian.exe",
};
export const STACK_STATE_LABEL: Record<StackState | "waiting", string> = {
waiting: "Waiting",
starting: "Starting",
ready: "Ready",
stopped: "Stopped",
failed: "Failed",
};
+394
View File
@@ -0,0 +1,394 @@
// App state as Svelte 5 runes. Mirrors backend Status/events and wraps every
// command so failures surface as a notice plus an activity entry.
import { backend } from "./backend";
import { errorText, hostOf } from "./format";
import { SIGN_IN_CANCELLED } from "./types";
import type {
InstallProgress,
LogEvent,
LogLevel,
Phase,
Settings,
StackComponent,
StackEvent,
Status,
Unlisten,
} from "./types";
export type ActionKind =
| "loading"
| "sign_in"
| "signing_in"
| "install"
| "offline_unavailable"
| "signed_out_offline"
| "installing"
| "restoring"
| "update"
| "launch"
| "stack"
| "error";
export const STACK_ORDER: readonly StackComponent[] = ["postgres", "api", "tiles", "odian"];
export type StackMap = Record<StackComponent, StackEvent | null>;
/** Which primary action the hub shows for a given status. */
export function deriveAction(s: Status | null): ActionKind {
if (!s) return "loading";
switch (s.phase) {
case "error":
return "error";
case "signing_in":
return "signing_in";
case "installing":
return "installing";
case "restoring":
return "restoring";
case "launching":
case "running":
return "stack";
}
// idle or checking
if (s.installed === null) {
if (!s.online) return "offline_unavailable";
return s.signed_in ? "install" : "sign_in";
}
if (s.update_required) return s.signed_in ? "update" : "sign_in";
// Launching runs ODIAN as the remembered operator; after Sign out there is none.
if (s.operator === null) return s.online ? "sign_in" : "signed_out_offline";
return "launch";
}
type LastOp = { kind: "install" } | { kind: "drive"; path: string } | { kind: "launch" };
interface NotesState {
key: string | null;
loading: boolean;
markdown: string | null;
error: string | null;
version: string | null;
fromCache: boolean;
}
const LOCKED_PHASES: ReadonlySet<Phase> = new Set([
"signing_in",
"installing",
"restoring",
"launching",
"running",
]);
const MAX_LOGS = 250;
const FOCUS_REFRESH_MS = 30_000;
const emptyStack = (): StackMap => ({ postgres: null, api: null, tiles: null, odian: null });
const nowSeconds = () => Math.floor(Date.now() / 1000);
class Launcher {
readonly kind = backend.kind;
readonly mock = backend.mock;
readonly win = backend.window;
status = $state<Status | null>(null);
settings = $state<Settings | null>(null);
progress = $state<InstallProgress | null>(null);
/** Smoothed bytes/sec for a steadier ETA. */
speed = $state(0);
stack = $state<StackMap>(emptyStack());
logs = $state<LogEvent[]>([]);
unseen = $state(0);
unseenError = $state(false);
activityOpen = $state(false);
/** Last command failure, shown next to the primary action. */
notice = $state<string | null>(null);
/** Name of the command in flight, if any. */
busy = $state<string | null>(null);
refreshing = $state(false);
stopping = $state(false);
notes = $state<NotesState>({
key: null,
loading: false,
markdown: null,
error: null,
version: null,
fromCache: false,
});
action: ActionKind = $derived(deriveAction(this.status));
/** Settings can't change while something is installing or running. */
locked = $derived(this.status ? LOCKED_PHASES.has(this.status.phase) : false);
siteName = $derived(this.status ? (this.status.site.name ?? hostOf(this.status.site.url)) : "");
notesVersion = $derived(
this.status ? (this.status.online ? (this.status.available ?? this.status.installed) : this.status.installed) : null,
);
notesKey = $derived(this.status ? `${this.status.online}|${this.notesVersion ?? ""}` : null);
eta = $derived.by(() => {
const p = this.progress;
if (!p || p.total_bytes <= 0 || this.speed <= 0) return null;
return Math.max(0, p.total_bytes - p.done_bytes) / this.speed;
});
private lastOp: LastOp | null = null;
private lastProbe = 0;
/** Subscribes to backend events and loads the first status. Returns a disposer. */
start(): () => void {
let disposed = false;
const unlisteners: Unlisten[] = [];
const keep = (p: Promise<Unlisten>) =>
p.then((unlisten) => {
if (disposed) unlisten();
else unlisteners.push(unlisten);
});
void (async () => {
try {
await Promise.all([
keep(backend.onStatus((s) => this.applyStatus(s))),
keep(backend.onInstallProgress((p) => this.applyProgress(p))),
keep(backend.onStack((e) => (this.stack[e.component] = e))),
keep(backend.onLog((l) => this.pushLog(l))),
]);
const [status, settings] = await Promise.all([backend.getStatus(), backend.getSettings()]);
if (disposed) return;
this.applyStatus(status);
this.settings = settings;
// A UI (re)loaded mid-launch redraws the stack from the backend's last known states.
for (const event of await backend.getStack()) this.stack[event.component] = event;
} catch (e) {
this.fail(e);
}
if (!disposed) void this.refresh();
})();
const onFocus = () => {
if (this.status?.phase === "idle" && Date.now() - this.lastProbe > FOCUS_REFRESH_MS) void this.refresh();
};
window.addEventListener("focus", onFocus);
return () => {
disposed = true;
unlisteners.forEach((u) => u());
window.removeEventListener("focus", onFocus);
};
}
// ── event reducers ──────────────────────────────────────────────────────
private applyStatus(next: Status) {
const prev = this.status;
if (next.phase === "launching" && prev?.phase !== "launching") this.stack = emptyStack();
if (next.phase !== "installing" && next.phase !== "restoring") {
this.progress = null;
this.speed = 0;
}
if (next.phase !== "running" && next.phase !== "launching") this.stopping = false;
if (prev && prev.phase !== next.phase && LOCKED_PHASES.has(next.phase)) this.notice = null;
this.status = next;
}
private applyProgress(p: InstallProgress) {
const prev = this.progress;
const sameStage = prev !== null && prev.stage_index === p.stage_index && prev.stage === p.stage;
if (p.bytes_per_sec <= 0) this.speed = 0;
else if (!sameStage || this.speed <= 0) this.speed = p.bytes_per_sec;
else this.speed = this.speed * 0.85 + p.bytes_per_sec * 0.15;
this.progress = p;
}
private pushLog(entry: LogEvent) {
this.logs = [entry, ...this.logs].slice(0, MAX_LOGS);
if (!this.activityOpen) {
this.unseen++;
if (entry.level === "error") this.unseenError = true;
}
}
private note(level: LogLevel, message: string) {
this.pushLog({ level, message, at: nowSeconds() });
}
private fail(error: unknown) {
const message = errorText(error);
this.notice = message;
this.note("error", message);
}
private async run(name: string, task: () => Promise<void>): Promise<boolean> {
if (this.busy) return false;
this.busy = name;
this.notice = null;
try {
await task();
return true;
} catch (e) {
this.fail(e);
return false;
} finally {
this.busy = null;
}
}
// ── commands ────────────────────────────────────────────────────────────
async refresh() {
if (this.refreshing) return;
this.refreshing = true;
this.lastProbe = Date.now();
try {
this.applyStatus(await backend.refresh());
} catch (e) {
this.fail(e);
} finally {
this.refreshing = false;
}
}
signIn() {
return this.run("sign_in", async () => {
try {
await backend.signIn();
} catch (e) {
// Cancelling is the operator's choice, not a failure to show.
if (errorText(e) !== SIGN_IN_CANCELLED) throw e;
this.note("info", SIGN_IN_CANCELLED);
}
this.applyStatus(await backend.getStatus());
});
}
/** Not wrapped in `run`: the sign-in it cancels is the command in flight. */
async cancelSignIn() {
try {
await backend.cancelSignIn();
} catch (e) {
this.fail(e);
}
}
signOut() {
return this.run("sign_out", async () => this.applyStatus(await backend.signOut()));
}
async openExternal(url: string) {
try {
await backend.openExternal(url);
} catch (e) {
this.fail(e);
}
}
install() {
return this.run("install", async () => {
this.lastOp = { kind: "install" };
await backend.startInstall();
});
}
async installFromDrive() {
const path = await this.pickFolder();
if (!path) return false;
return this.run("install", async () => {
this.lastOp = { kind: "drive", path };
await backend.startInstallFromDrive(path);
});
}
cancelInstall() {
return this.run("cancel", () => backend.cancelInstall());
}
launch() {
return this.run("launch", async () => {
this.lastOp = { kind: "launch" };
await backend.launch();
});
}
stop() {
return this.run("stop", async () => {
this.stopping = true;
try {
await backend.stop();
} catch (e) {
this.stopping = false;
throw e;
}
});
}
/** Repeats the failed operation; without history, infers it from status. */
retry() {
const op = this.lastOp;
const s = this.status;
if (op?.kind === "drive") {
return this.run("install", () => backend.startInstallFromDrive(op.path));
}
if (op?.kind === "install") return this.install();
if (op?.kind === "launch") return this.launch();
if (!s) return this.refresh();
if (s.installed === null || s.update_required) {
return s.online && s.signed_in ? this.install() : this.refresh();
}
return this.launch();
}
saveSettings(next: Settings) {
return this.run("settings", async () => {
this.applyStatus(await backend.setSettings(next));
this.settings = await backend.getSettings();
});
}
async pickFolder(): Promise<string | null> {
try {
return await backend.pickFolder();
} catch (e) {
this.fail(e);
return null;
}
}
async loadNotes() {
const key = this.notesKey;
const s = this.status;
if (key === null || !s) return;
if (key === this.notes.key && (this.notes.loading || this.notes.markdown !== null)) return;
const version = this.notesVersion ?? undefined;
this.notes = {
key,
loading: true,
markdown: null,
error: null,
version: version ?? null,
fromCache: !s.online,
};
try {
const markdown = await backend.getNotes(version);
if (this.notes.key === key) this.notes = { ...this.notes, loading: false, markdown };
} catch (e) {
if (this.notes.key === key) this.notes = { ...this.notes, loading: false, error: errorText(e) };
}
}
// ── activity panel ──────────────────────────────────────────────────────
setActivityOpen(open: boolean) {
this.activityOpen = open;
if (open) {
this.unseen = 0;
this.unseenError = false;
}
}
clearLogs() {
this.logs = [];
this.unseen = 0;
this.unseenError = false;
}
dismissNotice() {
this.notice = null;
}
}
export const launcher = new Launcher();
+84
View File
@@ -0,0 +1,84 @@
// Patch notes come from the site (or its cached copy) and are untrusted:
// Markdown is rendered with marked, then every byte of HTML goes through
// DOMPurify with a small allowlist before it reaches {@html}.
import { Marked, type Token, type TokensList } from "marked";
import DOMPurify from "dompurify";
const marked = new Marked({ gfm: true, breaks: false, async: false });
const ALLOWED_TAGS = [
"h1", "h2", "h3", "h4", "h5", "h6",
"p", "br", "hr", "blockquote",
"ul", "ol", "li",
"strong", "em", "b", "i", "del", "s", "code", "pre", "kbd", "sub", "sup",
"a",
"table", "thead", "tbody", "tr", "th", "td",
"input", // GFM task-list checkboxes (forced disabled below)
];
const ALLOWED_ATTR = ["href", "title", "align", "start", "type", "checked", "disabled"];
const SAFE_HREF = /^(https?:|mailto:|#)/i;
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
const href = node.getAttribute("href") ?? "";
if (!SAFE_HREF.test(href.trim())) node.removeAttribute("href");
node.removeAttribute("target");
node.setAttribute("rel", "noopener noreferrer");
if (href && !node.getAttribute("title")) node.setAttribute("title", href);
}
if (node.tagName === "INPUT") {
if (node.getAttribute("type") !== "checkbox") node.remove();
else node.setAttribute("disabled", "");
}
});
function sanitize(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
});
}
function render(tokens: Token[], links: TokensList["links"]): string {
const list = Object.assign(tokens, { links }) as TokensList;
return sanitize(marked.parser(list));
}
function isRoadmapHeading(token: Token): boolean {
return token.type === "heading" && token.depth === 2 && /^roadmap$/i.test(token.text.trim());
}
export interface RenderedNotes {
/** Everything except the Roadmap section. */
notes: string;
/** Contents under `## Roadmap` (heading excluded), or null when absent/empty. */
roadmap: string | null;
}
export function renderNotes(markdown: string): RenderedNotes {
const tokens = marked.lexer(markdown);
const notes: Token[] = [];
const roadmap: Token[] = [];
let inRoadmap = false;
let sawRoadmap = false;
for (const token of tokens) {
if (isRoadmapHeading(token)) {
inRoadmap = true;
sawRoadmap = true;
continue;
}
if (inRoadmap && token.type === "heading" && token.depth <= 2) inRoadmap = false;
(inRoadmap ? roadmap : notes).push(token);
}
const hasRoadmapContent = roadmap.some((t) => t.type !== "space");
return {
notes: render(notes, tokens.links),
roadmap: sawRoadmap && hasRoadmapContent ? render(roadmap, tokens.links) : null,
};
}
+75
View File
@@ -0,0 +1,75 @@
// Sample patch notes for the browser mock. They deliberately contain markup
// that must NOT survive sanitizing (a script, an event handler, a javascript:
// link): if any of it shows up or runs, the sanitizer is broken.
export type NotesVariant = "full" | "no-roadmap";
const PROBES = `
<script>alert("patch notes are untrusted")</script>
<img src="x" onerror="alert('patch notes are untrusted')">
`;
const ROADMAP = `
## Roadmap
### Next — 0.53
- Globally unique IDs across DHPs, the groundwork for merging offline edits.
- ODIAN assistant in the Chat panel, answering from the ontology.
- Pattern-of-life (Habits) summary available to the assistant by hex ID.
### Later
- Merge offline edits back into the DHP database, git-style.
- Search across entity documents from the map.
- NATO MIM governance workflow for exported data.
`;
const NOTES: Record<string, string> = {
"0.52.0": `
# ODIAN 0.52.0
Released 2026-09-12 · required update for all operators on this DHP.
## Highlights
- **Mission assignment** — target → aircraft tasking now proposes a ranked list. The pick you make is recorded with the solve.
- **Habits panel** — a pattern-of-life summary for each aircraft, one click from the track card.
- **Offline map tiles** — the dark basemap \`protomaps_20260423\` ships with the install, so panning and zooming never need the network.
## Improvements
- Track trails draw about 3× faster above 5,000 live contacts.
- NATO MIM export validates against the SHACL shapes before it writes anything.
- The Chat panel keeps its scroll position while new messages arrive.
- Install and update resume from the last verified file.
## Fixes
- Fixed a crash when a vessel report had no MMSI.
- Vessel positions no longer freeze after an AIS feed outage.
- The replay time slider no longer skips the final minute.
${PROBES}
## Known issues
- The first launch after an install can take up to 60 s while Postgres warms its cache.
> Report problems from **Help → Send diagnostics** inside ODIAN, or read the [operator guide](https://docs.example.invalid/odian/0.52) and [this link](javascript:alert(1)).
`,
"0.51.0": `
# ODIAN 0.51.0
Released 2026-08-04.
## Highlights
- **Airspace layers** — restricted and danger areas draw from the DHP's AIXM import.
- **Track history** — scrub up to 72 h of recorded positions per contact.
## Improvements
- Startup is 40% faster on machines with a local database.
- Layer panel remembers which groups you collapsed.
## Fixes
- Fixed tooltips lingering after the map was dragged.
- Fixed a rare deadlock when two operators edited the same annotation.
`,
};
export function mockNotes(version: string, variant: NotesVariant): string {
const body = NOTES[version] ?? `# ODIAN ${version}\n\nNo notes were published for this version.\n`;
return variant === "full" ? `${body}${ROADMAP}` : body;
}
+613
View File
@@ -0,0 +1,613 @@
// Browser-only stand-in for the Rust backend, so the hub can be designed with
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
//
// ?scenario=first-run | update-required | ready | offline-installed |
// offline-not-installed | installing | running | error
// &fail=sign_in | install | restore | launch | notes make that flow fail
// &speed=4 run timers 4x faster
// &notes=no-roadmap notes without a Roadmap
//
// Commands reject with a plain string, like Tauri does.
import { mockNotes, type NotesVariant } from "./mock-notes";
import {
MOCK_SCENARIOS,
SIGN_IN_CANCELLED,
type Backend,
type InstallProgress,
type InstallStage,
type LogEvent,
type LogLevel,
type MockControls,
type MockScenario,
type Settings,
type SignInResult,
type StackComponent,
type StackEvent,
type Status,
type Unlisten,
} from "./types";
const CURRENT = "0.52.0";
const PREVIOUS = "0.51.0";
const OPERATOR = "m1op";
const SITE_URL = "http://192.168.1.41:30097";
const SITE_NAME = "DHP Alpha";
const MCP_URL = "http://192.168.1.41:30091";
const DEFAULT_DIR = "D:\\ODIAN";
const KB = 1024;
const MB = 1024 ** 2;
const GB = 1024 ** 3;
const FAIL_POINTS = ["sign_in", "install", "restore", "launch", "notes"] as const;
type FailPoint = (typeof FAIL_POINTS)[number];
interface StagePlan {
stage: InstallStage;
/** Simulated wall time; the reported speed stays realistic. */
seconds: number;
bytes: number;
items: number;
bytesPerSec: number;
message: (done: number, total: number) => string | null;
}
function installPlan(kind: "fresh" | "update", source: "site" | "drive"): StagePlan[] {
const rate = source === "drive" ? 468 * MB : 112 * MB;
const plan: StagePlan[] = [
{
stage: "manifest",
seconds: 0.8,
bytes: 86 * KB,
items: 1,
bytesPerSec: source === "drive" ? 40 * MB : 2.4 * MB,
message: () => `manifest ${CURRENT} (${source === "drive" ? "drive" : SITE_NAME})`,
},
{
stage: "app",
seconds: 3,
bytes: kind === "update" ? 96 * MB : 412 * MB,
items: kind === "update" ? 138 : 1284,
bytesPerSec: rate,
message: (d, t) => `app: ${d}/${t} files`,
},
{
stage: "db",
seconds: 4,
bytes: kind === "update" ? 2.3 * GB : 18.6 * GB,
items: 1,
bytesPerSec: rate,
message: () => (kind === "update" ? "db odian_20260912.delta" : "db odian_20260912.dump"),
},
];
if (kind === "fresh") {
plan.push(
{
stage: "src",
seconds: 2.5,
bytes: 3.4 * GB,
items: 412,
bytesPerSec: rate,
message: (d, t) => `src: ${d}/${t} files`,
},
{
stage: "tiles",
seconds: 9,
bytes: 212 * GB,
items: 1402,
bytesPerSec: rate,
message: (d, t) => `tiles dark/protomaps_20260423: ${d}/${t} blocks`,
},
);
}
plan.push(
{
stage: "restore",
seconds: 5,
bytes: 0,
items: 231,
bytesPerSec: 0,
message: (d, t) => `pg_restore: ${d}/${t} tables`,
},
{
stage: "finalize",
seconds: 1,
bytes: 0,
items: 0,
bytesPerSec: 0,
message: () => "writing install manifest",
},
);
return plan;
}
const STAGE_DONE: Record<InstallStage, string> = {
manifest: "Manifest verified",
app: "Application files verified",
db: "Database snapshot verified",
src: "Source data verified",
tiles: "Map tiles verified",
restore: "Database restored into local Postgres",
finalize: "Install finalized",
};
type Payloads = {
status: Status;
"install-progress": InstallProgress;
stack: StackEvent;
log: LogEvent;
};
type Channel = keyof Payloads;
type Listeners = { [K in Channel]: Set<(payload: Payloads[K]) => void> };
const now = () => Math.floor(Date.now() / 1000);
const busy = "Busy: wait for the current task to finish.";
function pick<T extends string>(value: string | null, allowed: readonly T[]): T | null {
return value !== null && (allowed as readonly string[]).includes(value) ? (value as T) : null;
}
/** Enforces the contract's derived fields. */
function derive(s: Status): Status {
const available = s.online ? (s.available ?? CURRENT) : null;
const update_required = s.online && s.installed !== null && available !== null && s.installed !== available;
return {
...s,
available,
signed_in: s.online && s.signed_in,
update_required,
can_launch: s.installed !== null && !update_required && s.phase === "idle",
};
}
function initialStatus(scenario: MockScenario): Status {
const s: Status = {
online: true,
site: { url: SITE_URL, name: SITE_NAME },
operator: null,
signed_in: false,
installed: null,
available: CURRENT,
update_required: false,
can_launch: false,
phase: "idle",
error: null,
install_dir: DEFAULT_DIR,
};
switch (scenario) {
case "first-run":
break;
case "update-required":
Object.assign(s, { installed: PREVIOUS, operator: OPERATOR, signed_in: true });
break;
case "ready":
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true });
break;
case "offline-installed":
Object.assign(s, { online: false, installed: PREVIOUS, operator: OPERATOR });
break;
case "offline-not-installed":
Object.assign(s, { online: false });
break;
case "installing":
Object.assign(s, { operator: OPERATOR, signed_in: true, phase: "installing" });
break;
case "running":
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true, phase: "running" });
break;
case "error":
Object.assign(s, {
operator: OPERATOR,
signed_in: true,
phase: "error",
error:
"Tile block 0731 failed verification (sha256 mismatch). Everything already verified was kept; retry to resume.",
});
break;
}
return derive(s);
}
class MockBackend implements Backend {
readonly kind = "mock" as const;
readonly mock: MockControls;
readonly window = {
minimize: async () => this.log("info", "Minimize pressed (browser mock)"),
toggleMaximize: async () => this.log("info", "Maximize pressed (browser mock)"),
close: async () => this.log("info", "Close pressed (browser mock: the tab stays open)"),
isMaximized: async () => false,
onResized: async (): Promise<Unlisten> => () => {},
};
private status: Status;
private settings: Settings;
private network: boolean;
private job = 0;
private booted = false;
private signInCancelled = false;
/** Latest event per component, as `get_stack` reports it. */
private readonly stackStates = new Map<StackComponent, StackEvent>();
private readonly listeners: Listeners = {
status: new Set(),
"install-progress": new Set(),
stack: new Set(),
log: new Set(),
};
constructor(
private readonly scenario: MockScenario,
private readonly fail: FailPoint | null,
private readonly speed: number,
private readonly notesVariant: NotesVariant,
) {
this.status = initialStatus(scenario);
this.network = this.status.online;
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR };
const self = this;
this.mock = {
get scenario() {
return self.scenario;
},
get online() {
return self.network;
},
setOnline(online: boolean) {
self.network = online;
self.log(online ? "info" : "warn", online ? "Mock: network restored" : "Mock: network cable pulled");
void self.refresh();
},
};
}
// ── plumbing ────────────────────────────────────────────────────────────
private emit<K extends Channel>(channel: K, payload: Payloads[K]) {
if (channel === "stack") {
const event = payload as StackEvent;
this.stackStates.set(event.component, structuredClone(event));
}
for (const handler of this.listeners[channel]) handler(structuredClone(payload));
}
private listen<K extends Channel>(channel: K, handler: (payload: Payloads[K]) => void): Promise<Unlisten> {
this.listeners[channel].add(handler);
return Promise.resolve(() => void this.listeners[channel].delete(handler));
}
private commit(patch: Partial<Status>) {
this.status = derive({ ...this.status, ...patch });
this.emit("status", this.status);
}
private log(level: LogLevel, message: string, at = now()) {
this.emit("log", { level, message, at });
}
private wait(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms / this.speed));
}
private snapshot(): Status {
return structuredClone(this.status);
}
/** Scenario side effects start on the first get_status, once the UI listens. */
private boot() {
if (this.booted) return;
this.booted = true;
const t = now();
const s = this.status;
this.log("info", "ODIAN Launcher started", t - 6);
this.log(
s.online ? "info" : "warn",
s.online
? `${SITE_NAME} answered /v1/health (current version ${CURRENT})`
: `${SITE_URL} unreachable (2 s timeout): working offline`,
t - 5,
);
if (s.update_required) {
this.log("warn", `Installed ${s.installed} is behind the DHP (${CURRENT}): update required before launch`, t - 4);
}
if (this.scenario === "running") this.log("info", "Odian.exe is running (pid 14212)", t - 3);
if (this.scenario === "error" && s.error) this.log("error", s.error, t - 2);
if (this.scenario === "installing") {
setTimeout(() => void this.runInstall("fresh", "site", undefined, { index: 4, fraction: 0.27 }), 300);
}
}
// ── commands ────────────────────────────────────────────────────────────
async getStatus(): Promise<Status> {
this.boot();
return this.snapshot();
}
async refresh(): Promise<Status> {
const wasIdle = this.status.phase === "idle";
const wasOnline = this.status.online;
if (wasIdle) this.commit({ phase: "checking" });
await this.wait(this.network ? 450 : 2000);
const online = this.network;
this.commit({
online,
phase: wasIdle && this.status.phase === "checking" ? "idle" : this.status.phase,
});
if (online !== wasOnline) {
this.log(
online ? "info" : "warn",
online ? `${SITE_NAME} is reachable again (current version ${CURRENT})` : `${SITE_NAME} unreachable: working offline`,
);
if (online && this.status.update_required) {
this.log("warn", `Update to ${CURRENT} required before launch while connected`);
}
}
return this.snapshot();
}
async signIn(): Promise<SignInResult> {
if (!this.status.online) throw "Your DHP is unreachable. Connect to the DHP network to sign in.";
if (this.status.phase !== "idle") throw busy;
this.commit({ phase: "signing_in", error: null });
this.signInCancelled = false;
this.log("info", "Opened the system browser for Keycloak sign-in");
// Like the real backend, a cancel ends the wait at once instead of after the browser timeout.
for (let waited = 0; waited < 2400 && !this.signInCancelled; waited += 100) await this.wait(100);
if (this.signInCancelled) {
this.signInCancelled = false;
throw SIGN_IN_CANCELLED;
}
// Re-read after the await: settings or network toggles may have changed the phase.
if ((this.status.phase as string) !== "signing_in") throw "Sign-in was interrupted.";
if (this.fail === "sign_in") {
const message = "Sign-in timed out: the browser never called back to the launcher.";
this.commit({ phase: "idle", error: message });
throw message;
}
this.commit({ phase: "idle", signed_in: true, operator: OPERATOR });
this.log("info", `Signed in as ${OPERATOR}`);
return { username: OPERATOR };
}
async cancelSignIn(): Promise<void> {
if (this.status.phase !== "signing_in") return;
this.signInCancelled = true;
this.commit({ phase: "idle" });
this.log("info", "Sign-in cancelled");
}
async signOut(): Promise<Status> {
this.commit({ signed_in: false, operator: null });
this.log("info", "Signed out: this PC no longer remembers the operator");
return this.snapshot();
}
async openExternal(url: string): Promise<void> {
if (!/^https?:\/\//i.test(url)) throw "Only web links can be opened.";
this.log("info", `Opened ${url} in the system browser (browser mock: new tab)`);
window.open(url, "_blank", "noopener,noreferrer");
}
async getStack(): Promise<StackEvent[]> {
if (this.scenario === "running" && this.stackStates.size === 0) {
for (const component of ["postgres", "api", "tiles", "odian"] as const) {
this.stackStates.set(component, { component, state: "ready", message: null });
}
}
return [...this.stackStates.values()].map((event) => structuredClone(event));
}
async getNotes(version?: string): Promise<string> {
await this.wait(380);
if (this.fail === "notes") throw "Could not load patch notes: the DHP answered HTTP 502.";
if (!this.status.online) {
if (!this.status.installed) throw "No cached notes: ODIAN is not installed on this machine.";
return mockNotes(this.status.installed, this.notesVariant);
}
return mockNotes(version ?? this.status.available ?? CURRENT, this.notesVariant);
}
async startInstall(): Promise<void> {
const s = this.status;
if (!s.online) throw "Your DHP is unreachable. Connect to install, or install from a drive.";
if (!s.signed_in) throw "Sign in first: downloading from the DHP needs your DHP sign-in.";
if (s.phase !== "idle" && s.phase !== "error") throw busy;
const kind = s.installed && s.installed !== s.available ? "update" : "fresh";
void this.runInstall(kind, "site");
}
async startInstallFromDrive(path: string): Promise<void> {
const s = this.status;
if (s.phase !== "idle" && s.phase !== "error") throw busy;
if (!path.trim()) throw "Pick the root folder of an ODIAN drive.";
const kind = s.installed && s.installed !== CURRENT ? "update" : "fresh";
void this.runInstall(kind, "drive", path);
}
async cancelInstall(): Promise<void> {
if (this.status.phase === "restoring") throw "The database restore cannot be cancelled once it has started.";
if (this.status.phase !== "installing") throw "Nothing is installing.";
this.job++;
this.commit({ phase: "idle" });
this.log("warn", "Install cancelled at the next file boundary; everything already verified was kept");
}
async launch(): Promise<void> {
const s = this.status;
if (!s.can_launch) {
throw s.update_required
? `Update to ${s.available} before launching while connected.`
: "ODIAN is not ready to launch.";
}
void this.runLaunch();
}
async stop(): Promise<void> {
const phase = this.status.phase;
if (phase !== "running" && phase !== "launching") throw "ODIAN is not running.";
void this.runStop();
}
async getSettings(): Promise<Settings> {
return structuredClone(this.settings);
}
async setSettings(settings: Settings): Promise<Status> {
for (const [label, url] of [
["DHP URL", settings.site_url],
["MCP URL", settings.mcp_url],
] as const) {
try {
new URL(url);
} catch {
throw `${label} is not a valid URL: ${url}`;
}
}
if (!settings.install_dir.trim()) throw "Install folder cannot be empty.";
const siteChanged = settings.site_url !== this.settings.site_url;
this.settings = structuredClone(settings);
this.commit({
site: { url: settings.site_url, name: settings.site_url === SITE_URL ? SITE_NAME : null },
install_dir: settings.install_dir,
});
this.log("info", "Settings saved");
if (siteChanged) void this.refresh();
return this.snapshot();
}
async pickFolder(): Promise<string | null> {
await new Promise((resolve) => setTimeout(resolve, 450));
return "E:\\ODIAN";
}
onStatus(handler: (status: Status) => void) {
return this.listen("status", handler);
}
onInstallProgress(handler: (progress: InstallProgress) => void) {
return this.listen("install-progress", handler);
}
onStack(handler: (event: StackEvent) => void) {
return this.listen("stack", handler);
}
onLog(handler: (event: LogEvent) => void) {
return this.listen("log", handler);
}
// ── flows ───────────────────────────────────────────────────────────────
private failFlow(message: string) {
this.job++;
this.commit({ phase: "error", error: message });
this.log("error", message);
}
private async runInstall(
kind: "fresh" | "update",
source: "site" | "drive",
path?: string,
resume?: { index: number; fraction: number },
) {
const job = ++this.job;
const plan = installPlan(kind, source);
this.commit({ phase: "installing", error: null });
if (!resume) {
const verb = kind === "update" ? `Updating to ODIAN ${CURRENT}` : `Installing ODIAN ${CURRENT}`;
this.log("info", source === "drive" ? `${verb} from drive ${path}` : `${verb} from ${SITE_NAME}`);
}
for (let i = resume?.index ?? 0; i < plan.length; i++) {
const step = plan[i];
const phase = step.stage === "restore" ? "restoring" : "installing";
if (this.status.phase !== phase) this.commit({ phase });
const ticks = Math.max(1, Math.round(step.seconds * 10));
const first = resume && i === resume.index ? Math.floor(ticks * resume.fraction) : 0;
for (let t = first; t <= ticks; t++) {
if (t > first) await this.wait(100);
if (job !== this.job) return;
const fraction = t / ticks;
if (source === "site" && !this.network) {
return this.failFlow(
`Connection to ${SITE_NAME} lost during ${step.stage}. Everything already verified was kept; retry when you are back online.`,
);
}
if (this.fail === "install" && step.stage === (kind === "fresh" ? "tiles" : "db") && fraction >= 0.62) {
return this.failFlow(
step.stage === "tiles"
? "Tile block 0731 failed verification (sha256 mismatch). Everything already verified was kept; retry to resume."
: "odian_20260912.delta failed verification (sha256 mismatch). Retry to download it again.",
);
}
if (this.fail === "restore" && step.stage === "restore" && fraction >= 0.4) {
return this.failFlow("pg_restore failed on table track_point: could not extend file (No space left on device).");
}
const done = Math.round(step.items * fraction);
const wobble = 1 + 0.07 * Math.sin(t / 6) + (Math.random() - 0.5) * 0.06;
this.emit("install-progress", {
stage: step.stage,
stage_index: i + 1,
stage_count: plan.length,
done_bytes: Math.round(step.bytes * fraction),
total_bytes: Math.round(step.bytes),
done_items: done,
total_items: step.items,
bytes_per_sec: step.bytesPerSec > 0 && t < ticks ? Math.round(step.bytesPerSec * wobble) : 0,
message: step.message(done, step.items),
});
}
this.log("info", STAGE_DONE[step.stage]);
}
if (job !== this.job) return;
this.commit({ phase: "idle", installed: CURRENT });
this.log("info", `ODIAN ${CURRENT} is installed in ${this.status.install_dir}`);
}
private async runLaunch() {
const job = ++this.job;
this.commit({ phase: "launching", error: null });
this.log("info", `Starting the local stack for ODIAN ${this.status.installed}`);
const steps: [StackComponent, number, string][] = [
["postgres", 1300, "listening on 127.0.0.1:55432"],
["api", 1500, "healthy on 127.0.0.1:18080"],
["tiles", 900, "serving 3 tilesets"],
["odian", 1700, "Odian.exe pid 14212"],
];
for (const [component, ms, ready] of steps) {
this.emit("stack", { component, state: "starting", message: null });
await this.wait(ms);
if (job !== this.job) return;
if (this.fail === "launch" && component === "api") {
this.emit("stack", { component, state: "failed", message: "exit code 1" });
this.emit("stack", { component: "postgres", state: "stopped", message: null });
return this.failFlow("Engine API exited with code 1: bind 127.0.0.1:18080: address already in use.");
}
this.emit("stack", { component, state: "ready", message: ready });
this.log("info", `${component}: ${ready}`);
}
this.commit({ phase: "running" });
}
private async runStop() {
const job = ++this.job;
this.log("info", "Stopping the local stack");
for (const component of ["odian", "tiles", "api", "postgres"] as const) {
await this.wait(380);
if (job !== this.job) return;
this.emit("stack", { component, state: "stopped", message: null });
}
this.commit({ phase: "idle" });
this.log("info", "Local stack stopped cleanly");
}
}
export function createMockBackend(params: URLSearchParams): Backend {
const scenario = pick(params.get("scenario"), MOCK_SCENARIOS) ?? "first-run";
const fail = pick(params.get("fail"), FAIL_POINTS);
const speed = Math.min(20, Math.max(0.25, Number(params.get("speed")) || 1));
const notes: NotesVariant = params.get("notes") === "no-roadmap" ? "no-roadmap" : "full";
return new MockBackend(scenario, fail, speed, notes);
}
+160
View File
@@ -0,0 +1,160 @@
// Wire types shared with the Rust backend. Mirrors docs/CONTRACT.md exactly:
// snake_case fields, timestamps in Unix seconds.
export type Phase =
| "idle" // nothing running
| "checking" // probing the site
| "signing_in" // browser sign-in in progress
| "installing" // downloading + verifying a version
| "restoring" // loading the database snapshot into local Postgres
| "launching" // starting Postgres → API → tile server → Odian.exe
| "running" // Odian.exe is open
| "error";
export interface SiteInfo {
url: string;
name: string | null;
}
export interface Status {
/** The site's update service answered /v1/health. */
online: boolean;
site: SiteInfo;
/** Last signed-in ODIAN username (cached; valid offline). */
operator: string | null;
/** Holds a site token right now (online only). */
signed_in: boolean;
/** Installed version, e.g. "0.51.0". */
installed: string | null;
/** The site's CURRENT version (online only). */
available: string | null;
/** online && installed !== available (launch is blocked). */
update_required: boolean;
/** installed && !update_required && phase is idle. */
can_launch: boolean;
phase: Phase;
/** Last error, human readable. */
error: string | null;
/** e.g. "D:\\ODIAN". */
install_dir: string;
}
export interface Settings {
/** e.g. "http://192.168.1.41:30097". */
site_url: string;
/** Site MCP, for Keycloak sign-in. */
mcp_url: string;
install_dir: string;
}
export type InstallStage =
| "manifest"
| "app"
| "db"
| "src"
| "tiles"
| "restore"
| "finalize";
export interface InstallProgress {
stage: InstallStage;
/** 1-based. */
stage_index: number;
stage_count: number;
done_bytes: number;
/** 0 when unknown. */
total_bytes: number;
done_items: number;
total_items: number;
bytes_per_sec: number;
/** e.g. "tiles dark/protomaps_20260423: 412/1402 blocks". */
message: string | null;
}
export type StackComponent = "postgres" | "api" | "tiles" | "odian";
export type StackState = "starting" | "ready" | "stopped" | "failed";
export interface StackEvent {
component: StackComponent;
state: StackState;
message: string | null;
}
export type LogLevel = "info" | "warn" | "error";
export interface LogEvent {
level: LogLevel;
message: string;
/** Unix seconds. */
at: number;
}
export interface SignInResult {
username: string;
}
/** The message `sign_in` rejects with after `cancel_sign_in`: not an error to show. */
export const SIGN_IN_CANCELLED = "Sign-in was cancelled.";
export type Unlisten = () => void;
/** The frameless window's own controls (drawn by the UI title bar). */
export interface WindowControls {
minimize(): Promise<void>;
toggleMaximize(): Promise<void>;
close(): Promise<void>;
isMaximized(): Promise<boolean>;
onResized(handler: () => void): Promise<Unlisten>;
}
export const MOCK_SCENARIOS = [
"first-run",
"update-required",
"ready",
"offline-installed",
"offline-not-installed",
"installing",
"running",
"error",
] as const;
export type MockScenario = (typeof MOCK_SCENARIOS)[number];
/** Design-time controls, only present on the browser mock. Not part of the contract. */
export interface MockControls {
readonly scenario: MockScenario;
readonly online: boolean;
setOnline(online: boolean): void;
}
/** Everything the UI may ask of the backend. Commands and events from CONTRACT.md. */
export interface Backend {
readonly kind: "tauri" | "mock";
readonly mock: MockControls | null;
readonly window: WindowControls;
getStatus(): Promise<Status>;
refresh(): Promise<Status>;
signIn(): Promise<SignInResult>;
/** Abandons a sign-in waiting for the browser; `signIn` then rejects with SIGN_IN_CANCELLED. */
cancelSignIn(): Promise<void>;
signOut(): Promise<Status>;
getNotes(version?: string): Promise<string>;
/** Opens an http(s) link in the system browser. */
openExternal(url: string): Promise<void>;
startInstall(): Promise<void>;
startInstallFromDrive(path: string): Promise<void>;
cancelInstall(): Promise<void>;
launch(): Promise<void>;
stop(): Promise<void>;
/** Latest event per stack component, for a UI that (re)loads mid-launch. */
getStack(): Promise<StackEvent[]>;
getSettings(): Promise<Settings>;
setSettings(settings: Settings): Promise<Status>;
pickFolder(): Promise<string | null>;
onStatus(handler: (status: Status) => void): Promise<Unlisten>;
onInstallProgress(handler: (progress: InstallProgress) => void): Promise<Unlisten>;
onStack(handler: (event: StackEvent) => void): Promise<Unlisten>;
onLog(handler: (event: LogEvent) => void): Promise<Unlisten>;
}
+25
View File
@@ -0,0 +1,25 @@
// Fonts are bundled (the launcher must work fully offline; CSP is 'self').
import "@fontsource/space-grotesk/latin-500.css";
import "@fontsource/space-grotesk/latin-600.css";
import "@fontsource/space-grotesk/latin-ext-500.css";
import "@fontsource/space-grotesk/latin-ext-600.css";
import "@fontsource/inter/latin-400.css";
import "@fontsource/inter/latin-500.css";
import "@fontsource/inter/latin-600.css";
import "@fontsource/inter/latin-ext-400.css";
import "@fontsource/inter/latin-ext-500.css";
import "@fontsource/inter/latin-ext-600.css";
import "@fontsource/ibm-plex-mono/latin-400.css";
import "@fontsource/ibm-plex-mono/latin-500.css";
import "@fontsource/ibm-plex-mono/latin-ext-400.css";
import "@fontsource/ibm-plex-mono/latin-ext-500.css";
import "./styles/tokens.css";
import "./styles/base.css";
import { mount } from "svelte";
import App from "./App.svelte";
const app = mount(App, { target: document.getElementById("app")! });
export default app;
+321
View File
@@ -0,0 +1,321 @@
/* Global reset and the few shared primitives (buttons, inputs, glass). */
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
}
html {
background: var(--bg);
}
body {
margin: 0;
overflow: hidden;
background: var(--bg);
color: var(--ink);
font-family: var(--font-body);
font-size: var(--text-md);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
cursor: default;
user-select: none;
-webkit-user-select: none;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
h1,
h2,
h3,
p {
margin: 0;
}
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
::selection {
background: rgba(91, 140, 255, 0.38);
color: var(--ink);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--line-strong);
border: 3px solid transparent;
background-clip: padding-box;
border-radius: var(--radius);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--faint);
}
/* ── utilities ─────────────────────────────────────────────────────────── */
.mono {
font-family: var(--font-mono);
}
.tabular {
font-variant-numeric: tabular-nums;
}
.selectable {
user-select: text;
-webkit-user-select: text;
cursor: auto;
}
.eyebrow {
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.glass {
background: var(--glass);
border: 1px solid var(--line);
border-radius: var(--radius);
backdrop-filter: blur(20px) saturate(120%);
-webkit-backdrop-filter: blur(20px) saturate(120%);
}
/* ── buttons ───────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
height: 36px;
padding: 0 14px;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: transparent;
color: var(--ink-2);
font-size: var(--text-sm);
font-weight: 500;
white-space: nowrap;
transition:
background-color var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.btn:hover:not(:disabled) {
border-color: #4a4a52;
background: var(--surface-2);
color: var(--ink);
}
.btn:active:not(:disabled) {
background: var(--line);
}
.btn:disabled {
opacity: 0.45;
}
.btn-accent {
border-color: var(--accent);
background: var(--accent);
color: var(--accent-ink);
font-weight: 600;
}
.btn-accent:hover:not(:disabled) {
border-color: var(--accent-hover);
background: var(--accent-hover);
color: var(--accent-ink);
}
.btn-accent:active:not(:disabled) {
border-color: var(--accent-press);
background: var(--accent-press);
}
.btn-danger:hover:not(:disabled) {
border-color: rgba(248, 113, 113, 0.5);
background: var(--danger-soft);
color: var(--danger);
}
.btn-link {
display: inline;
height: auto;
padding: 0;
border: 0;
background: none;
color: var(--accent);
font-size: inherit;
font-weight: 500;
text-decoration: underline;
text-decoration-color: var(--accent-line);
text-underline-offset: 3px;
transition: color var(--dur-fast) var(--ease);
}
.btn-link:hover:not(:disabled) {
color: var(--accent-hover);
text-decoration-color: currentColor;
}
.btn-link:disabled {
color: var(--faint);
}
.icon-btn {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
transition:
background-color var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.icon-btn:hover:not(:disabled) {
border-color: var(--line);
background: var(--surface-2);
color: var(--ink);
}
.icon-btn:disabled {
opacity: 0.45;
}
/* ── form fields ───────────────────────────────────────────────────────── */
.field {
display: grid;
gap: 6px;
}
.field-label {
color: var(--ink-2);
font-size: var(--text-sm);
font-weight: 500;
}
.field-help {
color: var(--muted);
font-size: var(--text-xs);
}
.field-error {
color: var(--danger);
font-size: var(--text-xs);
}
.input {
width: 100%;
min-width: 0;
height: 36px;
padding: 0 10px;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--ink);
font-family: var(--font-mono);
font-size: var(--text-sm);
cursor: text;
user-select: text;
-webkit-user-select: text;
transition:
border-color var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease);
}
.input::placeholder {
color: var(--faint);
}
.input:hover:not(:disabled) {
border-color: #4a4a52;
}
.input:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input[aria-invalid="true"] {
border-color: var(--danger);
}
.input:disabled {
color: var(--muted);
cursor: not-allowed;
}
/* ── motion ────────────────────────────────────────────────────────────── */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+63
View File
@@ -0,0 +1,63 @@
/* ODIAN dark: the single source of design tokens. */
:root {
color-scheme: dark;
/* Brand palette */
--bg: #09090b;
--surface: #111114;
--surface-2: #16161a;
--line: #232326;
--line-strong: #34343a;
--ink: #f2f1ee;
--ink-2: #d4d3d0;
--muted: #9a999e;
--faint: #76757b;
--accent: #5b8cff;
--accent-soft: rgba(91, 140, 255, 0.13);
--warn: #f5b454;
--ok: #86efac;
--danger: #f87171;
/* Derived */
--accent-hover: #7aa2ff;
--accent-press: #4a79e8;
--accent-line: rgba(91, 140, 255, 0.45);
--accent-ink: #09090b;
--warn-soft: rgba(245, 180, 84, 0.12);
--ok-soft: rgba(134, 239, 172, 0.1);
--danger-soft: rgba(248, 113, 113, 0.12);
--glass: rgba(14, 14, 17, 0.78);
--glass-strong: rgba(17, 17, 20, 0.92);
--scrim: rgba(5, 5, 7, 0.62);
/* Type */
--font-display: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
--font-body: "Inter", "Segoe UI", system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
--text-xs: 12px;
--text-sm: 13px;
--text-md: 14px;
--text-lg: 16px;
--text-xl: 20px;
--wordmark-tracking: 0.28em;
/* Shape and space */
--radius: 2px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
/* Chrome */
--titlebar-h: 40px;
--statusbar-h: 34px;
/* Motion */
--dur-fast: 150ms;
--dur: 200ms;
--dur-slow: 250ms;
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};

Some files were not shown because too many files have changed in this diff Show More