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>
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/schemas/
|
||||
target/
|
||||
*.log
|
||||
.DS_Store
|
||||
# Local Claude Code preview config
|
||||
.claude/
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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=
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 |
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "ODIAN test DHP",
|
||||
"site_url": "http://192.168.1.41:30097",
|
||||
"mcp_url": "http://192.168.1.41:30091"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -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"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -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>"
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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} <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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
// ¬es=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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||