//! 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, } 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::().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::() .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 { 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}")), } }