Files
isokit/crates/isokit-wasm/src/lib.rs
T
grabowski c647f39059
Deploy web Studio / deploy (push) Successful in 3m15s
studio: version text in the status bar, favicon
The status bar ends with "isokit <engine version>"; the web build adds
the commit (linked on Gitea), its date as a tooltip and a "+" when built
from a tree with uncommitted changes (build_web.py writes a
<meta name="isokit-build"> from GITHUB_SHA or git). /api/meta now carries
"engine". studio/favicon.svg (isometric cube in the accent green) is
served by `isokit studio` (also on /view) and copied into the web build.
check_web.py and check_studio.py check both.
2026-10-23 17:08:48 +07:00

319 lines
11 KiB
Rust

//! isokit in the browser. [`Studio`] holds one project in memory (a
//! [`MemStore`]) and answers the same JSON as `isokit studio`'s `/api/*`
//! routes, so the Studio GUI runs unchanged on top of it; the page persists
//! the files it writes ([`Studio::take_written`]). Also exposes the MCP tools
//! ([`Studio::mcp`]) for agents working inside the page.
//!
//! Strings in, JSON strings out: no serde-wasm glue, and the same calls work
//! in native tests.
use isokit_project::store::MemStore;
use isokit_project::workspace::Workspace;
use isokit_project::{api, check, mcp, page, Files};
use serde_json::{json, Map, Value};
use wasm_bindgen::prelude::*;
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn error(s: &str);
}
/// Panics go to the browser console instead of "unreachable executed".
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(start)]
pub fn start() {
std::panic::set_hook(Box::new(|info| error(&format!("isokit: {info}"))));
}
fn parse_files(files: &str) -> Result<Files, String> {
let v: Map<String, Value> = serde_json::from_str(files).map_err(|e| format!("files: {e}"))?;
Ok(v.into_iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
.collect())
}
#[wasm_bindgen]
pub struct Studio {
ws: Workspace<MemStore>,
}
#[wasm_bindgen]
impl Studio {
/// `files`: JSON object, relative path -> text. `name` labels the
/// project in messages ("browser: Demo", a folder name).
#[wasm_bindgen(constructor)]
pub fn new(name: &str, files: &str) -> Result<Studio, String> {
let store = MemStore::new(name, parse_files(files)?);
let ws = Workspace::with_store(store).map_err(|e| e.to_string())?;
Ok(Studio { ws })
}
/// GET /api/meta
pub fn meta(&mut self) -> String {
self.ws.refresh();
api::meta(&self.ws).to_string()
}
/// GET /api/doc
pub fn state(&mut self) -> String {
self.ws.refresh();
api::state(&self.ws).to_string()
}
/// GET /api/version
pub fn version(&mut self) -> String {
self.ws.refresh();
self.ws.version.to_string()
}
/// POST /api/op: one operation or `{"ops": [...]}`.
pub fn op(&mut self, body: &str) -> String {
match serde_json::from_str::<Value>(body) {
Ok(v) => api::op(&mut self.ws, &v).to_string(),
Err(e) => json!({ "error": format!("bad operation: {e}") }).to_string(),
}
}
/// POST /api/undo
pub fn undo(&mut self) -> String {
api::undo(&mut self.ws, false).to_string()
}
/// POST /api/redo
pub fn redo(&mut self) -> String {
api::undo(&mut self.ws, true).to_string()
}
/// GET /api/render. `cfg`: JSON object of parameter values, or "".
pub fn render(&mut self, level: &str, rot: u8, cfg: &str, dev: bool) -> String {
self.ws.refresh();
let cfg = serde_json::from_str::<Map<String, Value>>(cfg).ok();
let level = Some(level).filter(|l| !l.is_empty());
api::render(&self.ws, level, rot % 4, cfg.as_ref(), dev).to_string()
}
/// GET /api/check
pub fn check(&mut self) -> String {
self.ws.refresh();
json!({ "findings": check::check(&self.ws.project) }).to_string()
}
/// The theme's CSS (built-in theme plus the project's layers).
pub fn theme_css(&mut self) -> String {
self.ws.refresh();
self.ws.project.theme.css()
}
/// The self-contained HTML page: `{"html"}` or `{"error"}`.
pub fn page(&mut self) -> String {
self.ws.refresh();
match page::static_page(&self.ws.project) {
Ok(html) => json!({ "html": html }).to_string(),
Err(e) => json!({ "error": e.to_string() }).to_string(),
}
}
/// One view as a standalone SVG: `{"svg"}` or `{"error"}`.
pub fn svg(&mut self, level: &str, rot: u8, light: bool) -> String {
self.ws.refresh();
let level = if level.is_empty() {
self.ws.project.manifest.default_level()
} else {
level.to_string()
};
match page::svg(&self.ws.project, &level, rot % 4, light) {
Ok(svg) => json!({ "svg": svg }).to_string(),
Err(e) => json!({ "error": e.to_string() }).to_string(),
}
}
/// One MCP JSON-RPC message (or a batch); "" for notifications.
pub fn mcp(&mut self, msg: &str) -> String {
let v = match serde_json::from_str::<Value>(msg) {
Ok(v) => v,
Err(e) => {
return json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } })
.to_string()
}
};
let out = match &v {
Value::Array(list) => {
let r: Vec<Value> = list
.iter()
.filter_map(|m| mcp::handle(&mut self.ws, m))
.collect();
(!r.is_empty()).then_some(Value::Array(r))
}
m => mcp::handle(&mut self.ws, m),
};
out.map(|v| v.to_string()).unwrap_or_default()
}
/// An edit made outside the Studio (code editor, watched folder); null
/// removes the file. The project reloads on the next call, and a
/// document change can be undone. `{"ok"}` or `{"error"}`.
pub fn set_file(&mut self, path: &str, body: Option<String>) -> String {
match self.ws.store.set(path, body) {
Ok(()) => json!({ "ok": true }).to_string(),
Err(e) => json!({ "error": e.to_string() }).to_string(),
}
}
pub fn file(&self, path: &str) -> Option<String> {
self.ws.store.files().get(path).cloned()
}
/// All files as a JSON object (path -> text).
pub fn files(&self) -> String {
serde_json::to_string(self.ws.store.files()).unwrap_or_default()
}
/// Files the Studio wrote since the last call (path -> text), for the
/// page to persist.
pub fn take_written(&mut self) -> String {
let paths = self.ws.store.take_written();
let files = self.ws.store.files();
let out: Map<String, Value> = paths
.into_iter()
.filter_map(|p| files.get(&p).map(|t| (p, Value::String(t.clone()))))
.collect();
Value::Object(out).to_string()
}
}
/// The files of a new project with a starter scene (JSON object).
#[wasm_bindgen]
pub fn new_project_files(title: &str) -> String {
serde_json::to_string(&isokit_project::scaffold_files(title)).unwrap_or_default()
}
/// The engine version.
#[wasm_bindgen]
pub fn engine_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn demo_files() -> String {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo");
serde_json::to_string(&isokit_project::read_dir_files(&dir).unwrap()).unwrap()
}
fn j(s: &str) -> Value {
serde_json::from_str(s).unwrap()
}
#[test]
fn demo_runs_through_the_web_api() {
let mut s = Studio::new("browser: demo", &demo_files()).unwrap();
let m = j(&s.meta());
assert_eq!(m["title"], "isokit demo works");
assert!(m["catalog"].as_array().unwrap().len() > 20);
assert!(m["script_error"].is_null());
assert_eq!(m["engine"], env!("CARGO_PKG_VERSION"));
let n = j(&s.state())["doc"]["objects"].as_array().unwrap().len();
let v = j(&s.render("site", 1, "", true));
assert!(
v["html"].as_str().unwrap().contains("data-obj=\"kiosk\""),
"project prefab drawn"
);
assert!(v["warnings"].as_array().unwrap().is_empty());
assert!(j(&s.render("", 2, "{\"labels\": false}", false))["error"].is_null());
// an edit is answered with the new state and handed out for saving
let r = j(&s.op(r#"{"op": "add", "type": "tree", "x": 3, "y": 4}"#));
assert_eq!(r["ok"], true);
assert_eq!(r["doc"]["objects"].as_array().unwrap().len(), n + 1);
let w = j(&s.take_written());
let saved: Value = serde_json::from_str(w["scene.json"].as_str().unwrap()).unwrap();
assert_eq!(saved["objects"].as_array().unwrap().len(), n + 1);
assert_eq!(j(&s.take_written()), json!({}));
assert!(j(&s.op(r#"{"op": "add", "type": "ufo"}"#))["error"]
.as_str()
.unwrap()
.contains("available"));
// undo, and an outside edit that reloads and can be undone
assert_eq!(j(&s.undo())["doc"]["objects"].as_array().unwrap().len(), n);
let v0 = s.version();
let mut doc = j(&s.file("scene.json").unwrap());
doc["objects"]
.as_array_mut()
.unwrap()
.retain(|o| o["type"] != "lamp");
assert_eq!(
j(&s.set_file("scene.json", Some(doc.to_string())))["ok"],
true
);
assert_ne!(s.version(), v0);
let st = j(&s.state());
assert!(st["doc"]["objects"]
.as_array()
.unwrap()
.iter()
.all(|o| o["type"] != "lamp"));
assert_eq!(st["can_undo"], true);
// a script error keeps the document editable and is reported
let good = s.file("scene.rhai").unwrap();
s.set_file("scene.rhai", Some("let x = ;".into()));
assert!(!j(&s.meta())["script_error"].is_null());
assert!(j(&s.render("site", 0, "", false))["error"].is_object());
s.set_file("scene.rhai", Some(good));
assert!(j(&s.meta())["script_error"].is_null());
// exports and the theme
assert!(j(&s.page())["html"]
.as_str()
.unwrap()
.contains("IsoKit.mount"));
assert!(j(&s.svg("", 0, true))["svg"]
.as_str()
.unwrap()
.starts_with("<svg"));
assert!(s.theme_css().contains(".iso-brick"));
assert!(j(&s.check())["findings"].is_array());
}
#[test]
fn mcp_tools_work_in_memory() {
let mut s = Studio::new("browser: demo", &demo_files()).unwrap();
let r = j(&s.mcp(r#"{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}"#));
assert_eq!(r["result"]["tools"].as_array().unwrap().len(), 15);
let r = j(&s.mcp(
r#"{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "add_object", "arguments": {"type": "lamp", "x": 1, "y": 1}}}"#,
));
assert_eq!(r["result"]["isError"], false, "{r}");
assert!(j(&s.take_written()).get("scene.json").is_some());
let r = j(&s.mcp(
r#"{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "build_html", "arguments": {}}}"#,
));
assert_eq!(r["result"]["isError"], false, "{r}");
assert!(j(&s.take_written())["dist/index.html"]
.as_str()
.unwrap()
.contains("<html"));
assert_eq!(
s.mcp(r#"{"jsonrpc": "2.0", "method": "notifications/initialized"}"#),
""
);
}
#[test]
fn new_projects_and_bad_input() {
let files = new_project_files("My \"site\"");
let mut s = Studio::new("browser: new", &files).unwrap();
assert_eq!(j(&s.meta())["title"], "My 'site'");
assert!(j(&s.render("site", 1, "", false))["error"].is_null());
assert!(Studio::new("x", "{}").is_err(), "no manifest");
assert!(Studio::new("x", "not json").is_err());
assert!(j(&s.set_file("../evil.rhai", Some(String::new())))["error"].is_string());
}
}