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
+98
View File
@@ -0,0 +1,98 @@
//! Launcher settings: which site to use and where ODIAN is installed.
//!
//! Stored per Windows user in the launcher's config dir. On first run they are
//! seeded from a `site.json` next to the launcher executable (shipped with the
//! setup or copied from an ODIAN drive), so operators never type addresses.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use crate::fsutil::write_json_atomic;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Settings {
/// The site's update service, e.g. `http://192.168.1.41:30097`.
#[serde(default)]
pub site_url: String,
/// The site's MCP, which runs the Keycloak sign-in.
#[serde(default)]
pub mcp_url: String,
#[serde(default)]
pub install_dir: String,
/// Display name from site.json; the UI's settings form does not edit it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub site_name: Option<String>,
}
#[derive(Deserialize, Default)]
struct SiteFile {
#[serde(default)]
name: Option<String>,
#[serde(default)]
site_url: String,
#[serde(default)]
mcp_url: String,
}
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_config_dir().map_err(|e| format!("no config dir: {e}"))?;
Ok(dir.join("settings.json"))
}
fn default_install_dir() -> String {
// Operator PCs keep the ~1 TB of data off the system drive when a D: exists.
// Not %LOCALAPPDATA%\ODIAN: on the release PC that folder holds the release
// signing key, and deleting a test install must never be able to take it along.
if Path::new("D:\\").is_dir() {
"D:\\ODIAN".to_string()
} else {
"C:\\ODIAN".to_string()
}
}
fn read_site_file() -> SiteFile {
let Some(dir) = std::env::current_exe().ok().and_then(|exe| exe.parent().map(Path::to_path_buf)) else {
return SiteFile::default();
};
std::fs::read(dir.join("site.json"))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
pub fn normalize_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_string()
}
impl Settings {
pub fn load(app: &AppHandle) -> Result<Settings, String> {
let path = settings_path(app)?;
if let Ok(bytes) = std::fs::read(&path) {
let mut settings: Settings =
serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid settings JSON: {e}", path.display()))?;
if settings.install_dir.trim().is_empty() {
settings.install_dir = default_install_dir();
}
return Ok(settings);
}
let site = read_site_file();
let settings = Settings {
site_url: normalize_url(&site.site_url),
mcp_url: normalize_url(&site.mcp_url),
install_dir: default_install_dir(),
site_name: site.name,
};
settings.save(app)?;
Ok(settings)
}
pub fn save(&self, app: &AppHandle) -> Result<(), String> {
write_json_atomic(&settings_path(app)?, self)
}
pub fn install_path(&self) -> PathBuf {
PathBuf::from(&self.install_dir)
}
}