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:
@@ -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}")),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user