Live mode on a PC that started offline: re-probe at launch, remember the Engine API

The dev drive, plugged into another PC that was offline and then connected,
ran two sessions with no live aircraft. Its API log, on the drive, says why:
"upstream: live mode off (ODIAN_UPSTREAM_URL is unset)" - both times.

Root cause: live mode is decided when the stack starts (StackConfig::
upstream_url = Inner::engine_url), and engine_url was only ever learned from a
site probe and held in MEMORY. A launcher opened offline has none, and it probes
again only on window focus (30 s debounce) or the refresh button - so a Launch
pressed after connecting still used the offline answer, the API got no
ODIAN_UPSTREAM_URL, and live_loop (which would have synced the offline edits and
switched the API to the DHP when the network came) never started.

- launch() re-probes the site first when the launcher thinks it is offline (one
  probe, 2 s at most on a PC that really is). It is also the update check a
  launch from a stale "offline" skipped.
- The Engine API address is kept in launcher.json (LauncherState::engine, keyed
  by site like tile_sync) and loaded at start, so a session started offline
  still arms live mode and goes live by itself once the network is back - the
  path live_loop already handles. Changing the DHP address in Settings drops it.

Spot-check: state::tests - per-site, unchanged-is-no-write, survives save/load,
and a launcher.json from before the field still loads.
This commit is contained in:
JIMMY_JFEXP
2026-09-29 15:29:09 +02:00
parent b46b81ac12
commit ce36cf0f37
2 changed files with 103 additions and 9 deletions
+35 -9
View File
@@ -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<Launcher, String> {
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 {
+68
View File
@@ -42,6 +42,20 @@ pub struct LauncherState {
/// while it was away. None until the first sync.
#[serde(default)]
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
@@ -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<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)]
@@ -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> {
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);
}
}