//! 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, /// The site's CURRENT version. pub version: Option, /// 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, /// 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, } /// 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 { 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 { let listed: Vec = 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::().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; } /// 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 { 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()); } }