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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kelorastus
2026-09-15 15:23:17 +02:00
commit b7f3061be3
102 changed files with 47611 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
//! Canonical JSON: the one serialisation that gets hashed or signed.
//!
//! Byte-for-byte equal to Python's
//! `json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")`.
//!
//! Object keys are sorted HERE, by code point (UTF-8 byte order is the same order).
//! Nothing relies on `serde_json::Map` iteration order, which changes when any crate in
//! the build enables serde_json's `preserve_order` feature.
//!
//! Numbers must be integers in the i64/u64 range. Floats (`1.0`, `1e3`, `-0`) and integers
//! outside that range are refused: Python would serialise them, but no pack document holds
//! one, and refusing them keeps float formatting out of the signed bytes.
use serde_json::Value;
use crate::error::{Error, Result};
/// Canonical bytes of `value`, or [`Error::Manifest`] if it holds a non-integer number.
pub fn canonical_bytes(value: &Value) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(1024);
write_value(value, &mut out)?;
Ok(out)
}
fn write_value(value: &Value, out: &mut Vec<u8>) -> Result<()> {
match value {
Value::Null => out.extend_from_slice(b"null"),
Value::Bool(true) => out.extend_from_slice(b"true"),
Value::Bool(false) => out.extend_from_slice(b"false"),
Value::Number(n) => {
if let Some(u) = n.as_u64() {
out.extend_from_slice(u.to_string().as_bytes());
} else if let Some(i) = n.as_i64() {
out.extend_from_slice(i.to_string().as_bytes());
} else {
return Err(Error::manifest(format!(
"number {n} is not an integer in the 64-bit range; pack documents hold integers only"
)));
}
}
Value::String(s) => write_str(s, out),
Value::Array(items) => {
out.push(b'[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(b',');
}
write_value(item, out)?;
}
out.push(b']');
}
Value::Object(map) => {
let mut entries: Vec<(&String, &Value)> = map.iter().collect();
entries.sort_unstable_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
out.push(b'{');
for (i, (key, item)) in entries.into_iter().enumerate() {
if i > 0 {
out.push(b',');
}
write_str(key, out);
out.push(b':');
write_value(item, out)?;
}
out.push(b'}');
}
}
Ok(())
}
/// Python's `encode_basestring` (the `ensure_ascii=False` escaper).
fn write_str(s: &str, out: &mut Vec<u8>) {
const HEX: &[u8; 16] = b"0123456789abcdef";
out.push(b'"');
// Escapes only ever replace ASCII bytes, and bytes of multi-byte UTF-8 sequences are
// all >= 0x80, so a byte loop copies every other character through untouched.
for &b in s.as_bytes() {
match b {
b'"' => out.extend_from_slice(b"\\\""),
b'\\' => out.extend_from_slice(b"\\\\"),
b'\n' => out.extend_from_slice(b"\\n"),
b'\r' => out.extend_from_slice(b"\\r"),
b'\t' => out.extend_from_slice(b"\\t"),
0x08 => out.extend_from_slice(b"\\b"),
0x0c => out.extend_from_slice(b"\\f"),
0x00..=0x1f => {
out.extend_from_slice(b"\\u00");
out.push(HEX[(b >> 4) as usize]);
out.push(HEX[(b & 0x0f) as usize]);
}
_ => out.push(b),
}
}
out.push(b'"');
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn sorts_and_escapes_like_python() {
let v = json!({"b": 1, "a": [1, {"d": 2, "c": 3}]});
assert_eq!(canonical_bytes(&v).unwrap(), br#"{"a":[1,{"c":3,"d":2}],"b":1}"#);
let v = json!({"s": "q\"b\\n\n\u{1}\u{7f}é/"});
assert_eq!(canonical_bytes(&v).unwrap(), "{\"s\":\"q\\\"b\\\\n\\n\\u0001\u{7f}é/\"}".as_bytes());
}
#[test]
fn refuses_floats() {
for text in ["1.0", "1e3", "-0", "[0.5]", "18446744073709551616"] {
let v: Value = serde_json::from_str(text).unwrap();
assert!(canonical_bytes(&v).is_err(), "{text}");
}
}
}