This commit is contained in:
Kelorastus
2026-09-29 15:54:30 +02:00
2 changed files with 103 additions and 9 deletions
+35 -9
View File
@@ -199,6 +199,19 @@ struct Inner {
} }
impl 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 { fn status(&self) -> Status {
let installed = self.state.installed_version.clone(); let installed = self.state.installed_version.clone();
// Online means "update before launch"; offline launches whatever is installed. // Online means "update before launch"; offline launches whatever is installed.
@@ -387,6 +400,8 @@ impl Launcher {
pub fn load(app: &AppHandle) -> Result<Launcher, String> { pub fn load(app: &AppHandle) -> Result<Launcher, String> {
let settings = Settings::load(app)?; let settings = Settings::load(app)?;
let state = LauncherState::load(&settings.install_path())?; 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 token = tokens::load(&settings.site_url);
let http = reqwest::Client::builder() let http = reqwest::Client::builder()
.user_agent(concat!("ODIAN-Launcher/", env!("CARGO_PKG_VERSION"))) .user_agent(concat!("ODIAN-Launcher/", env!("CARGO_PKG_VERSION")))
@@ -394,7 +409,7 @@ impl Launcher {
.map_err(|e| format!("cannot start the HTTP client: {e}"))?; .map_err(|e| format!("cannot start the HTTP client: {e}"))?;
Ok(Launcher { Ok(Launcher {
http, 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: Mutex::new(None),
stack_states: std::sync::Mutex::new(Vec::new()), stack_states: std::sync::Mutex::new(Vec::new()),
sign_in_cancel: Notify::new(), sign_in_cancel: Notify::new(),
@@ -562,14 +577,15 @@ impl Launcher {
false => self.log(app, "warn", format!("Your DHP did not answer. {why}")), false => self.log(app, "warn", format!("Your DHP did not answer. {why}")),
} }
} }
{ let kept = {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
inner.online = probe.online; inner.online = probe.online;
inner.available = probe.version; inner.available = probe.version;
// Keep the last known address while offline: it is where live mode resumes. // Keep the last known address while offline: it is where live mode resumes.
if engine.is_some() { engine.map(|url| inner.adopt_engine(url)).unwrap_or(Ok(()))
inner.engine_url = engine; };
} 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. // Offline nothing is checked or forgotten: the remembered operator still launches.
if let (true, Some(token)) = (probe.online, token) { if let (true, Some(token)) = (probe.online, token) {
@@ -903,6 +919,9 @@ impl Launcher {
if next.site_url != inner.settings.site_url { if next.site_url != inner.settings.site_url {
inner.token = tokens::load(&next.site_url); inner.token = tokens::load(&next.site_url);
inner.available = None; 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; inner.settings = next;
} }
@@ -1859,6 +1878,12 @@ impl Launcher {
/// Start the local stack. Returns once launching has begun; `stack` and `status` events follow. /// Start the local stack. Returns once launching has begun; `stack` and `status` events follow.
pub async fn launch(&self, app: &AppHandle) -> Result<(), String> { 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 config = {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
let status = inner.status(); let status = inner.status();
@@ -2428,12 +2453,13 @@ impl Launcher {
} }
let probe = site::probe(&self.http, &site_url).await; let probe = site::probe(&self.http, &site_url).await;
let engine = self.pick_engine(app, &probe).await; let engine = self.pick_engine(app, &probe).await;
{ let kept = {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
inner.online = probe.online; inner.online = probe.online;
if engine.is_some() { engine.map(|url| inner.adopt_engine(url)).unwrap_or(Ok(()))
inner.engine_url = engine; };
} if let Err(e) = kept {
self.log(app, "warn", format!("Could not remember the DHP's Engine API address: {e}"));
} }
if !probe.online { if !probe.online {
if was_online { if was_online {
+68
View File
@@ -42,6 +42,20 @@ pub struct LauncherState {
/// while it was away. None until the first sync. /// while it was away. None until the first sync.
#[serde(default)] #[serde(default)]
pub tile_sync: Option<TileSync>, pub tile_sync: Option<TileSync>,
/// 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<KnownEngine>,
}
/// 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 /// Whose journal, and how far through it. The site is named because a PC moved to another
@@ -60,6 +74,21 @@ impl LauncherState {
_ => 0, _ => 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<String> {
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)] #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -105,3 +134,42 @@ pub fn cached_notes(install_dir: &Path, version: &str) -> Option<String> {
pub fn cache_notes(install_dir: &Path, version: &str, markdown: &str) -> Result<(), String> { 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()) 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);
}
}