Expose a JavaScript command API for the browser app (#1713)

Thanks @olup.
This commit is contained in:
Olup authored and GitHub committed 2026-10-17 06:54:16 -04:00
1 parent 40bdc1132e
commit 4193ea26ac
6 files changed
+260 -1

No files matched your search

+3
View File
@@ -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:
+131
View File
@@ -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<Value>,
resolve: js_sys::Function,
reject: js_sys::Function,
}
#[derive(Clone)]
pub struct Api {
sender: Sender<ControlRequest>,
pending: Rc<RefCell<Vec<Pending>>>,
ctx: egui::Context,
}
fn params(value: &JsValue) -> Result<Value, String> {
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<ControlRequest>, ctx: egui::Context) -> Result<Self, JsValue> {
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::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(move |method: String, value: JsValue| {
request.request(&method, params(&value))
})
.into_js_value(),
),
(
"execute",
Closure::<dyn Fn(String, JsValue) -> 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::<dyn Fn() -> js_sys::Promise>::new(move || commands.request("engine.commands", Ok(json!({})))).into_js_value()),
(
"inspect",
Closure::<dyn Fn(JsValue) -> 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<Value, String>) -> 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));
}
}
}
+9 -1
View File
@@ -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<Files>,
/// 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,
+50
View File
@@ -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.
+5
View File
@@ -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(())
+62
View File
@@ -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<Mode>, 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",