V1 of laucher

This commit is contained in:
Kelorastus
2026-09-18 14:42:28 +02:00
parent 3fdfde7043
commit d5da361952
8 changed files with 338 additions and 32 deletions
+76 -3
View File
@@ -479,7 +479,65 @@ impl Launcher {
}
}
/// Wait until the DHP lets this PC download a whole version, then keep the slot
/// alive until `hold` is cancelled. Returns false if the wait was cancelled.
/// A DHP that hands out no slots (or cannot be asked) never blocks an install.
async fn wait_for_install_slot(&self, app: &AppHandle, cancel: &CancellationToken, hold: CancellationToken) -> bool {
let (site_url, install_id) = {
let inner = self.inner.lock().await;
(inner.settings.site_url.clone(), inner.state.install_id.clone())
};
let mut told = false;
loop {
let Ok(token) = self.site_access_token(app).await else { return true };
match site::claim_install(&self.http, &site_url, &token, &install_id).await {
site::InstallSlot::Queued { ahead } => {
if !told {
self.log(app, "info", format!("The DHP is busy with other installs: waiting for a slot ({ahead} ahead)."));
told = true;
}
tokio::select! {
_ = cancel.cancelled() => return false,
_ = tokio::time::sleep(Duration::from_secs(10)) => {}
}
}
site::InstallSlot::Granted { lease_sec } => {
if told {
self.log(app, "info", "The DHP freed a slot: starting the download.");
}
// Renew for as long as the install runs; a PC that dies frees its slot.
let (handle, url, id) = (app.clone(), site_url.clone(), install_id.clone());
let renew_every = Duration::from_secs((lease_sec / 2).clamp(15, 300));
tauri::async_runtime::spawn(async move {
let launcher = handle.state::<Launcher>();
loop {
tokio::select! {
_ = hold.cancelled() => break,
_ = tokio::time::sleep(renew_every) => {}
}
let Ok(token) = launcher.site_access_token(&handle).await else { continue };
let _ = site::claim_install(&launcher.http, &url, &token, &id).await;
}
if let Ok(token) = launcher.site_access_token(&handle).await {
site::release_install(&launcher.http, &url, &token, &id).await;
}
});
return true;
}
site::InstallSlot::NotOffered => return true,
}
}
}
async fn run_install(&self, app: &AppHandle, drive: Option<PathBuf>, install_dir: PathBuf, cancel: CancellationToken) {
// Downloading a whole version from the DHP takes a slot; a drive install takes none.
let hold = CancellationToken::new();
if drive.is_none() && !self.wait_for_install_slot(app, &cancel, hold.clone()).await {
hold.cancel();
self.log(app, "warn", "Install cancelled while waiting for the DHP.");
self.finish(app, None).await;
return;
}
let mut renewed = false;
let result = loop {
let source = match &drive {
@@ -502,6 +560,8 @@ impl Launcher {
other => break other,
}
};
// Whatever happened, let the next PC in.
hold.cancel();
if let Ok(mut slot) = self.install_cancel.lock() {
*slot = None;
}
@@ -544,6 +604,18 @@ impl Launcher {
let manifest = verified.manifest();
let version = verified.version().to_string();
install::check_runnable(manifest)?;
// A USB key cannot carry this: Postgres restores 16 GB into it, and unplugging the key
// would take the install with it. The default folder already avoids removable drives.
if crate::settings::is_removable_path(install_dir) {
self.log(
app,
"warn",
format!(
"{} is on a removable drive. ODIAN needs a disk inside this PC: change the folder in Settings, or the database restore will stall.",
install_dir.display()
),
);
}
let database_restored = self.inner.lock().await.state.db_snapshot.is_some();
let restores_db = manifest.db.is_some() && !database_restored;
@@ -822,7 +894,7 @@ impl Launcher {
}
};
match self.sync_with_dhp(app, &pg, &sql_dir, &site_url, install_id, origin, pull).await {
match self.sync_with_dhp(app, &pg, &sql_dir, &site_url, install_id, origin, layout.version(), pull).await {
Ok(report) => {
let s = &report.status;
self.log(
@@ -856,6 +928,7 @@ impl Launcher {
site_url: &str,
install_id: &str,
origin: Option<SyncOrigin>,
version: &str,
pull: bool,
) -> Result<sync::Report, String> {
let token = self.site_access_token(app).await?;
@@ -882,11 +955,11 @@ impl Launcher {
}
let dhp = sync::Dhp { http: &self.http, site_url, token: &token };
match sync::run(pg, &dhp, install_id, pull).await {
match sync::run(pg, &dhp, install_id, version, pull).await {
Err(SyncError::Unauthorized) => {
let token = self.renew(app).await?;
let dhp = sync::Dhp { http: &self.http, site_url, token: &token };
sync::run(pg, &dhp, install_id, pull).await.map_err(|e| e.to_string())
sync::run(pg, &dhp, install_id, version, pull).await.map_err(|e| e.to_string())
}
other => other.map_err(|e| e.to_string()),
}
+55 -3
View File
@@ -41,11 +41,58 @@ fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(dir.join("settings.json"))
}
/// Is this drive a disk built into the PC, rather than a USB key or a card?
///
/// A key plugged into a laptop takes D:, and ODIAN would install itself onto it: the
/// database restore then stalls on the key's write speed until it gives up, and the
/// install would vanish the moment someone unplugs it.
#[cfg(windows)]
fn is_fixed_drive(root: &str) -> bool {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW;
/// GetDriveType's answer for a disk built into the PC (windows-sys does not export it).
const DRIVE_FIXED: u32 = 3;
let wide: Vec<u16> = std::ffi::OsStr::new(root).encode_wide().chain(std::iter::once(0)).collect();
unsafe { GetDriveTypeW(wide.as_ptr()) == DRIVE_FIXED }
}
#[cfg(not(windows))]
fn is_fixed_drive(_root: &str) -> bool {
false
}
/// Does this path sit on a USB key or a card? Choosing one by hand is allowed, but it is
/// worth saying out loud before an install spends half an hour failing on it.
#[cfg(windows)]
pub fn is_removable_path(path: &Path) -> bool {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW;
/// GetDriveType's answer for a USB key or a card reader.
const DRIVE_REMOVABLE: u32 = 2;
let Some(std::path::Component::Prefix(prefix)) = path.components().next() else {
return false;
};
let root = format!("{}\\", prefix.as_os_str().to_string_lossy());
let wide: Vec<u16> = std::ffi::OsStr::new(&root).encode_wide().chain(std::iter::once(0)).collect();
unsafe { GetDriveTypeW(wide.as_ptr()) == DRIVE_REMOVABLE }
}
#[cfg(not(windows))]
pub fn is_removable_path(_path: &Path) -> bool {
false
}
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() {
if Path::new("D:\\").is_dir() && is_fixed_drive("D:\\") {
"D:\\ODIAN".to_string()
} else {
"C:\\ODIAN".to_string()
@@ -56,8 +103,13 @@ 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()
// Beside the exe is how a launcher copied onto a USB key is handed out, and where
// the NSIS installer puts the site it was built for. Tauri has moved bundled
// resources under `resources/` between versions, so look there too rather than
// silently starting with no site and asking the operator for an address.
["site.json", "resources/site.json"]
.iter()
.find_map(|name| std::fs::read(dir.join(name)).ok())
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
+44
View File
@@ -121,6 +121,50 @@ pub async fn end_session(http: &reqwest::Client, mcp_url: &str, launcher_token:
.await;
}
/// A DHP's answer to "may this PC download a whole version now?".
pub enum InstallSlot {
/// Go ahead; renew before `lease_sec` runs out.
Granted { lease_sec: u64 },
/// Wait: this many PCs are downloading or waiting ahead of this one.
Queued { ahead: u64 },
/// The DHP does not hand out slots (older service): just install.
NotOffered,
}
/// Ask for, or renew, this PC's install slot.
pub async fn claim_install(http: &reqwest::Client, site_url: &str, token: &str, install_id: &str) -> InstallSlot {
let sent = http
.post(format!("{site_url}/v1/install/claim"))
.bearer_auth(token)
.json(&serde_json::json!({ "install_id": install_id }))
.timeout(Duration::from_secs(10))
.send()
.await;
let Ok(response) = sent else { return InstallSlot::NotOffered };
if response.status() == StatusCode::NOT_FOUND {
return InstallSlot::NotOffered;
}
if !response.status().is_success() {
return InstallSlot::NotOffered;
}
let body: serde_json::Value = response.json().await.unwrap_or_default();
if body.get("granted").and_then(|g| g.as_bool()).unwrap_or(true) {
return InstallSlot::Granted { lease_sec: body.get("lease_sec").and_then(|l| l.as_u64()).unwrap_or(120) };
}
InstallSlot::Queued { ahead: body.get("ahead").and_then(|a| a.as_u64()).unwrap_or(0) }
}
/// Give the slot back. Best effort: an unreleased slot simply expires.
pub async fn release_install(http: &reqwest::Client, site_url: &str, token: &str, install_id: &str) {
let _ = http
.post(format!("{site_url}/v1/install/release"))
.bearer_auth(token)
.json(&serde_json::json!({ "install_id": install_id }))
.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"))
+136 -21
View File
@@ -96,6 +96,10 @@ impl Layout {
pub fn new(root: &Path, version: &str) -> Layout {
Layout { root: root.to_path_buf(), version: version.to_string() }
}
/// The installed version this layout is for; the DHP lists it against this install.
pub fn version(&self) -> &str {
&self.version
}
pub fn version_dir(&self) -> PathBuf {
self.root.join("versions").join(&self.version)
}
@@ -143,7 +147,7 @@ pub struct StackConfig {
pub pg_password: String,
pub install_id: String,
/// (style, dataset) of every tile set the installed version carries.
pub tile_sets: Vec<(String, String)>,
pub tile_sets: Vec<TileSet>,
/// The DHP's Engine API (from its health answer). With one, the local API serves
/// the DHP's data while it is reachable; without, this PC always serves its own.
pub upstream_url: Option<String>,
@@ -428,20 +432,58 @@ fn pg_ini(config: &StackConfig) -> String {
)
}
/// What a tile of this kind is served as. Anything else is bytes.
fn media_type(ext: &str) -> &'static str {
match ext {
".jpg" | ".jpeg" => "image/jpeg",
".png" => "image/png",
".webp" => "image/webp",
".tif" | ".tiff" => "image/tiff",
_ => "application/octet-stream",
}
}
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));
for set in &config.tile_sets {
let entry = doc.entry(set.dataset.clone()).or_insert_with(|| {
let mut entry = serde_json::json!({ "cache_dir": text(tiles.clone()) });
let pmtiles = config.layout.data_dir().join("src").join(format!("{}.pmtiles", set.dataset));
if pmtiles.is_file() {
entry["pmtiles"] = serde_json::Value::String(text(pmtiles));
}
entry
});
// Imagery is .jpg, a drawn basemap .png: the tile server assumes .png unless told,
// and looking for the wrong name is a 404 for every tile of that style.
if !set.ext.is_empty() {
let styles = entry
.as_object_mut()
.expect("a dataset entry is an object")
.entry("styles")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
styles[&set.style] = serde_json::json!({ "tile_ext": set.ext, "media_type": media_type(&set.ext) });
}
doc.insert(dataset.clone(), entry);
}
serde_json::Value::Object(doc)
}
/// Where the Engine reads elevation from.
///
/// The height map ships as a tile set like any basemap (style `terrarium`), so it lands in
/// `data iles errarium\<dataset>`. Without one installed this stays the empty `data\dem`
/// folder, and the Engine simply has no elevation.
fn dem_dir(config: &StackConfig) -> PathBuf {
let data = config.layout.data_dir();
installed_tile_sets(&config.layout)
.unwrap_or_default()
.into_iter()
.find(|set| set.style == "terrarium")
.map(|set| data.join("tiles").join(&set.style).join(&set.dataset))
.unwrap_or_else(|| data.join("dem").join("terrarium"))
}
fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> {
let api = config.layout.state_dir().join("api");
let logs = api.join("logs");
@@ -452,7 +494,7 @@ fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> {
("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_DEM_DIR", text(dem_dir(config))),
("ODIAN_RAIL_SYNC", "0".to_string()),
("ODIAN_WEATHER_INI", text(api.join("weather.ini"))),
("ODIAN_OPENAIP_INI", text(api.join("openaip.ini"))),
@@ -507,26 +549,44 @@ fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
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()));
.find(|set| set.style == "dark")
.or_else(|| config.tile_sets.iter().find(|set| set.style != "heightmap"));
if let Some(set) = basemap {
env.push(("ODIAN_TILE_STYLE", set.style.clone()));
env.push(("ODIAN_TILE_DATASET", set.dataset.clone()));
}
match config.tile_sets.iter().find(|(style, _)| style == "heightmap") {
Some((style, dataset)) => {
// Every style this install carries, so the client's basemap picker offers exactly
// what this PC can draw offline. Without it the client falls back to a fixed list
// of three, which is what hid the satellite and orthophoto maps 0.54.0 ships.
let styles: Vec<&str> = config.tile_sets.iter().map(|set| set.style.as_str()).collect();
if !styles.is_empty() {
env.push(("ODIAN_TILE_STYLES", styles.join(",")));
}
match config.tile_sets.iter().find(|set| set.style == "heightmap") {
Some(set) => {
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()));
env.push(("ODIAN_HEIGHT_STYLE", set.style.clone()));
env.push(("ODIAN_HEIGHT_DATASET", set.dataset.clone()));
}
None => env.push(("ODIAN_HEIGHT_ENABLED", "0".to_string())),
}
env
}
/// One tile set carried by the installed version.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TileSet {
pub style: String,
pub dataset: String,
/// What its tiles are named: ".jpg" for imagery, ".png" for a drawn basemap, ".lerc"
/// for elevation. Empty when the pack predates this, and the tile server's own default
/// (.png) then applies, which is what those older packs carried anyway.
pub ext: String,
}
/// The tile sets the installed version's manifest names.
pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<(String, String)>, String> {
pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, 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 =
@@ -536,7 +596,13 @@ pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<(String, String)>, Str
.map(|sets| {
sets
.iter()
.filter_map(|set| Some((set.get("style")?.as_str()?.to_string(), set.get("dataset")?.as_str()?.to_string())))
.filter_map(|set| {
Some(TileSet {
style: set.get("style")?.as_str()?.to_string(),
dataset: set.get("dataset")?.as_str()?.to_string(),
ext: set.get("tile_ext").and_then(|e| e.as_str()).unwrap_or_default().to_string(),
})
})
.collect()
})
.unwrap_or_default())
@@ -561,7 +627,10 @@ mod tests {
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(),
tile_sets: tile_sets
.iter()
.map(|(s, d)| TileSet { style: s.to_string(), dataset: d.to_string(), ext: String::new() })
.collect(),
upstream_url: None,
}
}
@@ -605,6 +674,22 @@ mod tests {
assert!(value(&env, "ODIAN_MCP_API_URL").is_none(), "an offline client must not know the MCP");
}
#[test]
fn the_client_is_told_every_style_this_install_carries() {
// The picker offers what the PC holds. Naming only the default basemap is what
// left 0.54.0's satellite and orthophoto maps installed but unreachable.
let env = client_env(&config(&[
("dark", "protomaps_20260423"),
("satellite_hd", "protomaps_20260423"),
("orthophotos", "protomaps_20260423"),
("heightmap", "planet_heightmap"),
]));
assert_eq!(value(&env, "ODIAN_TILE_STYLES"), Some("dark,satellite_hd,orthophotos,heightmap"));
let none = client_env(&config(&[]));
assert!(value(&none, "ODIAN_TILE_STYLES").is_none(), "no tile sets, nothing to name");
}
#[test]
fn heights_are_switched_off_when_the_version_has_none() {
let env = client_env(&config(&[("dark", "protomaps_20260423")]));
@@ -634,6 +719,30 @@ mod tests {
assert!(object["planet_heightmap"].get("pmtiles").is_none(), "no pmtiles on disk, so none is named");
}
#[test]
fn datasets_name_each_style_extension() {
let mut cfg = config(&[]);
cfg.tile_sets = vec![
TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string() },
TileSet { style: "satellite_hd".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".jpg".to_string() },
TileSet { style: "heightmap".to_string(), dataset: "planet_heightmap".to_string(), ext: ".lerc".to_string() },
];
let doc = datasets(&cfg);
// Two styles share one dataset: both must be named under it, not overwrite each other.
let shared = &doc["protomaps_20260423"]["styles"];
assert_eq!(shared["dark"]["tile_ext"], ".png");
assert_eq!(shared["satellite_hd"]["tile_ext"], ".jpg");
assert_eq!(shared["satellite_hd"]["media_type"], "image/jpeg");
assert_eq!(doc["planet_heightmap"]["styles"]["heightmap"]["tile_ext"], ".lerc");
}
#[test]
fn a_pack_without_extensions_names_no_styles() {
// Older packs said nothing, and the tile server's own default (.png) was right for them.
let doc = datasets(&config(&[("dark", "protomaps_20260423")]));
assert!(doc["protomaps_20260423"].get("styles").is_none());
}
#[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()));
@@ -646,7 +755,13 @@ mod tests {
.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())]);
assert_eq!(
sets,
vec![
TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: String::new() },
TileSet { style: "heightmap".to_string(), dataset: "planet_heightmap".to_string(), ext: String::new() },
]
);
}
}
+18 -4
View File
@@ -220,16 +220,25 @@ pub async fn hello(dhp: &Dhp<'_>) -> Result<(String, String), SyncError> {
}
/// Push this PC's edits; then, when `pull`, apply everything new on the DHP.
/// Postgres must be running and the local Engine API stopped.
pub async fn run(pg: &Pg, dhp: &Dhp<'_>, install_id: &str, pull: bool) -> Result<Report, SyncError> {
/// Postgres must be running and the local Engine API stopped. `version` is reported
/// so the DHP's install list can say which version each PC is on.
pub async fn run(pg: &Pg, dhp: &Dhp<'_>, install_id: &str, version: &str, pull: bool) -> Result<Report, SyncError> {
let mut report = Report::default();
// Say which DHP database these edits were made against. A PC carried over from another
// one (a test copy, say) would otherwise apply its edits, deletions included, here.
let joined = blocking(pg, joined_db_id).await?.unwrap_or_default();
loop {
let items = blocking(pg, |pg| query(pg, &format!("SELECT sync.outbox({PUSH_BATCH})"))).await?;
let count = items.as_array().map_or(0, Vec::len);
if count == 0 {
break;
}
let answer = call(dhp, "/v1/sync/push", Some(&json!({ "install_id": install_id, "changes": items }))).await?;
let answer = call(
dhp,
"/v1/sync/push",
Some(&json!({ "install_id": install_id, "version": version, "db_id": joined, "changes": items })),
)
.await?;
let recorded =
blocking(pg, move |pg| query(pg, &format!("SELECT sync.pushed({}, {})", jsonb(&items), jsonb(&answer)))).await?;
let accepted = recorded.get("accepted").and_then(Value::as_u64).unwrap_or(0);
@@ -247,7 +256,12 @@ pub async fn run(pg: &Pg, dhp: &Dhp<'_>, install_id: &str, pull: bool) -> Result
let mut cursor = json!({ "since": since });
let mut changes: Vec<Value> = Vec::new();
let db_id = loop {
let page = call(dhp, "/v1/sync/pull", Some(&json!({ "cursor": cursor, "limit": PULL_PAGE }))).await?;
let page = call(
dhp,
"/v1/sync/pull",
Some(&json!({ "cursor": cursor, "limit": PULL_PAGE, "install_id": install_id, "version": version })),
)
.await?;
if let Some(list) = page.get("changes").and_then(Value::as_array) {
changes.extend(list.iter().cloned());
}