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
|
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
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
|
## Scripts
|
||||||
|
|
||||||
A script is a JSON list of commands, the same ones the UI runs:
|
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.
|
//! `?webgpu`), `?sample` opens the sample part.
|
||||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
#![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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod files;
|
mod files;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
@@ -22,6 +24,7 @@ mod web {
|
|||||||
use solidcraft_ui_egui::{Services, SolveApp};
|
use solidcraft_ui_egui::{Services, SolveApp};
|
||||||
use wasm_bindgen::JsCast as _;
|
use wasm_bindgen::JsCast as _;
|
||||||
|
|
||||||
|
use crate::api::Api;
|
||||||
use crate::files::{self, Files, Intent};
|
use crate::files::{self, Files, Intent};
|
||||||
use crate::space_mouse::SpaceMouse;
|
use crate::space_mouse::SpaceMouse;
|
||||||
|
|
||||||
@@ -84,6 +87,7 @@ mod web {
|
|||||||
|
|
||||||
struct Shell {
|
struct Shell {
|
||||||
app: SolveApp,
|
app: SolveApp,
|
||||||
|
api: Api,
|
||||||
files: Rc<Files>,
|
files: Rc<Files>,
|
||||||
/// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
|
/// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
|
||||||
autosaved_at: f64,
|
autosaved_at: f64,
|
||||||
@@ -413,6 +417,7 @@ mod web {
|
|||||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
self.tick_font(ctx);
|
self.tick_font(ctx);
|
||||||
self.app.logic(ctx);
|
self.app.logic(ctx);
|
||||||
|
self.api.poll();
|
||||||
self.space_mouse.logic(&mut self.app, ctx);
|
self.space_mouse.logic(&mut self.app, ctx);
|
||||||
self.files_tick(ctx);
|
self.files_tick(ctx);
|
||||||
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
|
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.
|
// Thumbnails of saves and start-page cards.
|
||||||
solidcraft_ui_egui::gpu::install_cpu_picture_renderer();
|
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))) {
|
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||||
app.load_prefs(&p);
|
app.load_prefs(&p);
|
||||||
}
|
}
|
||||||
@@ -542,8 +548,10 @@ mod web {
|
|||||||
let rev = app.session.revision;
|
let rev = app.session.revision;
|
||||||
app.home.show(rev);
|
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 {
|
Ok(Box::new(Shell {
|
||||||
app,
|
app,
|
||||||
|
api,
|
||||||
files,
|
files,
|
||||||
autosaved_at: f64::NEG_INFINITY,
|
autosaved_at: f64::NEG_INFINITY,
|
||||||
autosaved: None,
|
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]
|
web-offline [DIR]
|
||||||
write the web build's service worker (sw.js: offline use, updates); run by
|
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
|
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]
|
web-offline-test [--site DIR]
|
||||||
check in headless Chrome that the web app works offline and updates to a new
|
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)
|
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();
|
let site = rest.iter().position(|a| *a == "--site").and_then(|i| rest.get(i + 1)).copied();
|
||||||
web_offline_test::run(&root(), site, true)
|
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 => {
|
Some("-h" | "--help" | "help") | None => {
|
||||||
print!("{USAGE}");
|
print!("{USAGE}");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -66,6 +66,67 @@ const SLOW_NETWORK_WAIT: Duration = Duration::from_secs(3);
|
|||||||
/// How long a file takes in Mode::SlowFiles.
|
/// How long a file takes in Mode::SlowFiles.
|
||||||
const SLOW_FILE: Duration = Duration::from_secs(6);
|
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.
|
/// 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> {
|
pub fn run(root: &Path, site: Option<&str>, required: bool) -> Result<(), String> {
|
||||||
let Some(chrome) = find_chrome() else {
|
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()) {
|
let kind = match file.and_then(|f| f.rsplit('.').next()) {
|
||||||
Some("html") => "text/html; charset=utf-8",
|
Some("html") => "text/html; charset=utf-8",
|
||||||
Some("js") => "text/javascript",
|
Some("js") => "text/javascript",
|
||||||
|
Some("css") => "text/css",
|
||||||
Some("json") => "application/json",
|
Some("json") => "application/json",
|
||||||
Some("wasm") => "application/wasm",
|
Some("wasm") => "application/wasm",
|
||||||
_ => "application/octet-stream",
|
_ => "application/octet-stream",
|
||||||
|
|||||||
Reference in new issue
Block a user