V1 of laucher
This commit is contained in:
@@ -34,4 +34,4 @@ junction = "1"
|
|||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
# Win32_System_Threading gates JOBOBJECT_EXTENDED_LIMIT_INFORMATION (it embeds IO_COUNTERS).
|
# 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"] }
|
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading"] }
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "ODIAN test DHP",
|
||||||
|
"site_url": "http://192.168.1.41:30097",
|
||||||
|
"mcp_url": "http://192.168.1.41:30091"
|
||||||
|
}
|
||||||
@@ -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) {
|
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 mut renewed = false;
|
||||||
let result = loop {
|
let result = loop {
|
||||||
let source = match &drive {
|
let source = match &drive {
|
||||||
@@ -502,6 +560,8 @@ impl Launcher {
|
|||||||
other => break other,
|
other => break other,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Whatever happened, let the next PC in.
|
||||||
|
hold.cancel();
|
||||||
if let Ok(mut slot) = self.install_cancel.lock() {
|
if let Ok(mut slot) = self.install_cancel.lock() {
|
||||||
*slot = None;
|
*slot = None;
|
||||||
}
|
}
|
||||||
@@ -544,6 +604,18 @@ impl Launcher {
|
|||||||
let manifest = verified.manifest();
|
let manifest = verified.manifest();
|
||||||
let version = verified.version().to_string();
|
let version = verified.version().to_string();
|
||||||
install::check_runnable(manifest)?;
|
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 database_restored = self.inner.lock().await.state.db_snapshot.is_some();
|
||||||
let restores_db = manifest.db.is_some() && !database_restored;
|
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) => {
|
Ok(report) => {
|
||||||
let s = &report.status;
|
let s = &report.status;
|
||||||
self.log(
|
self.log(
|
||||||
@@ -856,6 +928,7 @@ impl Launcher {
|
|||||||
site_url: &str,
|
site_url: &str,
|
||||||
install_id: &str,
|
install_id: &str,
|
||||||
origin: Option<SyncOrigin>,
|
origin: Option<SyncOrigin>,
|
||||||
|
version: &str,
|
||||||
pull: bool,
|
pull: bool,
|
||||||
) -> Result<sync::Report, String> {
|
) -> Result<sync::Report, String> {
|
||||||
let token = self.site_access_token(app).await?;
|
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 };
|
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) => {
|
Err(SyncError::Unauthorized) => {
|
||||||
let token = self.renew(app).await?;
|
let token = self.renew(app).await?;
|
||||||
let dhp = sync::Dhp { http: &self.http, site_url, token: &token };
|
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()),
|
other => other.map_err(|e| e.to_string()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,11 +41,58 @@ fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
|||||||
Ok(dir.join("settings.json"))
|
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 {
|
fn default_install_dir() -> String {
|
||||||
// Operator PCs keep the ~1 TB of data off the system drive when a D: exists.
|
// 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
|
// 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.
|
// 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()
|
"D:\\ODIAN".to_string()
|
||||||
} else {
|
} else {
|
||||||
"C:\\ODIAN".to_string()
|
"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 {
|
let Some(dir) = std::env::current_exe().ok().and_then(|exe| exe.parent().map(Path::to_path_buf)) else {
|
||||||
return SiteFile::default();
|
return SiteFile::default();
|
||||||
};
|
};
|
||||||
std::fs::read(dir.join("site.json"))
|
// Beside the exe is how a launcher copied onto a USB key is handed out, and where
|
||||||
.ok()
|
// 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())
|
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,50 @@ pub async fn end_session(http: &reqwest::Client, mcp_url: &str, launcher_token:
|
|||||||
.await;
|
.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> {
|
pub async fn notes(http: &reqwest::Client, site_url: &str, token: &str, version: &str) -> Result<String, String> {
|
||||||
let response = http
|
let response = http
|
||||||
.get(format!("{site_url}/v1/versions/{version}/notes"))
|
.get(format!("{site_url}/v1/versions/{version}/notes"))
|
||||||
|
|||||||
+133
-18
@@ -96,6 +96,10 @@ impl Layout {
|
|||||||
pub fn new(root: &Path, version: &str) -> Layout {
|
pub fn new(root: &Path, version: &str) -> Layout {
|
||||||
Layout { root: root.to_path_buf(), version: version.to_string() }
|
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 {
|
pub fn version_dir(&self) -> PathBuf {
|
||||||
self.root.join("versions").join(&self.version)
|
self.root.join("versions").join(&self.version)
|
||||||
}
|
}
|
||||||
@@ -143,7 +147,7 @@ pub struct StackConfig {
|
|||||||
pub pg_password: String,
|
pub pg_password: String,
|
||||||
pub install_id: String,
|
pub install_id: String,
|
||||||
/// (style, dataset) of every tile set the installed version carries.
|
/// (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 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.
|
/// the DHP's data while it is reachable; without, this PC always serves its own.
|
||||||
pub upstream_url: Option<String>,
|
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 {
|
fn datasets(config: &StackConfig) -> serde_json::Value {
|
||||||
let tiles = config.layout.data_dir().join("tiles");
|
let tiles = config.layout.data_dir().join("tiles");
|
||||||
let mut doc = serde_json::Map::new();
|
let mut doc = serde_json::Map::new();
|
||||||
for (_, dataset) in &config.tile_sets {
|
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 mut entry = serde_json::json!({ "cache_dir": text(tiles.clone()) });
|
||||||
let pmtiles = config.layout.data_dir().join("src").join(format!("{dataset}.pmtiles"));
|
let pmtiles = config.layout.data_dir().join("src").join(format!("{}.pmtiles", set.dataset));
|
||||||
if pmtiles.is_file() {
|
if pmtiles.is_file() {
|
||||||
entry["pmtiles"] = serde_json::Value::String(text(pmtiles));
|
entry["pmtiles"] = serde_json::Value::String(text(pmtiles));
|
||||||
}
|
}
|
||||||
doc.insert(dataset.clone(), entry);
|
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) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
serde_json::Value::Object(doc)
|
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)> {
|
fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
||||||
let api = config.layout.state_dir().join("api");
|
let api = config.layout.state_dir().join("api");
|
||||||
let logs = api.join("logs");
|
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_MINT_SECRET", config.mint_secret.clone()),
|
||||||
("ODIAN_DISCOVERY", "0".to_string()),
|
("ODIAN_DISCOVERY", "0".to_string()),
|
||||||
("ODIAN_DEM_OFFLINE", "1".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_RAIL_SYNC", "0".to_string()),
|
||||||
("ODIAN_WEATHER_INI", text(api.join("weather.ini"))),
|
("ODIAN_WEATHER_INI", text(api.join("weather.ini"))),
|
||||||
("ODIAN_OPENAIP_INI", text(api.join("openaip.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
|
let basemap = config
|
||||||
.tile_sets
|
.tile_sets
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(style, _)| style == "dark")
|
.find(|set| set.style == "dark")
|
||||||
.or_else(|| config.tile_sets.iter().find(|(style, _)| style != "heightmap"));
|
.or_else(|| config.tile_sets.iter().find(|set| set.style != "heightmap"));
|
||||||
if let Some((style, dataset)) = basemap {
|
if let Some(set) = basemap {
|
||||||
env.push(("ODIAN_TILE_STYLE", style.clone()));
|
env.push(("ODIAN_TILE_STYLE", set.style.clone()));
|
||||||
env.push(("ODIAN_TILE_DATASET", dataset.clone()));
|
env.push(("ODIAN_TILE_DATASET", set.dataset.clone()));
|
||||||
}
|
}
|
||||||
match config.tile_sets.iter().find(|(style, _)| style == "heightmap") {
|
// Every style this install carries, so the client's basemap picker offers exactly
|
||||||
Some((style, dataset)) => {
|
// 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_SERVER_URL", tiles_url));
|
||||||
env.push(("ODIAN_HEIGHT_FORMAT", "lerc".to_string()));
|
env.push(("ODIAN_HEIGHT_FORMAT", "lerc".to_string()));
|
||||||
env.push(("ODIAN_HEIGHT_STYLE", style.clone()));
|
env.push(("ODIAN_HEIGHT_STYLE", set.style.clone()));
|
||||||
env.push(("ODIAN_HEIGHT_DATASET", dataset.clone()));
|
env.push(("ODIAN_HEIGHT_DATASET", set.dataset.clone()));
|
||||||
}
|
}
|
||||||
None => env.push(("ODIAN_HEIGHT_ENABLED", "0".to_string())),
|
None => env.push(("ODIAN_HEIGHT_ENABLED", "0".to_string())),
|
||||||
}
|
}
|
||||||
env
|
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.
|
/// 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 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 bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?;
|
||||||
let manifest: serde_json::Value =
|
let manifest: serde_json::Value =
|
||||||
@@ -536,7 +596,13 @@ pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<(String, String)>, Str
|
|||||||
.map(|sets| {
|
.map(|sets| {
|
||||||
sets
|
sets
|
||||||
.iter()
|
.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()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
@@ -561,7 +627,10 @@ mod tests {
|
|||||||
mint_secret: "secret".to_string(),
|
mint_secret: "secret".to_string(),
|
||||||
pg_password: "pw".to_string(),
|
pg_password: "pw".to_string(),
|
||||||
install_id: "install".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,
|
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");
|
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]
|
#[test]
|
||||||
fn heights_are_switched_off_when_the_version_has_none() {
|
fn heights_are_switched_off_when_the_version_has_none() {
|
||||||
let env = client_env(&config(&[("dark", "protomaps_20260423")]));
|
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");
|
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]
|
#[test]
|
||||||
fn tile_sets_come_from_the_installed_manifest() {
|
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 root = std::env::temp_dir().join(format!("odian-launcher-test-{}", uuid::Uuid::new_v4().simple()));
|
||||||
@@ -646,7 +755,13 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let sets = installed_tile_sets(&layout).unwrap();
|
let sets = installed_tile_sets(&layout).unwrap();
|
||||||
std::fs::remove_dir_all(&root).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
@@ -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.
|
/// Push this PC's edits; then, when `pull`, apply everything new on the DHP.
|
||||||
/// Postgres must be running and the local Engine API stopped.
|
/// Postgres must be running and the local Engine API stopped. `version` is reported
|
||||||
pub async fn run(pg: &Pg, dhp: &Dhp<'_>, install_id: &str, pull: bool) -> Result<Report, SyncError> {
|
/// 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();
|
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 {
|
loop {
|
||||||
let items = blocking(pg, |pg| query(pg, &format!("SELECT sync.outbox({PUSH_BATCH})"))).await?;
|
let items = blocking(pg, |pg| query(pg, &format!("SELECT sync.outbox({PUSH_BATCH})"))).await?;
|
||||||
let count = items.as_array().map_or(0, Vec::len);
|
let count = items.as_array().map_or(0, Vec::len);
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
break;
|
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 =
|
let recorded =
|
||||||
blocking(pg, move |pg| query(pg, &format!("SELECT sync.pushed({}, {})", jsonb(&items), jsonb(&answer)))).await?;
|
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);
|
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 cursor = json!({ "since": since });
|
||||||
let mut changes: Vec<Value> = Vec::new();
|
let mut changes: Vec<Value> = Vec::new();
|
||||||
let db_id = loop {
|
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) {
|
if let Some(list) = page.get("changes").and_then(Value::as_array) {
|
||||||
changes.extend(list.iter().cloned());
|
changes.extend(list.iter().cloned());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["nsis"],
|
"targets": ["nsis"],
|
||||||
|
"resources": [
|
||||||
|
"site.json"
|
||||||
|
],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
Reference in New Issue
Block a user