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
+137
View File
@@ -0,0 +1,137 @@
//! Talking to the site's update service: reachability, token check, patch notes.
use std::time::Duration;
use reqwest::StatusCode;
/// How long a launch waits for the site before treating the PC as offline.
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
pub struct Probe {
pub online: bool,
/// The site's CURRENT version.
pub version: Option<String>,
}
pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
let offline = Probe { online: false, version: None };
if site_url.is_empty() {
return offline;
}
let Ok(response) = http.get(format!("{site_url}/v1/health")).timeout(PROBE_TIMEOUT).send().await else {
return offline;
};
if !response.status().is_success() {
return offline;
}
let body: serde_json::Value = response.json().await.unwrap_or_default();
Probe {
online: true,
version: body.get("version").and_then(|v| v.as_str()).map(str::to_string),
}
}
pub enum TokenCheck {
Valid,
Invalid,
/// The site could not say (unreachable, Engine down): keep the token.
Unknown,
}
pub async fn check_token(http: &reqwest::Client, site_url: &str, token: &str) -> TokenCheck {
let request = http
.get(format!("{site_url}/v1/versions/current"))
.bearer_auth(token)
.timeout(Duration::from_secs(5));
match request.send().await {
// 404 means "no current version yet", which is only answered after the token passed.
Ok(r) if r.status().is_success() || r.status() == StatusCode::NOT_FOUND => TokenCheck::Valid,
Ok(r) if r.status() == StatusCode::UNAUTHORIZED => TokenCheck::Invalid,
_ => TokenCheck::Unknown,
}
}
pub enum SessionRefresh {
Renewed { access_token: String, expires_at: i64, username: String },
/// The site no longer honours the launcher session: signed out elsewhere, revoked, or the user is gone.
Revoked(String),
/// The sign-in service could not answer (unreachable, Engine down, an MCP without launcher
/// sessions): keep the session and try again later.
Unavailable(String),
}
#[derive(serde::Deserialize)]
struct RenewedBody {
#[serde(default)]
access_token: String,
#[serde(default)]
expires_at: i64,
#[serde(default)]
username: String,
}
/// A fresh Engine token from the site MCP's launcher session, without asking the operator.
pub async fn refresh_session(http: &reqwest::Client, mcp_url: &str, launcher_token: &str) -> SessionRefresh {
if mcp_url.is_empty() {
return SessionRefresh::Unavailable("no sign-in address (MCP URL) is set".to_string());
}
let sent = http
.post(format!("{mcp_url}/auth/launcher/refresh"))
.json(&serde_json::json!({ "launcher_token": launcher_token }))
.timeout(Duration::from_secs(10))
.send()
.await;
let response = match sent {
Ok(response) => response,
Err(e) => return SessionRefresh::Unavailable(format!("the sign-in service did not answer: {e}")),
};
match response.status() {
s if s.is_success() => match response.json::<RenewedBody>().await {
Ok(body) if !body.access_token.is_empty() => SessionRefresh::Renewed {
access_token: body.access_token,
expires_at: body.expires_at,
username: body.username,
},
_ => SessionRefresh::Unavailable("the sign-in service gave an unexpected answer".to_string()),
},
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
let detail = response
.json::<serde_json::Value>()
.await
.ok()
.and_then(|body| body.get("detail").and_then(|d| d.as_str()).map(str::to_string))
.unwrap_or_else(|| "the DHP ended this sign-in".to_string());
SessionRefresh::Revoked(detail)
}
s => SessionRefresh::Unavailable(format!("the sign-in service answered HTTP {s}")),
}
}
/// Best effort: tell the site MCP to end the launcher session. Signing out works offline too.
pub async fn end_session(http: &reqwest::Client, mcp_url: &str, launcher_token: &str) {
if mcp_url.is_empty() {
return;
}
let _ = http
.post(format!("{mcp_url}/auth/launcher/logout"))
.json(&serde_json::json!({ "launcher_token": launcher_token }))
.timeout(Duration::from_secs(5))
.send()
.await;
}
pub async fn notes(http: &reqwest::Client, site_url: &str, token: &str, version: &str) -> Result<String, String> {
let response = http
.get(format!("{site_url}/v1/versions/{version}/notes"))
.bearer_auth(token)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("the DHP did not answer: {e}"))?;
match response.status() {
s if s.is_success() => response.text().await.map_err(|e| e.to_string()),
StatusCode::UNAUTHORIZED => Err("Sign in to read the patch notes.".to_string()),
StatusCode::NOT_FOUND => Err(format!("Version {version} has no patch notes.")),
s => Err(format!("the DHP answered HTTP {s}")),
}
}