diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index 7e893c9..c1c1d1c 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -199,6 +199,19 @@ struct Inner { } impl Inner { + /// Take `url` as the DHP's Engine API, and keep it in launcher.json so a launcher started + /// offline next time still arms live mode with it. Err is only the save failing: the + /// address is in use either way. + fn adopt_engine(&mut self, url: String) -> Result<(), String> { + let site = self.settings.site_url.clone(); + let changed = self.state.remember_engine(&site, &url); + self.engine_url = Some(url); + if changed { + self.state.save(&self.settings.install_path())?; + } + Ok(()) + } + fn status(&self) -> Status { let installed = self.state.installed_version.clone(); // Online means "update before launch"; offline launches whatever is installed. @@ -343,6 +356,8 @@ impl Launcher { pub fn load(app: &AppHandle) -> Result { let settings = Settings::load(app)?; let state = LauncherState::load(&settings.install_path())?; + // Where live mode forwards, known before the first probe: offline, there is no probe. + let engine_url = state.engine_url(&settings.site_url); let token = tokens::load(&settings.site_url); let http = reqwest::Client::builder() .user_agent(concat!("ODIAN-Launcher/", env!("CARGO_PKG_VERSION"))) @@ -350,7 +365,7 @@ impl Launcher { .map_err(|e| format!("cannot start the HTTP client: {e}"))?; Ok(Launcher { http, - inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None, map_from_site: None, map_problem: None, tiles_waiting: None, unfinished: None, dev: None, dev_refusal: None, market_checked: None, portable: crate::settings::portable_drive().is_some(), updating_self: false, offline_reason: None, drive_extras: None }), + inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url, live: None, map_from_site: None, map_problem: None, tiles_waiting: None, unfinished: None, dev: None, dev_refusal: None, market_checked: None, portable: crate::settings::portable_drive().is_some(), updating_self: false, offline_reason: None, drive_extras: None }), stack: Mutex::new(None), stack_states: std::sync::Mutex::new(Vec::new()), sign_in_cancel: Notify::new(), @@ -518,14 +533,15 @@ impl Launcher { false => self.log(app, "warn", format!("Your DHP did not answer. {why}")), } } - { + let kept = { let mut inner = self.inner.lock().await; inner.online = probe.online; inner.available = probe.version; // Keep the last known address while offline: it is where live mode resumes. - if engine.is_some() { - inner.engine_url = engine; - } + engine.map(|url| inner.adopt_engine(url)).unwrap_or(Ok(())) + }; + if let Err(e) = kept { + self.log(app, "warn", format!("Could not remember the DHP's Engine API address: {e}")); } // Offline nothing is checked or forgotten: the remembered operator still launches. if let (true, Some(token)) = (probe.online, token) { @@ -856,6 +872,9 @@ impl Launcher { if next.site_url != inner.settings.site_url { inner.token = tokens::load(&next.site_url); inner.available = None; + // Another DHP's Engine API is not this one's: only what this PC learned from the + // new site, if it was ever on it. + inner.engine_url = inner.state.engine_url(&next.site_url); } inner.settings = next; } @@ -1745,6 +1764,12 @@ impl Launcher { /// Start the local stack. Returns once launching has begun; `stack` and `status` events follow. pub async fn launch(&self, app: &AppHandle) -> Result<(), String> { + // The DHP is looked at on start, on focus and on request. A PC that was offline then and + // has connected since would otherwise launch on that stale answer - no live mode for the + // whole session, and no update check. Offline it costs one probe (2 s at most). + if !self.inner.lock().await.online { + self.refresh(app).await; + } let config = { let mut inner = self.inner.lock().await; let status = inner.status(); @@ -2314,12 +2339,13 @@ impl Launcher { } let probe = site::probe(&self.http, &site_url).await; let engine = self.pick_engine(app, &probe).await; - { + let kept = { let mut inner = self.inner.lock().await; inner.online = probe.online; - if engine.is_some() { - inner.engine_url = engine; - } + engine.map(|url| inner.adopt_engine(url)).unwrap_or(Ok(())) + }; + if let Err(e) = kept { + self.log(app, "warn", format!("Could not remember the DHP's Engine API address: {e}")); } if !probe.online { if was_online { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index c964155..38d964a 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -42,6 +42,20 @@ pub struct LauncherState { /// while it was away. None until the first sync. #[serde(default)] pub tile_sync: Option, + /// The DHP's Engine API this PC last reached, so a launcher started OFFLINE still arms + /// live mode: the local API then switches to the DHP by itself once the network comes. + /// Held only in memory before, so a PC that started the launcher offline ran the whole + /// session on its own data even after it connected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub engine: Option, +} + +/// Whose Engine API, and where. The site is named for the same reason as `TileSync`: an +/// address learned from one DHP means nothing to a PC pointed at another. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct KnownEngine { + pub site: String, + pub url: String, } /// Whose journal, and how far through it. The site is named because a PC moved to another @@ -60,6 +74,21 @@ impl LauncherState { _ => 0, } } + + /// The Engine API this PC last reached on `site`, None for a site it never reached. + pub fn engine_url(&self, site: &str) -> Option { + self.engine.as_ref().filter(|known| known.site == site && !site.is_empty()).map(|known| known.url.clone()) + } + + /// Remember `url` as `site`'s Engine API. True when that changed what is on disk. + pub fn remember_engine(&mut self, site: &str, url: &str) -> bool { + let known = KnownEngine { site: site.to_string(), url: url.to_string() }; + if site.is_empty() || self.engine.as_ref() == Some(&known) { + return false; + } + self.engine = Some(known); + true + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -105,3 +134,42 @@ pub fn cached_notes(install_dir: &Path, version: &str) -> Option { pub fn cache_notes(install_dir: &Path, version: &str, markdown: &str) -> Result<(), String> { write_atomic(&state_dir(install_dir).join("notes").join(format!("{version}.md")), markdown.as_bytes()) } + +#[cfg(test)] +mod tests { + use super::*; + + const SITE: &str = "https://engine.odian.fr/dhp"; + const ENGINE: &str = "https://engine-api.odian.fr"; + + #[test] + fn the_engine_api_is_remembered_per_site() { + let mut state = LauncherState::default(); + assert_eq!(state.engine_url(SITE), None, "a PC that never reached the DHP knows no address"); + assert!(state.remember_engine(SITE, ENGINE)); + assert_eq!(state.engine_url(SITE).as_deref(), Some(ENGINE)); + // Another DHP's address is not this one's. + assert_eq!(state.engine_url("http://192.168.7.2:30097"), None); + // Unchanged is not a change: no write for every probe that finds the same address. + assert!(!state.remember_engine(SITE, ENGINE)); + // And a PC set offline on purpose (no DHP address) remembers nothing. + assert!(!state.remember_engine("", ENGINE)); + assert_eq!(state.engine_url(""), None); + } + + #[test] + fn the_engine_api_survives_a_restart_and_old_files_still_load() { + let dir = std::env::temp_dir().join(format!("odian-state-{}", uuid::Uuid::new_v4())); + let mut state = LauncherState::default(); + state.remember_engine(SITE, ENGINE); + state.save(&dir).unwrap(); + // What a launcher started offline reads: the address it had before it was closed. + assert_eq!(LauncherState::load(&dir).unwrap().engine_url(SITE).as_deref(), Some(ENGINE)); + + // A launcher.json from before this field loads, with no address. + std::fs::write(dir.join("state").join("launcher.json"), br#"{"install_id":"abc","operator":"jimmy"}"#).unwrap(); + let old = LauncherState::load(&dir).unwrap(); + assert_eq!((old.install_id.as_str(), old.engine_url(SITE)), ("abc", None)); + let _ = std::fs::remove_dir_all(&dir); + } +}