From d5da361952ed835f9ea9dc53ce72223ce2f38d57 Mon Sep 17 00:00:00 2001 From: Kelorastus <108483685+Kelorastus@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:42:28 +0200 Subject: [PATCH] V1 of laucher --- src-tauri/Cargo.toml | 2 +- src-tauri/site.json | 5 ++ src-tauri/src/launcher.rs | 79 ++++++++++++++++++- src-tauri/src/settings.rs | 58 +++++++++++++- src-tauri/src/site.rs | 44 +++++++++++ src-tauri/src/stack.rs | 157 +++++++++++++++++++++++++++++++++----- src-tauri/src/sync.rs | 22 +++++- src-tauri/tauri.conf.json | 3 + 8 files changed, 338 insertions(+), 32 deletions(-) create mode 100644 src-tauri/site.json diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 864d81d..607d897 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,4 +34,4 @@ 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"] } +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading"] } diff --git a/src-tauri/site.json b/src-tauri/site.json new file mode 100644 index 0000000..59da76c --- /dev/null +++ b/src-tauri/site.json @@ -0,0 +1,5 @@ +{ + "name": "ODIAN test DHP", + "site_url": "http://192.168.1.41:30097", + "mcp_url": "http://192.168.1.41:30091" +} \ No newline at end of file diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index a3b6352..84196cf 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -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::(); + 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, 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, + version: &str, pull: bool, ) -> Result { 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()), } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 7c20a47..ce19cd3 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -41,11 +41,58 @@ fn settings_path(app: &AppHandle) -> Result { 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 = 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 = 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() } diff --git a/src-tauri/src/site.rs b/src-tauri/src/site.rs index 581f788..a47a9c8 100644 --- a/src-tauri/src/site.rs +++ b/src-tauri/src/site.rs @@ -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 { let response = http .get(format!("{site_url}/v1/versions/{version}/notes")) diff --git a/src-tauri/src/stack.rs b/src-tauri/src/stack.rs index 3a15ca0..e301f14 100644 --- a/src-tauri/src/stack.rs +++ b/src-tauri/src/stack.rs @@ -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, /// 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, @@ -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\`. 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, String> { +pub fn installed_tile_sets(layout: &Layout) -> Result, 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, 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() }, + ] + ); } } diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 6938e3f..f582722 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -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 { +/// 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 { 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 = 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()); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b1d7913..809c4e2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -31,6 +31,9 @@ "bundle": { "active": true, "targets": ["nsis"], + "resources": [ + "site.json" + ], "icon": [ "icons/32x32.png", "icons/128x128.png",