mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
web: window.vectorcraft, the control channel for the page
The same JSON methods as the desktop control server (engine.execute, ui.inspect, ui.screenshot, …), as promises on window.vectorcraft, like FilmCraft's window.filmcraft: an agent driving the editor in a browser tab or an <iframe> uses the same commands as the CLI and the MCP server. openFile opens bytes the way a dropped file opens; captureDownloads keeps saves in the page for readFile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
36aecff81d
commit
9dbab52168
Generated
+1
@@ -5007,6 +5007,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-text",
|
||||
|
||||
@@ -26,6 +26,7 @@ log = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
serde_json = { workspace = true }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! `window.vectorcraft`: the control channel for the page, as promises.
|
||||
//!
|
||||
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
|
||||
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
|
||||
//! the CLI and the MCP server:
|
||||
//!
|
||||
//! ```js
|
||||
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
|
||||
//! await vectorcraft.request("ui.inspect") // control method → its result
|
||||
//! await vectorcraft.commands() // command list
|
||||
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
|
||||
//! vectorcraft.captureDownloads(true) // saves stay in the page…
|
||||
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
|
||||
//! ```
|
||||
//!
|
||||
//! Results resolve with the method's `result`; errors reject with an `Error`.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::saved_files::SavedFiles;
|
||||
|
||||
use crate::web::Inbox;
|
||||
|
||||
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
|
||||
|
||||
thread_local! {
|
||||
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
|
||||
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
|
||||
static CAPTURE: Cell<bool> = const { Cell::new(false) };
|
||||
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
|
||||
}
|
||||
|
||||
/// Wire the page's requests to the app: control requests go to `tx`, opened files to `inbox`.
|
||||
/// After a graphics restart the app keeps its channel: `tx` is `None` and only the context changes.
|
||||
pub fn connect(tx: Option<Sender<ControlRequest>>, inbox: Inbox, ctx: egui::Context) {
|
||||
if let Some(tx) = tx {
|
||||
TX.with(|t| *t.borrow_mut() = Some(tx));
|
||||
}
|
||||
INBOX.with(|i| *i.borrow_mut() = Some(inbox));
|
||||
CTX.with(|c| *c.borrow_mut() = Some(ctx));
|
||||
}
|
||||
|
||||
fn repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(ctx) = c.borrow().as_ref() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
|
||||
pub fn capture(path: &str, bytes: &[u8]) -> bool {
|
||||
if !CAPTURE.with(Cell::get) {
|
||||
return false;
|
||||
}
|
||||
SAVED.with(|s| s.borrow_mut().keep(path, bytes));
|
||||
true
|
||||
}
|
||||
|
||||
fn to_js(v: &Value) -> JsValue {
|
||||
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
fn from_js(v: &JsValue) -> Value {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return json!({});
|
||||
}
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Settle the promises of answered requests (called every frame).
|
||||
pub fn poll_replies() {
|
||||
WAITING.with(|w| {
|
||||
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
|
||||
Ok(v) => {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
|
||||
} else {
|
||||
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
|
||||
}
|
||||
false
|
||||
}
|
||||
Err(TryRecvError::Empty) => true,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
|
||||
false
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn request(method: &str, params: Value) -> js_sys::Promise {
|
||||
let method = method.to_string();
|
||||
let p = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
|
||||
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
|
||||
if sent {
|
||||
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
|
||||
} else {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
|
||||
}
|
||||
});
|
||||
repaint();
|
||||
p
|
||||
}
|
||||
|
||||
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
|
||||
}
|
||||
|
||||
/// Install `window.vectorcraft`.
|
||||
pub fn install() -> Result<(), JsValue> {
|
||||
let obj = js_sys::Object::new();
|
||||
set_fn(
|
||||
&obj,
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(¶ms))).into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
|
||||
request("engine.execute", json!({"command": command, "params": from_js(¶ms)}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
|
||||
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"screenshot",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(¶ms))).into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"openFile",
|
||||
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(|name: String, bytes: js_sys::Uint8Array| {
|
||||
let queued = INBOX.with(|i| {
|
||||
i.borrow().as_ref().is_some_and(|inbox| {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes.to_vec()));
|
||||
true
|
||||
})
|
||||
});
|
||||
repaint();
|
||||
JsValue::from_bool(queued)
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"files",
|
||||
Closure::<dyn Fn() -> JsValue>::new(|| {
|
||||
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"readFile",
|
||||
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
|
||||
SAVED.with(|s| {
|
||||
s.borrow()
|
||||
.get(&name)
|
||||
.map(|b| js_sys::Uint8Array::from(b).into())
|
||||
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -23,6 +23,10 @@ mod locks;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod pencil;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod saved_files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
|
||||
|
||||
/// Saved files kept (the oldest go first).
|
||||
pub const MAX_SAVED: usize = 16;
|
||||
|
||||
/// The last component of a `/` or `\` path.
|
||||
fn file_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Files by name, newest last: saving a name again replaces it.
|
||||
#[derive(Default)]
|
||||
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
|
||||
|
||||
impl SavedFiles {
|
||||
/// Keep `bytes` under the last component of `path`.
|
||||
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
|
||||
let name = file_name(path).to_string();
|
||||
self.0.retain(|(n, _)| *n != name);
|
||||
self.0.push((name, bytes.to_vec()));
|
||||
let extra = self.0.len().saturating_sub(MAX_SAVED);
|
||||
self.0.drain(..extra);
|
||||
}
|
||||
|
||||
/// A kept file by its name, or by a path ending in it.
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
let wanted = file_name(name);
|
||||
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
|
||||
}
|
||||
|
||||
/// `(name, size)` of each kept file, oldest first.
|
||||
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
|
||||
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_by_name_replaces_and_reads_back_by_path() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("exports/poster.png", b"one");
|
||||
s.keep(r"C:\out\poster.png", b"two");
|
||||
s.keep("notes.txt", b"three");
|
||||
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
|
||||
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("missing.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_the_oldest_past_the_cap() {
|
||||
let mut s = SavedFiles::default();
|
||||
for i in 0..MAX_SAVED + 3 {
|
||||
s.keep(&format!("f{i}.png"), &[0]);
|
||||
}
|
||||
assert_eq!(s.list().count(), MAX_SAVED);
|
||||
assert_eq!(s.get("f0.png"), None);
|
||||
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_separator_keeps_the_whole_name() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("odd/", b"x");
|
||||
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use wasm_bindgen::JsCast as _;
|
||||
use crate::locks::WebLocks;
|
||||
use crate::pencil::SharedPointers;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
|
||||
/// whether Shift was held: where dropped files land.
|
||||
@@ -122,9 +122,18 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
// The old services asked the old context for frames.
|
||||
app.services = services;
|
||||
app.status("The graphics were lost and have been restarted");
|
||||
crate::api::connect(None, host.inbox.clone(), ctx.clone());
|
||||
app
|
||||
}
|
||||
None => VectorcraftApp::new(Session::new(), services),
|
||||
None => {
|
||||
// `window.vectorcraft`: the page's control channel (crate::api).
|
||||
let (control_tx, control_rx) = std::sync::mpsc::channel();
|
||||
crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone());
|
||||
if let Err(e) = crate::api::install() {
|
||||
log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
|
||||
}
|
||||
VectorcraftApp::new(Session::new(), services).with_control(control_rx)
|
||||
}
|
||||
};
|
||||
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
|
||||
}),
|
||||
@@ -386,6 +395,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
app.logic(ctx);
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -399,6 +409,7 @@ impl eframe::App for WebShell {
|
||||
if let Some(app) = &mut self.app {
|
||||
app.ui(ui);
|
||||
}
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +555,9 @@ impl RecoveryStore for BrowserStore {
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if crate::api::capture(path, bytes) {
|
||||
return Ok(());
|
||||
}
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -880,9 +880,30 @@ split paths return an error without changing the workspace.
|
||||
- [x] Release connection slots on worker exit, including timeout and failed worker creation.
|
||||
- [x] Preserve correlated replies and persistent connections within the transport budgets.
|
||||
|
||||
## In the browser: `window.vectorcraft`
|
||||
|
||||
The web build has no TCP server (a page can't listen on a socket), so it offers the same methods to the
|
||||
page itself, as promises on `window.vectorcraft` (`apps/vectorcraft-web/src/api.rs`). An agent driving VectorCraft in a
|
||||
browser tab, or a host app showing it in an `<iframe>`, uses the commands it would send to the desktop
|
||||
app:
|
||||
|
||||
| Call | |
|
||||
|---|---|
|
||||
| `vectorcraft.request(method, params)` | any control method of this document → its `result` |
|
||||
| `vectorcraft.execute(command, params)` | `engine.execute` |
|
||||
| `vectorcraft.commands()` / `vectorcraft.inspect()` | `engine.commands` / the inspect method |
|
||||
| `vectorcraft.screenshot(params)` | `ui.screenshot` (no `path`: the PNG comes back inline) |
|
||||
| `vectorcraft.openFile(name, bytes)` | opens the bytes the way a dropped file opens (returns `true` once queued) |
|
||||
| `vectorcraft.captureDownloads(on)` | while on, a save or export is kept in the page instead of downloaded |
|
||||
| `vectorcraft.files()` / `vectorcraft.readFile(name)` | the kept files (`{path, size}`) / one of them as a `Uint8Array` |
|
||||
|
||||
Errors reject with an `Error` carrying the method's message. Requests run on the UI's next frame, so a
|
||||
hidden tab (whose frames the browser pauses) answers when it is shown again.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Documented count, pending-event and per-frame request budgets (#1709). |
|
||||
| 2026-10-13 | minor | Documented transport deadlines and connection-slot recovery (#1726). |
|
||||
| 2026-10-18 | minor | Documented `window.vectorcraft`, the web build's control channel. |
|
||||
|
||||
Reference in New Issue
Block a user