Files
ODIAN_Launcher/src-tauri/src/site.rs
T
Kelorastus 21d6e6bffb update
2026-09-25 10:14:44 +02:00

296 lines
12 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. Short on
/// purpose: a PC with no DHP at all must not sit through a stall at every start.
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// The second chance, for a link that answers slowly rather than not at all. Over a
/// phone's hotspot the first HTTPS round trip to a DHP - name, connection, handshake,
/// answer - runs past two seconds often enough that the DHP read as offline while a
/// browser on the same PC reached it fine. Only a TIMEOUT is retried: a refused
/// connection or a name that does not resolve is what an absent DHP looks like, and
/// waiting ten seconds on those at every start is exactly what the short budget avoids.
const SLOW_LINK_TIMEOUT: Duration = Duration::from_secs(10);
pub struct Probe {
pub online: bool,
/// Why the DHP is not online, in one line. Without it "offline" is all the operator
/// gets, and a URL that a browser on the same PC opens fine leaves nothing to go on.
pub problem: Option<String>,
/// The site's CURRENT version.
pub version: Option<String>,
/// The DHP's Engine API, as this PC can reach it: where live mode forwards while
/// online. Absent when the DHP offers none, and this PC then serves its own data.
pub engine_url: Option<String>,
/// Every address the DHP offers for it, best first: the site's own network, then the
/// one that answers from outside. A PC cannot be told which applies to it - a laptop
/// is at the office in the morning and at home in the evening - so it tries them in
/// turn. Older DHPs name one address and this holds just that one.
pub engine_urls: Vec<String>,
}
/// How long one Engine API gets to answer before the next address is tried.
const ENGINE_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
/// Does this Engine API answer? `/health` is its open route: no token, no data.
pub async fn engine_answers(http: &reqwest::Client, engine_url: &str) -> bool {
let url = format!("{}/health", engine_url.trim_end_matches('/'));
matches!(http.get(url).timeout(ENGINE_PROBE_TIMEOUT).send().await, Ok(answer) if answer.status().is_success())
}
/// The first of `urls` that answers, or None when this PC can reach none of them - which
/// is not an error: the local API then serves this PC's own data, as it does offline.
pub async fn first_engine(http: &reqwest::Client, urls: &[String]) -> Option<String> {
for url in urls {
if engine_answers(http, url).await {
return Some(url.clone());
}
}
None
}
fn unreachable(url: &str, what: impl std::fmt::Display) -> Probe {
Probe { online: false, problem: Some(format!("{url}: {what}")), version: None, engine_url: None, engine_urls: Vec::new() }
}
/// The reason, short enough for one log line: reqwest nests its causes, and the one
/// that names the fault (a refused connection, an expired certificate, a bad name) is
/// the innermost.
fn cause(error: &reqwest::Error) -> String {
let mut source: &dyn std::error::Error = error;
let mut text = error.to_string();
while let Some(inner) = source.source() {
text = inner.to_string();
source = inner;
}
text
}
pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
if site_url.is_empty() {
return Probe {
online: false,
problem: Some("no DHP address is set".to_string()),
version: None,
engine_url: None,
engine_urls: Vec::new(),
};
}
let url = format!("{site_url}/v1/health");
let response = match http.get(&url).timeout(PROBE_TIMEOUT).send().await {
Ok(response) => response,
Err(quick) if quick.is_timeout() => match http.get(&url).timeout(SLOW_LINK_TIMEOUT).send().await {
Ok(response) => response,
Err(slow) if slow.is_timeout() => {
return unreachable(&url, format!("no answer within {}s", SLOW_LINK_TIMEOUT.as_secs()))
}
Err(slow) => return unreachable(&url, cause(&slow)),
},
Err(quick) => return unreachable(&url, cause(&quick)),
};
if !response.status().is_success() {
return unreachable(&url, format!("answered {}", response.status()));
}
let body: serde_json::Value = response.json().await.unwrap_or_default();
let text = |key: &str| body.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string);
let engine_url = text("engine_api_url");
let engine_urls = engine_urls_from(&body, engine_url.as_deref());
Probe { online: true, problem: None, version: text("version"), engine_url, engine_urls }
}
/// The addresses a health answer offers for the Engine API, in the order to try them.
/// A DHP from before the list names one, and it is the whole list then.
fn engine_urls_from(body: &serde_json::Value, single: Option<&str>) -> Vec<String> {
let listed: Vec<String> = body
.get("engine_api_urls")
.and_then(|v| v.as_array())
.map(|urls| urls.iter().filter_map(|u| u.as_str()).map(str::trim).filter(|u| !u.is_empty()).map(str::to_string).collect())
.unwrap_or_default();
if listed.is_empty() {
return single.filter(|u| !u.is_empty()).map(str::to_string).into_iter().collect();
}
listed
}
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;
}
/// A DHP's answer to "may this PC download a whole version now?".
pub enum InstallSlot {
/// Go ahead; renew before `lease_sec` runs out.
Granted { lease_sec: u64 },
/// Wait: this many PCs are downloading or waiting ahead of this one.
Queued { ahead: u64 },
/// The DHP does not hand out slots (older service): just install.
NotOffered,
}
/// Ask for, or renew, this PC's install slot.
pub async fn claim_install(http: &reqwest::Client, site_url: &str, token: &str, install_id: &str) -> InstallSlot {
let sent = http
.post(format!("{site_url}/v1/install/claim"))
.bearer_auth(token)
.json(&serde_json::json!({ "install_id": install_id }))
.timeout(Duration::from_secs(10))
.send()
.await;
let Ok(response) = sent else { return InstallSlot::NotOffered };
if response.status() == StatusCode::NOT_FOUND {
return InstallSlot::NotOffered;
}
if !response.status().is_success() {
return InstallSlot::NotOffered;
}
let body: serde_json::Value = response.json().await.unwrap_or_default();
if body.get("granted").and_then(|g| g.as_bool()).unwrap_or(true) {
return InstallSlot::Granted { lease_sec: body.get("lease_sec").and_then(|l| l.as_u64()).unwrap_or(120) };
}
InstallSlot::Queued { ahead: body.get("ahead").and_then(|a| a.as_u64()).unwrap_or(0) }
}
/// Give the slot back. Best effort: an unreleased slot simply expires.
pub async fn release_install(http: &reqwest::Client, site_url: &str, token: &str, install_id: &str) {
let _ = http
.post(format!("{site_url}/v1/install/release"))
.bearer_auth(token)
.json(&serde_json::json!({ "install_id": install_id }))
.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}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_addresses_are_taken_in_the_order_the_dhp_gives_them() {
let body = serde_json::json!({
"ok": true,
"engine_api_urls": ["http://192.168.1.41:30684", "https://engine-api.odian.fr"],
"engine_api_url": "https://engine-api.odian.fr"
});
assert_eq!(
engine_urls_from(&body, Some("https://engine-api.odian.fr")),
vec!["http://192.168.1.41:30684".to_string(), "https://engine-api.odian.fr".to_string()],
"the site's own network first, so an office PC does not go out and back"
);
}
#[test]
fn a_dhp_that_names_one_address_still_works() {
// Every DHP in the field before the list: one address, and it is the whole list.
let body = serde_json::json!({"ok": true, "engine_api_url": "http://192.168.1.41:30684"});
assert_eq!(engine_urls_from(&body, Some("http://192.168.1.41:30684")), vec!["http://192.168.1.41:30684".to_string()]);
// And a DHP offering none leaves the PC on its own data.
assert!(engine_urls_from(&serde_json::json!({"ok": true}), None).is_empty());
assert!(engine_urls_from(&serde_json::json!({"engine_api_urls": ["", " "]}), None).is_empty());
}
}