Deploy web Studio / deploy (push) Successful in 3m15s
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.
319 lines
11 KiB
Rust
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());
|
|
}
|
|
}
|