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:
grabowski committed 2026-10-22 17:00:42 +07:00
1 parent 564aeb75f6
commit 5eb60d2a0c
20 files changed
+1950 -178

No files matched your search

+317
View File
@@ -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());
}
}