From 4193ea26acf0b486b3640f01f0d2270b4420ad7f Mon Sep 17 00:00:00 2001 From: Olup Date: Sat, 17 Oct 2026 12:54:16 +0200 Subject: [PATCH] Expose a JavaScript command API for the browser app (#1713) Thanks @olup. --- README.md | 3 + apps/solidcraft-web/src/api.rs | 131 ++++++++++++++++++++++++++++++++ apps/solidcraft-web/src/main.rs | 10 ++- docs/browser-api.md | 50 ++++++++++++ xtask/src/main.rs | 5 ++ xtask/src/web_offline_test.rs | 62 +++++++++++++++ 6 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 apps/solidcraft-web/src/api.rs create mode 100644 docs/browser-api.md diff --git a/README.md b/README.md index 094dc3f3..6452da9f 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,9 @@ Each axis is scaled from the range the descriptor declares. In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or WebGL2 with `?webgl`; `?sample` opens the sample part). +Agents can control the document open in a browser tab through `window.solidcraft.execute(...)` +and `window.solidcraft.request(...)`; see the [browser command API](docs/browser-api.md). + ## Scripts A script is a JSON list of commands, the same ones the UI runs: diff --git a/apps/solidcraft-web/src/api.rs b/apps/solidcraft-web/src/api.rs new file mode 100644 index 00000000..121fbc8d --- /dev/null +++ b/apps/solidcraft-web/src/api.rs @@ -0,0 +1,131 @@ +//! Promise-based access to the running app's existing control channel. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::mpsc::{Receiver, Sender, TryRecvError}; +use std::time::Duration; + +use serde_json::{Value, json}; +use solidcraft_ui_egui::ControlRequest; +use wasm_bindgen::{JsValue, closure::Closure}; + +const MAX_PENDING: usize = 256; +const MAX_JSON_BYTES: usize = 16 * 1024 * 1024; + +struct Pending { + reply: Receiver, + resolve: js_sys::Function, + reject: js_sys::Function, +} + +#[derive(Clone)] +pub struct Api { + sender: Sender, + pending: Rc>>, + ctx: egui::Context, +} + +fn params(value: &JsValue) -> Result { + if value.is_undefined() || value.is_null() { + return Ok(json!({})); + } + let text = js_sys::JSON::stringify(value).ok().and_then(|v| v.as_string()).ok_or("params must be JSON serializable")?; + if text.len() > MAX_JSON_BYTES { + return Err("params exceed 16 MiB".into()); + } + serde_json::from_str(&text).map_err(|e| format!("invalid params: {e}")) +} + +fn error(message: &str) -> JsValue { + js_sys::Error::new(message).into() +} + +impl Api { + pub fn install(sender: Sender, ctx: egui::Context) -> Result { + let api = Self { sender, pending: Rc::default(), ctx }; + let object = js_sys::Object::new(); + let request = api.clone(); + let execute = api.clone(); + let commands = api.clone(); + let inspect = api.clone(); + for (name, function) in [ + ( + "request", + Closure:: js_sys::Promise>::new(move |method: String, value: JsValue| { + request.request(&method, params(&value)) + }) + .into_js_value(), + ), + ( + "execute", + Closure:: js_sys::Promise>::new(move |command: String, value: JsValue| { + execute.request("engine.execute", params(&value).map(|p| json!({"command": command, "params": p}))) + }) + .into_js_value(), + ), + ("commands", Closure:: js_sys::Promise>::new(move || commands.request("engine.commands", Ok(json!({})))).into_js_value()), + ( + "inspect", + Closure:: js_sys::Promise>::new(move |value: JsValue| inspect.request("document.inspect", params(&value))) + .into_js_value(), + ), + ] { + js_sys::Reflect::set(&object, &name.into(), &function)?; + } + let window = web_sys::window().ok_or_else(|| error("no window"))?; + js_sys::Reflect::set(&window, &"solidcraft".into(), &object)?; + Ok(api) + } + + fn request(&self, method: &str, params: Result) -> js_sys::Promise { + let params = match params { + Ok(params) => params, + Err(message) => return js_sys::Promise::reject(&error(&message)), + }; + if self.pending.borrow().len() >= MAX_PENDING { + return js_sys::Promise::reject(&error("too many pending SolidCraft requests (maximum 256)")); + } + js_sys::Promise::new(&mut |resolve, reject| { + let (request, reply) = ControlRequest::new(method, params.clone()); + if self.sender.send(request).is_err() { + let _ = reject.call1(&JsValue::UNDEFINED, &error("SolidCraft is not running")); + return; + } + self.pending.borrow_mut().push(Pending { reply, resolve, reject }); + self.ctx.request_repaint(); + }) + } + + pub fn poll(&self) { + let mut completed = Vec::new(); + self.pending.borrow_mut().retain_mut(|pending| { + let result = match pending.reply.try_recv() { + Ok(reply) => { + if reply.get("ok").and_then(Value::as_bool) == Some(true) { + js_sys::JSON::parse(&reply.get("result").unwrap_or(&Value::Null).to_string()) + } else { + Err(error(reply.get("error").and_then(Value::as_str).unwrap_or("SolidCraft request failed"))) + } + } + Err(TryRecvError::Empty) => return true, + Err(TryRecvError::Disconnected) => Err(error("SolidCraft request dropped")), + }; + completed.push((pending.resolve.clone(), pending.reject.clone(), result)); + false + }); + // Release the queue before calling JavaScript, which can submit another request. + for (resolve, reject, result) in completed { + match result { + Ok(value) => { + let _ = resolve.call1(&JsValue::UNDEFINED, &value); + } + Err(error) => { + let _ = reject.call1(&JsValue::UNDEFINED, &error); + } + } + } + if !self.pending.borrow().is_empty() { + self.ctx.request_repaint_after(Duration::from_millis(16)); + } + } +} diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index 5d760c66..b134903b 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -6,6 +6,8 @@ //! `?webgpu`), `?sample` opens the sample part. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(target_arch = "wasm32")] +mod api; #[cfg(target_arch = "wasm32")] mod files; #[cfg(target_arch = "wasm32")] @@ -22,6 +24,7 @@ mod web { use solidcraft_ui_egui::{Services, SolveApp}; use wasm_bindgen::JsCast as _; + use crate::api::Api; use crate::files::{self, Files, Intent}; use crate::space_mouse::SpaceMouse; @@ -84,6 +87,7 @@ mod web { struct Shell { app: SolveApp, + api: Api, files: Rc, /// When the last autosave was (seconds), and the revision it holds (none stored: `None`). autosaved_at: f64, @@ -413,6 +417,7 @@ mod web { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.tick_font(ctx); self.app.logic(ctx); + self.api.poll(); self.space_mouse.logic(&mut self.app, ctx); self.files_tick(ctx); UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app))); @@ -520,7 +525,8 @@ mod web { }; // Thumbnails of saves and start-page cards. solidcraft_ui_egui::gpu::install_cpu_picture_renderer(); - let mut app = SolveApp::new(Session::default(), services); + let (sender, receiver) = std::sync::mpsc::channel(); + let mut app = SolveApp::new(Session::default(), services).with_control(receiver); if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) { app.load_prefs(&p); } @@ -542,8 +548,10 @@ mod web { let rev = app.session.revision; app.home.show(rev); } + let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?; Ok(Box::new(Shell { app, + api, files, autosaved_at: f64::NEG_INFINITY, autosaved: None, diff --git a/docs/browser-api.md b/docs/browser-api.md new file mode 100644 index 00000000..33ded368 --- /dev/null +++ b/docs/browser-api.md @@ -0,0 +1,50 @@ +# Browser command API + +After the browser app starts, `window.solidcraft` controls the design open in that tab. +Calls use the same command ids, parameters, control handlers and undo history as the desktop +control channel. No separate engine or server is started. + +| Call | Result | +|---|---| +| `solidcraft.request(method, params?)` | Any [control method](control-protocol.md), such as `ui.inspect`, `document.inspect` or `engine.batch` | +| `solidcraft.execute(command, params?)` | Run an engine command without opening its interactive dialog | +| `solidcraft.commands()` | Discover commands, their parameters and current enablement | +| `solidcraft.inspect(params?)` | Inspect the active document, optionally with `{measure: true}` | + +Every call returns a promise. It resolves to the control reply's `result`, or rejects with an +`Error`. Omitted or `null` parameters become `{}`. Parameters must be JSON serializable and +at most 16 MiB of JSON. At most 256 requests may be pending; excess requests reject without +running. Requests run on the UI thread between frames. A hidden or suspended tab can delay +completion; keep it active while driving the app. A reload ends the session and its pending work. + +In the browser's developer console, on an empty design: + +```js +await solidcraft.commands(); +await solidcraft.request("ui.home", {open: false}); +await solidcraft.execute("solid.box", { + length: 30, width: 20, height: 10, body_name: "Browser API box" +}); +await solidcraft.inspect({measure: true}); +await solidcraft.execute("edit.undo"); +await solidcraft.execute("edit.redo"); +await solidcraft.request("ui.inspect"); +``` + +An agent with JavaScript evaluation access to this tab can make the same calls. The API does +not register WebMCP tools or expose a network listener. Scripts executing in the page can use +the API with the page's browser permissions. File paths refer to the browser's virtual file +system, not arbitrary files on the user's computer. Native-only operations remain subject to +the browser host's limitations; this API does not grant desktop filesystem or window access. + +## Verify a browser build + +Build the app with `trunk build` in `apps/solidcraft-web`, then run from the repository root: + +```sh +cargo xtask web-api-test --site dist/web +``` + +The test starts a local server and headless Chrome, discovers commands, creates a box in the +live document, checks undo and redo, and verifies error handling and the pending-request limit. +Set `CHROME` to a Chrome or Chromium executable if it is not installed in a usual location. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ae7ded72..96faebd5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -60,6 +60,7 @@ commands: web-offline [DIR] write the web build's service worker (sw.js: offline use, updates); run by Trunk after release builds of apps/solidcraft-web, or on a built site in DIR + web-api-test [--site DIR] Test the command API in a real browser build (default: dist/web) web-offline-test [--site DIR] check in headless Chrome that the web app works offline and updates to a new build (a stand-in app, or the web build in DIR such as dist/web) @@ -97,6 +98,10 @@ fn main() -> ExitCode { let site = rest.iter().position(|a| *a == "--site").and_then(|i| rest.get(i + 1)).copied(); web_offline_test::run(&root(), site, true) } + Some("web-api-test") => { + let site = rest.iter().position(|a| *a == "--site").and_then(|i| rest.get(i + 1)).copied().unwrap_or("dist/web"); + web_offline_test::run_api(site) + } Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); Ok(()) diff --git a/xtask/src/web_offline_test.rs b/xtask/src/web_offline_test.rs index 817d9753..e006b229 100644 --- a/xtask/src/web_offline_test.rs +++ b/xtask/src/web_offline_test.rs @@ -66,6 +66,67 @@ const SLOW_NETWORK_WAIT: Duration = Duration::from_secs(3); /// How long a file takes in Mode::SlowFiles. const SLOW_FILE: Duration = Duration::from_secs(6); +/// Exercise the real browser build's command bridge, using the same Chrome harness as the offline test. +pub fn run_api(site: &str) -> Result<(), String> { + let site = std::fs::canonicalize(site).map_err(|e| format!("{site}: {e}; build apps/solidcraft-web with trunk first"))?; + if !site.join("index.html").is_file() { + return Err(format!("{}: index.html missing; build apps/solidcraft-web with trunk first", site.display())); + } + let chrome = find_chrome().ok_or("web-api-test: Chrome not found (set CHROME to its executable)")?; + let work = TempDir::new()?; + let server = Server::start(site.clone())?; + server.set(Mode::Serve(site)); + let browser = Chrome::launch(&chrome, &work.0.join("profile"))?; + let mut cdp = browser.connect()?; + cdp.call("Emulation.setDeviceMetricsOverride", json!({"width": 1280, "height": 900, "deviceScaleFactor": 1, "mobile": false}))?; + cdp.call("Page.navigate", json!({"url": format!("http://127.0.0.1:{}{PREFIX}?webgl", server.port)}))?; + wait_for(&mut cdp, "!!window.solidcraft && !document.getElementById('solidcraft_loading')", "the browser API is ready", |v| v == true)?; + let result = cdp.eval(API_TEST)?; + println!("web-api-test: {result}"); + Ok(()) +} + +const API_TEST: &str = r#"(async () => { + const app = window.solidcraft; + const check = (condition, message) => { if (!condition) throw new Error(message); }; + const rejects = async (call, message) => { + try { await call(); } catch (error) { + check(error instanceof Error, 'rejections must be Error objects'); + check(error.message.includes(message), error.message); + return; + } + throw new Error('expected rejection: ' + message); + }; + const commands = await app.commands(); + check(JSON.stringify(commands).includes('solid.box'), 'command discovery'); + await app.request('ui.home', {open: false}); + const before = await app.inspect(); + check(before.bodies.length === 0, 'test starts with an empty design'); + await app.execute('solid.box', {length: 30, width: 20, height: 10, body_name: 'Browser API box'}); + const after = await app.inspect({measure: true}); + check(after.bodies.length === 1, 'box appears in the active document'); + check(Math.abs(after.bodies[0].volume_mm3 - 6000) < 0.001, 'box volume is 6000 mm³'); + await app.execute('edit.undo'); + check((await app.inspect()).bodies.length === 0, 'undo removes the box'); + await app.execute('edit.redo', null); + check((await app.inspect()).bodies.length === 1, 'redo restores the box'); + await rejects(() => app.request('missing.browser.method'), 'unknown'); + await rejects(() => app.execute('missing.browser.command'), 'unknown'); + const circular = {}; circular.self = circular; + await rejects(() => app.request('ui.inspect', circular), 'JSON serializable'); + await rejects(() => app.request('ui.inspect', {value: 1n}), 'JSON serializable'); + await rejects(() => app.request('ui.inspect', {text: 'x'.repeat(16 * 1024 * 1024)}), '16 MiB'); + const queued = Array.from({length: 257}, () => app.request('ui.inspect')); + const settled = await Promise.allSettled(queued); + check(settled.filter(r => r.status === 'fulfilled').length === 256, 'queued requests drain across frames'); + check(settled.filter(r => r.status === 'rejected' && r.reason instanceof Error && r.reason.message.includes('pending')).length === 1, + 'queue limit rejects excess work'); + check((await app.inspect()).bodies.length === 1, 'API still works after rejected requests'); + const ui = await app.request('ui.inspect'); + check(ui.home === false && ui.viewport[2] > 600, 'model viewport is visible'); + return {commands: 'discovered', bodies: [0, 1, 0, 1], errors: 'rejected', queue: '256 completed, 1 rejected'}; +})()"#; + /// Run the test; `required: false` (from `cargo xtask ci`) skips it when Chrome is missing. pub fn run(root: &Path, site: Option<&str>, required: bool) -> Result<(), String> { let Some(chrome) = find_chrome() else { @@ -555,6 +616,7 @@ fn answer(mut stream: TcpStream, shared: &Mutex, slow: &AtomicUsize, elsew let kind = match file.and_then(|f| f.rsplit('.').next()) { Some("html") => "text/html; charset=utf-8", Some("js") => "text/javascript", + Some("css") => "text/css", Some("json") => "application/json", Some("wasm") => "application/wasm", _ => "application/octet-stream",