//! 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; use odian_packs::{InstallOptions, Progress, Source, Stage}; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::{Mutex, Notify}; use tokio_util::sync::CancellationToken; use crate::install; 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, SyncOrigin}; use crate::sync::{self, SyncError, SyncStatus}; use crate::tokens::{self, SiteToken}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum Phase { Idle, Checking, SigningIn, Installing, Restoring, /// Exchanging operator data with the DHP (ODIAN is not running meanwhile). Syncing, Launching, Running, Error, } #[derive(Clone, Serialize)] pub struct SiteInfo { pub url: String, pub name: Option, } #[derive(Clone, Serialize)] pub struct Status { pub online: bool, pub site: SiteInfo, pub operator: Option, pub signed_in: bool, pub installed: Option, pub available: Option, pub update_required: bool, pub can_launch: bool, pub phase: Phase, pub error: Option, pub install_dir: String, /// Operator-data sync counts from the last sync this session; None before one. pub sync: Option, /// 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, } #[derive(Clone, Serialize)] struct LogEvent { level: &'static str, message: String, at: u64, } enum InstallFailure { Cancelled, /// The site refused the Engine token (expired, or the site's API restarted). Unauthorized, Failed(String), } impl From for InstallFailure { fn from(message: String) -> Self { InstallFailure::Failed(message) } } impl From for InstallFailure { fn from(error: odian_packs::Error) -> Self { match error { odian_packs::Error::Cancelled => InstallFailure::Cancelled, odian_packs::Error::Unauthorized { .. } => InstallFailure::Unauthorized, other => InstallFailure::Failed(other.to_string()), } } } struct Inner { settings: Settings, state: LauncherState, online: bool, available: Option, token: Option, phase: Phase, error: Option, sync: Option, /// 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, live: Option, } impl Inner { fn status(&self) -> Status { let installed = self.state.installed_version.clone(); // Online means "update before launch"; offline launches whatever is installed. // Nothing installed is never "update required": that is a first install. let update_required = self.online && installed.is_some() && self.available.is_some() && installed != self.available; Status { online: self.online, site: SiteInfo { url: self.settings.site_url.clone(), name: self.settings.site_name.clone() }, operator: self.state.operator.clone(), // Remembered until Sign out: a launcher session renews the Engine token on its own. signed_in: self.token.as_ref().is_some_and(|t| t.launcher_token.is_some() || !t.expired()), can_launch: installed.is_some() && !update_required && matches!(self.phase, Phase::Idle | Phase::Error), installed, available: self.available.clone(), update_required, phase: self.phase, error: self.error.clone(), install_dir: self.settings.install_dir.clone(), sync: self.sync.clone(), live: self.live, } } } pub struct Launcher { pub http: reqwest::Client, inner: Mutex, stack: Mutex>, /// The latest event per stack component, so a reloaded UI can redraw the stack. stack_states: std::sync::Mutex>, sign_in_cancel: Notify, install_cancel: std::sync::Mutex>, } fn now_secs() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } impl Launcher { pub fn load(app: &AppHandle) -> Result { let settings = Settings::load(app)?; let state = LauncherState::load(&settings.install_path())?; let token = tokens::load(&settings.site_url); let http = reqwest::Client::builder() .user_agent(concat!("ODIAN-Launcher/", env!("CARGO_PKG_VERSION"))) .build() .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 }), stack: Mutex::new(None), stack_states: std::sync::Mutex::new(Vec::new()), sign_in_cancel: Notify::new(), install_cancel: std::sync::Mutex::new(None), }) } pub async fn status(&self) -> Status { self.inner.lock().await.status() } async fn emit(&self, app: &AppHandle) -> Status { let status = self.status().await; let _ = app.emit("status", &status); status } fn log(&self, app: &AppHandle, level: &'static str, message: impl Into) { let _ = app.emit("log", &LogEvent { level, message: message.into(), at: now_secs() }); } async fn finish(&self, app: &AppHandle, error: Option) { { let mut inner = self.inner.lock().await; inner.phase = if error.is_some() { Phase::Error } else { Phase::Idle }; inner.error = error; } self.emit(app).await; } async fn set_phase(&self, app: &AppHandle, phase: Phase) { { self.inner.lock().await.phase = phase; } self.emit(app).await; } pub async fn settings(&self) -> Settings { self.inner.lock().await.settings.clone() } // ── DHP sign-in ───────────────────────────────────────────────────────── /// Swap the launcher session for a fresh Engine token. Never asks the operator. /// Ok(token) when renewed; Err when the session ended (the token is forgotten) or the /// sign-in service couldn't answer (the token is kept for a later try). async fn renew(&self, app: &AppHandle) -> Result { let (mcp_url, site_url, token) = { let inner = self.inner.lock().await; (inner.settings.mcp_url.clone(), inner.settings.site_url.clone(), inner.token.clone()) }; let Some(mut token) = token else { return Err("Sign in to download from the DHP.".to_string()); }; let Some(launcher_token) = token.launcher_token.clone() else { // A sign-in from before launcher sessions: nothing to renew with. let mut inner = self.inner.lock().await; tokens::clear(&site_url); inner.token = None; return Err("Your DHP sign-in has expired. Sign in again.".to_string()); }; match site::refresh_session(&self.http, &mcp_url, &launcher_token).await { SessionRefresh::Renewed { access_token, expires_at, username } => { token.access_token = access_token.clone(); token.expires_at = expires_at; if !username.is_empty() { token.username = username; } let _ = tokens::save(&site_url, &token); self.inner.lock().await.token = Some(token); self.log(app, "info", "Renewed the DHP sign-in."); Ok(access_token) } SessionRefresh::Revoked(detail) => { tokens::clear(&site_url); self.inner.lock().await.token = None; self.log(app, "warn", format!("The DHP ended this PC's sign-in: {detail}")); Err(format!("The DHP ended this PC's sign-in ({detail}). Sign in again.")) } SessionRefresh::Unavailable(reason) => Err(format!("Couldn't renew the DHP sign-in: {reason}")), } } /// An Engine token the site should accept: the stored one while it hasn't expired, else renewed. async fn site_access_token(&self, app: &AppHandle) -> Result { let current = self.inner.lock().await.token.clone(); match current { Some(token) if !token.expired() => Ok(token.access_token), Some(_) => self.renew(app).await, None => Err("Sign in to download from the DHP.".to_string()), } } /// Re-probe the site and re-check the stored sign-in, renewing it quietly when the site /// no longer takes it. Never fails: an unreachable site is "offline". Also how the UI /// dismisses an error. pub async fn refresh(&self, app: &AppHandle) -> Status { let (site_url, token) = { let mut inner = self.inner.lock().await; if matches!(inner.phase, Phase::Idle | Phase::Error) { inner.phase = Phase::Checking; inner.error = None; } (inner.settings.site_url.clone(), inner.token.clone()) }; self.emit(app).await; let probe = site::probe(&self.http, &site_url).await; { 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) { let check = if token.expired() { TokenCheck::Invalid } else { site::check_token(&self.http, &site_url, &token.access_token).await }; if matches!(check, TokenCheck::Invalid) { // Renewal failures are already logged; an unavailable sign-in service keeps the token. let _ = self.renew(app).await; } } { let mut inner = self.inner.lock().await; if inner.phase == Phase::Checking { inner.phase = Phase::Idle; } } self.emit(app).await } pub async fn sign_in(&self, app: &AppHandle) -> Result { let (mcp_url, site_url) = { let mut inner = self.inner.lock().await; if !matches!(inner.phase, Phase::Idle | Phase::Error) { return Err("The launcher is busy.".to_string()); } inner.phase = Phase::SigningIn; inner.error = None; (inner.settings.mcp_url.clone(), inner.settings.site_url.clone()) }; self.emit(app).await; let result = crate::auth::sign_in(app, &self.http, &mcp_url, &self.sign_in_cancel).await; let outcome = { let mut inner = self.inner.lock().await; match result { Ok(token) => { let username = token.username.clone(); let remembered = token.launcher_token.is_some(); let stored = tokens::save(&site_url, &token); inner.token = Some(token); inner.state.operator = Some(username.clone()); let install_dir = inner.settings.install_path(); let saved = inner.state.save(&install_dir); inner.phase = Phase::Idle; // Staying signed in for this session still works if Credential Manager refused. inner.error = stored.and(saved).err(); if !remembered { self.log(app, "warn", "This DHP's sign-in service can't remember you yet: you may be asked to sign in again after a day."); } Ok(username) } Err(crate::auth::SignInError::Cancelled) => { inner.phase = Phase::Idle; Err("Sign-in was cancelled.".to_string()) } Err(crate::auth::SignInError::Failed(message)) => { inner.phase = Phase::Error; inner.error = Some(message.clone()); Err(message) } } }; self.emit(app).await; outcome } /// Abandon a browser sign-in that is waiting for its callback. pub fn cancel_sign_in(&self) { self.sign_in_cancel.notify_waiters(); } /// Forget the operator on this PC: the DHP sign-in, its launcher session (ended on the site /// when reachable) and the remembered operator. ODIAN won't launch offline until the next sign-in. pub async fn sign_out(&self, app: &AppHandle) -> Status { let (mcp_url, launcher_token) = { let mut inner = self.inner.lock().await; tokens::clear(&inner.settings.site_url); let launcher_token = inner.token.take().and_then(|t| t.launcher_token); inner.state.operator = None; let install_dir = inner.settings.install_path(); if let Err(e) = inner.state.save(&install_dir) { inner.error = Some(e); } (inner.settings.mcp_url.clone(), launcher_token) }; if let Some(launcher_token) = launcher_token { site::end_session(&self.http, &mcp_url, &launcher_token).await; } self.log(app, "info", "Signed out: this PC no longer remembers the operator."); self.emit(app).await } pub async fn set_settings(&self, app: &AppHandle, incoming: Settings) -> Result { { let mut inner = self.inner.lock().await; if !matches!(inner.phase, Phase::Idle | Phase::Error) { return Err("Settings can't change while the launcher is busy.".to_string()); } let mut next = Settings { site_url: normalize_url(&incoming.site_url), mcp_url: normalize_url(&incoming.mcp_url), install_dir: incoming.install_dir.trim().to_string(), site_name: incoming.site_name.or_else(|| inner.settings.site_name.clone()), }; if next.install_dir.is_empty() { next.install_dir = inner.settings.install_dir.clone(); } next.save(app)?; if next.install_dir != inner.settings.install_dir { inner.state = LauncherState::load(&next.install_path())?; } if next.site_url != inner.settings.site_url { inner.token = tokens::load(&next.site_url); inner.available = None; } inner.settings = next; } Ok(self.refresh(app).await) } /// Patch notes: from the DHP when possible, else the cached copy. pub async fn notes(&self, version: Option) -> Result { let (site_url, token, online, install_dir, fallback) = { let inner = self.inner.lock().await; let fallback = inner.available.clone().or_else(|| inner.state.installed_version.clone()); (inner.settings.site_url.clone(), inner.token.clone(), inner.online, inner.settings.install_path(), fallback) }; let version = version.or(fallback).ok_or("No version is installed or available yet.")?; if let (true, Some(token)) = (online, token.filter(|t| !t.expired())) { if let Ok(markdown) = site::notes(&self.http, &site_url, &token.access_token, &version).await { let _ = state::cache_notes(&install_dir, &version, &markdown); return Ok(markdown); } } state::cached_notes(&install_dir, &version).ok_or_else(|| { if online { "Sign in to read the patch notes.".to_string() } else { "Patch notes for this version aren't available offline yet.".to_string() } }) } // ── install ────────────────────────────────────────────────────────────── /// Install or update from the DHP (`drive: None`) or from an ODIAN drive. /// Returns once installing has begun; `install-progress`, `log` and `status` events follow. pub async fn start_install(&self, app: &AppHandle, drive: Option) -> Result<(), String> { { let inner = self.inner.lock().await; if !matches!(inner.phase, Phase::Idle | Phase::Error) { return Err("The launcher is busy.".to_string()); } match &drive { Some(root) => { if !root.join("packs").join("versions").join("CURRENT").is_file() { return Err(format!("{} is not an ODIAN drive: it has no packs\\versions\\CURRENT.", root.display())); } } None => { if !inner.online { return Err("Your DHP is unreachable. Connect to install, or install from a drive.".to_string()); } } } } if drive.is_none() { // Renew before starting rather than failing a minute in. self.site_access_token(app).await?; } let install_dir = { let mut inner = self.inner.lock().await; if !matches!(inner.phase, Phase::Idle | Phase::Error) { return Err("The launcher is busy.".to_string()); } inner.phase = Phase::Installing; inner.error = None; inner.settings.install_path() }; let cancel = CancellationToken::new(); if let Ok(mut slot) = self.install_cancel.lock() { *slot = Some(cancel.clone()); } self.emit(app).await; let handle = app.clone(); tauri::async_runtime::spawn(async move { handle.state::().run_install(&handle, drive, install_dir, cancel).await; }); Ok(()) } /// Stop an install at the next file, chunk, block or table; everything verified is kept. pub fn cancel_install(&self) -> Result<(), String> { match self.install_cancel.lock().ok().and_then(|slot| slot.clone()) { Some(token) => { token.cancel(); Ok(()) } None => Err("Nothing is installing.".to_string()), } } async fn run_install(&self, app: &AppHandle, drive: Option, install_dir: PathBuf, cancel: CancellationToken) { let mut renewed = false; let result = loop { let source = match &drive { Some(root) => Source::Drive { root: root.clone() }, None => match self.site_access_token(app).await { Ok(token) => Source::Site { base_url: self.inner.lock().await.settings.site_url.clone(), token }, Err(message) => break Err(InstallFailure::Failed(message)), }, }; match self.install_steps(app, &source, &install_dir, &cancel).await { // The site's API restarted (or the token aged out) mid-way: renew once and resume. // Everything already verified is skipped on the second pass. Err(InstallFailure::Unauthorized) if drive.is_none() && !renewed => { renewed = true; self.log(app, "info", "The DHP no longer accepted the sign-in; renewing it and resuming."); if let Err(message) = self.renew(app).await { break Err(InstallFailure::Failed(message)); } } other => break other, } }; if let Ok(mut slot) = self.install_cancel.lock() { *slot = None; } match result { Ok(version) => { self.log(app, "info", format!("ODIAN {version} is installed in {}", install_dir.display())); self.finish(app, None).await; if drive.is_none() { self.refresh(app).await; } } Err(InstallFailure::Cancelled) => { self.log(app, "warn", "Install cancelled. Everything already verified was kept; installing again resumes from there."); self.finish(app, None).await; } Err(InstallFailure::Unauthorized) => { let message = "The DHP refused the sign-in even after renewing it. Sign in again.".to_string(); self.log(app, "error", message.clone()); self.finish(app, Some(message)).await; } Err(InstallFailure::Failed(message)) => { self.log(app, "error", message.clone()); self.finish(app, Some(message)).await; } } } async fn install_steps( &self, app: &AppHandle, source: &Source, install_dir: &Path, cancel: &CancellationToken, ) -> Result { match source { Source::Site { base_url, .. } => self.log(app, "info", format!("Reading the current version from {base_url}")), Source::Drive { root } => self.log(app, "info", format!("Reading the ODIAN drive at {}", root.display())), } let verified = odian_packs::current_manifest(source, odian_packs::bundled_public_key()).await?; let manifest = verified.manifest(); let version = verified.version().to_string(); install::check_runnable(manifest)?; let database_restored = self.inner.lock().await.state.db_snapshot.is_some(); let restores_db = manifest.db.is_some() && !database_restored; let extra = install::extra_stages(restores_db); self.log(app, "info", format!("Installing ODIAN {version} (signature verified)")); if manifest.db.is_some() && database_restored { self.log(app, "info", "Keeping this PC's database: operator work stays; world data refreshes with sync."); } // The crate numbers only its own stages; the launcher appends restore and finalize. let crate_stages = Arc::new(AtomicU32::new(0)); let progress = { let app = app.clone(); let crate_stages = crate_stages.clone(); move |mut report: Progress| { crate_stages.store(report.stage_count, Ordering::Relaxed); report.stage_count += extra; let _ = app.emit("install-progress", &report); } }; odian_packs::install(source, install_dir, &verified, InstallOptions::default(), progress, cancel.clone()).await?; let base = match crate_stages.load(Ordering::Relaxed) { 0 => 5, n => n, }; let total = base + extra; if let (true, Some(db)) = (restores_db, manifest.db.as_ref()) { self.set_phase(app, Phase::Restoring).await; self.log(app, "info", format!("Loading database snapshot {} into this PC's Postgres", db.snapshot)); let password = { let mut inner = self.inner.lock().await; if inner.state.pg_password.is_empty() { inner.state.pg_password = state::new_secret(); inner.state.save(install_dir)?; } inner.state.pg_password.clone() }; let pg = Pg::new(&Layout::new(install_dir, &version), &password); let files: Vec = db .files .iter() .map(|f| localdb::DbFile { section: f.section.clone(), path: f.path.clone(), size: f.size }) .collect(); let data_root = install_dir.join("data"); let reporter = app.clone(); let stop = cancel.clone(); let restored = tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { let report = |done_bytes: u64, total_bytes: u64, done: usize, count: usize, path: &str| { let _ = reporter.emit( "install-progress", &Progress { stage: Stage::Restore, stage_index: base + 1, stage_count: total, done_bytes, total_bytes, done_items: done as u64, total_items: count as u64, bytes_per_sec: 0.0, message: Some(format!("restored {path}")), }, ); }; if !pg.is_initialized() { pg.init()?; } pg.start()?; let result = pg.restore(&data_root, &files, 4, &report, &|| stop.is_cancelled()); let stopped = pg.stop(); result.and(stopped) }) .await .map_err(|e| e.to_string())?; if cancel.is_cancelled() { return Err(InstallFailure::Cancelled); } 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)?; } let _ = app.emit( "install-progress", &Progress { stage: Stage::Finalize, stage_index: total, stage_count: total, done_bytes: 0, total_bytes: 0, done_items: 0, total_items: 0, bytes_per_sec: 0.0, message: Some(format!("ODIAN {version}")), }, ); if let Ok(markdown) = odian_packs::fetch_notes(source, &version).await { let _ = state::cache_notes(install_dir, &version, &markdown); } { let mut inner = self.inner.lock().await; inner.state.installed_version = Some(version.clone()); inner.state.save(install_dir)?; } Ok(version) } // ── local stack ─────────────────────────────────────────────────────────── pub fn stack_states(&self) -> Vec { self.stack_states.lock().map(|states| states.clone()).unwrap_or_default() } fn record_stack(&self, app: &AppHandle, event: StackEvent) { if let Ok(mut states) = self.stack_states.lock() { states.retain(|known| known.component != event.component); states.push(event.clone()); } let _ = app.emit("stack", &event); } /// Start the local stack. Returns once launching has begun; `stack` and `status` events follow. pub async fn launch(&self, app: &AppHandle) -> Result<(), String> { let config = { let mut inner = self.inner.lock().await; let status = inner.status(); if status.update_required { return Err("An update is available: update before launching while connected to the DHP.".to_string()); } if !status.can_launch { return Err("ODIAN isn't ready to launch.".to_string()); } let operator = inner .state .operator .clone() .ok_or("Sign in once while connected, so the launcher knows which operator to start ODIAN as.")?; 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()); } let install_dir = inner.settings.install_path(); if inner.state.mint_secret.is_empty() { inner.state.mint_secret = state::new_secret(); inner.state.save(&install_dir)?; } let layout = Layout::new(&install_dir, &version); let tile_sets = stack::installed_tile_sets(&layout)?; inner.phase = Phase::Launching; inner.error = None; StackConfig { layout, operator, mint_secret: inner.state.mint_secret.clone(), 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() { states.clear(); } self.emit(app).await; let handle = app.clone(); tauri::async_runtime::spawn(async move { handle.state::().run_stack(&handle, config).await; }); Ok(()) } 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, Err(message) => { self.log(app, "error", message.clone()); return self.finish(app, Some(message)).await; } }; let odian = running.odian(); *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::().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; } pub async fn stop(&self, app: &AppHandle) -> Result<(), String> { let taken = self.stack.lock().await.take(); let Some(mut running) = taken else { return Err("ODIAN isn't running.".to_string()); }; running.stop(&|event: StackEvent| self.record_stack(app, event)).await; 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 { 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, pull: bool, ) -> Result { 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::() .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( &self, app: &AppHandle, work: impl FnOnce(&Pg) -> Result + Send + 'static, ) -> Result { 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, 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, ) -> Result, 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() { if let Some(token) = slot.as_ref() { token.cancel(); } } let taken = self.stack.lock().await.take(); if let Some(mut running) = taken { running.stop(&|_: StackEvent| {}).await; } } }