Files
figcraft/apps/figcraft-web/src/api.rs
T
Helen Kwok d252d35f27 figcraft-web: inspect and openFile go through craft-web-api, so they time out and cancel like the other functions
inspect is docInspect under its earlier name, and openFile calls the crate's execute with file.open, so a call the app does not answer within 30 seconds rejects and a request the app has not started is cancelled.
2026-11-07 15:30:01 +08:00

204 lines
7.8 KiB
Rust

//! `window.figcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) plus FigCraft's
//! own file functions.
//!
//! The crate installs `request`, `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot`
//! over the same JSON methods as the desktop control server (`docs/control-protocol.md`), as
//! promises with a timeout, so an agent driving FigCraft in a browser tab (or an `<iframe>` in
//! another app) uses the same commands as the CLI and the MCP server:
//!
//! ```js
//! await figcraft.execute("node.create", {type: "frame", name: "Home"}) // engine command → its result
//! await figcraft.request("document.inspect") // control method → its result
//! await figcraft.commands() // command list
//! const {data} = await figcraft.screenshot() // the canvas as PNG (base64)
//! ```
//!
//! FigCraft adds, on the same object:
//!
//! ```js
//! await figcraft.openFile("home.fig", bytes) // `file.open` with the bytes
//! figcraft.captureDownloads(true) // saves stay in the page…
//! figcraft.files(); await figcraft.readFile("home.figcraft") // …to be read back
//! await figcraft.inspect() // `document.inspect`
//! ```
//!
//! Results resolve with the method's `result`; errors reject with an `Error`.
use std::cell::{Cell, RefCell};
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::mpsc::{Receiver, TryRecvError};
use base64::Engine as _;
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate};
use serde_json::{Value, json};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use crate::saved_files::SavedFiles;
/// The app id in `window.<app>`.
const APP: &str = "figcraft";
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
const POLL_MS: i32 = 16;
thread_local! {
static QUEUE: RefCell<Vec<ControlRequest>> = const { RefCell::new(Vec::new()) };
static NEXT_ID: Cell<u64> = const { Cell::new(0) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
static CAPTURE: Cell<bool> = const { Cell::new(false) };
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
}
pub fn connect(ctx: egui::Context) {
CTX.with(|c| *c.borrow_mut() = Some(ctx));
}
/// The page's requests since the last frame, for the app's control queue.
pub fn take_requests() -> Vec<ControlRequest> {
QUEUE.with(|q| std::mem::take(&mut *q.borrow_mut()))
}
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
pub fn capture(name: &str, bytes: &[u8]) -> bool {
if !CAPTURE.with(Cell::get) {
return false;
}
SAVED.with(|s| s.borrow_mut().keep(name, bytes));
true
}
fn to_js(v: &Value) -> JsValue {
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
}
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
/// timeout fired) cancels the request if the app has not started it: the page was told it failed,
/// so the app must not run it late.
struct Waiting {
reply: Receiver<ControlReply>,
gate: RequestGate,
answered: bool,
}
impl Waiting {
/// The reply as the protocol's object once the app has answered or gone away; `None` while the
/// request is still waiting.
fn poll(&mut self) -> Option<Value> {
match self.reply.try_recv() {
Ok(ControlReply { result, .. }) => {
self.answered = true;
Some(match result {
Ok(value) => json!({"ok": true, "result": value}),
Err(error) => json!({"ok": false, "error": error}),
})
}
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
self.answered = true;
Some(json!({"ok": false, "error": "request dropped"}))
}
}
}
}
impl Drop for Waiting {
fn drop(&mut self) {
if !self.answered {
self.gate.cancel_if_open();
}
}
}
/// Queue a control request for the next frame and wait for the app's reply, as the protocol's
/// `{ok, result}` / `{ok: false, error}` object.
fn send(method: &str, params: Value) -> Pin<Box<dyn Future<Output = Value>>> {
let (reply, rx) = std::sync::mpsc::channel();
let id = NEXT_ID.with(|n| {
n.set(n.get() + 1);
n.get()
});
let gate = RequestGate::new();
QUEUE.with(|q| q.borrow_mut().push(ControlRequest { id: json!(id), method: method.to_string(), params, reply, state: gate.clone() }));
CTX.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.request_repaint();
}
});
let mut waiting = Waiting { reply: rx, gate, answered: false };
Box::pin(async move {
loop {
if let Some(reply) = waiting.poll() {
return reply;
}
sleep(POLL_MS).await;
}
})
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
/// Install `window.figcraft`: the crate's functions, then FigCraft's own.
pub fn install() -> Result<(), JsValue> {
let request: Request = Rc::new(|method, params| send(&method, params));
Rc::new(PageApi::new(APP, Methods::default(), request)).install()?;
let window = web_sys::window().ok_or("no window")?;
let obj: js_sys::Object = js_sys::Reflect::get(&window, &APP.into())?.unchecked_into();
// `inspect` is `docInspect` under its earlier name: the crate's function, so it times out too.
let doc_inspect = js_sys::Reflect::get(&obj, &"docInspect".into())?;
js_sys::Reflect::set(&obj, &"inspect".into(), &doc_inspect)?;
// `openFile` runs `file.open` through the crate's `execute`, so a call the app does not answer times out and is cancelled.
let execute: js_sys::Function = js_sys::Reflect::get(&obj, &"execute".into())?.unchecked_into();
let this = obj.clone();
set_fn(
&obj,
"openFile",
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(move |name: String, bytes: js_sys::Uint8Array| {
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
execute
.call2(&this, &"file.open".into(), &to_js(&json!({"name": name, "data": data})))
.unwrap_or_else(|e| js_sys::Promise::reject(&e).into())
})
.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(),
)?;
Ok(())
}