b7f3061be3
- 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>
138 lines
4.7 KiB
Rust
138 lines
4.7 KiB
Rust
//! 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}")),
|
|
}
|
|
}
|