isokit: web Studio (engine as WASM, projects in the browser or a live folder)
- isokit-wasm: wasm-bindgen build of the engine; a project in memory behind the same JSON API as `isokit studio`, plus the MCP tools - isokit-project: Store trait (FsStore folder, MemStore memory) under a generic Workspace; Project::from_files; shared Studio API module (api.rs) used by serve.rs and the WASM build; web_time for clocks in the browser - studio.js runs on a pluggable backend (window.IsoKitBackend); no Studio keys while a dialog is open - web/: projects in IndexedDB, new/demo/delete, zip export/import (stored and deflated), folder import by drop or picker, folders opened live via File System Access with outside edits followed, code editor, page build and preview, two tabs in step, isokit.mcp() and WebMCP registration - tools/build_web.py -> dist/web (static), tools/check_web.py (44 checks)
This commit is contained in:
1 parent
564aeb75f6
commit
5eb60d2a0c
20 files changed
+1950
-178
No files matched your search
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "isokit-wasm"
|
||||
description = "isokit in the browser: the engine behind the web Studio (wasm-bindgen)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
isokit-project = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
wasm-bindgen = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# Rhai's hasher seeds from getrandom: use the browser's crypto
|
||||
rhai = { workspace = true, features = ["wasm-bindgen"] }
|
||||
@@ -0,0 +1,317 @@
|
||||
//! 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());
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user