1099 lines
40 KiB
Rust
1099 lines
40 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
#[derive(Clone, Serialize)]
|
|
pub struct Status {
|
|
pub online: bool,
|
|
pub site: SiteInfo,
|
|
pub operator: Option<String>,
|
|
pub signed_in: bool,
|
|
pub installed: Option<String>,
|
|
pub available: Option<String>,
|
|
pub update_required: bool,
|
|
pub can_launch: bool,
|
|
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)]
|
|
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<String> for InstallFailure {
|
|
fn from(message: String) -> Self {
|
|
InstallFailure::Failed(message)
|
|
}
|
|
}
|
|
|
|
impl From<odian_packs::Error> 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<String>,
|
|
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 {
|
|
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<Inner>,
|
|
stack: Mutex<Option<RunningStack>>,
|
|
/// The latest event per stack component, so a reloaded UI can redraw the stack.
|
|
stack_states: std::sync::Mutex<Vec<StackEvent>>,
|
|
sign_in_cancel: Notify,
|
|
install_cancel: std::sync::Mutex<Option<CancellationToken>>,
|
|
}
|
|
|
|
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<Launcher, String> {
|
|
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<String>) {
|
|
let _ = app.emit("log", &LogEvent { level, message: message.into(), at: now_secs() });
|
|
}
|
|
|
|
async fn finish(&self, app: &AppHandle, error: Option<String>) {
|
|
{
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<Status, String> {
|
|
{
|
|
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<String>) -> Result<String, String> {
|
|
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<PathBuf>) -> 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::<Launcher>().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<PathBuf>, 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<String, InstallFailure> {
|
|
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<localdb::DbFile> = 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<StackEvent> {
|
|
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::<Launcher>().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::<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;
|
|
}
|
|
|
|
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<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() {
|
|
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;
|
|
}
|
|
}
|
|
}
|