Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ egui front end, headless screenshots for UI work.
| L1 | `data` | features, typed attributes, GeoJSON/CSV/Shapefile I/O, SQL where clauses, rasters (GeoTIFF/ASCII grid, EPSG reprojection) |
| L2 | `map`, `analysis` | layers, symbology, labels, camera, basemaps; geoprocessing tools |
| L3 | `render` | tiny-skia map renderer, label placement, PNG export, font discovery |
| L3 | `geolibre` | the GeoLibre/Whitebox tool suite as geoprocessing tools, run through the optional `geolibre` runner executable (native only, behind the engine's `geolibre` feature) |
| L3 | `geolibre` | the GeoLibre/Whitebox tool suite as geoprocessing tools (engine feature `geolibre`): run by the optional `geolibre` program natively, by geolibre-rust's WASI runner in the browser |
| L4 | `engine` | `Session`, undo, command registry, ribbon feature catalog, sample project |
| L6 | `ui-egui` | ribbon, panes, map view, attribute table, dialogs (swappable) |
| apps | `rustgis`, `rustgis-cli`, `rustgis-web` | desktop app, command line, browser (WebAssembly) |
Expand All @@ -29,8 +29,9 @@ egui front end, headless screenshots for UI work.
and CLI dispatch by id. Commands never open dialogs.
- **Never break wasm**: everything below `apps/` must build for `wasm32-unknown-unknown`
(CI runs `cargo build --target wasm32-unknown-unknown -p rustgis-web`). Use `web_time`, not
`std::time`; file access goes through `Services` on the web. The one exception is
`rustgis-geolibre`, which only the desktop app and CLI enable (engine feature `geolibre`).
`std::time`; file access goes through `Services` on the web. `rustgis-geolibre` builds for
both: natively it runs the `geolibre` program; in the browser the web app runs geolibre-rust's
WASI runner asynchronously (`external_start` → `Services::geolibre_run` → `gp.finish`).
- **Interface text is translatable**: wrap user-visible literals in `tl!("…")`, use
`i18n::fmt("… {name} …", &[("name", …)])` instead of `format!` for messages, and pass labels to
the ribbon widgets (they translate). Then add the new strings to every catalog in
Expand Down
5 changes: 3 additions & 2 deletions ATTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ app.

The GeoLibre tool suite ([geolibre-rust](https://github.com/opengeos/geolibre-rust), MIT, built
on [whitebox-wasm](https://github.com/opengeos/whitebox-wasm), MIT) is not part of RustGIS: the
desktop app and command line run its separately downloaded `geolibre` runner, and its tool
names and descriptions come from the runner's manifests.
desktop app and command line run its separately downloaded `geolibre` runner, and the web app
loads its WebAssembly runner (the `geolibre-wasm` npm package, from jsDelivr) when asked. Its
tool names and descriptions come from the runner's manifests.

Icons are original vector drawings in code (`crates/ui-egui/src/icons.rs`). Fonts are not
bundled: the interface uses an installed system sans-serif when one is found, otherwise the
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Every screenshot is RustGIS itself, rendered offscreen by `cargo run -p rustgis-
| **Labeling** | Per-layer labels with field, size, colour, bold and halo; collision-free placement |
| **Selection** | Click and box select (add/remove), Select By Attributes with live SQL validation, Select By Location, switch/clear/zoom to selection, layer from selection |
| **Editing** | Create points, lines and polygons; delete; edit cells in the attribute table; add, delete and calculate fields; undo/redo for everything |
| **Analysis** | Buffer, Clip, Erase, Dissolve, Minimum Bounding Geometry, Feature To Point, Simplify, Summarize Within, Calculate Geometry, Summary Statistics, Export Features; plus 1,000+ raster, vector, LiDAR, hydrology and terrain tools from the [GeoLibre](https://github.com/opengeos/geolibre-rust) suite (desktop and command line, with the optional GeoLibre runner download) |
| **Analysis** | Buffer, Clip, Erase, Dissolve, Minimum Bounding Geometry, Feature To Point, Simplify, Summarize Within, Calculate Geometry, Summary Statistics, Export Features; plus 1,000+ raster, vector, LiDAR, hydrology and terrain tools from the [GeoLibre](https://github.com/opengeos/geolibre-rust) suite (an optional download: desktop, command line and web app) |
| **Inquiry** | Identify pop-ups, geodesic distance and area measurement, live coordinates (DD or DMS) |
| **Share** | PNG export with labels and a scale bar at any DPI; export layers to GeoJSON/CSV |
| **Productivity** | Command search (Ctrl+K), keyboard shortcuts, dark mode, Catalog pane with folder browsing |
Expand Down
2 changes: 2 additions & 0 deletions apps/rustgis-web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ workspace = true

[dependencies]
rustgis-ui-egui = { workspace = true }
# The GeoLibre tools, run in the browser by geolibre-rust's WASI runner.
rustgis-engine = { workspace = true, features = ["geolibre"] }
egui = { workspace = true }
log = { workspace = true }

Expand Down
111 changes: 111 additions & 0 deletions apps/rustgis-web/src/geolibre.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//! The GeoLibre tools in the browser: geolibre-rust's WASI runner (the `geolibre-wasm` npm
//! package), loaded on demand from jsDelivr and run in an in-memory `/work` folder.

use rustgis_ui_egui::Slot;
use rustgis_ui_egui::rustgis_engine::cmd::gp::{ExternalOutput, ExternalRun};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::prelude::*;

// The runner's version is pinned: the tool forms and the runner must come from the same
// release. The JS module is jsDelivr's ES-module build of the package; the WASI binary is
// passed explicitly, since that build can't find it beside itself.
//
// Trust: this runs code from jsDelivr in the app's origin, only after the user asks for the
// tools (Load GeoLibre tools). A dynamic import can't carry Subresource Integrity, and the
// runner (~25 MB) is too large to ship with the app, so the pinned version and HTTPS are the
// safeguards: the same trust as installing the npm package.
#[wasm_bindgen(inline_js = r#"
const BASE = "https://cdn.jsdelivr.net/npm/geolibre-wasm@1.6.0";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Supply-chain note (confidence: medium): this dynamically imports third-party JS from a CDN with full page privileges and no Subresource Integrity or hash check. The version is pinned, but jsDelivr's /+esm build is rewritten on the fly and may resolve its own dependencies by range. Also, the "geolibre_web" pref means the ~25 MB runner is fetched and executed automatically on every later visit. Consider self-hosting the runner, or at least documenting the trust decision.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same trust decision as answered in the earlier thread and documented in 1c857c5 (code comment and user guide). On the automatic load on later visits: it happens only after the user chose Load GeoLibre tools once, and it re-imports the same pinned version, usually from the browser cache. Whether to keep that convenience or ask every visit, and whether to self-host the runner (e.g. in rustgis-assets), is a product call, so I'm leaving this thread open for a maintainer.

let ready = null;
function tools() {
if (!ready) {
ready = (async () => {
const mod = await import(`${BASE}/tools.mjs/+esm`);
Comment thread
giswqs marked this conversation as resolved.
await mod.initTools(`${BASE}/geolibre-cli.wasm`);
return mod;
})();
// A failed download can be retried.
ready.catch(() => { ready = null; });
}
return ready;
}
export async function geolibre_manifests() {
const t = await tools();
return JSON.stringify(await t.listManifests());
}
export async function geolibre_run(tool, argv, names, datas) {
const t = await tools();
const input = {};
for (let i = 0; i < names.length; i++) input[names[i]] = datas[i];
const r = await t.runTool(tool, { args: argv, input });
const out = Object.entries(r.files);
return { exitCode: r.exitCode, stdout: r.stdout, names: out.map((e) => e[0]), datas: out.map((e) => e[1]) };
}
"#)]
extern "C" {
#[wasm_bindgen(catch)]
async fn geolibre_manifests() -> Result<JsValue, JsValue>;
#[wasm_bindgen(catch)]
async fn geolibre_run(tool: &str, argv: js_sys::Array, names: js_sys::Array, datas: js_sys::Array) -> Result<JsValue, JsValue>;
}

fn js_error(e: &JsValue) -> String {
match e.dyn_ref::<js_sys::Error>() {
Some(err) => String::from(err.message()),
None => e.as_string().unwrap_or_else(|| format!("{e:?}")),
}
}

fn get(obj: &JsValue, key: &str) -> JsValue {
js_sys::Reflect::get(obj, &JsValue::from_str(key)).unwrap_or(JsValue::UNDEFINED)
}

fn strings(v: &JsValue) -> Vec<String> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strings assumes stdout is a JS array of lines. If runTool returns stdout as a single string, Array::from on a string splits it into characters, so lines and the exit-code error tail would be garbled. If it is undefined, Array::from yields an empty list. I couldn't check geolibre-wasm's return type from here, so please confirm it against the pinned 1.6.0 API. Splitting a string on \n in the JS shim would make this robust either way. Confidence: low-medium.

js_sys::Array::from(v).iter().filter_map(|s| s.as_string()).collect()
}

/// Lists the tools' manifests into `slot`, downloading the runner the first time.
pub fn manifests(slot: Slot<Result<String, String>>, ctx: egui::Context) {
wasm_bindgen_futures::spawn_local(async move {
let r = match geolibre_manifests().await {
Ok(v) => v.as_string().ok_or_else(|| "the GeoLibre runner returned no tool list".to_string()),
Err(e) => Err(format!("couldn't load the GeoLibre tools: {}", js_error(&e))),
};
if let Ok(mut s) = slot.lock() {
*s = Some(r);
}
ctx.request_repaint();
});
}

/// Runs one tool and puts its new files and printed lines (or its error) in `slot`.
pub fn run(job: ExternalRun, slot: Slot<ExternalOutput>, ctx: egui::Context) {
wasm_bindgen_futures::spawn_local(async move {
let argv: js_sys::Array = job.argv.iter().map(|a| JsValue::from_str(a)).collect();
let names: js_sys::Array = job.files.iter().map(|(n, _)| JsValue::from_str(n)).collect();
let datas: js_sys::Array = job.files.iter().map(|(_, b)| JsValue::from(js_sys::Uint8Array::from(b.as_slice()))).collect();
let r = match geolibre_run(&job.tool, argv, names, datas).await {
Err(e) => Err(js_error(&e)),
Ok(out) => {
let lines = strings(&get(&out, "stdout"));
if get(&out, "exitCode").as_f64().unwrap_or(1.0) != 0.0 {
// The tool's error is the end of what it printed.
let tail: Vec<&String> = lines.iter().rev().take(12).collect();
let msg = tail.into_iter().rev().map(String::as_str).collect::<Vec<_>>().join("\n");
Err(if msg.trim().is_empty() { "the tool stopped unexpectedly".to_string() } else { msg })
} else {
let files = strings(&get(&out, "names"))
.into_iter()
.zip(js_sys::Array::from(&get(&out, "datas")).iter())
.map(|(n, d)| (n, js_sys::Uint8Array::new(&d).to_vec()))
.collect();
Ok((files, lines))
}
}
};
if let Ok(mut s) = slot.lock() {
*s = Some(r);
}
ctx.request_repaint();
});
}
5 changes: 4 additions & 1 deletion apps/rustgis-web/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
//! Differences from the desktop app: Open and Add Data use the browser's file picker (GeoJSON,
//! CSV and `.rgisx`; Shapefiles need the desktop app), files can be dropped on the page, and
//! Save and Export download. URL flags: `?webgl` forces WebGL2; `?blank` starts an empty project;
//! `?lang=de` sets the interface language.
//! `?lang=de` sets the interface language. The GeoLibre tools run in the browser through
//! geolibre-rust's WASI runner, loaded on demand (see `geolibre.rs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]

#[cfg(target_arch = "wasm32")]
mod geolibre;
#[cfg(target_arch = "wasm32")]
mod web;

Expand Down
3 changes: 3 additions & 0 deletions apps/rustgis-web/src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
fn services(inbox: Inbox, font_inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
let font_ctx = ctx.clone();
let (gl_list_ctx, gl_run_ctx) = (ctx.clone(), ctx.clone());
let font_sink = font_inbox.clone();
Services {
load_pref: Some(Box::new(|key: &str| storage().and_then(|s| s.get_item(&format!("rustgis.{key}")).ok().flatten()))),
Expand Down Expand Up @@ -187,6 +188,8 @@ fn services(inbox: Inbox, font_inbox: Inbox, ctx: egui::Context) -> Services {
}
})),
inbox: Some(inbox),
geolibre_manifests: Some(Box::new(move |slot| crate::geolibre::manifests(slot, gl_list_ctx.clone()))),
geolibre_run: Some(Box::new(move |job, slot| crate::geolibre::run(job, slot, gl_run_ctx.clone()))),
}
}

Expand Down
15 changes: 11 additions & 4 deletions crates/analysis/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,10 @@ pub fn external_placeholder(_: &dyn Inputs, _: &Value) -> Result<ToolResult, Str
Err("this tool runs through the external suite".into())
}

/// Runs a tool with JSON parameters, filling defaults and checking required ones.
pub fn run(id: &str, inputs: &dyn Inputs, params: &Value) -> Result<ToolResult, String> {
let spec = tool(id).ok_or_else(|| format!("no tool '{id}'"))?;
/// A tool's parameters with defaults filled in, or an error naming a missing required one.
/// [`run`] does this first; a host that runs an external tool another way (the browser's
/// asynchronous runner) does it too, so both check parameters the same way.
pub fn resolve_params(spec: &ToolSpec, params: &Value) -> Result<Value, String> {
let mut p = params.as_object().cloned().unwrap_or_default();
for ps in spec.params {
if !p.contains_key(ps.name)
Expand All @@ -214,7 +215,13 @@ pub fn run(id: &str, inputs: &dyn Inputs, params: &Value) -> Result<ToolResult,
return Err(format!("{}: '{}' is required", spec.name, ps.label));
}
}
let p = Value::Object(p);
Ok(Value::Object(p))
}

/// Runs a tool with JSON parameters, filling defaults and checking required ones.
pub fn run(id: &str, inputs: &dyn Inputs, params: &Value) -> Result<ToolResult, String> {
let spec = tool(id).ok_or_else(|| format!("no tool '{id}'"))?;
let p = resolve_params(spec, params)?;
// A bug in a tool must not take the app down.
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match PROVIDER.get() {
Some((_, runner)) if is_external(spec) => runner(spec, inputs, &p),
Expand Down
Loading
Loading