ODIAN Launcher: hub, installer, local stack and remembered sign-in (M2)
- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and update progress, local stack status, settings with DHP and MCP addresses. - crates/odian-packs: Rust installer for pack format v1 (signed manifests, resumable verified downloads from a DHP or an ODIAN drive), byte-compatible with ODIAN_MCP_Server's Python odian_packs. - Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe, one Job Object, clean shutdown; database snapshot restore on a fresh PC only. - Keycloak sign-in through the DHP's MCP, remembered until Sign out with a launcher session that renews the Engine token silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "odian-launcher"
|
||||
version = "0.1.0"
|
||||
description = "ODIAN Launcher: installs, updates and runs ODIAN on an operator PC"
|
||||
authors = ["ODIAN"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.81"
|
||||
|
||||
[lib]
|
||||
name = "odian_launcher_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.3", features = [] }
|
||||
|
||||
[dependencies]
|
||||
odian-packs = { path = "../crates/odian-packs" }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.11.3", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tokio = { version = "1", features = ["net", "io-util", "time", "sync", "macros", "rt-multi-thread", "fs"] }
|
||||
tokio-util = "0.7"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
junction = "1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Win32_System_Threading gates JOBOBJECT_EXTENDED_LIMIT_INFORMATION (it embeds IO_COUNTERS).
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Main window: core IPC plus the custom title bar (the window is frameless)",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,195 @@
|
||||
//! Keycloak sign-in through the site MCP's desktop flow.
|
||||
//!
|
||||
//! The launcher listens on a random 127.0.0.1 port and opens the system browser
|
||||
//! at `{mcp}/auth/desktop/start?cb=http://127.0.0.1:<port>/cb`. The MCP runs
|
||||
//! Keycloak (Auth Code + PKCE), mints an Engine token, and redirects the browser
|
||||
//! back to the loopback with a one-time `code` (or `error`). The launcher then
|
||||
//! swaps that code at `POST {mcp}/auth/desktop/exchange` for the token, so the
|
||||
//! token itself never appears in a browser URL.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::tokens::SiteToken;
|
||||
|
||||
/// The MCP forgets a pending sign-in after 300 s.
|
||||
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExchangePayload {
|
||||
#[serde(default)]
|
||||
access_token: String,
|
||||
#[serde(default)]
|
||||
expires_at: i64,
|
||||
#[serde(default)]
|
||||
username: String,
|
||||
#[serde(default)]
|
||||
dataless: bool,
|
||||
/// Only issued for `client=launcher`; an MCP without launcher sessions leaves it out.
|
||||
#[serde(default)]
|
||||
launcher_token: Option<String>,
|
||||
}
|
||||
|
||||
enum Callback {
|
||||
Code(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub enum SignInError {
|
||||
/// The operator cancelled from the launcher; not an error to show.
|
||||
Cancelled,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl From<String> for SignInError {
|
||||
fn from(message: String) -> Self {
|
||||
SignInError::Failed(message)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sign_in(
|
||||
app: &AppHandle,
|
||||
http: &reqwest::Client,
|
||||
mcp_url: &str,
|
||||
cancel: &Notify,
|
||||
) -> Result<SiteToken, SignInError> {
|
||||
if mcp_url.is_empty() {
|
||||
return Err("Set the DHP's sign-in address (MCP URL) in Settings first.".to_string().into());
|
||||
}
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("cannot open the sign-in callback port: {e}"))?;
|
||||
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
||||
let callback = format!("http://127.0.0.1:{port}/cb");
|
||||
// client=launcher asks the MCP for a launcher session too, so the operator is remembered until Sign out.
|
||||
let start = url::Url::parse_with_params(
|
||||
&format!("{mcp_url}/auth/desktop/start"),
|
||||
&[("cb", callback.as_str()), ("client", "launcher")],
|
||||
)
|
||||
.map_err(|e| format!("the MCP URL is not valid: {e}"))?;
|
||||
app
|
||||
.opener()
|
||||
.open_url(start.as_str(), None::<&str>)
|
||||
.map_err(|e| format!("cannot open the browser: {e}"))?;
|
||||
|
||||
let waited = tokio::select! {
|
||||
waited = tokio::time::timeout(SIGN_IN_TIMEOUT, wait_for_callback(listener)) => waited,
|
||||
_ = cancel.notified() => return Err(SignInError::Cancelled),
|
||||
};
|
||||
let code = match waited {
|
||||
Err(_) => return Err("Sign-in timed out. Try again.".to_string().into()),
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
Ok(Ok(Callback::Error(error))) => return Err(describe_error(&error).into()),
|
||||
Ok(Ok(Callback::Code(code))) => code,
|
||||
};
|
||||
|
||||
let response = http
|
||||
.post(format!("{mcp_url}/auth/desktop/exchange"))
|
||||
.json(&serde_json::json!({ "code": code }))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("the sign-in service did not answer: {e}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("sign-in failed: the MCP answered HTTP {}", response.status()).into());
|
||||
}
|
||||
let payload: ExchangePayload = response.json().await.map_err(|e| format!("unexpected sign-in answer: {e}"))?;
|
||||
if payload.access_token.is_empty() || payload.username.is_empty() {
|
||||
return Err("sign-in failed: the MCP returned no token".to_string().into());
|
||||
}
|
||||
if payload.dataless {
|
||||
// A dataless session means the site's Engine has no database bound: nothing to install from.
|
||||
return Err("The DHP's Engine has no database connected. Ask an administrator.".to_string().into());
|
||||
}
|
||||
Ok(SiteToken {
|
||||
access_token: payload.access_token,
|
||||
expires_at: payload.expires_at,
|
||||
username: payload.username,
|
||||
launcher_token: payload.launcher_token.filter(|t| !t.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
fn describe_error(error: &str) -> String {
|
||||
match error {
|
||||
"access_denied" => "Sign-in was cancelled.".to_string(),
|
||||
"auth_failed" => "Keycloak did not accept the sign-in.".to_string(),
|
||||
"mint_failed" => "You signed in, but this DHP has no ODIAN account for you. Ask an administrator.".to_string(),
|
||||
"keycloak_unavailable" => "The DHP's Keycloak is unreachable.".to_string(),
|
||||
other => format!("Sign-in failed ({other})."),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_callback(listener: TcpListener) -> Result<Callback, String> {
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().await.map_err(|e| format!("sign-in callback failed: {e}"))?;
|
||||
let Some(target) = read_request_target(&mut stream).await else {
|
||||
continue;
|
||||
};
|
||||
if !target.starts_with("/cb") {
|
||||
respond(&mut stream, "404 Not Found", "").await;
|
||||
continue;
|
||||
}
|
||||
let query: HashMap<String, String> = url::Url::parse(&format!("http://127.0.0.1{target}"))
|
||||
.map(|u| u.query_pairs().into_owned().collect())
|
||||
.unwrap_or_default();
|
||||
if let Some(code) = query.get("code").filter(|c| !c.is_empty()) {
|
||||
respond(&mut stream, "200 OK", &page("Signed in", "You can close this tab and return to the ODIAN Launcher.")).await;
|
||||
return Ok(Callback::Code(code.clone()));
|
||||
}
|
||||
let error = query.get("error").cloned().unwrap_or_else(|| "no_code".to_string());
|
||||
respond(&mut stream, "200 OK", &page("Sign-in failed", "Return to the ODIAN Launcher for details.")).await;
|
||||
return Ok(Callback::Error(error));
|
||||
}
|
||||
}
|
||||
|
||||
/// The path of a `GET` request line, or None for anything else (or a client that stalls).
|
||||
async fn read_request_target(stream: &mut TcpStream) -> Option<String> {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut len = 0;
|
||||
let read = async {
|
||||
while len < buf.len() {
|
||||
let n = stream.read(&mut buf[len..]).await.ok()?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
len += n;
|
||||
if buf[..len].windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
};
|
||||
tokio::time::timeout(Duration::from_secs(5), read).await.ok()??;
|
||||
let head = String::from_utf8_lossy(&buf[..len]);
|
||||
let mut parts = head.lines().next()?.split_whitespace();
|
||||
if parts.next()? != "GET" {
|
||||
return None;
|
||||
}
|
||||
parts.next().map(str::to_string)
|
||||
}
|
||||
|
||||
async fn respond(stream: &mut TcpStream, status: &str, body: &str) {
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.shutdown().await;
|
||||
}
|
||||
|
||||
fn page(title: &str, message: &str) -> String {
|
||||
format!(
|
||||
"<!doctype html><meta charset=utf-8><title>ODIAN — {title}</title>\
|
||||
<body style=\"margin:0;min-height:100vh;display:grid;place-content:center;background:#09090b;color:#f2f1ee;\
|
||||
font-family:'Segoe UI',system-ui,sans-serif;text-align:center\">\
|
||||
<div><div style=\"letter-spacing:.28em;font-weight:600;font-size:15px;color:#9a999e\">ODIAN</div>\
|
||||
<h1 style=\"font-weight:600;margin:18px 0 8px\">{title}</h1><p style=\"color:#d4d3d0\">{message}</p></div>"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Tauri commands: thin wrappers over `Launcher` (see docs/CONTRACT.md).
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use crate::launcher::{Launcher, Status};
|
||||
use crate::settings::Settings;
|
||||
use crate::stack::StackEvent;
|
||||
|
||||
type CmdResult<T> = Result<T, String>;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct SignedIn {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult<Status> {
|
||||
Ok(launcher.status().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn refresh(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<Status> {
|
||||
Ok(launcher.refresh(&app).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sign_in(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<SignedIn> {
|
||||
launcher.sign_in(&app).await.map(|username| SignedIn { username })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_sign_in(launcher: State<'_, Launcher>) -> CmdResult<()> {
|
||||
launcher.cancel_sign_in();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sign_out(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<Status> {
|
||||
Ok(launcher.sign_out(&app).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_notes(launcher: State<'_, Launcher>, version: Option<String>) -> CmdResult<String> {
|
||||
launcher.notes(version).await
|
||||
}
|
||||
|
||||
/// Links in patch notes open in the system browser; the launcher window never navigates.
|
||||
#[tauri::command]
|
||||
pub async fn open_external(app: AppHandle, url: String) -> CmdResult<()> {
|
||||
let parsed = url::Url::parse(&url).map_err(|_| "That link is not a valid address.".to_string())?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err("Only web links can be opened.".to_string());
|
||||
}
|
||||
app
|
||||
.opener()
|
||||
.open_url(parsed.as_str(), None::<&str>)
|
||||
.map_err(|e| format!("cannot open the browser: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_install(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<()> {
|
||||
launcher.start_install(&app, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_install_from_drive(app: AppHandle, launcher: State<'_, Launcher>, path: String) -> CmdResult<()> {
|
||||
launcher.start_install(&app, Some(std::path::PathBuf::from(path))).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_install(launcher: State<'_, Launcher>) -> CmdResult<()> {
|
||||
launcher.cancel_install()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn launch(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<()> {
|
||||
launcher.launch(&app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<()> {
|
||||
launcher.stop(&app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_stack(launcher: State<'_, Launcher>) -> CmdResult<Vec<StackEvent>> {
|
||||
Ok(launcher.stack_states())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_settings(launcher: State<'_, Launcher>) -> CmdResult<Settings> {
|
||||
Ok(launcher.settings().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_settings(app: AppHandle, launcher: State<'_, Launcher>, settings: Settings) -> CmdResult<Status> {
|
||||
launcher.set_settings(&app, settings).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pick_folder(app: AppHandle) -> CmdResult<Option<String>> {
|
||||
// The native dialog blocks its thread, so keep it off the async runtime's workers.
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
app
|
||||
.dialog()
|
||||
.file()
|
||||
.blocking_pick_folder()
|
||||
.and_then(|picked| picked.into_path().ok())
|
||||
.map(|path| path.display().to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Small file helpers shared by the settings and state stores.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Replace `path` with `bytes` so a reader sees the old file or the new one, never half of either.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
|
||||
}
|
||||
let temp = path.with_extension(format!("tmp-{}", std::process::id()));
|
||||
std::fs::write(&temp, bytes).map_err(|e| format!("cannot write {}: {e}", temp.display()))?;
|
||||
// std::fs::rename replaces an existing target on Windows (MoveFileEx with REPLACE_EXISTING).
|
||||
std::fs::rename(&temp, path).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&temp);
|
||||
format!("cannot replace {}: {e}", path.display())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), String> {
|
||||
let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
|
||||
bytes.push(b'\n');
|
||||
write_atomic(path, &bytes)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Install glue around the `odian-packs` crate: what the launcher adds to a verified install.
|
||||
|
||||
use odian_packs::manifest::Manifest;
|
||||
|
||||
use crate::stack::{API_EXE, ODIAN_EXE, PG_CTL_EXE, PG_RESTORE_EXE, PYTHON_EXE, TILE_SERVER_APP};
|
||||
|
||||
/// Refuse, before downloading anything, a version that can't run offline on this PC.
|
||||
pub fn check_runnable(manifest: &Manifest) -> Result<(), String> {
|
||||
let required = [
|
||||
(ODIAN_EXE, "ODIAN"),
|
||||
(API_EXE, "the Engine API"),
|
||||
(TILE_SERVER_APP, "the tile server"),
|
||||
(PYTHON_EXE, "a Python runtime"),
|
||||
(PG_CTL_EXE, "Postgres"),
|
||||
(PG_RESTORE_EXE, "Postgres restore tools"),
|
||||
];
|
||||
let missing: Vec<&str> = required
|
||||
.iter()
|
||||
.filter(|(path, _)| !manifest.app.files.iter().any(|file| file.path.eq_ignore_ascii_case(path)))
|
||||
.map(|(_, what)| *what)
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"ODIAN {} can't run on its own on this PC: the version doesn't include {}. Ask for a launcher-ready version.",
|
||||
manifest.version,
|
||||
missing.join(", ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The launcher's steps after the crate's own stages: restore (fresh database only), then finalize.
|
||||
pub fn extra_stages(restores_db: bool) -> u32 {
|
||||
if restores_db {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn manifest(paths: &[&str]) -> Manifest {
|
||||
let files: Vec<serde_json::Value> = paths
|
||||
.iter()
|
||||
.map(|path| serde_json::json!({ "path": path, "size": 1, "sha256": "a".repeat(64) }))
|
||||
.collect();
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"schema": 1, "product": "odian", "version": "0.52.0", "released_at": "2026-09-15T12:00:00Z",
|
||||
"app": { "files": files }
|
||||
}))
|
||||
.expect("a manifest")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launcher_ready_version_passes() {
|
||||
let all = [ODIAN_EXE, API_EXE, TILE_SERVER_APP, PYTHON_EXE, PG_CTL_EXE, PG_RESTORE_EXE];
|
||||
assert_eq!(check_runnable(&manifest(&all)), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_packed_version_is_refused_before_any_download() {
|
||||
// 0.51.0 shipped only the client and the API.
|
||||
let message = check_runnable(&manifest(&[ODIAN_EXE, API_EXE])).unwrap_err();
|
||||
assert!(message.contains("0.52.0"), "{message}");
|
||||
for part in ["the tile server", "a Python runtime", "Postgres"] {
|
||||
assert!(message.contains(part), "{message} should name {part}");
|
||||
}
|
||||
assert!(!message.contains("the Engine API"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_count_adds_restore_only_for_a_fresh_database() {
|
||||
assert_eq!(extra_stages(true), 2);
|
||||
assert_eq!(extra_stages(false), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
//! The launcher's live state behind every command, and the `status` event.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
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};
|
||||
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,
|
||||
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,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 }),
|
||||
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;
|
||||
}
|
||||
// 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.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,
|
||||
}
|
||||
};
|
||||
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) {
|
||||
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;
|
||||
|
||||
if let Some(odian) = odian {
|
||||
stack::wait_exit(&odian).await;
|
||||
}
|
||||
// Odian.exe closed. If Stop already took the stack, it stopped the rest too.
|
||||
let taken = self.stack.lock().await.take();
|
||||
if let Some(mut running) = taken {
|
||||
running.stop(&emit).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(())
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! ODIAN Launcher backend: the Tauri commands and events in docs/CONTRACT.md.
|
||||
|
||||
mod auth;
|
||||
mod commands;
|
||||
mod fsutil;
|
||||
mod install;
|
||||
mod launcher;
|
||||
mod localdb;
|
||||
mod settings;
|
||||
mod site;
|
||||
mod stack;
|
||||
mod state;
|
||||
mod tokens;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
app.manage(launcher::Launcher::load(app.handle())?);
|
||||
|
||||
// First look at the site in the background; the window shows "checking" meanwhile.
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
handle.state::<launcher::Launcher>().refresh(&handle).await;
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
commands::refresh,
|
||||
commands::sign_in,
|
||||
commands::cancel_sign_in,
|
||||
commands::sign_out,
|
||||
commands::get_notes,
|
||||
commands::open_external,
|
||||
commands::start_install,
|
||||
commands::start_install_from_drive,
|
||||
commands::cancel_install,
|
||||
commands::launch,
|
||||
commands::stop,
|
||||
commands::get_stack,
|
||||
commands::get_settings,
|
||||
commands::set_settings,
|
||||
commands::pick_folder,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building the launcher")
|
||||
.run(|app, event| {
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
tauri::async_runtime::block_on(app.state::<launcher::Launcher>().shutdown());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! The PC's own Postgres cluster (`data\pg`): create it, start and stop it, load a snapshot.
|
||||
//!
|
||||
//! It listens on 127.0.0.1 only, with a password generated on this PC. The
|
||||
//! snapshot is restored in the order proven on the Data PC's dumps: pre-data,
|
||||
//! every table's data (in parallel), sequences, post-data.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::stack::{command, log_output, Layout, PG_PORT};
|
||||
|
||||
pub const PG_USER: &str = "odian";
|
||||
pub const PG_DATABASE: &str = "odian";
|
||||
|
||||
/// One dump file from the manifest's `db.files`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DbFile {
|
||||
pub section: String,
|
||||
/// Relative to `data\`, e.g. `db/20260915T082529Z/data/public.db_ground_poi.dump`.
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// Restore progress: (done bytes, total bytes, done files, total files, current file).
|
||||
pub type RestoreProgress<'a> = &'a (dyn Fn(u64, u64, usize, usize, &str) + Sync);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Pg {
|
||||
bin: PathBuf,
|
||||
data: PathBuf,
|
||||
logs: PathBuf,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl Pg {
|
||||
pub fn new(layout: &Layout, password: &str) -> Pg {
|
||||
Pg {
|
||||
bin: layout.pg_bin(),
|
||||
data: layout.pg_data(),
|
||||
logs: layout.state_dir().join("pg"),
|
||||
password: password.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool(&self, name: &str) -> Command {
|
||||
let mut cmd = command(&self.bin.join(format!("{name}.exe")), &self.logs, &[self.bin.clone()]);
|
||||
cmd.env("PGPASSWORD", &self.password).env("PGCONNECT_TIMEOUT", "5").env("PGAPPNAME", "odian-launcher");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn run(mut cmd: Command, what: &str) -> Result<String, String> {
|
||||
let out = cmd
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.map_err(|e| format!("cannot run {what}: {e}"))?;
|
||||
if out.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let tail: String = stderr.chars().rev().take(1500).collect::<Vec<_>>().into_iter().rev().collect();
|
||||
Err(format!("{what} failed ({}): {}", out.status, tail.trim()))
|
||||
}
|
||||
|
||||
fn ensure_logs(&self) -> Result<(), String> {
|
||||
std::fs::create_dir_all(&self.logs).map_err(|e| format!("cannot create {}: {e}", self.logs.display()))
|
||||
}
|
||||
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
self.data.join("PG_VERSION").is_file()
|
||||
}
|
||||
|
||||
/// Create the cluster. A `data\pg` without PG_VERSION is a failed earlier attempt and is replaced.
|
||||
pub fn init(&self) -> Result<(), String> {
|
||||
self.ensure_logs()?;
|
||||
if self.data.exists() && !self.is_initialized() {
|
||||
std::fs::remove_dir_all(&self.data).map_err(|e| format!("cannot clear {}: {e}", self.data.display()))?;
|
||||
}
|
||||
let pwfile = self.logs.join("initdb.pw");
|
||||
std::fs::write(&pwfile, &self.password).map_err(|e| format!("cannot write {}: {e}", pwfile.display()))?;
|
||||
let mut cmd = self.tool("initdb");
|
||||
cmd
|
||||
.arg("-D")
|
||||
.arg(&self.data)
|
||||
.args(["-U", PG_USER, "-A", "scram-sha-256", "-E", "UTF8", "--locale=C"])
|
||||
.args(["--locale-provider=builtin", "--builtin-locale=C.UTF-8"])
|
||||
.arg(format!("--pwfile={}", pwfile.display()));
|
||||
let result = Self::run(cmd, "initdb");
|
||||
let _ = std::fs::remove_file(&pwfile);
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
pub fn running(&self) -> bool {
|
||||
let mut cmd = self.tool("pg_ctl");
|
||||
cmd.arg("status").arg("-D").arg(&self.data).stdout(Stdio::null()).stderr(Stdio::null());
|
||||
cmd.status().map(|status| status.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn start(&self) -> Result<(), String> {
|
||||
if !self.is_initialized() {
|
||||
return Err("The local database isn't set up. Reinstall from the DHP or a drive.".to_string());
|
||||
}
|
||||
self.ensure_logs()?;
|
||||
if self.running() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut cmd = self.tool("pg_ctl");
|
||||
cmd
|
||||
.arg("start")
|
||||
.arg("-D")
|
||||
.arg(&self.data)
|
||||
.arg("-l")
|
||||
.arg(self.logs.join("postgres.log"))
|
||||
.args(["-w", "-t", "120", "-o"])
|
||||
.arg(format!("-p {PG_PORT} -c listen_addresses=127.0.0.1"));
|
||||
// The postmaster inherits pg_ctl's handles and outlives it, so a pipe would never
|
||||
// reach EOF: its output goes to a file.
|
||||
log_output(&mut cmd, &self.logs.join("pg_ctl.log"))?;
|
||||
let status = cmd.status().map_err(|e| format!("cannot run pg_ctl: {e}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Postgres did not start ({status}). See state\\pg\\postgres.log."))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> Result<(), String> {
|
||||
if !self.is_initialized() || !self.running() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut cmd = self.tool("pg_ctl");
|
||||
cmd.arg("stop").arg("-D").arg(&self.data).args(["-m", "fast", "-w", "-t", "60"]);
|
||||
log_output(&mut cmd, &self.logs.join("pg_ctl.log"))?;
|
||||
let status = cmd.status().map_err(|e| format!("cannot run pg_ctl: {e}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Postgres did not stop cleanly ({status})."))
|
||||
}
|
||||
}
|
||||
|
||||
fn psql(&self, database: &str, sql: &str) -> Result<String, String> {
|
||||
let port = PG_PORT.to_string();
|
||||
let mut cmd = self.tool("psql");
|
||||
cmd
|
||||
.args(["-X", "-A", "-t", "-v", "ON_ERROR_STOP=1", "-h", "127.0.0.1", "-p", port.as_str()])
|
||||
.args(["-U", PG_USER, "-d", database, "-c", sql]);
|
||||
Self::run(cmd, "psql")
|
||||
}
|
||||
|
||||
/// Replace the `odian` database with a snapshot. Only for a PC whose restore never
|
||||
/// completed: anything an operator did in the old database is dropped.
|
||||
pub fn restore(
|
||||
&self,
|
||||
data_root: &Path,
|
||||
files: &[DbFile],
|
||||
workers: usize,
|
||||
progress: RestoreProgress<'_>,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
) -> Result<(), String> {
|
||||
self.ensure_logs()?;
|
||||
self.psql("postgres", &format!("DROP DATABASE IF EXISTS {PG_DATABASE}"))?;
|
||||
self.psql("postgres", &format!("CREATE DATABASE {PG_DATABASE} TEMPLATE template0 ENCODING 'UTF8'"))?;
|
||||
|
||||
let total_bytes: u64 = files.iter().map(|f| f.size).sum();
|
||||
let done_bytes = AtomicU64::new(0);
|
||||
let done_files = AtomicUsize::new(0);
|
||||
let finished = |file: &DbFile| {
|
||||
let bytes = done_bytes.fetch_add(file.size, Ordering::Relaxed) + file.size;
|
||||
let count = done_files.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
progress(bytes, total_bytes, count, files.len(), &file.path);
|
||||
};
|
||||
let section = |name: &'static str| files.iter().filter(move |f| f.section == name);
|
||||
|
||||
for file in section("pre-data") {
|
||||
self.restore_one(data_root, file, true)?;
|
||||
finished(file);
|
||||
}
|
||||
|
||||
let queue = Mutex::new(section("data").collect::<VecDeque<_>>());
|
||||
let failure: Mutex<Option<String>> = Mutex::new(None);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..workers.max(1) {
|
||||
scope.spawn(|| loop {
|
||||
if cancelled() || failure.lock().map(|f| f.is_some()).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
let Some(file) = queue.lock().ok().and_then(|mut q| q.pop_front()) else {
|
||||
return;
|
||||
};
|
||||
match self.restore_one(data_root, file, false) {
|
||||
Ok(()) => finished(file),
|
||||
Err(message) => {
|
||||
if let Ok(mut slot) = failure.lock() {
|
||||
slot.get_or_insert(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if let Some(message) = failure.into_inner().unwrap_or(None) {
|
||||
return Err(message);
|
||||
}
|
||||
if cancelled() {
|
||||
return Err("cancelled".to_string());
|
||||
}
|
||||
|
||||
for file in section("sequences").chain(section("post-data")) {
|
||||
self.restore_one(data_root, file, false)?;
|
||||
finished(file);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_one(&self, data_root: &Path, file: &DbFile, pre_data: bool) -> Result<(), String> {
|
||||
let dump = file.path.split('/').fold(data_root.to_path_buf(), |path, part| path.join(part));
|
||||
let port = PG_PORT.to_string();
|
||||
let mut cmd = self.tool("pg_restore");
|
||||
cmd
|
||||
.args(["-h", "127.0.0.1", "-p", port.as_str(), "-U", PG_USER, "-d", PG_DATABASE])
|
||||
.args(["--no-owner", "--no-privileges", "--exit-on-error"]);
|
||||
if pre_data {
|
||||
cmd.arg("-L").arg(self.pre_data_list(&dump)?);
|
||||
}
|
||||
cmd.arg(&dump);
|
||||
Self::run(cmd, &format!("pg_restore {}", file.path)).map(|_| ())
|
||||
}
|
||||
|
||||
/// The pre-data table of contents without `CREATE SCHEMA public`: a fresh database
|
||||
/// already has that schema, and --exit-on-error would stop on the duplicate.
|
||||
fn pre_data_list(&self, dump: &Path) -> Result<PathBuf, String> {
|
||||
let mut cmd = self.tool("pg_restore");
|
||||
cmd.arg("-l").arg(dump);
|
||||
let listing = Self::run(cmd, "pg_restore -l")?;
|
||||
let kept: String = listing
|
||||
.lines()
|
||||
.filter(|line| !(line.contains(" SCHEMA - public ") || line.contains(" COMMENT - SCHEMA public ")))
|
||||
.map(|line| format!("{line}\n"))
|
||||
.collect();
|
||||
let path = self.logs.join("pre-data.list");
|
||||
std::fs::write(&path, kept).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
odian_launcher_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Launcher settings: which site to use and where ODIAN is installed.
|
||||
//!
|
||||
//! Stored per Windows user in the launcher's config dir. On first run they are
|
||||
//! seeded from a `site.json` next to the launcher executable (shipped with the
|
||||
//! setup or copied from an ODIAN drive), so operators never type addresses.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::fsutil::write_json_atomic;
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
/// The site's update service, e.g. `http://192.168.1.41:30097`.
|
||||
#[serde(default)]
|
||||
pub site_url: String,
|
||||
/// The site's MCP, which runs the Keycloak sign-in.
|
||||
#[serde(default)]
|
||||
pub mcp_url: String,
|
||||
#[serde(default)]
|
||||
pub install_dir: String,
|
||||
/// Display name from site.json; the UI's settings form does not edit it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub site_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct SiteFile {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
site_url: String,
|
||||
#[serde(default)]
|
||||
mcp_url: String,
|
||||
}
|
||||
|
||||
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app.path().app_config_dir().map_err(|e| format!("no config dir: {e}"))?;
|
||||
Ok(dir.join("settings.json"))
|
||||
}
|
||||
|
||||
fn default_install_dir() -> String {
|
||||
// Operator PCs keep the ~1 TB of data off the system drive when a D: exists.
|
||||
// Not %LOCALAPPDATA%\ODIAN: on the release PC that folder holds the release
|
||||
// signing key, and deleting a test install must never be able to take it along.
|
||||
if Path::new("D:\\").is_dir() {
|
||||
"D:\\ODIAN".to_string()
|
||||
} else {
|
||||
"C:\\ODIAN".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_site_file() -> SiteFile {
|
||||
let Some(dir) = std::env::current_exe().ok().and_then(|exe| exe.parent().map(Path::to_path_buf)) else {
|
||||
return SiteFile::default();
|
||||
};
|
||||
std::fs::read(dir.join("site.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn normalize_url(url: &str) -> String {
|
||||
url.trim().trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn load(app: &AppHandle) -> Result<Settings, String> {
|
||||
let path = settings_path(app)?;
|
||||
if let Ok(bytes) = std::fs::read(&path) {
|
||||
let mut settings: Settings =
|
||||
serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid settings JSON: {e}", path.display()))?;
|
||||
if settings.install_dir.trim().is_empty() {
|
||||
settings.install_dir = default_install_dir();
|
||||
}
|
||||
return Ok(settings);
|
||||
}
|
||||
let site = read_site_file();
|
||||
let settings = Settings {
|
||||
site_url: normalize_url(&site.site_url),
|
||||
mcp_url: normalize_url(&site.mcp_url),
|
||||
install_dir: default_install_dir(),
|
||||
site_name: site.name,
|
||||
};
|
||||
settings.save(app)?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn save(&self, app: &AppHandle) -> Result<(), String> {
|
||||
write_json_atomic(&settings_path(app)?, self)
|
||||
}
|
||||
|
||||
pub fn install_path(&self) -> PathBuf {
|
||||
PathBuf::from(&self.install_dir)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Talking to the site's update service: reachability, token check, patch notes.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::StatusCode;
|
||||
|
||||
/// How long a launch waits for the site before treating the PC as offline.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
pub struct Probe {
|
||||
pub online: bool,
|
||||
/// The site's CURRENT version.
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
|
||||
let offline = Probe { online: false, version: None };
|
||||
if site_url.is_empty() {
|
||||
return offline;
|
||||
}
|
||||
let Ok(response) = http.get(format!("{site_url}/v1/health")).timeout(PROBE_TIMEOUT).send().await else {
|
||||
return offline;
|
||||
};
|
||||
if !response.status().is_success() {
|
||||
return offline;
|
||||
}
|
||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||
Probe {
|
||||
online: true,
|
||||
version: body.get("version").and_then(|v| v.as_str()).map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TokenCheck {
|
||||
Valid,
|
||||
Invalid,
|
||||
/// The site could not say (unreachable, Engine down): keep the token.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
pub async fn check_token(http: &reqwest::Client, site_url: &str, token: &str) -> TokenCheck {
|
||||
let request = http
|
||||
.get(format!("{site_url}/v1/versions/current"))
|
||||
.bearer_auth(token)
|
||||
.timeout(Duration::from_secs(5));
|
||||
match request.send().await {
|
||||
// 404 means "no current version yet", which is only answered after the token passed.
|
||||
Ok(r) if r.status().is_success() || r.status() == StatusCode::NOT_FOUND => TokenCheck::Valid,
|
||||
Ok(r) if r.status() == StatusCode::UNAUTHORIZED => TokenCheck::Invalid,
|
||||
_ => TokenCheck::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SessionRefresh {
|
||||
Renewed { access_token: String, expires_at: i64, username: String },
|
||||
/// The site no longer honours the launcher session: signed out elsewhere, revoked, or the user is gone.
|
||||
Revoked(String),
|
||||
/// The sign-in service could not answer (unreachable, Engine down, an MCP without launcher
|
||||
/// sessions): keep the session and try again later.
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RenewedBody {
|
||||
#[serde(default)]
|
||||
access_token: String,
|
||||
#[serde(default)]
|
||||
expires_at: i64,
|
||||
#[serde(default)]
|
||||
username: String,
|
||||
}
|
||||
|
||||
/// A fresh Engine token from the site MCP's launcher session, without asking the operator.
|
||||
pub async fn refresh_session(http: &reqwest::Client, mcp_url: &str, launcher_token: &str) -> SessionRefresh {
|
||||
if mcp_url.is_empty() {
|
||||
return SessionRefresh::Unavailable("no sign-in address (MCP URL) is set".to_string());
|
||||
}
|
||||
let sent = http
|
||||
.post(format!("{mcp_url}/auth/launcher/refresh"))
|
||||
.json(&serde_json::json!({ "launcher_token": launcher_token }))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
let response = match sent {
|
||||
Ok(response) => response,
|
||||
Err(e) => return SessionRefresh::Unavailable(format!("the sign-in service did not answer: {e}")),
|
||||
};
|
||||
match response.status() {
|
||||
s if s.is_success() => match response.json::<RenewedBody>().await {
|
||||
Ok(body) if !body.access_token.is_empty() => SessionRefresh::Renewed {
|
||||
access_token: body.access_token,
|
||||
expires_at: body.expires_at,
|
||||
username: body.username,
|
||||
},
|
||||
_ => SessionRefresh::Unavailable("the sign-in service gave an unexpected answer".to_string()),
|
||||
},
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
|
||||
let detail = response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|body| body.get("detail").and_then(|d| d.as_str()).map(str::to_string))
|
||||
.unwrap_or_else(|| "the DHP ended this sign-in".to_string());
|
||||
SessionRefresh::Revoked(detail)
|
||||
}
|
||||
s => SessionRefresh::Unavailable(format!("the sign-in service answered HTTP {s}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best effort: tell the site MCP to end the launcher session. Signing out works offline too.
|
||||
pub async fn end_session(http: &reqwest::Client, mcp_url: &str, launcher_token: &str) {
|
||||
if mcp_url.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = http
|
||||
.post(format!("{mcp_url}/auth/launcher/logout"))
|
||||
.json(&serde_json::json!({ "launcher_token": launcher_token }))
|
||||
.timeout(Duration::from_secs(5))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn notes(http: &reqwest::Client, site_url: &str, token: &str, version: &str) -> Result<String, String> {
|
||||
let response = http
|
||||
.get(format!("{site_url}/v1/versions/{version}/notes"))
|
||||
.bearer_auth(token)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("the DHP did not answer: {e}"))?;
|
||||
match response.status() {
|
||||
s if s.is_success() => response.text().await.map_err(|e| e.to_string()),
|
||||
StatusCode::UNAUTHORIZED => Err("Sign in to read the patch notes.".to_string()),
|
||||
StatusCode::NOT_FOUND => Err(format!("Version {version} has no patch notes.")),
|
||||
s => Err(format!("the DHP answered HTTP {s}")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
//! The local ODIAN stack on this PC: Postgres → Engine API → tile server → Odian.exe.
|
||||
//!
|
||||
//! Everything listens on 127.0.0.1 only. Each process starts from a clean
|
||||
//! environment (the Windows basics plus what is set here), without a console
|
||||
//! window, inside one Job Object so nothing outlives the launcher.
|
||||
//! `versions\<v>\` is only ever read: logs, configs and caches go under
|
||||
//! `state\`, data under `data\`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::fsutil::{write_atomic, write_json_atomic};
|
||||
use crate::localdb::Pg;
|
||||
|
||||
pub const PG_PORT: u16 = 55432;
|
||||
pub const API_PORT: u16 = 18080;
|
||||
pub const TILES_PORT: u16 = 18081;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// Windows basics a child still needs once its environment is cleared. Nothing
|
||||
/// from a developer's shell (ODIAN_*, PYTHONPATH, PG*) may leak into the stack.
|
||||
const INHERITED_ENV: &[&str] = &[
|
||||
"SystemRoot", "SystemDrive", "windir", "ComSpec", "PATHEXT", "OS",
|
||||
"TEMP", "TMP", "USERPROFILE", "USERNAME", "USERDOMAIN", "HOMEDRIVE", "HOMEPATH",
|
||||
"APPDATA", "LOCALAPPDATA", "ProgramData", "ProgramFiles", "ProgramFiles(x86)", "CommonProgramFiles",
|
||||
"COMPUTERNAME", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "PROCESSOR_IDENTIFIER",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Component {
|
||||
Postgres,
|
||||
Api,
|
||||
Tiles,
|
||||
Odian,
|
||||
}
|
||||
|
||||
impl Component {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Component::Postgres => "Postgres",
|
||||
Component::Api => "the Engine API",
|
||||
Component::Tiles => "the tile server",
|
||||
Component::Odian => "ODIAN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentState {
|
||||
Starting,
|
||||
Ready,
|
||||
Stopped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct StackEvent {
|
||||
pub component: Component,
|
||||
pub state: ComponentState,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl StackEvent {
|
||||
fn new(component: Component, state: ComponentState) -> StackEvent {
|
||||
StackEvent { component, state, message: None }
|
||||
}
|
||||
}
|
||||
|
||||
pub type Emit<'a> = &'a (dyn Fn(StackEvent) + Send + Sync);
|
||||
|
||||
/// Where a launcher-ready version keeps each runnable part, as paths in its
|
||||
/// manifest's `app.files`. A version missing any of them cannot run on its own.
|
||||
pub const ODIAN_EXE: &str = "Odian/x64/MinGW-Release/Odian.exe";
|
||||
pub const API_EXE: &str = "api/odian_api.exe";
|
||||
pub const TILE_SERVER_APP: &str = "tile_server/app.py";
|
||||
pub const PYTHON_EXE: &str = "runtime/python/python.exe";
|
||||
pub const PG_CTL_EXE: &str = "runtime/pgsql/bin/pg_ctl.exe";
|
||||
pub const PG_RESTORE_EXE: &str = "runtime/pgsql/bin/pg_restore.exe";
|
||||
|
||||
/// Where one install keeps each part of one version.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Layout {
|
||||
root: PathBuf,
|
||||
version: String,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn new(root: &Path, version: &str) -> Layout {
|
||||
Layout { root: root.to_path_buf(), version: version.to_string() }
|
||||
}
|
||||
pub fn version_dir(&self) -> PathBuf {
|
||||
self.root.join("versions").join(&self.version)
|
||||
}
|
||||
/// A manifest app path (`a/b/c`) inside this version.
|
||||
fn app_file(&self, rel: &str) -> PathBuf {
|
||||
rel.split('/').fold(self.version_dir(), |path, part| path.join(part))
|
||||
}
|
||||
pub fn data_dir(&self) -> PathBuf {
|
||||
self.root.join("data")
|
||||
}
|
||||
pub fn state_dir(&self) -> PathBuf {
|
||||
self.root.join("state")
|
||||
}
|
||||
pub fn pg_bin(&self) -> PathBuf {
|
||||
parent(&self.app_file(PG_CTL_EXE))
|
||||
}
|
||||
pub fn pg_data(&self) -> PathBuf {
|
||||
self.data_dir().join("pg")
|
||||
}
|
||||
fn python(&self) -> PathBuf {
|
||||
self.app_file(PYTHON_EXE)
|
||||
}
|
||||
fn tile_server_dir(&self) -> PathBuf {
|
||||
parent(&self.app_file(TILE_SERVER_APP))
|
||||
}
|
||||
fn api_exe(&self) -> PathBuf {
|
||||
self.app_file(API_EXE)
|
||||
}
|
||||
fn odian_dir(&self) -> PathBuf {
|
||||
self.version_dir().join("Odian")
|
||||
}
|
||||
fn odian_exe(&self) -> PathBuf {
|
||||
self.app_file(ODIAN_EXE)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StackConfig {
|
||||
pub layout: Layout,
|
||||
pub operator: String,
|
||||
pub mint_secret: String,
|
||||
pub pg_password: String,
|
||||
pub install_id: String,
|
||||
/// (style, dataset) of every tile set the installed version carries.
|
||||
pub tile_sets: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// A process started from a clean environment, in `cwd`, with `search_path` first on PATH.
|
||||
pub fn command(program: &Path, cwd: &Path, search_path: &[PathBuf]) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.env_clear();
|
||||
for key in INHERITED_ENV {
|
||||
if let Some(value) = std::env::var_os(key) {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
}
|
||||
let windows = std::env::var_os("SystemRoot").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
|
||||
let mut path = search_path.to_vec();
|
||||
path.extend([windows.join("System32"), windows.clone(), windows.join("System32").join("Wbem")]);
|
||||
if let Ok(joined) = std::env::join_paths(path) {
|
||||
cmd.env("PATH", joined);
|
||||
}
|
||||
cmd.current_dir(cwd).stdin(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Append the process's stdout and stderr to `log`.
|
||||
pub fn log_output(cmd: &mut Command, log: &Path) -> Result<(), String> {
|
||||
if let Some(dir) = log.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
}
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(log)
|
||||
.map_err(|e| format!("cannot open {}: {e}", log.display()))?;
|
||||
let err = file.try_clone().map_err(|e| e.to_string())?;
|
||||
cmd.stdout(file).stderr(err);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type Shared = Arc<Mutex<Child>>;
|
||||
|
||||
pub struct RunningStack {
|
||||
job: job::Job,
|
||||
pg: Pg,
|
||||
children: Vec<(Component, Shared)>,
|
||||
}
|
||||
|
||||
impl RunningStack {
|
||||
fn spawn(&mut self, component: Component, cmd: &mut Command) -> Result<Shared, String> {
|
||||
let child = cmd.spawn().map_err(|e| format!("cannot start {}: {e}", component.label()))?;
|
||||
if let Err(e) = self.job.assign(&child) {
|
||||
log::warn!("{} is not in the launcher's job object: {e}", component.label());
|
||||
}
|
||||
let shared = Arc::new(Mutex::new(child));
|
||||
self.children.push((component, shared.clone()));
|
||||
Ok(shared)
|
||||
}
|
||||
|
||||
pub fn odian(&self) -> Option<Shared> {
|
||||
self.children.iter().find(|(c, _)| *c == Component::Odian).map(|(_, child)| child.clone())
|
||||
}
|
||||
|
||||
/// Stop in reverse start order; Postgres last, and cleanly.
|
||||
pub async fn stop(&mut self, emit: Emit<'_>) {
|
||||
for (component, child) in self.children.drain(..).rev() {
|
||||
if let Ok(mut process) = child.lock() {
|
||||
if process.try_wait().ok().flatten().is_none() {
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
}
|
||||
}
|
||||
emit(StackEvent::new(component, ComponentState::Stopped));
|
||||
}
|
||||
let pg = self.pg.clone();
|
||||
match run_blocking(move || pg.stop()).await {
|
||||
Ok(()) => emit(StackEvent::new(Component::Postgres, ComponentState::Stopped)),
|
||||
Err(message) => emit(StackEvent { component: Component::Postgres, state: ComponentState::Failed, message: Some(message) }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>) -> Result<RunningStack, String> {
|
||||
prepare(config)?;
|
||||
let mut running = RunningStack {
|
||||
job: job::Job::new()?,
|
||||
pg: Pg::new(&config.layout, &config.pg_password),
|
||||
children: Vec::new(),
|
||||
};
|
||||
match start_all(http, config, emit, &mut running).await {
|
||||
Ok(()) => Ok(running),
|
||||
Err(message) => {
|
||||
running.stop(emit).await;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>, running: &mut RunningStack) -> Result<(), String> {
|
||||
let layout = &config.layout;
|
||||
let state = layout.state_dir();
|
||||
|
||||
emit(StackEvent::new(Component::Postgres, ComponentState::Starting));
|
||||
let pg = running.pg.clone();
|
||||
run_blocking(move || pg.start()).await.map_err(|e| failed(emit, Component::Postgres, e))?;
|
||||
emit(StackEvent::new(Component::Postgres, ComponentState::Ready));
|
||||
|
||||
emit(StackEvent::new(Component::Api, ComponentState::Starting));
|
||||
let api_dir = state.join("api");
|
||||
let api_exe = layout.api_exe();
|
||||
let mut api = command(&api_exe, &api_dir, &[parent(&api_exe)]);
|
||||
api.arg(API_PORT.to_string()).envs(api_env(config));
|
||||
log_output(&mut api, &api_dir.join("logs").join("console.log"))?;
|
||||
let child = running.spawn(Component::Api, &mut api).map_err(|e| failed(emit, Component::Api, e))?;
|
||||
wait_http(http, &format!("http://127.0.0.1:{API_PORT}/health"), &child, Duration::from_secs(90))
|
||||
.await
|
||||
.map_err(|e| failed(emit, Component::Api, format!("The Engine API {e}. See state\\api\\logs.")))?;
|
||||
emit(StackEvent::new(Component::Api, ComponentState::Ready));
|
||||
|
||||
emit(StackEvent::new(Component::Tiles, ComponentState::Starting));
|
||||
let tiles_dir = state.join("tiles");
|
||||
let python = layout.python();
|
||||
let mut tiles = command(&python, &tiles_dir, &[parent(&python)]);
|
||||
tiles
|
||||
.args(["-m", "uvicorn", "--app-dir"])
|
||||
.arg(layout.tile_server_dir())
|
||||
.args(["app:app", "--host", "127.0.0.1", "--log-level", "warning", "--port"])
|
||||
.arg(TILES_PORT.to_string())
|
||||
.envs(tiles_env(config));
|
||||
log_output(&mut tiles, &tiles_dir.join("tile_server.log"))?;
|
||||
let child = running.spawn(Component::Tiles, &mut tiles).map_err(|e| failed(emit, Component::Tiles, e))?;
|
||||
wait_http(http, &format!("http://127.0.0.1:{TILES_PORT}/health"), &child, Duration::from_secs(120))
|
||||
.await
|
||||
.map_err(|e| failed(emit, Component::Tiles, format!("The tile server {e}. See state\\tiles\\tile_server.log.")))?;
|
||||
emit(StackEvent::new(Component::Tiles, ComponentState::Ready));
|
||||
|
||||
emit(StackEvent::new(Component::Odian, ComponentState::Starting));
|
||||
let client_dir = state.join("client");
|
||||
let odian_exe = layout.odian_exe();
|
||||
let mut odian = command(&odian_exe, &client_dir, &[parent(&odian_exe)]);
|
||||
odian.envs(client_env(config));
|
||||
log_output(&mut odian, &client_dir.join("logs").join("console.log"))?;
|
||||
let child = running.spawn(Component::Odian, &mut odian).map_err(|e| failed(emit, Component::Odian, e))?;
|
||||
// Odian.exe has no health endpoint; a client that survives its first seconds has signed in.
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
if let Some(status) = exit_status(&child) {
|
||||
return Err(failed(
|
||||
emit,
|
||||
Component::Odian,
|
||||
format!("ODIAN closed right away ({status}). See state\\client\\odian_engine.log."),
|
||||
));
|
||||
}
|
||||
emit(StackEvent::new(Component::Odian, ComponentState::Ready));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn failed(emit: Emit<'_>, component: Component, message: String) -> String {
|
||||
emit(StackEvent { component, state: ComponentState::Failed, message: Some(message.clone()) });
|
||||
message
|
||||
}
|
||||
|
||||
fn parent(path: &Path) -> PathBuf {
|
||||
path.parent().map(Path::to_path_buf).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn exit_status(child: &Shared) -> Option<ExitStatus> {
|
||||
child.lock().ok()?.try_wait().ok().flatten()
|
||||
}
|
||||
|
||||
/// Resolves once the process has exited.
|
||||
pub async fn wait_exit(child: &Shared) {
|
||||
loop {
|
||||
let gone = match child.lock() {
|
||||
Ok(mut process) => !matches!(process.try_wait(), Ok(None)),
|
||||
Err(_) => true,
|
||||
};
|
||||
if gone {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_http(http: &reqwest::Client, url: &str, child: &Shared, within: Duration) -> Result<(), String> {
|
||||
let deadline = Instant::now() + within;
|
||||
loop {
|
||||
if let Ok(response) = http.get(url).timeout(Duration::from_secs(2)).send().await {
|
||||
if response.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if let Some(status) = exit_status(child) {
|
||||
return Err(format!("stopped while starting ({status})"));
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err(format!("did not answer within {} s", within.as_secs()));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_blocking<T: Send + 'static>(work: impl FnOnce() -> Result<T, String> + Send + 'static) -> Result<T, String> {
|
||||
tauri::async_runtime::spawn_blocking(work).await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn prepare(config: &StackConfig) -> Result<(), String> {
|
||||
let layout = &config.layout;
|
||||
let state = layout.state_dir();
|
||||
let data = layout.data_dir();
|
||||
for dir in [
|
||||
state.join("api").join("logs"),
|
||||
state.join("api").join("keys"),
|
||||
state.join("tiles"),
|
||||
state.join("client").join("logs"),
|
||||
state.join("pg"),
|
||||
data.join("tiles"),
|
||||
data.join("dem").join("terrarium"),
|
||||
] {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
}
|
||||
// Odian.exe reads assets\ and shaders\ from its working directory and writes
|
||||
// config\ and logs\ next to them. Junctions give it both at once: this
|
||||
// version's read-only files, inside a folder of its own it may write to.
|
||||
let client = state.join("client");
|
||||
for name in ["assets", "shaders"] {
|
||||
replace_junction(&client.join(name), &layout.odian_dir().join(name))?;
|
||||
}
|
||||
write_atomic(&state.join("api").join("pg.ini"), pg_ini(config).as_bytes())?;
|
||||
write_json_atomic(&state.join("tiles").join("datasets.json"), &datasets(config))
|
||||
}
|
||||
|
||||
fn replace_junction(link: &Path, target: &Path) -> Result<(), String> {
|
||||
if !target.is_dir() {
|
||||
return Err(format!("{} is missing from this version", target.display()));
|
||||
}
|
||||
if std::fs::symlink_metadata(link).is_ok() {
|
||||
// On a junction this removes only the link; a real folder with files in it refuses.
|
||||
std::fs::remove_dir(link).map_err(|e| format!("cannot replace {}: {e}", link.display()))?;
|
||||
}
|
||||
junction::create(target, link).map_err(|e| format!("cannot link {} to {}: {e}", link.display(), target.display()))
|
||||
}
|
||||
|
||||
fn text(path: PathBuf) -> String {
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
fn pg_ini(config: &StackConfig) -> String {
|
||||
format!(
|
||||
"hosts = 127.0.0.1\nport = {PG_PORT}\ndb = {}\nuser = {}\npassword = {}\nconnect_timeout_sec = 2\npoll_interval_ms = 1000\n",
|
||||
crate::localdb::PG_DATABASE,
|
||||
crate::localdb::PG_USER,
|
||||
config.pg_password
|
||||
)
|
||||
}
|
||||
|
||||
fn datasets(config: &StackConfig) -> serde_json::Value {
|
||||
let tiles = config.layout.data_dir().join("tiles");
|
||||
let mut doc = serde_json::Map::new();
|
||||
for (_, dataset) in &config.tile_sets {
|
||||
let mut entry = serde_json::json!({ "cache_dir": text(tiles.clone()) });
|
||||
let pmtiles = config.layout.data_dir().join("src").join(format!("{dataset}.pmtiles"));
|
||||
if pmtiles.is_file() {
|
||||
entry["pmtiles"] = serde_json::Value::String(text(pmtiles));
|
||||
}
|
||||
doc.insert(dataset.clone(), entry);
|
||||
}
|
||||
serde_json::Value::Object(doc)
|
||||
}
|
||||
|
||||
fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
||||
let api = config.layout.state_dir().join("api");
|
||||
let logs = api.join("logs");
|
||||
vec![
|
||||
("ODIAN_API_BIND", "127.0.0.1".to_string()),
|
||||
("ODIAN_API_PORT", API_PORT.to_string()),
|
||||
("ODIAN_PG_INI", text(api.join("pg.ini"))),
|
||||
("ODIAN_MINT_SECRET", config.mint_secret.clone()),
|
||||
("ODIAN_DISCOVERY", "0".to_string()),
|
||||
("ODIAN_DEM_OFFLINE", "1".to_string()),
|
||||
("ODIAN_DEM_DIR", text(config.layout.data_dir().join("dem").join("terrarium"))),
|
||||
("ODIAN_RAIL_SYNC", "0".to_string()),
|
||||
("ODIAN_WEATHER_INI", text(api.join("weather.ini"))),
|
||||
("ODIAN_OPENAIP_INI", text(api.join("openaip.ini"))),
|
||||
("BDO_SIGNING_KEY_DIR", text(api.join("keys"))),
|
||||
("ODIAN_API_LOGDIR", text(logs.clone())),
|
||||
("ODIAN_API_LOG", text(logs.join("odian_api.log"))),
|
||||
("ODIAN_API_THREAD_STATE", text(logs.join("thread_state.txt"))),
|
||||
]
|
||||
}
|
||||
|
||||
fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
||||
let tiles = config.layout.data_dir().join("tiles");
|
||||
vec![
|
||||
("ODIAN_TILE_DATASETS", text(config.layout.state_dir().join("tiles").join("datasets.json"))),
|
||||
("ODIAN_TILE_CACHE_ONLY", "1".to_string()),
|
||||
("ODIAN_TILE_CACHE_DIR", text(tiles.clone())),
|
||||
("ODIAN_TILE_JOURNAL", text(tiles.join("tiles.db"))),
|
||||
("ODIAN_TILE_ORIGIN", config.install_id.clone()),
|
||||
("ODIAN_TILE_BAKER_EXE", String::new()),
|
||||
("PYTHONDONTWRITEBYTECODE", "1".to_string()),
|
||||
("PYTHONNOUSERSITE", "1".to_string()),
|
||||
("PYTHONUTF8", "1".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
||||
let client = config.layout.state_dir().join("client");
|
||||
let tiles_url = format!("http://127.0.0.1:{TILES_PORT}");
|
||||
let mut env = vec![
|
||||
("ODIAN_ENGINE_API_URL", format!("http://127.0.0.1:{API_PORT}")),
|
||||
("ODIAN_LAUNCH_USER", config.operator.clone()),
|
||||
("ODIAN_MINT_SECRET", config.mint_secret.clone()),
|
||||
("ODIAN_OFFLINE", "1".to_string()),
|
||||
("ODIAN_TILE_SERVER_URL", tiles_url.clone()),
|
||||
("ODIAN_TILE_PROVIDER", "odian".to_string()),
|
||||
("ODIAN_TILE_REQUEST_MODE", "xyz".to_string()),
|
||||
("ODIAN_TILE_Y_SCHEME", "xyz".to_string()),
|
||||
("ODIAN_TILE_AUTH_MODE", "none".to_string()),
|
||||
("ODIAN_TILE_CACHE_DIR", text(client.join("tile_cache"))),
|
||||
("ODIAN_POP_SERVICE_URL", tiles_url.clone()),
|
||||
("ODIAN_LIDAR_ENABLED", "0".to_string()),
|
||||
("ODIAN_HEIGHT_CACHE_DIR", text(client.join("heightmap"))),
|
||||
];
|
||||
let basemap = config
|
||||
.tile_sets
|
||||
.iter()
|
||||
.find(|(style, _)| style == "dark")
|
||||
.or_else(|| config.tile_sets.iter().find(|(style, _)| style != "heightmap"));
|
||||
if let Some((style, dataset)) = basemap {
|
||||
env.push(("ODIAN_TILE_STYLE", style.clone()));
|
||||
env.push(("ODIAN_TILE_DATASET", dataset.clone()));
|
||||
}
|
||||
match config.tile_sets.iter().find(|(style, _)| style == "heightmap") {
|
||||
Some((style, dataset)) => {
|
||||
env.push(("ODIAN_HEIGHT_SERVER_URL", tiles_url));
|
||||
env.push(("ODIAN_HEIGHT_FORMAT", "lerc".to_string()));
|
||||
env.push(("ODIAN_HEIGHT_STYLE", style.clone()));
|
||||
env.push(("ODIAN_HEIGHT_DATASET", dataset.clone()));
|
||||
}
|
||||
None => env.push(("ODIAN_HEIGHT_ENABLED", "0".to_string())),
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// The tile sets the installed version's manifest names.
|
||||
pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<(String, String)>, String> {
|
||||
let path = layout.version_dir().join("manifest.json");
|
||||
let bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?;
|
||||
let manifest: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid JSON: {e}", path.display()))?;
|
||||
let sets = manifest.pointer("/tiles/sets").and_then(|sets| sets.as_array());
|
||||
Ok(sets
|
||||
.map(|sets| {
|
||||
sets
|
||||
.iter()
|
||||
.filter_map(|set| Some((set.get("style")?.as_str()?.to_string(), set.get("dataset")?.as_str()?.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config(tile_sets: &[(&str, &str)]) -> StackConfig {
|
||||
StackConfig {
|
||||
layout: Layout::new(Path::new(r"D:\ODIAN"), "0.52.0"),
|
||||
operator: "m1op".to_string(),
|
||||
mint_secret: "secret".to_string(),
|
||||
pg_password: "pw".to_string(),
|
||||
install_id: "install".to_string(),
|
||||
tile_sets: tile_sets.iter().map(|(s, d)| (s.to_string(), d.to_string())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn value<'a>(env: &'a [(&'static str, String)], key: &str) -> Option<&'a str> {
|
||||
env.iter().find(|(k, _)| *k == key).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_resolves_manifest_paths_inside_the_version() {
|
||||
let layout = Layout::new(Path::new(r"D:\ODIAN"), "0.52.0");
|
||||
assert_eq!(layout.odian_exe(), PathBuf::from(r"D:\ODIAN\versions\0.52.0\Odian\x64\MinGW-Release\Odian.exe"));
|
||||
assert_eq!(layout.pg_bin(), PathBuf::from(r"D:\ODIAN\versions\0.52.0\runtime\pgsql\bin"));
|
||||
assert_eq!(layout.tile_server_dir(), PathBuf::from(r"D:\ODIAN\versions\0.52.0\tile_server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_runs_offline_on_the_local_stack_only() {
|
||||
let env = client_env(&config(&[("heightmap", "planet_heightmap"), ("dark", "protomaps_20260423")]));
|
||||
assert_eq!(value(&env, "ODIAN_ENGINE_API_URL"), Some("http://127.0.0.1:18080"));
|
||||
assert_eq!(value(&env, "ODIAN_TILE_SERVER_URL"), Some("http://127.0.0.1:18081"));
|
||||
assert_eq!(value(&env, "ODIAN_OFFLINE"), Some("1"));
|
||||
assert_eq!(value(&env, "ODIAN_LAUNCH_USER"), Some("m1op"));
|
||||
assert_eq!(value(&env, "ODIAN_TILE_STYLE"), Some("dark"));
|
||||
assert_eq!(value(&env, "ODIAN_TILE_DATASET"), Some("protomaps_20260423"));
|
||||
assert_eq!(value(&env, "ODIAN_HEIGHT_DATASET"), Some("planet_heightmap"));
|
||||
assert_eq!(value(&env, "ODIAN_HEIGHT_FORMAT"), Some("lerc"));
|
||||
assert!(value(&env, "ODIAN_MCP_API_URL").is_none(), "an offline client must not know the MCP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heights_are_switched_off_when_the_version_has_none() {
|
||||
let env = client_env(&config(&[("dark", "protomaps_20260423")]));
|
||||
assert_eq!(value(&env, "ODIAN_HEIGHT_ENABLED"), Some("0"));
|
||||
assert!(value(&env, "ODIAN_HEIGHT_SERVER_URL").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_and_tile_server_are_loopback_and_cache_only() {
|
||||
let config = config(&[("dark", "protomaps_20260423")]);
|
||||
let api = api_env(&config);
|
||||
assert_eq!(value(&api, "ODIAN_API_BIND"), Some("127.0.0.1"));
|
||||
assert_eq!(value(&api, "ODIAN_DISCOVERY"), Some("0"));
|
||||
assert_eq!(value(&api, "ODIAN_RAIL_SYNC"), Some("0"));
|
||||
let tiles = tiles_env(&config);
|
||||
assert_eq!(value(&tiles, "ODIAN_TILE_CACHE_ONLY"), Some("1"));
|
||||
assert_eq!(value(&tiles, "ODIAN_TILE_BAKER_EXE"), Some(""));
|
||||
assert!(pg_ini(&config).contains("port = 55432\n") && pg_ini(&config).contains("password = pw\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datasets_point_every_set_at_the_shared_tile_store() {
|
||||
let doc = datasets(&config(&[("dark", "protomaps_20260423"), ("heightmap", "planet_heightmap")]));
|
||||
let object = doc.as_object().expect("an object");
|
||||
assert_eq!(object.len(), 2);
|
||||
assert_eq!(object["protomaps_20260423"]["cache_dir"], r"D:\ODIAN\data\tiles");
|
||||
assert!(object["planet_heightmap"].get("pmtiles").is_none(), "no pmtiles on disk, so none is named");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_sets_come_from_the_installed_manifest() {
|
||||
let root = std::env::temp_dir().join(format!("odian-launcher-test-{}", uuid::Uuid::new_v4().simple()));
|
||||
let layout = Layout::new(&root, "0.52.0");
|
||||
std::fs::create_dir_all(layout.version_dir()).unwrap();
|
||||
std::fs::write(
|
||||
layout.version_dir().join("manifest.json"),
|
||||
r#"{"tiles":{"block_shift":6,"sets":[{"style":"dark","dataset":"protomaps_20260423"},{"style":"heightmap","dataset":"planet_heightmap"}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let sets = installed_tile_sets(&layout).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(sets, vec![("dark".to_string(), "protomaps_20260423".to_string()), ("heightmap".to_string(), "planet_heightmap".to_string())]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod job {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
};
|
||||
|
||||
/// Every stack process joins this job; when the launcher exits, however it exits, Windows ends them.
|
||||
pub struct Job(HANDLE);
|
||||
|
||||
// A job handle may be used from any thread.
|
||||
unsafe impl Send for Job {}
|
||||
unsafe impl Sync for Job {}
|
||||
|
||||
impl Job {
|
||||
pub fn new() -> Result<Job, String> {
|
||||
unsafe {
|
||||
let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null());
|
||||
if handle.is_null() {
|
||||
return Err(format!("cannot create a job object: {}", std::io::Error::last_os_error()));
|
||||
}
|
||||
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
let ok = SetInformationJobObject(
|
||||
handle,
|
||||
JobObjectExtendedLimitInformation,
|
||||
&info as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION as *const core::ffi::c_void,
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
);
|
||||
if ok == 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
CloseHandle(handle);
|
||||
return Err(format!("cannot configure the job object: {error}"));
|
||||
}
|
||||
Ok(Job(handle))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assign(&self, child: &std::process::Child) -> Result<(), String> {
|
||||
let ok = unsafe { AssignProcessToJobObject(self.0, child.as_raw_handle() as HANDLE) };
|
||||
if ok == 0 {
|
||||
Err(std::io::Error::last_os_error().to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Job {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod job {
|
||||
pub struct Job;
|
||||
impl Job {
|
||||
pub fn new() -> Result<Job, String> {
|
||||
Ok(Job)
|
||||
}
|
||||
pub fn assign(&self, _child: &std::process::Child) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! What this PC's install is: `<install_dir>\state\launcher.json`.
|
||||
//!
|
||||
//! Nothing here may be baked into an ODIAN drive: sites clone drives freely, so
|
||||
//! the install id and the local secrets are generated on the PC itself.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fsutil::{write_atomic, write_json_atomic};
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct LauncherState {
|
||||
#[serde(default)]
|
||||
pub install_id: String,
|
||||
/// The version whose files, database and tiles are fully in place.
|
||||
#[serde(default)]
|
||||
pub installed_version: Option<String>,
|
||||
/// The last operator who signed in; offline launches run as them.
|
||||
#[serde(default)]
|
||||
pub operator: Option<String>,
|
||||
/// Shared by the launcher and the local Engine API for `/auth/mint`. The API
|
||||
/// only listens on 127.0.0.1, so it never leaves this PC.
|
||||
#[serde(default)]
|
||||
pub mint_secret: String,
|
||||
/// Password of the local Postgres superuser `odian` (127.0.0.1 only).
|
||||
#[serde(default)]
|
||||
pub pg_password: String,
|
||||
/// The database snapshot whose restore completed. None means the local
|
||||
/// database holds no operator work yet and may be (re)created from a snapshot.
|
||||
#[serde(default)]
|
||||
pub db_snapshot: Option<String>,
|
||||
}
|
||||
|
||||
fn state_dir(install_dir: &Path) -> PathBuf {
|
||||
install_dir.join("state")
|
||||
}
|
||||
|
||||
/// 256 random bits as hex.
|
||||
pub fn new_secret() -> String {
|
||||
format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
impl LauncherState {
|
||||
pub fn load(install_dir: &Path) -> Result<LauncherState, String> {
|
||||
let path = state_dir(install_dir).join("launcher.json");
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid JSON: {e}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LauncherState::default()),
|
||||
Err(e) => Err(format!("cannot read {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&mut self, install_dir: &Path) -> Result<(), String> {
|
||||
if self.install_id.is_empty() {
|
||||
self.install_id = uuid::Uuid::new_v4().simple().to_string();
|
||||
}
|
||||
write_json_atomic(&state_dir(install_dir).join("launcher.json"), self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch notes are kept per version so the hub still has them offline.
|
||||
pub fn cached_notes(install_dir: &Path, version: &str) -> Option<String> {
|
||||
std::fs::read_to_string(state_dir(install_dir).join("notes").join(format!("{version}.md"))).ok()
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The site token, kept in Windows Credential Manager (one entry per site).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const SERVICE: &str = "ODIAN Launcher";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SiteToken {
|
||||
/// Engine session token: 24 h, and gone whenever the site's Engine API restarts.
|
||||
pub access_token: String,
|
||||
/// Unix seconds.
|
||||
pub expires_at: i64,
|
||||
pub username: String,
|
||||
/// The site MCP's launcher session: no expiry, ended only by Sign out, an admin, or
|
||||
/// the ODIAN user disappearing. It renews `access_token` without asking the operator.
|
||||
#[serde(default)]
|
||||
pub launcher_token: Option<String>,
|
||||
}
|
||||
|
||||
impl SiteToken {
|
||||
pub fn expired(&self) -> bool {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
self.expires_at <= now
|
||||
}
|
||||
}
|
||||
|
||||
fn entry(site_url: &str) -> Option<keyring::Entry> {
|
||||
if site_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
keyring::Entry::new(SERVICE, site_url).ok()
|
||||
}
|
||||
|
||||
pub fn load(site_url: &str) -> Option<SiteToken> {
|
||||
let secret = entry(site_url)?.get_password().ok()?;
|
||||
serde_json::from_str(&secret).ok()
|
||||
}
|
||||
|
||||
pub fn save(site_url: &str, token: &SiteToken) -> Result<(), String> {
|
||||
let entry = entry(site_url).ok_or("no DHP configured")?;
|
||||
let secret = serde_json::to_string(token).map_err(|e| e.to_string())?;
|
||||
entry.set_password(&secret).map_err(|e| format!("cannot store the sign-in in Credential Manager: {e}"))
|
||||
}
|
||||
|
||||
pub fn clear(site_url: &str) {
|
||||
if let Some(entry) = entry(site_url) {
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "ODIAN Launcher",
|
||||
"version": "0.1.0",
|
||||
"identifier": "fr.odian.launcher",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "ODIAN",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"minWidth": 1024,
|
||||
"minHeight": 640,
|
||||
"center": true,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||