M4
This commit is contained in:
+356
-2
@@ -1,6 +1,7 @@
|
||||
//! The launcher's live state behind every command, and the `status` event.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -15,7 +16,8 @@ use crate::localdb::{self, Pg};
|
||||
use crate::settings::{normalize_url, Settings};
|
||||
use crate::site::{self, SessionRefresh, TokenCheck};
|
||||
use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent};
|
||||
use crate::state::{self, LauncherState};
|
||||
use crate::state::{self, LauncherState, SyncOrigin};
|
||||
use crate::sync::{self, SyncError, SyncStatus};
|
||||
use crate::tokens::{self, SiteToken};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
@@ -26,6 +28,8 @@ pub enum Phase {
|
||||
SigningIn,
|
||||
Installing,
|
||||
Restoring,
|
||||
/// Exchanging operator data with the DHP (ODIAN is not running meanwhile).
|
||||
Syncing,
|
||||
Launching,
|
||||
Running,
|
||||
Error,
|
||||
@@ -50,6 +54,11 @@ pub struct Status {
|
||||
pub phase: Phase,
|
||||
pub error: Option<String>,
|
||||
pub install_dir: String,
|
||||
/// Operator-data sync counts from the last sync this session; None before one.
|
||||
pub sync: Option<SyncStatus>,
|
||||
/// While ODIAN runs: true when its data is coming from the DHP, false when from
|
||||
/// this PC. None when ODIAN is not running, or the DHP offers no Engine API.
|
||||
pub live: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -90,6 +99,11 @@ struct Inner {
|
||||
token: Option<SiteToken>,
|
||||
phase: Phase,
|
||||
error: Option<String>,
|
||||
sync: Option<SyncStatus>,
|
||||
/// The DHP's Engine API, learned from its health answer; kept once seen so a launch
|
||||
/// during a blip still knows where live mode will forward.
|
||||
engine_url: Option<String>,
|
||||
live: Option<bool>,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
@@ -111,6 +125,8 @@ impl Inner {
|
||||
phase: self.phase,
|
||||
error: self.error.clone(),
|
||||
install_dir: self.settings.install_dir.clone(),
|
||||
sync: self.sync.clone(),
|
||||
live: self.live,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +156,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 }),
|
||||
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None }),
|
||||
stack: Mutex::new(None),
|
||||
stack_states: std::sync::Mutex::new(Vec::new()),
|
||||
sign_in_cancel: Notify::new(),
|
||||
@@ -253,6 +269,10 @@ impl Launcher {
|
||||
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 probe.engine_url.is_some() {
|
||||
inner.engine_url = probe.engine_url;
|
||||
}
|
||||
}
|
||||
// Offline nothing is checked or forgotten: the remembered operator still launches.
|
||||
if let (true, Some(token)) = (probe.online, token) {
|
||||
@@ -604,6 +624,8 @@ impl Launcher {
|
||||
restored.map_err(|e| format!("The database restore failed: {e}"))?;
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.state.db_snapshot = Some(db.snapshot.clone());
|
||||
inner.state.sync_origin =
|
||||
db.sync.as_ref().map(|origin| SyncOrigin { db_id: origin.db_id.clone(), horizon: origin.horizon.clone() });
|
||||
inner.state.save(install_dir)?;
|
||||
}
|
||||
|
||||
@@ -682,6 +704,7 @@ impl Launcher {
|
||||
pg_password: inner.state.pg_password.clone(),
|
||||
install_id: inner.state.install_id.clone(),
|
||||
tile_sets,
|
||||
upstream_url: inner.engine_url.clone(),
|
||||
}
|
||||
};
|
||||
if let Ok(mut states) = self.stack_states.lock() {
|
||||
@@ -697,6 +720,19 @@ impl Launcher {
|
||||
}
|
||||
|
||||
async fn run_stack(&self, app: &AppHandle, config: StackConfig) {
|
||||
// Take in the other operators' work before the API loads it. Never blocks the launch.
|
||||
self.set_phase(app, Phase::Syncing).await;
|
||||
self.sync_run(app, &config.layout, &config.pg_password, &config.install_id, true).await;
|
||||
self.set_phase(app, Phase::Launching).await;
|
||||
|
||||
// Live mode: hand the API the DHP token before it starts, so a launch made online
|
||||
// comes up on the DHP's data instead of switching a moment later.
|
||||
let live_mode = config.upstream_url.is_some();
|
||||
if live_mode {
|
||||
let token = self.site_access_token(app).await.ok();
|
||||
self.write_upstream_token(&config.layout, token.as_deref());
|
||||
}
|
||||
|
||||
let emit = |event: StackEvent| self.record_stack(app, event);
|
||||
let running = match stack::start(&self.http, &config, &emit).await {
|
||||
Ok(running) => running,
|
||||
@@ -709,14 +745,31 @@ impl Launcher {
|
||||
*self.stack.lock().await = Some(running);
|
||||
self.set_phase(app, Phase::Running).await;
|
||||
|
||||
let live_cancel = CancellationToken::new();
|
||||
if live_mode {
|
||||
let handle = app.clone();
|
||||
let cancel = live_cancel.clone();
|
||||
let (layout, password, install_id) =
|
||||
(config.layout.clone(), config.pg_password.clone(), config.install_id.clone());
|
||||
tauri::async_runtime::spawn(async move {
|
||||
handle.state::<Launcher>().live_loop(&handle, layout, password, install_id, cancel).await;
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(odian) = odian {
|
||||
stack::wait_exit(&odian).await;
|
||||
}
|
||||
// Odian.exe closed. If Stop already took the stack, it stopped the rest too.
|
||||
live_cancel.cancel();
|
||||
self.write_upstream_token(&config.layout, None);
|
||||
self.inner.lock().await.live = None;
|
||||
let taken = self.stack.lock().await.take();
|
||||
if let Some(mut running) = taken {
|
||||
running.stop(&emit).await;
|
||||
}
|
||||
// Send this session's work while it is fresh; pulling waits for the next launch.
|
||||
self.set_phase(app, Phase::Syncing).await;
|
||||
self.sync_run(app, &config.layout, &config.pg_password, &config.install_id, false).await;
|
||||
self.finish(app, None).await;
|
||||
}
|
||||
|
||||
@@ -729,6 +782,307 @@ impl Launcher {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── operator-data sync ──────────────────────────────────────────────────
|
||||
|
||||
/// Sync with the DHP while the local Engine API is not running. Never fails the caller:
|
||||
/// offline, signed out or refused, the edits stay on this PC for the next connected run.
|
||||
async fn sync_run(&self, app: &AppHandle, layout: &Layout, pg_password: &str, install_id: &str, pull: bool) {
|
||||
let sql_dir = layout.sync_sql_dir();
|
||||
if !sync::schema_shipped(&sql_dir) {
|
||||
return; // a version from before sync
|
||||
}
|
||||
let (online, site_url, origin) = {
|
||||
let inner = self.inner.lock().await;
|
||||
(inner.online, inner.settings.site_url.clone(), inner.state.sync_origin.clone())
|
||||
};
|
||||
if !online {
|
||||
self.log(app, "info", "Offline: this PC's changes stay here until the next connected launch.");
|
||||
return;
|
||||
}
|
||||
|
||||
let pg = Pg::new(layout, pg_password);
|
||||
let db = pg.clone();
|
||||
let started = match tauri::async_runtime::spawn_blocking(move || -> Result<bool, String> {
|
||||
let started = !db.running();
|
||||
if started {
|
||||
db.start()?;
|
||||
}
|
||||
Ok(started)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(started)) => started,
|
||||
Ok(Err(message)) => {
|
||||
self.log(app, "warn", format!("Sync skipped: {message}"));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
self.log(app, "warn", format!("Sync skipped: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match self.sync_with_dhp(app, &pg, &sql_dir, &site_url, install_id, origin, pull).await {
|
||||
Ok(report) => {
|
||||
let s = &report.status;
|
||||
self.log(
|
||||
app,
|
||||
"info",
|
||||
format!(
|
||||
"Synced with the DHP: {} change(s) sent, {} received. {} waiting to send, {} conflict(s), {} refused.",
|
||||
report.accepted, report.received, s.pending, s.conflicts, s.rejected
|
||||
),
|
||||
);
|
||||
if s.conflicts + s.rejected > 0 {
|
||||
self.log(app, "warn", "Some changes need your decision: review them under Sync.");
|
||||
}
|
||||
self.inner.lock().await.sync = Some(report.status);
|
||||
}
|
||||
Err(message) => self.log(app, "warn", format!("Sync did not complete: {message}")),
|
||||
}
|
||||
|
||||
if started {
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || pg.stop()).await;
|
||||
}
|
||||
self.emit(app).await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn sync_with_dhp(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
pg: &Pg,
|
||||
sql_dir: &Path,
|
||||
site_url: &str,
|
||||
install_id: &str,
|
||||
origin: Option<SyncOrigin>,
|
||||
pull: bool,
|
||||
) -> Result<sync::Report, String> {
|
||||
let token = self.site_access_token(app).await?;
|
||||
let (db, dir) = (pg.clone(), sql_dir.to_path_buf());
|
||||
let joined = tauri::async_runtime::spawn_blocking(move || {
|
||||
sync::load_schema(&db, &dir)?;
|
||||
sync::joined_db_id(&db)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
if joined.is_none() {
|
||||
let (db_id, since) = match origin {
|
||||
Some(origin) => (origin.db_id, origin.horizon),
|
||||
None => {
|
||||
let dhp = sync::Dhp { http: &self.http, site_url, token: &token };
|
||||
let greeting = sync::hello(&dhp).await.map_err(|e| e.to_string())?;
|
||||
self.log(app, "warn", "This PC's database predates sync: it takes the DHP's changes from now on.");
|
||||
greeting
|
||||
}
|
||||
};
|
||||
let db = pg.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || sync::join(&db, &db_id, &since)).await.map_err(|e| e.to_string())??;
|
||||
}
|
||||
|
||||
let dhp = sync::Dhp { http: &self.http, site_url, token: &token };
|
||||
match sync::run(pg, &dhp, install_id, pull).await {
|
||||
Err(SyncError::Unauthorized) => {
|
||||
let token = self.renew(app).await?;
|
||||
let dhp = sync::Dhp { http: &self.http, site_url, token: &token };
|
||||
sync::run(pg, &dhp, install_id, pull).await.map_err(|e| e.to_string())
|
||||
}
|
||||
other => other.map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the local API the DHP token it forwards with — or take it away, which sends
|
||||
/// the API back to this PC's data without restarting anything.
|
||||
fn write_upstream_token(&self, layout: &Layout, token: Option<&str>) {
|
||||
let path = stack::upstream_token_file(layout);
|
||||
match token {
|
||||
Some(token) => {
|
||||
if let Err(e) = crate::fsutil::write_atomic(&path, token.as_bytes()) {
|
||||
log::warn!("cannot hand the local API its DHP token: {e}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the local API where ODIAN's data is coming from right now.
|
||||
async fn read_live_state(&self) {
|
||||
let url = format!("http://127.0.0.1:{}/offline/status", stack::API_PORT);
|
||||
let live = match self.http.get(url).timeout(Duration::from_secs(3)).send().await {
|
||||
Ok(response) => response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|body| body.get("mode").and_then(|mode| mode.as_str()).map(|mode| mode == "live")),
|
||||
Err(_) => None,
|
||||
};
|
||||
self.inner.lock().await.live = live;
|
||||
}
|
||||
|
||||
/// While ODIAN runs against a DHP: keep its token fresh, notice the connection coming
|
||||
/// and going, send this PC's offline work the moment it returns, and keep this PC's
|
||||
/// own copy warm so an abrupt drop lands on fresh data rather than an old snapshot.
|
||||
async fn live_loop(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
layout: Layout,
|
||||
pg_password: String,
|
||||
install_id: String,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
const TICK: Duration = Duration::from_secs(30);
|
||||
const MIRROR_EVERY: Duration = Duration::from_secs(120);
|
||||
let (site_url, mut was_online) = {
|
||||
let inner = self.inner.lock().await;
|
||||
(inner.settings.site_url.clone(), inner.online)
|
||||
};
|
||||
let mut mirrored = Instant::now();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = tokio::time::sleep(TICK) => {}
|
||||
}
|
||||
let probe = site::probe(&self.http, &site_url).await;
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.online = probe.online;
|
||||
if probe.engine_url.is_some() {
|
||||
inner.engine_url = probe.engine_url.clone();
|
||||
}
|
||||
}
|
||||
if !probe.online {
|
||||
if was_online {
|
||||
self.log(app, "warn", "The DHP is out of reach: ODIAN is working from this PC. Your changes stay here until it is back.");
|
||||
}
|
||||
was_online = false;
|
||||
self.read_live_state().await;
|
||||
self.emit(app).await;
|
||||
continue;
|
||||
}
|
||||
let Ok(token) = self.site_access_token(app).await else {
|
||||
self.read_live_state().await;
|
||||
continue;
|
||||
};
|
||||
if !was_online {
|
||||
// Hold the API on this PC's data until this PC's offline work has reached the
|
||||
// DHP: nothing may be edited there on top of edits that have not arrived yet.
|
||||
self.write_upstream_token(&layout, None);
|
||||
self.log(app, "info", "Back on the DHP: sending the changes made offline…");
|
||||
self.sync_run(app, &layout, &pg_password, &install_id, false).await;
|
||||
mirrored = Instant::now();
|
||||
}
|
||||
self.write_upstream_token(&layout, Some(&token));
|
||||
if !was_online {
|
||||
self.log(app, "info", "ODIAN is back on the DHP's data.");
|
||||
was_online = true;
|
||||
}
|
||||
if mirrored.elapsed() >= MIRROR_EVERY {
|
||||
self.sync_run(app, &layout, &pg_password, &install_id, true).await;
|
||||
mirrored = Instant::now();
|
||||
}
|
||||
self.read_live_state().await;
|
||||
self.emit(app).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync on demand while ODIAN is stopped.
|
||||
pub async fn sync_now(&self, app: &AppHandle) -> Result<(), String> {
|
||||
let (layout, password, install_id) = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
|
||||
return Err("ODIAN is busy: sync once it has stopped.".to_string());
|
||||
}
|
||||
let version = inner.state.installed_version.clone().ok_or("ODIAN is not installed.")?;
|
||||
if inner.state.pg_password.is_empty() || inner.state.db_snapshot.is_none() {
|
||||
return Err("The local database was never set up. Reinstall from the DHP or a drive.".to_string());
|
||||
}
|
||||
inner.phase = Phase::Syncing;
|
||||
inner.error = None;
|
||||
(Layout::new(&inner.settings.install_path(), &version), inner.state.pg_password.clone(), inner.state.install_id.clone())
|
||||
};
|
||||
self.emit(app).await;
|
||||
self.sync_run(app, &layout, &password, &install_id, true).await;
|
||||
self.finish(app, None).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `work` on this PC's database while ODIAN is stopped, starting Postgres just for it.
|
||||
async fn with_local_db<T: Send + 'static>(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
work: impl FnOnce(&Pg) -> Result<T, String> + Send + 'static,
|
||||
) -> Result<T, String> {
|
||||
let (layout, password) = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
|
||||
return Err("ODIAN is busy: review sync once it has stopped.".to_string());
|
||||
}
|
||||
let version = inner.state.installed_version.clone().ok_or("ODIAN is not installed.")?;
|
||||
if inner.state.pg_password.is_empty() {
|
||||
return Err("The local database was never set up.".to_string());
|
||||
}
|
||||
inner.phase = Phase::Syncing;
|
||||
(Layout::new(&inner.settings.install_path(), &version), inner.state.pg_password.clone())
|
||||
};
|
||||
self.emit(app).await;
|
||||
let pg = Pg::new(&layout, &password);
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
let started = !pg.running();
|
||||
if started {
|
||||
pg.start()?;
|
||||
}
|
||||
let result = work(&pg);
|
||||
if started {
|
||||
let _ = pg.stop();
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|result| result);
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
if inner.phase == Phase::Syncing {
|
||||
inner.phase = Phase::Idle;
|
||||
}
|
||||
}
|
||||
self.emit(app).await;
|
||||
result
|
||||
}
|
||||
|
||||
/// What the operator has to decide, oldest first.
|
||||
pub async fn sync_conflicts(&self, app: &AppHandle) -> Result<Vec<sync::Conflict>, String> {
|
||||
self
|
||||
.with_local_db(app, |pg| match sync::joined_db_id(pg)? {
|
||||
None => Ok(Vec::new()),
|
||||
Some(_) => sync::conflicts(pg),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Record one decision; returns what is left to decide.
|
||||
pub async fn resolve_sync_conflict(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
tbl: String,
|
||||
key: serde_json::Value,
|
||||
choice: String,
|
||||
value: Option<serde_json::Value>,
|
||||
) -> Result<Vec<sync::Conflict>, String> {
|
||||
let (left, status) = self
|
||||
.with_local_db(app, move |pg| {
|
||||
sync::resolve(pg, &tbl, &key, &choice, value.as_ref())?;
|
||||
Ok((sync::conflicts(pg)?, sync::status(pg)?))
|
||||
})
|
||||
.await?;
|
||||
self.inner.lock().await.sync = Some(status);
|
||||
self.emit(app).await;
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
/// The launcher is exiting: stop the stack cleanly rather than leave Postgres to crash recovery.
|
||||
pub async fn shutdown(&self) {
|
||||
if let Ok(slot) = self.install_cancel.lock() {
|
||||
|
||||
Reference in New Issue
Block a user