V1 of laucher
This commit is contained in:
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user