Expose a JavaScript command API for the browser app (#1713)
Thanks @olup.
This commit is contained in:
6 files changed
+260
-1
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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(())
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user