From 5eb60d2a0c02383112dc69770fda9e3f34c8fd5c Mon Sep 17 00:00:00 2001 From: grabowski Date: Thu, 22 Oct 2026 17:00:42 +0700 Subject: [PATCH] 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) --- Cargo.lock | 14 + Cargo.toml | 12 + README.md | 48 +- crates/isokit-cli/src/serve.rs | 113 +---- crates/isokit-project/Cargo.toml | 1 + crates/isokit-project/src/api.rs | 75 +++ crates/isokit-project/src/lib.rs | 115 +++-- crates/isokit-project/src/mcp.rs | 25 +- crates/isokit-project/src/page.rs | 5 +- crates/isokit-project/src/store.rs | 197 ++++++++ crates/isokit-project/src/workspace.rs | 55 ++- crates/isokit-wasm/Cargo.toml | 18 + crates/isokit-wasm/src/lib.rs | 317 +++++++++++++ docs/ROADMAP.md | 21 +- studio/index.html | 2 +- studio/studio.js | 24 +- tools/build_web.py | 125 +++++ tools/check_web.py | 313 +++++++++++++ web/web.css | 42 ++ web/web.js | 606 +++++++++++++++++++++++++ 20 files changed, 1950 insertions(+), 178 deletions(-) create mode 100644 crates/isokit-project/src/api.rs create mode 100644 crates/isokit-project/src/store.rs create mode 100644 crates/isokit-wasm/Cargo.toml create mode 100644 crates/isokit-wasm/src/lib.rs create mode 100644 tools/build_web.py create mode 100644 tools/check_web.py create mode 100644 web/web.css create mode 100644 web/web.js diff --git a/Cargo.lock b/Cargo.lock index 8cc52c0..f7eecfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -251,9 +251,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -318,6 +320,7 @@ dependencies = [ "serde", "serde_json", "toml", + "web-time", ] [[package]] @@ -337,6 +340,16 @@ dependencies = [ "toml", ] +[[package]] +name = "isokit-wasm" +version = "0.1.0" +dependencies = [ + "isokit-project", + "rhai", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "itoa" version = "1.0.18" @@ -441,6 +454,7 @@ dependencies = [ "ahash", "bitflags", "compact_str", + "getrandom 0.3.4", "num-traits", "once_cell", "rhai_codegen", diff --git a/Cargo.toml b/Cargo.toml index bebb7a5..dc27666 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/isokit-theme", "crates/isokit-project", "crates/isokit-cli", + "crates/isokit-wasm", ] [workspace.package] @@ -27,7 +28,18 @@ rhai = { version = "1.27", default-features = true } tiny_http = "0.12" clap = { version = "4", features = ["derive"] } anyhow = "1" +web-time = "1" +# keep in step with the wasm-bindgen CLI (tools/build_web.py checks) +wasm-bindgen = "=0.2.129" [profile.release] lto = "thin" codegen-units = 4 + +# the web Studio's engine (tools/build_web.py): small over fast +[profile.web] +inherits = "release" +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" diff --git a/README.md b/README.md index e189277..167019f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ click for details. One Rust binary does everything: - **Studio**: a GUI editor in the browser for people who don't code. You add objects from a palette, drag them into place, and edit their properties in forms. +- **Web Studio**: the same Studio running entirely in the browser, with the + engine compiled to WebAssembly. Nothing to install: projects live in the + browser, or in a folder on disk (Chrome / Edge) that agents edit alongside. - **MCP server**: AI agents (Hermes and others) edit the same scene with the same operations; their changes show up live in an open Studio. - **Build**: a self-contained HTML page with every view prerendered, or @@ -47,6 +50,33 @@ Every change is saved to `scene.json` at once. When the file changes on disk (an agent, git pull, another editor), the Studio reloads it, and that change can be undone like any other. +### Web Studio (no install) + +```bash +rustup target add wasm32-unknown-unknown +cargo install wasm-bindgen-cli --version 0.2.129 # must match Cargo.lock +python tools/build_web.py # -> dist/web/ (static files, ~2.8 MB, 0.75 MB gzipped) +``` + +`dist/web/` runs from any static web server (http or https, not `file://`). +The first visit opens a copy of the demo. Everything the desktop Studio does +works the same, because the page runs the same engine and answers the same +requests. In addition: + +| | | +|---|---| +| **Projects ▾** | list, open, delete; new project; demo copy; export as `.zip`; import a `.zip` or a project folder (drop it on the strip, or pick it) | +| **Open folder live** | Chrome / Edge: edits are written straight to the folder's files, and changes made there (an agent running `isokit mcp `, an editor, `git pull`) show up within 1.5 s and can be undone | +| **{ } Code** | edit `scene.rhai`, `lib/*.rhai` prefabs, `theme.toml`, `isokit.toml`; Ctrl+S applies, script errors show with file and line | +| **Build page** | downloads the self-contained HTML page (a folder project gets `dist/index.html` like `isokit build`) | +| **Preview** | the built page in a new tab | + +Projects in the browser are stored in IndexedDB (per browser and site; export +them to keep a copy). Two tabs on the same project follow each other. The 15 +MCP tools also run inside the page: `await isokit.mcp({jsonrpc: "2.0", id: 1, +method: "tools/list"})`, and browsers with WebMCP (`navigator.modelContext`) +get them as page tools. + ### Agents (MCP) While the Studio runs, it also serves MCP over HTTP at `http://127.0.0.1:7878/mcp`. @@ -109,15 +139,19 @@ crates/isokit-theme TOML palettes / materials / rules -> CSS; built-in defaul crates/isokit-script Rhai runtime: drawing API, prefab library (stdlib/*.rhai), scene document driver crates/isokit-project project folder, document ops, export, check, Workspace (undo, reload), MCP tools crates/isokit-cli the `isokit` binary: Studio HTTP server, MCP stdio, build/render/check +crates/isokit-wasm the engine for the browser (wasm-bindgen): a project in memory behind the Studio API viewer/ isokit-view.js/.css: pan, zoom, levels, rotation, tooltips (any page can embed it) -studio/ the Studio GUI (served by `isokit studio`) +studio/ the Studio GUI (served by `isokit studio`, and by the web Studio) +web/ web Studio shell: WASM backend for studio.js, projects, folders, zip, code editor examples/demo demo project ``` The layers are independent. A Rust program can draw with `isokit-core` alone. -`isokit-script` reads sources from memory, with no disk access, so the same -runtime can later run in the browser as WASM. The Studio and MCP both apply the -same `ops::Op` JSON operations through a `Workspace`. +`isokit-script` reads sources from memory, with no disk access, and a +`Workspace` reads and writes through a `Store` (a folder on disk, or memory), +so the same code runs natively and in the browser. The Studio, the web Studio +and MCP all apply the same `ops::Op` JSON operations through a `Workspace`, and +both Studio front ends speak the JSON API in `isokit_project::api`. Writing prefabs and scripts: [docs/SCRIPTING.md](docs/SCRIPTING.md). Plans: [docs/ROADMAP.md](docs/ROADMAP.md). @@ -129,8 +163,14 @@ cargo test # engine, runtime, ops, demo, MCP cargo clippy --all-targets -- -D warnings isokit studio examples/demo --port 7890 --no-open & uv run --with playwright python tools/check_studio.py http://127.0.0.1:7890 # GUI + MCP end to end (Edge) +cargo clippy -p isokit-wasm --target wasm32-unknown-unknown -- -D warnings +python tools/build_web.py && uv run --with playwright python tools/check_web.py # web Studio end to end ``` +`python tools/build_web.py --skip-cargo` re-copies the front end (studio/, +viewer/, web/) without rebuilding the WASM. Code that runs in the browser must +not use `std::time` (it traps on wasm32-unknown-unknown): use `web_time`. + Set `ISOKIT_ASSETS=C:/dev/isokit` to serve `viewer/` and `studio/` from disk, so you can edit the GUI without rebuilding. `tools/css2theme.py` converted the original views' CSS into `crates/isokit-theme/themes/default.toml`. diff --git a/crates/isokit-cli/src/serve.rs b/crates/isokit-cli/src/serve.rs index b9ee41b..a69496a 100644 --- a/crates/isokit-cli/src/serve.rs +++ b/crates/isokit-cli/src/serve.rs @@ -3,9 +3,8 @@ //! live. Single-threaded: one request at a time against one Workspace. use anyhow::Result; -use isokit_project::ops::Op; use isokit_project::workspace::Workspace; -use isokit_project::{assets, check, mcp, page}; +use isokit_project::{api, assets, check, mcp, page}; use serde_json::{json, Map, Value}; use std::io::Read; use std::path::Path; @@ -100,18 +99,6 @@ fn origin_ok(req: &Request) -> bool { } } -fn state(ws: &Workspace) -> Value { - json!({ "doc": ws.project.doc, "version": ws.version.to_string(), "can_undo": ws.can_undo(), "can_redo": ws.can_redo() }) -} - -fn meta(ws: &Workspace) -> Value { - let mut v = serde_json::to_value(ws.project.meta()).unwrap_or_default(); - v["dir"] = json!(ws.dir.display().to_string()); - v["load_error"] = json!(ws.load_error); - v["script_error"] = json!(ws.project.runtime.as_ref().err()); - v -} - pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> { let dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); // Windows canonical paths carry a \?\ prefix that other tools do not accept @@ -191,21 +178,16 @@ pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> { } (Method::Get, "/api/meta") => { ws.refresh(); - send_json(req, meta(&ws)) + send_json(req, api::meta(&ws)) } (Method::Get, "/api/doc") => { ws.refresh(); - send_json(req, state(&ws)) + send_json(req, api::state(&ws)) } (Method::Get, "/api/render") => { ws.refresh(); let q = query(&url); - let p = &ws.project; - let level = q - .get("level") - .and_then(|v| v.as_str()) - .map(str::to_string) - .unwrap_or_else(|| p.manifest.default_level()); + let level = q.get("level").and_then(|v| v.as_str()); let rot = q .get("rot") .and_then(|v| v.as_str()) @@ -216,85 +198,40 @@ pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> { .and_then(|v| v.as_str()) .and_then(|s| serde_json::from_str::>(s).ok()); let dev = q.get("dev").is_some(); - match p.render(&level, rot, cfg.as_ref(), dev) { - Ok(v) => send_json(req, serde_json::to_value(v).unwrap_or_default()), - Err(e) => send( - req, - 200, - "application/json", - json!({ "error": e }).to_string(), - ), - } + // script errors are an answer for the viewer, not a failed request + send( + req, + 200, + "application/json; charset=utf-8", + api::render(&ws, level, rot, cfg.as_ref(), dev).to_string(), + ) } (Method::Get, "/api/check") => { ws.refresh(); send_json(req, json!({ "findings": check::check(&ws.project) })) } (Method::Post, "/api/op") => { - let r = if let Some(list) = bodyv.get("ops").and_then(|v| v.as_array()) { - let ops: Result, String> = list - .iter() - .map(|v| serde_json::from_value::(v.clone()).map_err(|e| e.to_string())) - .collect(); - ops.and_then(|l| ws.apply_all(l)) - .map(|o| o.last().and_then(|x| x.id.clone())) - } else { - serde_json::from_value::(bodyv.clone()) - .map_err(|e| format!("bad operation: {e}")) - .and_then(|op| ws.apply(op)) - .map(|o| o.id) - }; - match r { - Ok(id) => { - let mut s = state(&ws); - s["ok"] = json!(true); - s["id"] = json!(id); - send_json(req, s) - } - Err(e) => send( - req, - 200, - "application/json", - json!({ "error": e }).to_string(), - ), - } + let r = api::op(&mut ws, &bodyv); + send(req, 200, "application/json; charset=utf-8", r.to_string()) } (Method::Post, "/api/undo") | (Method::Post, "/api/redo") => { - let r = if path.ends_with("undo") { - ws.undo() - } else { - ws.redo() - }; - match r { - Ok(_) => send_json(req, state(&ws)), - Err(e) => send( - req, - 200, - "application/json", - json!({ "error": e }).to_string(), - ), - } + let r = api::undo(&mut ws, path.ends_with("redo")); + send(req, 200, "application/json; charset=utf-8", r.to_string()) } (Method::Post, "/api/build") => { ws.refresh(); - let p = &ws.project; - let out = p.dir.join( - p.manifest - .build - .out - .clone() - .unwrap_or_else(|| "dist/index.html".into()), - ); - let r = page::static_page(p) + let out = ws + .project + .manifest + .build + .out + .clone() + .unwrap_or_else(|| "dist/index.html".into()); + let r = page::static_page(&ws.project) .map_err(|e| e.to_string()) - .and_then(|html| { - if let Some(d) = out.parent() { - std::fs::create_dir_all(d).map_err(|e| e.to_string())?; - } - std::fs::write(&out, html).map_err(|e| e.to_string()) - }); + .and_then(|html| ws.write_file(&out, &html)); match r { - Ok(_) => send_json(req, json!({ "path": out.display().to_string() })), + Ok(path) => send_json(req, json!({ "path": path.display().to_string() })), Err(e) => send( req, 200, diff --git a/crates/isokit-project/Cargo.toml b/crates/isokit-project/Cargo.toml index 093bd01..35cd086 100644 --- a/crates/isokit-project/Cargo.toml +++ b/crates/isokit-project/Cargo.toml @@ -12,3 +12,4 @@ isokit-theme = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } +web-time = { workspace = true } diff --git a/crates/isokit-project/src/api.rs b/crates/isokit-project/src/api.rs new file mode 100644 index 0000000..1c61d23 --- /dev/null +++ b/crates/isokit-project/src/api.rs @@ -0,0 +1,75 @@ +//! The Studio's JSON API, shared by `isokit studio` (HTTP, `/api/*`) and the +//! web Studio (WASM): the same requests and answers on both. + +use crate::ops::Op; +use crate::store::Store; +use crate::workspace::Workspace; +use serde_json::{json, Map, Value}; + +/// The document and the undo state. +pub fn state(ws: &Workspace) -> Value { + // the version goes out as a string: u64 does not fit a JS number + json!({ "doc": ws.project.doc, "version": ws.version.to_string(), "can_undo": ws.can_undo(), "can_redo": ws.can_redo() }) +} + +/// Project facts: title, levels, parameters, catalog, materials, errors. +pub fn meta(ws: &Workspace) -> Value { + let mut v = serde_json::to_value(ws.project.meta()).unwrap_or_default(); + v["dir"] = json!(ws.dir.display().to_string()); + v["load_error"] = json!(ws.load_error); + v["script_error"] = json!(ws.project.runtime.as_ref().err()); + v +} + +/// One operation, or `{ "ops": [...] }` as one undo step. Answers the new +/// state with `ok` and the id of the object made or changed, or `error`. +pub fn op(ws: &mut Workspace, body: &Value) -> Value { + let r = if let Some(list) = body.get("ops").and_then(|v| v.as_array()) { + let ops: Result, String> = list + .iter() + .map(|v| serde_json::from_value::(v.clone()).map_err(|e| e.to_string())) + .collect(); + ops.and_then(|l| ws.apply_all(l)) + .map(|o| o.last().and_then(|x| x.id.clone())) + } else { + serde_json::from_value::(body.clone()) + .map_err(|e| format!("bad operation: {e}")) + .and_then(|op| ws.apply(op)) + .map(|o| o.id) + }; + match r { + Ok(id) => { + let mut s = state(ws); + s["ok"] = json!(true); + s["id"] = json!(id); + s + } + Err(e) => json!({ "error": e }), + } +} + +pub fn undo(ws: &mut Workspace, redo: bool) -> Value { + let r = if redo { ws.redo() } else { ws.undo() }; + match r { + Ok(_) => state(ws), + Err(e) => json!({ "error": e }), + } +} + +/// One view for the viewer (`level` default: the project's default level). +pub fn render( + ws: &Workspace, + level: Option<&str>, + rot: u8, + cfg: Option<&Map>, + dev: bool, +) -> Value { + let p = &ws.project; + let level = level + .map(str::to_string) + .unwrap_or_else(|| p.manifest.default_level()); + match p.render(&level, rot, cfg, dev) { + Ok(v) => serde_json::to_value(v).unwrap_or_default(), + Err(e) => json!({ "error": e }), + } +} diff --git a/crates/isokit-project/src/lib.rs b/crates/isokit-project/src/lib.rs index b44c155..555616f 100644 --- a/crates/isokit-project/src/lib.rs +++ b/crates/isokit-project/src/lib.rs @@ -13,13 +13,17 @@ //! [`Project`] loads and renders it; [`ops`] edits the document; [`page`] //! exports HTML/SVG; [`check`] lints it; [`workspace::Workspace`] keeps a //! project loaded across edits (undo, reload on file changes) for the Studio -//! and the MCP server ([`mcp`]). +//! and the MCP server ([`mcp`]). Where the files live is a [`store::Store`]: +//! a folder on disk, or memory (the web Studio, WASM). [`api`] is the JSON +//! API both Studio front ends speak. +pub mod api; pub mod assets; pub mod check; pub mod mcp; pub mod ops; pub mod page; +pub mod store; pub mod workspace; use isokit_core::{render, Info, RenderOpts}; @@ -29,12 +33,16 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use std::time::Instant; +use web_time::Instant; pub use isokit_script; pub const MANIFEST: &str = "isokit.toml"; +/// Project files by forward-slash relative path ("isokit.toml", +/// "lib/kiosk.rhai"): what a project is made of, wherever it is stored. +pub type Files = BTreeMap; + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct LevelDef { pub id: String, @@ -213,9 +221,18 @@ fn rd(path: &Path) -> Result { std::fs::read_to_string(path).map_err(|e| Error::Io(format!("{}: {e}", path.display()))) } -/// Every `*.rhai` below `dir` (skipping dist, target and dot folders), -/// keyed by forward-slash relative path. -fn collect_sources(dir: &Path) -> Result, Error> { +/// Folders a project walk skips. +pub fn skip_dir(name: &str) -> bool { + name.starts_with('.') || name == "dist" || name == "target" || name == "node_modules" +} + +/// Files that make up a project (scripts, manifest, themes, documents). +pub fn is_project_file(name: &str) -> bool { + name.ends_with(".rhai") || name.ends_with(".toml") || name.ends_with(".json") +} + +/// Every project file below `dir` (skipping dist, target and dot folders). +pub fn read_dir_files(dir: &Path) -> Result { let mut out = BTreeMap::new(); let mut stack = vec![dir.to_path_buf()]; while let Some(d) = stack.pop() { @@ -224,14 +241,10 @@ fn collect_sources(dir: &Path) -> Result, Error> { let p = ent.path(); let name = ent.file_name().to_string_lossy().to_string(); if p.is_dir() { - if !name.starts_with('.') - && name != "dist" - && name != "target" - && name != "node_modules" - { + if !skip_dir(&name) { stack.push(p); } - } else if name.ends_with(".rhai") { + } else if is_project_file(&name) { let rel = p .strip_prefix(dir) .unwrap_or(&p) @@ -259,35 +272,49 @@ impl Project { /// document stays editable and [`Project::runtime`] holds the error. pub fn load(dir: impl AsRef) -> Result { let dir = dir.as_ref().to_path_buf(); - let mpath = dir.join(MANIFEST); - let manifest: Manifest = if mpath.exists() { - toml::from_str(&rd(&mpath)?).map_err(|e| Error::Manifest(e.to_string()))? - } else { + if !dir.join(MANIFEST).exists() { return Err(Error::Io(format!( "{} not found (create a project with `isokit new`)", - mpath.display() + dir.join(MANIFEST).display() ))); + } + let files = read_dir_files(&dir)?; + Project::from_files(dir, &files) + } + + /// Load a project from its files (no disk access). `dir` names it in + /// messages and is where the CLI writes builds. + pub fn from_files(dir: impl Into, files: &Files) -> Result { + let dir = dir.into(); + let get = |name: &str| { + files + .get(name) + .ok_or_else(|| Error::Io(format!("{name} not found"))) }; + let manifest: Manifest = + toml::from_str(get(MANIFEST)?).map_err(|e| Error::Manifest(e.to_string()))?; let mut layers = vec![]; for t in &manifest.theme { - layers.push((t.clone(), rd(&dir.join(t))?)); + layers.push((t.clone(), get(t)?.clone())); } let refs: Vec<(&str, &str)> = layers .iter() .map(|(a, b)| (a.as_str(), b.as_str())) .collect(); let theme = isokit_theme::load(&refs).map_err(Error::Theme)?; - let dpath = dir.join(manifest.doc_path()); - let doc: Doc = if dpath.exists() { - serde_json::from_str(&rd(&dpath)?) - .map_err(|e| Error::Doc(format!("{}: {e}", manifest.doc_path())))? - } else { - Doc { + let doc: Doc = match files.get(&manifest.doc_path()) { + Some(text) => serde_json::from_str(text) + .map_err(|e| Error::Doc(format!("{}: {e}", manifest.doc_path())))?, + None => Doc { version: 1, ..Default::default() - } + }, }; - let sources = collect_sources(&dir)?; + let sources: BTreeMap = files + .iter() + .filter(|(k, _)| k.ends_with(".rhai")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); let main = match &manifest.script { Some(s) => Some(s.clone()), None if sources.contains_key("scene.rhai") => Some("scene.rhai".into()), @@ -402,16 +429,12 @@ impl Project { }) } - /// Write the document (pretty JSON, atomically). - pub fn save_doc(&self) -> Result<(), Error> { - let path = self.dir.join(self.manifest.doc_path()); + /// The document as saved: pretty JSON with a final newline. + pub fn doc_text(&self) -> Result { let mut s = serde_json::to_string_pretty(&self.doc).map_err(|e| Error::Doc(e.to_string()))?; s.push('\n'); - let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, s).map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?; - std::fs::rename(&tmp, &path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; - Ok(()) + Ok(s) } } @@ -430,15 +453,10 @@ pub fn version(dir: &Path) -> u64 { let p = ent.path(); let name = ent.file_name().to_string_lossy().to_string(); if p.is_dir() { - if !name.starts_with('.') - && name != "dist" - && name != "target" - && name != "node_modules" - { + if !skip_dir(&name) { stack.push(p); } - } else if name.ends_with(".rhai") || name.ends_with(".toml") || name.ends_with(".json") - { + } else if is_project_file(&name) { if let Ok(m) = ent.metadata() { files.push((p.to_string_lossy().to_string(), m.len(), m.modified().ok())); } @@ -450,6 +468,19 @@ pub fn version(dir: &Path) -> u64 { h.finish() } +/// The files of a new project with a starter scene. +pub fn scaffold_files(title: &str) -> Files { + let title = title.replace('\\', "").replace('"', "'"); + let mut f = Files::new(); + f.insert( + MANIFEST.into(), + assets::NEW_MANIFEST.replace("{title}", &title), + ); + f.insert("scene.json".into(), assets::NEW_DOC.into()); + f.insert("theme.toml".into(), assets::NEW_THEME.into()); + f +} + /// Write a new project folder with a starter scene. pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> { if dir.join(MANIFEST).exists() { @@ -462,9 +493,9 @@ pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> { let w = |name: &str, body: &str| { std::fs::write(dir.join(name), body).map_err(|e| Error::Io(format!("{name}: {e}"))) }; - w(MANIFEST, &assets::NEW_MANIFEST.replace("{title}", title))?; - w("scene.json", assets::NEW_DOC)?; - w("theme.toml", assets::NEW_THEME)?; + for (name, body) in scaffold_files(title) { + w(&name, &body)?; + } w("lib/README.md", assets::NEW_LIB_README)?; Ok(()) } diff --git a/crates/isokit-project/src/mcp.rs b/crates/isokit-project/src/mcp.rs index 5b98b83..5fea8e8 100644 --- a/crates/isokit-project/src/mcp.rs +++ b/crates/isokit-project/src/mcp.rs @@ -6,6 +6,7 @@ use crate::check::check; use crate::ops::Op; use crate::page; +use crate::store::Store; use crate::workspace::Workspace; use serde_json::{json, Map, Value}; @@ -85,7 +86,7 @@ fn tool_error(msg: impl Into) -> Value { json!({ "content": [{ "type": "text", "text": msg.into() }], "isError": true }) } -fn boxes(ws: &Workspace) -> Map { +fn boxes(ws: &Workspace) -> Map { let p = &ws.project; match p.render(&p.manifest.default_level(), 0, None, false) { Ok(v) => v @@ -97,7 +98,7 @@ fn boxes(ws: &Workspace) -> Map { } } -fn object_json(ws: &Workspace, id: &str, bx: &Map) -> Option { +fn object_json(ws: &Workspace, id: &str, bx: &Map) -> Option { let o = ws.project.doc.get(id)?; let mut v = serde_json::to_value(o).ok()?; if let Some(b) = bx.get(id) { @@ -111,7 +112,7 @@ fn str_arg(a: &Map, k: &str) -> Option { } /// Run one tool. -pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map) -> Value { +pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map) -> Value { ws.refresh(); match name { "project_info" => { @@ -287,18 +288,12 @@ pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map) -> Valu let out = str_arg(a, "out") .or_else(|| p.manifest.build.out.clone()) .unwrap_or_else(|| "dist/index.html".into()); - let path = p.dir.join(&out); match page::static_page(p) { Err(e) => tool_error(e.to_string()), - Ok(html) => { - if let Some(d) = path.parent() { - let _ = std::fs::create_dir_all(d); - } - match std::fs::write(&path, html) { - Ok(_) => text(json!({ "path": path.display().to_string() })), - Err(e) => tool_error(format!("{}: {e}", path.display())), - } - } + Ok(html) => match ws.write_file(&out, &html) { + Ok(path) => text(json!({ "path": path.display().to_string() })), + Err(e) => tool_error(e), + }, } } "undo" => match ws.undo() { @@ -313,7 +308,7 @@ pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map) -> Valu } } -fn render_warnings(ws: &Workspace) -> Vec { +fn render_warnings(ws: &Workspace) -> Vec { let p = &ws.project; p.render(&p.manifest.default_level(), 0, None, false) .map(|v| v.warnings) @@ -321,7 +316,7 @@ fn render_warnings(ws: &Workspace) -> Vec { } /// Handle one JSON-RPC message. -pub fn handle(ws: &mut Workspace, msg: &Value) -> Option { +pub fn handle(ws: &mut Workspace, msg: &Value) -> Option { let id = msg.get("id").cloned(); let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); let params = msg.get("params").cloned().unwrap_or(json!({})); diff --git a/crates/isokit-project/src/page.rs b/crates/isokit-project/src/page.rs index fd6c42b..cff44f8 100644 --- a/crates/isokit-project/src/page.rs +++ b/crates/isokit-project/src/page.rs @@ -69,8 +69,9 @@ pub fn svg(p: &Project, level: &str, rot: u8, light: bool) -> Result String { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + // web_time: std's clock traps in the browser (wasm32-unknown-unknown) + let secs = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let days = (secs / 86_400) as i64; diff --git a/crates/isokit-project/src/store.rs b/crates/isokit-project/src/store.rs new file mode 100644 index 0000000..dc29b3f --- /dev/null +++ b/crates/isokit-project/src/store.rs @@ -0,0 +1,197 @@ +//! Where a project's files live. [`FsStore`]: a folder on disk (the CLI, +//! `isokit studio`, `isokit mcp`). [`MemStore`]: memory, filled and drained +//! by a host (the web Studio keeps the files in the browser or in a folder +//! the user opened, and persists what the workspace writes). + +use crate::{is_project_file, read_dir_files, Error, Files, MANIFEST}; +use std::path::{Path, PathBuf}; + +pub trait Store { + /// The project's name in messages (a folder path, or a label). + fn dir(&self) -> PathBuf; + /// All project files. + fn read(&self) -> Result; + /// Changes whenever a file is edited, added or removed. + fn version(&self) -> u64; + /// Write one file (relative path). + fn write(&mut self, path: &str, body: &str) -> Result<(), Error>; +} + +/// A project folder on disk. +pub struct FsStore { + pub dir: PathBuf, +} + +impl FsStore { + pub fn new(dir: impl AsRef) -> FsStore { + FsStore { + dir: dir.as_ref().to_path_buf(), + } + } +} + +/// A relative path that stays inside the project (no `..`, no root). +fn safe_rel(path: &str) -> Result { + let p = path.replace('\\', "/"); + let p = p.trim_start_matches("./"); + if p.is_empty() + || p.starts_with('/') + || p.contains(':') + || p.split('/').any(|s| s == ".." || s.is_empty()) + { + return Err(Error::Io(format!("{path}: not a path inside the project"))); + } + Ok(p.to_string()) +} + +impl Store for FsStore { + fn dir(&self) -> PathBuf { + self.dir.clone() + } + fn read(&self) -> Result { + if !self.dir.join(MANIFEST).exists() { + return Err(Error::Io(format!( + "{} not found (create a project with `isokit new`)", + self.dir.join(MANIFEST).display() + ))); + } + read_dir_files(&self.dir) + } + fn version(&self) -> u64 { + crate::version(&self.dir) + } + /// Atomic: a temporary file renamed over the old one. + fn write(&mut self, path: &str, body: &str) -> Result<(), Error> { + let path = self.dir.join(safe_rel(path)?); + if let Some(d) = path.parent() { + std::fs::create_dir_all(d).map_err(|e| Error::Io(format!("{}: {e}", d.display())))?; + } + let mut tmp = path.clone().into_os_string(); + tmp.push(".tmp"); + let tmp = PathBuf::from(tmp); + std::fs::write(&tmp, body).map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?; + std::fs::rename(&tmp, &path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + Ok(()) + } +} + +/// Project files in memory. The host fills it ([`MemStore::set`] for edits +/// made outside the workspace) and persists what the workspace wrote +/// ([`MemStore::take_written`]). +#[derive(Default)] +pub struct MemStore { + pub name: String, + files: Files, + version: u64, + written: Vec, +} + +impl MemStore { + pub fn new(name: impl Into, files: Files) -> MemStore { + MemStore { + name: name.into(), + files, + version: 1, + written: vec![], + } + } + pub fn files(&self) -> &Files { + &self.files + } + /// An edit from outside (code editor, another tab, a watched folder): + /// the workspace reloads on its next refresh. `None` removes the file. + pub fn set(&mut self, path: &str, body: Option) -> Result<(), Error> { + let p = safe_rel(path)?; + match body { + Some(b) => { + if self.files.get(&p) == Some(&b) { + return Ok(()); + } + self.files.insert(p, b); + } + None => { + if self.files.remove(&p).is_none() { + return Ok(()); + } + } + } + self.version += 1; + Ok(()) + } + /// Paths the workspace wrote since the last call (deduplicated, in order). + pub fn take_written(&mut self) -> Vec { + let mut out: Vec = vec![]; + for p in self.written.drain(..) { + if !out.contains(&p) { + out.push(p); + } + } + out + } +} + +impl Store for MemStore { + fn dir(&self) -> PathBuf { + PathBuf::from(&self.name) + } + fn read(&self) -> Result { + if !self.files.contains_key(MANIFEST) { + return Err(Error::Io(format!("{MANIFEST} not found"))); + } + Ok(self.files.clone()) + } + fn version(&self) -> u64 { + self.version + } + /// Exports (dist/index.html) do not change the version: like on disk, + /// only project files make the workspace reload. + fn write(&mut self, path: &str, body: &str) -> Result<(), Error> { + let p = safe_rel(path)?; + if is_project_file(&p) { + self.version += 1; + } + self.files.insert(p.clone(), body.to_string()); + self.written.push(p); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_stay_inside_the_project() { + assert_eq!(safe_rel("lib/a.rhai").unwrap(), "lib/a.rhai"); + assert_eq!(safe_rel("./scene.json").unwrap(), "scene.json"); + assert_eq!(safe_rel("dist\\index.html").unwrap(), "dist/index.html"); + for bad in ["../x", "/etc/passwd", "C:/x", "a//b", "", "lib/../../x"] { + assert!(safe_rel(bad).is_err(), "{bad}"); + } + } + + #[test] + fn mem_store_tracks_versions_and_writes() { + let mut m = MemStore::new("t", Files::new()); + assert!(m.read().is_err(), "no manifest yet"); + let v0 = m.version(); + m.set("isokit.toml", Some("title = \"t\"".into())).unwrap(); + assert!(m.version() > v0); + let v1 = m.version(); + m.set("isokit.toml", Some("title = \"t\"".into())).unwrap(); + assert_eq!(m.version(), v1, "same content is not a change"); + m.write("scene.json", "{}").unwrap(); + m.write("scene.json", "{ }").unwrap(); + let v2 = m.version(); + assert!(v2 > v1); + m.write("dist/index.html", "").unwrap(); + assert_eq!(m.version(), v2, "an export is not a project change"); + assert_eq!( + m.take_written(), + vec!["scene.json".to_string(), "dist/index.html".to_string()] + ); + assert!(m.take_written().is_empty()); + m.set("scene.json", None).unwrap(); + assert!(!m.files().contains_key("scene.json")); + } +} diff --git a/crates/isokit-project/src/workspace.rs b/crates/isokit-project/src/workspace.rs index 6cee6dc..a4d99ef 100644 --- a/crates/isokit-project/src/workspace.rs +++ b/crates/isokit-project/src/workspace.rs @@ -1,16 +1,18 @@ //! A project kept loaded across edits: reload on file changes, save after -//! every operation, undo and redo. The Studio server and the MCP server -//! each hold one. +//! every operation, undo and redo. The Studio server, the MCP server and the +//! web Studio each hold one; the [`Store`] says where the files live. use crate::ops::{self, Op, Outcome}; -use crate::{version, Error, Project}; +use crate::store::{FsStore, Store}; +use crate::{Error, Project}; use isokit_script::doc::Doc; use std::path::{Path, PathBuf}; -pub struct Workspace { +pub struct Workspace { pub dir: PathBuf, + pub store: S, pub project: Project, - /// Fingerprint of the files the loaded project was read from. + /// Version of the files the loaded project was read from. pub version: u64, /// Error of the last reload attempt (the previous project stays loaded). pub load_error: Option, @@ -20,13 +22,21 @@ pub struct Workspace { const UNDO_DEPTH: usize = 200; -impl Workspace { +impl Workspace { + /// A project folder on disk. pub fn open(dir: impl AsRef) -> Result { - let dir = dir.as_ref().to_path_buf(); - let project = Project::load(&dir)?; - let version = version(&dir); + Workspace::with_store(FsStore::new(dir)) + } +} + +impl Workspace { + pub fn with_store(store: S) -> Result, Error> { + let dir = store.dir(); + let project = Project::from_files(dir.clone(), &store.read()?)?; + let version = store.version(); Ok(Workspace { dir, + store, project, version, load_error: None, @@ -35,15 +45,19 @@ impl Workspace { }) } - /// Reload when a project file changed on disk (another editor, an agent, - /// a git pull). Returns true when something was reloaded. + /// Reload when a project file changed in the store (another editor, an + /// agent, a git pull). Returns true when something was reloaded. pub fn refresh(&mut self) -> bool { - let v = version(&self.dir); + let v = self.store.version(); if v == self.version { return false; } self.version = v; - match Project::load(&self.dir) { + match self + .store + .read() + .and_then(|f| Project::from_files(self.dir.clone(), &f)) + { Ok(p) => { if p.doc != self.project.doc { self.push_undo(self.project.doc.clone()); @@ -65,11 +79,22 @@ impl Workspace { } fn save(&mut self) -> Result<(), String> { - self.project.save_doc().map_err(|e| e.to_string())?; - self.version = version(&self.dir); + let text = self.project.doc_text().map_err(|e| e.to_string())?; + let path = self.project.manifest.doc_path(); + self.store.write(&path, &text).map_err(|e| e.to_string())?; + self.version = self.store.version(); Ok(()) } + /// Write a file that is not the document (an exported page), relative + /// to the project. Returns the path as the store names it. + pub fn write_file(&mut self, path: &str, body: &str) -> Result { + self.refresh(); + self.store.write(path, body).map_err(|e| e.to_string())?; + self.version = self.store.version(); + Ok(self.dir.join(path)) + } + /// Apply an operation to the document and save it. pub fn apply(&mut self, op: Op) -> Result { self.refresh(); diff --git a/crates/isokit-wasm/Cargo.toml b/crates/isokit-wasm/Cargo.toml new file mode 100644 index 0000000..cc30ca8 --- /dev/null +++ b/crates/isokit-wasm/Cargo.toml @@ -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"] } diff --git a/crates/isokit-wasm/src/lib.rs b/crates/isokit-wasm/src/lib.rs new file mode 100644 index 0000000..6a876e2 --- /dev/null +++ b/crates/isokit-wasm/src/lib.rs @@ -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 { + let v: Map = 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, +} + +#[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 { + 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::(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::>(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::(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 = 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 { + 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 { + 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 = 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("` + `', f'') + html = replace_once(html, '', + f'\n' + '') + assert "/assets/" not in html, "an absolute /assets/ URL is left in index.html" + (out / "index.html").write_text(html, encoding="utf-8") + + total = 0 + for p in sorted(out.rglob("*")): + if p.is_file(): + total += p.stat().st_size + print(f" {p.relative_to(out).as_posix():40} {p.stat().st_size / 1024:8.1f} kB") + print(f"{out} ({total / 1024 / 1024:.1f} MB, {v})") + + +if __name__ == "__main__": + main() diff --git a/tools/check_web.py b/tools/check_web.py new file mode 100644 index 0000000..1ede426 --- /dev/null +++ b/tools/check_web.py @@ -0,0 +1,313 @@ +"""Functional test of the web Studio (dist/web), headless Edge. + + python tools/build_web.py + uv run --with playwright python tools/check_web.py [dist/web] + +Serves the build on a local port and checks: the demo loads from the bundled +files, placing / dragging / props / undo, persistence across reloads, the code +editor (script error and repair), new / export / import (.zip, stored and +deflated), page build and preview, MCP in the page and WebMCP, two tabs on one +project, and a folder project (the origin-private file system stands in for a +folder picked with showDirectoryPicker: same handle API) following outside edits. +""" +import io +import json +import subprocess +import sys +import time +import zipfile +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parent.parent +OUT = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "dist" / "web-shots" +DIST = Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "dist" / "web" +PORT = 7931 +BASE = f"http://127.0.0.1:{PORT}/" +OUT.mkdir(parents=True, exist_ok=True) +fails = [] + + +def check(cond, msg): + print(("ok " if cond else "FAIL ") + msg, flush=True) + if not cond: + fails.append(msg) + + +def doc(pg): + return pg.evaluate("() => JSON.parse(isokit.studio.state()).doc") + + +def obj(pg, id_): + return next((o for o in doc(pg)["objects"] if o["id"] == id_), None) + + +def ready(pg): + pg.wait_for_selector(".iso-svg [data-obj]", timeout=15000) + pg.wait_for_timeout(400) + + +def shot(pg, name): + pg.screenshot(path=str(OUT / name), type="jpeg", quality=80) + + +def title(pg): + return pg.locator("#title").inner_text().strip() + + +WEBMCP_STUB = "navigator.modelContext = { provideContext: function (c) { window.__webmcp = c; } };" + +srv = subprocess.Popen([sys.executable, "-m", "http.server", str(PORT), "--bind", "127.0.0.1", "--directory", str(DIST)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +time.sleep(1.0) +try: + with sync_playwright() as p: + b = p.chromium.launch(channel="msedge", headless=True) + ctx = b.new_context(viewport={"width": 1500, "height": 950}, accept_downloads=True) + ctx.add_init_script(WEBMCP_STUB) + errs = [] + + def watch(page): + page.on("pageerror", lambda e: errs.append(str(e))) + page.on("console", lambda m: errs.append(m.text) if m.type == "error" else None) + + pg = ctx.new_page() + watch(pg) + + # ---------------------------------------------------------------- first visit: the demo + t0 = time.time() + pg.goto(BASE) + ready(pg) + check(time.time() - t0 < 8, f"first visit opens the demo in {time.time() - t0:.1f} s") + check(title(pg) == "isokit demo works", "the demo project is open") + n0 = len(doc(pg)["objects"]) + check(pg.locator("#objects li").count() == n0, f"object list shows {n0} objects") + check(pg.locator(".st-tile").count() >= 20, "palette lists the prefabs") + check(pg.locator("#wb-where").inner_text() == "in this browser", "header says where the project lives") + check("ms" in pg.locator("#stats").inner_text(), "render stats show: " + pg.locator("#stats").inner_text()) + check(pg.evaluate("() => getComputedStyle(document.querySelector('.iso-svg .iso-t')).fill") not in ("", "rgb(0, 0, 0)"), + "theme CSS styles the scene") + check(pg.evaluate("() => isokit.webmcp") == 15, "15 MCP tools registered with WebMCP") + shot(pg, "web-start.jpg") + + # ---------------------------------------------------------------- edit: place, drag, props, undo + pg.locator('.st-tile[data-type="tree"]').click() + stage = pg.locator(".iso-stage").bounding_box() + pg.mouse.click(stage["x"] + stage["width"] * 0.30, stage["y"] + stage["height"] * 0.62) + pg.wait_for_timeout(500) + d = doc(pg) + check(len(d["objects"]) == n0 + 1 and d["objects"][-1]["type"] == "tree", "click on the map adds a tree") + check(pg.locator("#right h3").inner_text().strip().endswith("Tree"), "the new tree is selected") + check(pg.locator("#save").inner_text() == "Saved", "save indicator says Saved") + + g = obj(pg, "genset") + box = pg.locator('.iso-svg g[data-obj="genset"] path.iso-t').nth(1).bounding_box() + cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 + pg.mouse.move(cx, cy) + pg.mouse.down() + for i in range(1, 11): + pg.mouse.move(cx + 9 * i, cy + 3 * i) + pg.wait_for_timeout(20) + pg.mouse.up() + pg.wait_for_timeout(500) + g2 = obj(pg, "genset") + check((g2["x"], g2["y"]) != (g["x"], g["y"]), f"drag moves the generator ({g['x']},{g['y']}) -> ({g2['x']},{g2['y']})") + + pg.locator('#objects li[data-id="hall"]').click() + pg.locator("#p-floors").fill("3") + pg.locator("#p-floors").press("Enter") + pg.wait_for_timeout(500) + check(obj(pg, "hall")["props"].get("floors") == 3, "prop form edits the building (3 floors)") + pg.keyboard.press("Escape") + pg.locator("#undo").click() + pg.wait_for_timeout(400) + check(obj(pg, "hall")["props"].get("floors") == 2, "undo restores 2 floors") + pg.locator("#redo").click() + pg.wait_for_timeout(400) + check(obj(pg, "hall")["props"].get("floors") == 3, "redo") + + # ---------------------------------------------------------------- persistence + n1 = len(doc(pg)["objects"]) + pg.reload() + ready(pg) + check(len(doc(pg)["objects"]) == n1 and obj(pg, "hall")["props"].get("floors") == 3, "edits survive a reload (IndexedDB)") + + # ---------------------------------------------------------------- code editor + pg.locator("#wb-code").click() + pg.wait_for_selector("#wb-codedlg[open]") + files = pg.locator("#wb-cfiles li").all_inner_texts() + check({"isokit.toml", "scene.json", "scene.rhai", "lib/kiosk.rhai", "theme.toml"} <= set(files), f"code editor lists the files {files}") + check(pg.locator("#wb-cpath").inner_text() == "scene.rhai", "it opens the main script") + good = pg.locator("#wb-ctext").input_value() + pg.locator("#wb-ctext").fill(good + "\nlet broken = ;\n") + pg.keyboard.press("Control+s") + pg.wait_for_timeout(1200) + msg = pg.locator("#wb-cmsg").inner_text() + check("scene.rhai:" in msg and pg.locator("#wb-cmsg.wb-bad").count() == 1, f"a script error shows with its line: {msg.strip()[:80]}") + shot(pg, "web-code-error.jpg") + pg.locator("#wb-ctext").fill(good + "\n// edited in the browser\n") + pg.locator("#wb-capply").click() + pg.wait_for_timeout(1200) + check(pg.locator("#wb-cmsg.wb-bad").count() == 0 and "Applied" in pg.locator("#wb-cmsg").inner_text(), "repaired script applies cleanly") + pg.locator("#wb-cfiles li", has_text="theme.toml").click() + check("palette" in pg.locator("#wb-ctext").input_value() or "#" in pg.locator("#wb-ctext").input_value(), "switching files shows theme.toml") + shot(pg, "web-code.jpg") + pg.locator("#wb-cclose").click() + pg.wait_for_timeout(1000) + check("script error" not in pg.locator("#stats").inner_text(), "the scene renders again after the fix") + + # ---------------------------------------------------------------- build page and preview + with pg.expect_download() as dl: + pg.locator("#build").click() + html = Path(dl.value.path()).read_text(encoding="utf-8") + check(dl.value.suggested_filename.endswith(".html") and "IsoKit.mount" in html and '"site/1"' in html, + f"Build page downloads {dl.value.suggested_filename} ({len(html) // 1024} kB)") + with ctx.expect_page() as pop: + pg.locator("#preview").click() + prev = pop.value + prev.wait_for_selector(".iso-svg [data-obj]", timeout=10000) + check(prev.locator(".iso-svg [data-obj]").count() > 10, "Preview opens the built page in a tab") + prev.close() + + # ---------------------------------------------------------------- MCP in the page and WebMCP + r = pg.evaluate("""() => isokit.mcp({jsonrpc: "2.0", id: 7, method: "tools/call", + params: {name: "add_object", arguments: {type: "lamp", x: 2, y: -14, id: "agent-lamp"}}})""") + check(r["result"]["isError"] is False, "isokit.mcp(add_object) works") + pg.wait_for_timeout(1200) + check(pg.locator('#objects li[data-id="agent-lamp"]').count() == 1, "the Studio follows the agent's edit") + check("agent" in pg.locator("#toast").inner_text().lower(), "toast names the agent: " + pg.locator("#toast").inner_text()) + r = pg.evaluate("""async () => { const t = window.__webmcp.tools.find(t => t.name === "update_object"); + return t.execute({id: "agent-lamp", tag: "via WebMCP"}); }""") + check(r["isError"] is False and obj(pg, "agent-lamp")["tag"] == "via WebMCP", "a WebMCP tool call edits the scene") + + # ---------------------------------------------------------------- two tabs on one project + pg2 = ctx.new_page() + watch(pg2) + pg2.goto(pg.url) + ready(pg2) + pg2.evaluate("() => __studio.op({op: 'remove', id: 'agent-lamp'})") + pg.wait_for_timeout(1800) + check(pg.locator('#objects li[data-id="agent-lamp"]').count() == 0, "an edit in another tab shows up here") + pg2.close() + + # ---------------------------------------------------------------- export, new, import + demo_id = pg.evaluate("() => location.hash") + pg.locator("#wb-projects").click() + pg.wait_for_selector("#wb-projdlg[open]") + shot(pg, "web-projects.jpg") + with pg.expect_download() as dl: + pg.locator('#wb-cur button[data-act="export"]').click() + zpath = OUT / dl.value.suggested_filename + dl.value.save_as(zpath) + with zipfile.ZipFile(zpath) as z: + names = z.namelist() + check(z.testzip() is None and any(n.endswith("/isokit.toml") for n in names), f"Export .zip is a valid zip ({len(names)} files)") + check("// edited in the browser" in z.read(next(n for n in names if n.endswith("scene.rhai"))).decode(), "the export has the edited script") + pg.locator("#wb-ntitle").fill("Test site") + pg.locator("#wb-ncreate").click() + pg.wait_for_load_state() + ready(pg) + check(title(pg) == "Test site" and len(doc(pg)["objects"]) == 5, "New project opens the starter scene") + + pg.locator("#wb-projects").click() + pg.locator("#wb-fzip").set_input_files(str(zpath)) + pg.wait_for_timeout(1500) + ready(pg) + check(title(pg) == "isokit demo works" and len(doc(pg)["objects"]) == n1, "importing the exported .zip restores the project") + + # a zip made elsewhere: deflated, nested one folder deeper, with junk + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + for f in (ROOT / "examples" / "demo").rglob("*"): + if f.is_file(): + z.write(f, "repo/scenes/plant/" + f.relative_to(ROOT / "examples" / "demo").as_posix()) + z.writestr("repo/README.md", "not part of it") + dz = OUT / "deflated.zip" + dz.write_bytes(buf.getvalue()) + pg.locator("#wb-projects").click() + pg.locator("#wb-fzip").set_input_files(str(dz)) + pg.wait_for_timeout(1500) + ready(pg) + fl = pg.evaluate("() => Object.keys(isokit.files()).sort()") + check(title(pg) == "isokit demo works" and "dist/index.html" not in fl and "lib/kiosk.rhai" in fl, + f"a deflated zip with the project in a subfolder imports: {fl}") + bad = OUT / "bad.zip" + with zipfile.ZipFile(bad, "w") as z: + z.writestr("x/readme.txt", "no project") + pg.locator("#wb-projects").click() + pg.locator("#wb-fzip").set_input_files(str(bad)) + pg.wait_for_timeout(600) + check("isokit.toml" in pg.locator("#toast").inner_text(), "a zip without a project is refused: " + pg.locator("#toast").inner_text()) + n_proj = pg.locator("#wb-plist li").count() + pg.once("dialog", lambda d: d.accept()) + pg.locator("#wb-plist li", has_text="Test site").locator('button[data-act="del"]').click() + pg.wait_for_timeout(600) + check(pg.locator("#wb-plist li").count() == n_proj - 1, "a project can be deleted") + pg.keyboard.press("Escape") + + # ---------------------------------------------------------------- folder project (OPFS stands in for a picked folder) + demo = {f.relative_to(ROOT / "examples" / "demo").as_posix(): f.read_text(encoding="utf-8") + for f in (ROOT / "examples" / "demo").rglob("*") if f.is_file() and "dist" not in f.parts} + fid = pg.evaluate("""async (files) => { + const root = await navigator.storage.getDirectory(); + await root.removeEntry("plant", {recursive: true}).catch(() => {}); + const dir = await root.getDirectoryHandle("plant", {create: true}); + for (const [path, text] of Object.entries(files)) { + const parts = path.split("/"); let d = dir; + for (const s of parts.slice(0, -1)) d = await d.getDirectoryHandle(s, {create: true}); + const w = await (await d.getFileHandle(parts.at(-1), {create: true})).createWritable(); + await w.write(text); await w.close(); + } + const id = "p-folder-test"; + await new Promise((res, rej) => { const r = indexedDB.open("isokit-studio", 1); r.onsuccess = () => { + const t = r.result.transaction("projects", "readwrite"); + t.objectStore("projects").put({id, name: "plant", kind: "folder", handle: dir, updated: Date.now()}); + t.oncomplete = res; t.onerror = rej; }; }); + return id; }""", demo) + pg.goto("about:blank") + pg.goto(BASE + "#p=" + fid) + ready(pg) + check(pg.locator("#wb-where").inner_text() == "📁 plant", "a folder project opens from its handle") + pg.locator('.st-tile[data-type="tank"]').click() + stage = pg.locator(".iso-stage").bounding_box() + pg.mouse.click(stage["x"] + stage["width"] * 0.25, stage["y"] + stage["height"] * 0.70) + pg.wait_for_timeout(800) + on_disk = pg.evaluate("""async () => { const d = await (await navigator.storage.getDirectory()).getDirectoryHandle("plant"); + return JSON.parse(await (await (await d.getFileHandle("scene.json")).getFile()).text()); }""") + check(any(o["type"] == "tank" and o["id"] != "tank" for o in on_disk["objects"]), "an edit is written to the folder's scene.json") + # an agent edits the file on disk: the Studio follows + pg.evaluate("""async () => { const d = await (await navigator.storage.getDirectory()).getDirectoryHandle("plant"); + const h = await d.getFileHandle("scene.json"); const doc = JSON.parse(await (await h.getFile()).text()); + doc.objects = doc.objects.filter(o => o.type !== "lamp"); + const w = await h.createWritable(); await w.write(JSON.stringify(doc, null, 2)); await w.close(); }""") + pg.wait_for_timeout(3500) + check(not any(o["type"] == "lamp" for o in doc(pg)["objects"]) and pg.locator("#objects li", has_text="Street lamp").count() == 0, + "a change made on disk shows up in the Studio") + check("folder" in pg.locator("#toast").inner_text().lower(), "toast: " + pg.locator("#toast").inner_text()) + pg.locator("#undo").click() + pg.wait_for_timeout(800) + check(any(o["type"] == "lamp" for o in doc(pg)["objects"]), "the outside change can be undone") + pg.locator("#build").click() + pg.wait_for_timeout(1500) + check("written to the folder" in pg.locator("#toast").inner_text(), "toast: " + pg.locator("#toast").inner_text()) + built = pg.evaluate("""async () => { const d = await (await (await navigator.storage.getDirectory()).getDirectoryHandle("plant")).getDirectoryHandle("dist"); + return (await (await d.getFileHandle("index.html")).getFile()).size; }""") + check(built > 10000 and "dist/index.html" not in pg.evaluate("() => Object.keys(isokit.files())"), + f"Build page writes dist/index.html into the folder ({built // 1024} kB) and keeps it out of the project files") + + # ---------------------------------------------------------------- dark theme, narrow screen + pg.locator("#theme").click() + pg.wait_for_timeout(300) + shot(pg, "web-dark.jpg") + pg.set_viewport_size({"width": 820, "height": 900}) + pg.wait_for_timeout(400) + shot(pg, "web-narrow.jpg") + + check(not errs, f"no page errors {errs[:5]}") + b.close() +finally: + srv.terminate() + +print("\n%d failure(s)" % len(fails)) +sys.exit(1 if fails else 0) diff --git a/web/web.css b/web/web.css new file mode 100644 index 0000000..448413e --- /dev/null +++ b/web/web.css @@ -0,0 +1,42 @@ +/* isokit web Studio: the parts the desktop Studio does not have (projects, code editor). */ +.wb-where { color: var(--mute); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; } + +.wb-projdlg { width: min(660px, 94vw); max-width: none; } +.wb-projdlg h3 { margin: 0 0 10px; } +.wb-cur { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: var(--panel2); border-radius: 6px; margin-bottom: 10px; } +.wb-cur small { color: var(--mute); } +.wb-plist { list-style: none; margin: 0 0 10px; padding: 0; max-height: 40vh; overflow: auto; border: 1px solid var(--stroke); border-radius: 6px; } +.wb-plist li { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-bottom: 1px solid var(--stroke); } +.wb-plist li:last-child { border-bottom: 0; } +.wb-plist li[aria-current=true] { background: color-mix(in srgb, var(--accent) 14%, var(--panel)); } +.wb-plist li i { font-style: normal; width: 18px; text-align: center; } +.wb-plist li span { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wb-plist li small { color: var(--mute); white-space: nowrap; } +.wb-plist li button { padding: 2px 8px; } +.wb-plist .wb-empty { color: var(--mute); } +.wb-new { display: flex; gap: 6px; margin-bottom: 8px; } +.wb-new input { flex: 1 1 auto; } +/* one compact line: drop target and pickers together */ +.wb-drop { border: 1px dashed var(--stroke); border-radius: 6px; padding: 6px 8px; color: var(--mute); display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; } +.wb-drop button { padding: 1px 8px; } +.wb-drop.wb-over { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--panel)); color: var(--fg); } +.wb-projdlg .st-help { margin: 8px 0 0; } + +.wb-codedlg { width: min(1180px, 96vw); max-width: none; height: min(820px, 90vh); padding: 0; display: none; flex-direction: column; overflow: hidden; } +.wb-codedlg[open] { display: flex; } +.wb-chead { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--stroke); } +.wb-cpath { color: var(--mute); font-family: ui-monospace, Consolas, monospace; font-size: 12px; margin-left: 6px; } +.wb-cbody { flex: 1 1 auto; display: grid; grid-template-columns: 200px minmax(0, 1fr); min-height: 0; } +.wb-cfiles { list-style: none; margin: 0; padding: 6px; overflow: auto; border-right: 1px solid var(--stroke); font-family: ui-monospace, Consolas, monospace; font-size: 12px; } +.wb-cfiles li { padding: 3px 6px; border-radius: 4px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.wb-cfiles li:hover { background: var(--panel2); } +.wb-cfiles li[aria-selected=true] { background: color-mix(in srgb, var(--accent) 20%, var(--panel)); } +.wb-cbody textarea { border: 0; border-radius: 0; resize: none; padding: 10px 12px; font: 12.5px/1.5 ui-monospace, "Cascadia Code", Consolas, monospace; tab-size: 2; white-space: pre; overflow: auto; background: var(--bg); } +.wb-cmsg { padding: 6px 10px; border-top: 1px solid var(--stroke); color: var(--mute); font: 12px/1.4 ui-monospace, Consolas, monospace; white-space: pre-wrap; max-height: 9em; overflow: auto; } +.wb-cmsg.wb-bad { color: var(--danger); } + +.wb-gate[hidden] { display: none; } +.wb-gate { position: fixed; inset: 0; z-index: 40; background: color-mix(in srgb, var(--bg) 82%, transparent); display: flex; align-items: center; justify-content: center; } +.wb-gatebox { background: var(--panel); border: 1px solid var(--stroke); border-radius: 8px; padding: 16px 18px; max-width: min(620px, 92vw); } +.wb-gatebox h3 { margin: 0 0 8px; } +.wb-gatebox pre { white-space: pre-wrap; color: var(--danger); font-size: 12px; max-height: 40vh; overflow: auto; } diff --git a/web/web.js b/web/web.js new file mode 100644 index 0000000..ca742d5 --- /dev/null +++ b/web/web.js @@ -0,0 +1,606 @@ +/* isokit web Studio: the Studio GUI with the engine running in the page (WASM). + * + * Projects live in this browser (IndexedDB), or in a folder on disk the user + * opened (File System Access API, Chrome / Edge): edits are written straight + * to its files and changes made there by agents or editors show up live. + * This module answers the Studio's /api requests (window.IsoKitBackend), adds + * the project and code dialogs, and then starts the unchanged studio.js. + */ +import init, { Studio, new_project_files, engine_version } from "./isokit_wasm.js"; + +var Q = import.meta.url.split("?")[1] || ""; +function asset(rel) { return new URL(rel + (Q ? "?" + Q : ""), import.meta.url); } +var $ = function (id) { return document.getElementById(id); }; +function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } +function slug(s) { return String(s || "scene").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "scene"; } +function newId() { return "p-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } + +var toastT = 0; +function toast(msg, bad) { + if (window.__studio && window.__studio.toast) { window.__studio.toast(msg, bad); return; } + var t = $("toast"); + t.textContent = msg; t.hidden = false; t.classList.toggle("st-bad", !!bad); + clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, bad ? 6000 : 2600); +} + +// ------------------------------------------------------------ project files +var SKIP_DIRS = { dist: 1, target: 1, node_modules: 1 }; +function skipDir(name) { return name.charAt(0) === "." || SKIP_DIRS[name]; } +function projectFile(name) { return /\.(rhai|toml|json)$/.test(name); } +function keepFile(path) { return projectFile(path) || /(^|\/)README\.md$/.test(path); } +/** A file the project keeps (not an export under dist/, not in a dot folder). */ +function inProject(path) { return keepFile(path) && !path.split("/").slice(0, -1).some(skipDir); } + +/** Files of an import (path -> text), re-rooted at the folder that holds isokit.toml. */ +function reroot(all) { + var roots = Object.keys(all).filter(function (p) { return /(^|\/)isokit\.toml$/.test(p); }) + .sort(function (a, b) { return a.split("/").length - b.split("/").length; }); + if (!roots.length) throw new Error("no isokit.toml found: an isokit project has one at its top"); + var pre = roots[0].slice(0, roots[0].length - "isokit.toml".length), out = {}; + Object.keys(all).forEach(function (p) { + if (p.indexOf(pre) !== 0) return; + var rel = p.slice(pre.length), parts = rel.split("/"); + if (parts.slice(0, -1).some(skipDir) || !keepFile(rel)) return; + out[rel] = all[p]; + }); + return out; +} + +// ------------------------------------------------------------ zip (store to write, store + deflate to read) +var CRC = (function () { var t = new Uint32Array(256); for (var n = 0; n < 256; n++) { var c = n; for (var k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })(); +function crc32(b) { var c = 0xFFFFFFFF; for (var i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 255] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; } +function zip(files, root) { + var enc = new TextEncoder(), parts = [], cd = [], off = 0, n = 0, d = new Date(); + var time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1); + var date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(); + Object.keys(files).sort().forEach(function (p) { + var name = enc.encode(root + p), data = enc.encode(files[p]), crc = crc32(data); + var h = new DataView(new ArrayBuffer(30)); + h.setUint32(0, 0x04034b50, true); h.setUint16(4, 20, true); h.setUint16(6, 0x0800, true); + h.setUint16(10, time, true); h.setUint16(12, date, true); h.setUint32(14, crc, true); + h.setUint32(18, data.length, true); h.setUint32(22, data.length, true); h.setUint16(26, name.length, true); + parts.push(h.buffer, name, data); + var c = new DataView(new ArrayBuffer(46)); + c.setUint32(0, 0x02014b50, true); c.setUint16(4, 20, true); c.setUint16(6, 20, true); c.setUint16(8, 0x0800, true); + c.setUint16(12, time, true); c.setUint16(14, date, true); c.setUint32(16, crc, true); + c.setUint32(20, data.length, true); c.setUint32(24, data.length, true); c.setUint16(28, name.length, true); + c.setUint32(42, off, true); + cd.push(c.buffer, name); + off += 30 + name.length + data.length; n++; + }); + var size = cd.reduce(function (s, x) { return s + x.byteLength; }, 0); + var e = new DataView(new ArrayBuffer(22)); + e.setUint32(0, 0x06054b50, true); e.setUint16(8, n, true); e.setUint16(10, n, true); e.setUint32(12, size, true); e.setUint32(16, off, true); + return new Blob(parts.concat(cd, [e.buffer]), { type: "application/zip" }); +} +async function unzip(buf) { + var v = new DataView(buf), u8 = new Uint8Array(buf), dec = new TextDecoder(), out = {}; + var i = buf.byteLength - 22; + while (i >= 0 && v.getUint32(i, true) !== 0x06054b50) i--; + if (i < 0) throw new Error("not a zip file"); + var n = v.getUint16(i + 10, true), p = v.getUint32(i + 16, true); + for (var k = 0; k < n; k++) { + if (v.getUint32(p, true) !== 0x02014b50) throw new Error("damaged zip file"); + var method = v.getUint16(p + 10, true), csize = v.getUint32(p + 20, true), nlen = v.getUint16(p + 28, true); + var skip = nlen + v.getUint16(p + 30, true) + v.getUint16(p + 32, true), loc = v.getUint32(p + 42, true); + var name = dec.decode(u8.subarray(p + 46, p + 46 + nlen)).replace(/\\/g, "/"); + p += 46 + skip; + if (/\/$/.test(name) || !keepFile(name) || csize > 8 << 20) continue; + var start = loc + 30 + v.getUint16(loc + 26, true) + v.getUint16(loc + 28, true), data = u8.subarray(start, start + csize); + if (method === 8) data = new Uint8Array(await new Response(new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw"))).arrayBuffer()); + else if (method !== 0) continue; + out[name] = dec.decode(data); + } + return out; +} +function download(name, blob) { + var a = document.createElement("a"); + a.href = URL.createObjectURL(blob); a.download = name; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(function () { URL.revokeObjectURL(a.href); }, 30000); +} + +// ------------------------------------------------------------ folders (File System Access) and drops +var canOpenFolder = typeof window.showDirectoryPicker === "function"; +async function readFolder(dir, pre, out, stats) { + out = out || {}; stats = stats || {}; pre = pre || ""; + for await (var entry of dir.values()) { + if (entry.kind === "directory") { if (!skipDir(entry.name)) await readFolder(entry, pre + entry.name + "/", out, stats); } + else if (keepFile(pre + entry.name)) { + var f = await entry.getFile(); + stats[pre + entry.name] = f.size + ":" + f.lastModified; + if (!(pre + entry.name in out)) out[pre + entry.name] = await f.text(); + } + } + return { files: out, stats: stats }; +} +async function folderStats(dir, pre, stats, handles) { + for await (var entry of dir.values()) { + if (entry.kind === "directory") { if (!skipDir(entry.name)) await folderStats(entry, pre + entry.name + "/", stats, handles); } + else if (keepFile(pre + entry.name)) { var f = await entry.getFile(); stats[pre + entry.name] = f.size + ":" + f.lastModified; handles[pre + entry.name] = f; } + } +} +async function fileHandle(dir, path, create) { + var parts = path.split("/"), d = dir; + for (var i = 0; i < parts.length - 1; i++) d = await d.getDirectoryHandle(parts[i], { create: create }); + return d.getFileHandle(parts[parts.length - 1], { create: create }); +} +async function permitted(handle, ask) { + var o = { mode: "readwrite" }; + if ((await handle.queryPermission(o)) === "granted") return true; + return ask ? (await handle.requestPermission(o)) === "granted" : false; +} +function readEntry(entry, path, out) { + if (entry.isFile) { + if (!keepFile(path)) return Promise.resolve(); + return new Promise(function (res, rej) { entry.file(function (f) { f.text().then(function (t) { out[path] = t; res(); }, rej); }, rej); }); + } + if (!entry.isDirectory || skipDir(entry.name)) return Promise.resolve(); + var reader = entry.createReader(), jobs = []; + return new Promise(function (res, rej) { + (function more() { + reader.readEntries(function (list) { + if (!list.length) { Promise.all(jobs).then(res, rej); return; } + list.forEach(function (e) { jobs.push(readEntry(e, path + "/" + e.name, out)); }); + more(); + }, rej); + })(); + }); +} + +// ------------------------------------------------------------ storage (IndexedDB) +var DB = null; +function db() { + if (!DB) DB = new Promise(function (res, rej) { + var r = indexedDB.open("isokit-studio", 1); + r.onupgradeneeded = function () { r.result.createObjectStore("projects", { keyPath: "id" }); }; + r.onsuccess = function () { res(r.result); }; + r.onerror = function () { rej(r.error); }; + }); + return DB; +} +function tx(mode, fn) { + return db().then(function (d) { + return new Promise(function (res, rej) { + var t = d.transaction("projects", mode), r = fn(t.objectStore("projects")), out; + if (r) r.onsuccess = function () { out = r.result; }; + t.oncomplete = function () { res(out); }; + t.onerror = t.onabort = function () { rej(t.error); }; + }); + }); +} +var store = { + all: function () { return tx("readonly", function (s) { return s.getAll(); }); }, + get: function (id) { return tx("readonly", function (s) { return s.get(id); }); }, + put: function (rec) { return tx("readwrite", function (s) { return s.put(rec); }); }, + del: function (id) { return tx("readwrite", function (s) { return s.delete(id); }); } +}; +function titleOf(files) { var m = /^\s*title\s*=\s*"([^"]*)"/m.exec(files["isokit.toml"] || ""); return (m && m[1]) || "Untitled"; } +async function createProject(files, name) { + var rec = { id: newId(), name: name || titleOf(files), kind: "local", files: files, updated: Date.now() }; + await store.put(rec); + return rec; +} +function openProject(id) { history.replaceState(null, "", "#p=" + encodeURIComponent(id)); location.reload(); } + +// ------------------------------------------------------------ the open project +var P = { rec: null, files: {}, stats: {}, why: null, queue: Promise.resolve() }; +/** Run storage jobs one after the other; a failed job does not block the next. */ +function enqueue(job) { + var run = P.queue.then(job); + P.queue = run.catch(function (e) { console.error("isokit: storage", e); }); + return run; +} +var studio = null, chan = typeof BroadcastChannel === "function" ? new BroadcastChannel("isokit-studio") : null; +function isFolder() { return P.rec && P.rec.kind === "folder"; } + +/** Persist files written by the engine or the code editor (in order; null deletes). */ +function persist(changes) { + var paths = Object.keys(changes); + if (!paths.length) return Promise.resolve(); + return enqueue(async function () { + if (isFolder()) { + for (var p of paths) { + if (changes[p] === null) { + var parts = p.split("/"), d = P.rec.handle; + for (var i = 0; i < parts.length - 1; i++) d = await d.getDirectoryHandle(parts[i]); + await d.removeEntry(parts[parts.length - 1]).catch(function () { /* already gone */ }); + delete P.files[p]; delete P.stats[p]; + } else { + var h = await fileHandle(P.rec.handle, p, true), w = await h.createWritable(); + await w.write(changes[p]); await w.close(); + var f = await h.getFile(); + if (inProject(p)) { P.files[p] = changes[p]; P.stats[p] = f.size + ":" + f.lastModified; } + } + } + } else { + var kept = {}; + paths.forEach(function (p) { + if (!inProject(p)) return; // exports (dist/index.html) stay out of the browser store + if (changes[p] === null) delete P.files[p]; else P.files[p] = changes[p]; + kept[p] = changes[p]; + }); + if (!Object.keys(kept).length) return; + P.rec.files = P.files; P.rec.updated = Date.now(); + await store.put(P.rec); + if (chan) chan.postMessage({ id: P.rec.id, files: kept }); + } + }); +} +function flush() { return persist(JSON.parse(studio.take_written())); } + +/** An edit from outside the Studio (code editor, another tab, the folder). */ +function outside(changes, why) { + Object.keys(changes).forEach(function (p) { + if (changes[p] === null) delete P.files[p]; else P.files[p] = changes[p]; + if (studio) studio.set_file(p, changes[p]); + }); + P.why = why; +} +if (chan) chan.onmessage = function (e) { + if (P.rec && !isFolder() && e.data && e.data.id === P.rec.id) outside(e.data.files, "Changed in another tab"); +}; + +// a folder project follows changes made on disk (agents, editors, git) +function watchFolder() { + setInterval(function () { + if (document.hidden) return; + enqueue(async function () { + var stats = {}, got = {}, changes = {}, n = 0; + try { await folderStats(P.rec.handle, "", stats, got); } catch (e) { return; } + for (var p in stats) { + if (stats[p] === P.stats[p]) continue; + var t = await got[p].text(); + P.stats[p] = stats[p]; + if (t !== P.files[p]) { changes[p] = t; n++; } + } + Object.keys(P.files).forEach(function (p) { if (!(p in stats) && projectFile(p)) { changes[p] = null; delete P.stats[p]; n++; } }); + if (n) outside(changes, "Files changed in the folder (agent or editor)"); + }); + }, 1500); +} + +// ------------------------------------------------------------ the backend studio.js talks to +var themeCss = null, lastVersion = null; +function J(s) { return JSON.parse(s); } +function backend() { + return { + web: true, + saveTitle: isFolder() ? "Every change is written to the folder at once" : "Every change is saved in this browser at once", + api: function (path, body) { + try { return route(path, body); } catch (e) { crashed(e); throw e; } + }, + source: function (meta) { + return { + meta: meta, dev: true, + render: function (level, rot, cfg) { + try { return J(studio.render(level, rot, cfg ? JSON.stringify(cfg) : "", true)); } catch (e) { crashed(e); throw e; } + } + }; + }, + preview: function () { + var r = J(studio.page()); + if (r.error) { toast(r.error.message || String(r.error), true); return; } + window.open(URL.createObjectURL(new Blob([r.html], { type: "text/html" })), "_blank", "noopener"); + }, + agentBox: function () { + var dir = isFolder() ? "" : ""; + return '

AI agents (MCP)

' + + '

The 15 isokit MCP tools run inside this page: browsers with WebMCP offer them to their assistant, and scripts call isokit.mcp(message).

' + + (isFolder() ? "Desktop agents work on the same folder; their edits show up here live:" : "For desktop agents, open a folder (Projects → Open folder) or export the project, then:") + + "isokit mcp " + esc(dir) + "
"; + } + }; +} +function route(path, body) { + var get = body === undefined; + if (get && path === "/api/meta") { + var m = J(studio.meta()), css = studio.theme_css(); + if (css !== themeCss) { themeCss = css; $("iso-theme").textContent = css; } + if (!isFolder() && m.title && m.title !== P.rec.name) { P.rec.name = m.title; store.put(P.rec); } + $("wb-where").textContent = isFolder() ? "📁 " + P.rec.name : "in this browser"; + return m; + } + if (get && path === "/api/doc") return J(studio.state()); + if (get && path === "/api/version") { + var v = studio.version(), r = { version: v }; + if (v !== lastVersion) { lastVersion = v; if (P.why) { r.why = P.why; P.why = null; } } + return r; + } + if (get && path === "/api/check") return J(studio.check()); + var res; + if (path === "/api/op") res = J(studio.op(JSON.stringify(body))); + else if (path === "/api/undo") res = J(studio.undo()); + else if (path === "/api/redo") res = J(studio.redo()); + else if (path === "/api/build") return buildPage(); + else return { error: "unknown request " + path }; + lastVersion = studio.version(); + return flush().then(function () { return res; }); +} +function buildPage() { + if (isFolder()) { // like `isokit build`: the page goes where isokit.toml says (dist/index.html) + var out = J(studio.mcp(JSON.stringify({ jsonrpc: "2.0", id: 0, method: "tools/call", params: { name: "build_html", arguments: {} } }))); + var res = out.result || {}; + if (res.isError) return { error: res.content[0].text }; + return flush().then(function () { return { message: "Page written to the folder: " + J(res.content[0].text).path.replace(/\\/g, "/") }; }); + } + var r = J(studio.page()); + if (r.error) return { error: r.error.message || String(r.error) }; + var name = slug(P.rec.name) + ".html"; + download(name, new Blob([r.html], { type: "text/html" })); + return { message: "Page downloaded: " + name }; +} + +// ------------------------------------------------------------ MCP inside the page (and WebMCP) +var rpcId = 0; +function mcpCall(msg) { + var out = studio.mcp(typeof msg === "string" ? msg : JSON.stringify(msg)); + P.why = "Edited by an agent (MCP)"; + return flush().then(function () { return out ? J(out) : null; }); +} +function callTool(name, args) { + return mcpCall({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name: name, arguments: args || {} } }).then(function (r) { return r.result; }); +} +function registerWebMcp() { + var mc = navigator.modelContext; + if (!mc) return 0; + var tools = J(studio.mcp(JSON.stringify({ jsonrpc: "2.0", id: 0, method: "tools/list" }))).result.tools.map(function (t) { + return { name: t.name, description: "isokit: " + t.description, inputSchema: t.inputSchema, execute: function (args) { return callTool(t.name, args); } }; + }); + try { + if (typeof mc.provideContext === "function") mc.provideContext({ tools: tools }); + else if (typeof mc.registerTool === "function") tools.forEach(function (t) { mc.registerTool(t); }); + else return 0; + } catch (e) { console.warn("isokit: WebMCP registration failed", e); return 0; } + return tools.length; +} + +// ------------------------------------------------------------ UI: header, projects, code +function injectUI() { + var title = $("title"); + title.insertAdjacentHTML("afterend", + '' + + '' + + ''); + document.body.insertAdjacentHTML("beforeend", + '' + + '

Projects

' + + '
' + + '
    ' + + '
    ' + + '
    Import: drop a project folder or .zip here, or ' + + (canOpenFolder ? '' : "") + "
    " + + '

    Projects are kept in this browser. ' + (canOpenFolder ? "A folder opened live is edited on disk, so agents (isokit mcp), editors and git see the same files." : "To edit a folder on disk directly, use Chrome or Edge.") + "

    " + + '' + + '
    ' + + '' + + '
    Code' + + '' + + '
    ' + + '
      ' + + '
      ' + + ''); + $("wb-projects").addEventListener("click", showProjects); + $("wb-code").addEventListener("click", function () { showCode(); }); + wireProjects(); wireCode(); +} + +async function showProjects() { + var list = (await store.all()).sort(function (a, b) { return b.updated - a.updated; }); + var cur = P.rec; + $("wb-cur").innerHTML = cur ? "" + esc(cur.name) + " " + (cur.kind === "folder" ? "folder on disk" : "in this browser") + "" + + '' : "No project open"; + $("wb-plist").innerHTML = list.map(function (r) { + var when = new Date(r.updated).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }); + return '
    • " + (r.kind === "folder" ? "📁" : "▣") + "" + esc(r.name) + "" + esc(when) + "" + + '
    • "; + }).join("") || '
    • No projects yet.
    • '; + if (!$("wb-projdlg").open) $("wb-projdlg").showModal(); +} +function wireProjects() { + $("wb-projdlg").addEventListener("click", async function (e) { + var b = e.target.closest("button[data-act]"); if (!b) return; + var a = b.getAttribute("data-act"), li = b.closest("li"), id = li && li.getAttribute("data-id"); + if (a === "export") exportZip(); + else if (a === "open") openProject(id); + else if (a === "del") { + var r = await store.get(id); + if (!r || !confirm((r.kind === "folder" ? "Forget the folder \u201c" : "Delete \u201c") + r.name + "\u201d" + (r.kind === "folder" ? "? Its files stay on disk." : " from this browser? Export it first to keep a copy."))) return; + await store.del(id); + if (P.rec && id === P.rec.id) { localStorage.removeItem("isokit-web-last"); location.hash = ""; location.reload(); return; } + showProjects(); + } + }); + $("wb-ncreate").addEventListener("click", async function () { + var t = $("wb-ntitle").value.trim() || "New site"; + openProject((await createProject(J(new_project_files(t)), t)).id); + }); + $("wb-ntitle").addEventListener("keydown", function (e) { if (e.key === "Enter") $("wb-ncreate").click(); }); + $("wb-ndemo").addEventListener("click", async function () { openProject((await createProject(await demoFiles())).id); }); + $("wb-izip").addEventListener("click", function () { $("wb-fzip").click(); }); + $("wb-idir").addEventListener("click", function () { $("wb-fdir").click(); }); + $("wb-fzip").addEventListener("change", function () { var f = this.files[0]; if (f) importZip(f); this.value = ""; }); + $("wb-fdir").addEventListener("change", async function () { + var all = {}, list = Array.prototype.slice.call(this.files); + for (var f of list) { var p = f.webkitRelativePath || f.name; if (inProject(p)) all[p] = await f.text(); } + this.value = ""; + importFiles(all); + }); + if (canOpenFolder) $("wb-ofolder").addEventListener("click", openFolder); + var drop = $("wb-drop"); + ["dragenter", "dragover"].forEach(function (k) { drop.addEventListener(k, function (e) { e.preventDefault(); drop.classList.add("wb-over"); }); }); + drop.addEventListener("dragleave", function () { drop.classList.remove("wb-over"); }); + drop.addEventListener("drop", async function (e) { + e.preventDefault(); drop.classList.remove("wb-over"); + var items = Array.prototype.slice.call(e.dataTransfer.items || []), files = Array.prototype.slice.call(e.dataTransfer.files || []); + var zipFile = files.filter(function (f) { return /\.zip$/i.test(f.name); })[0]; + if (zipFile) { importZip(zipFile); return; } + var all = {}, jobs = []; + items.forEach(function (it) { var en = it.webkitGetAsEntry && it.webkitGetAsEntry(); if (en) jobs.push(readEntry(en, en.name, all)); }); + try { await Promise.all(jobs); importFiles(all); } catch (err) { toast("Could not read the drop: " + err, true); } + }); +} +async function importFiles(all) { + try { + var files = reroot(all); + new Studio("check", JSON.stringify(files)).free(); + openProject((await createProject(files)).id); + } catch (e) { toast("Not imported: " + (e.message || e), true); } +} +async function importZip(f) { + try { importFiles(await unzip(await f.arrayBuffer())); } catch (e) { toast("Not imported: " + (e.message || e), true); } +} +async function openFolder() { + var dir; + try { dir = await window.showDirectoryPicker({ id: "isokit", mode: "readwrite" }); } catch (e) { return; } + var got = await readFolder(dir); + if (!got.files["isokit.toml"]) { + if (!confirm("\u201c" + dir.name + "\u201d has no isokit.toml. Start a new project in it?")) return; + var fresh = J(new_project_files(dir.name)); + for (var p in fresh) { var h = await fileHandle(dir, p, true), w = await h.createWritable(); await w.write(fresh[p]); await w.close(); } + } + var rec = { id: newId(), name: dir.name, kind: "folder", handle: dir, updated: Date.now() }; + await store.put(rec); + openProject(rec.id); +} +function exportZip() { + var name = slug(P.rec.name); + download(name + ".zip", zip(P.files, name + "/")); + toast("Exported " + name + ".zip"); +} +async function demoFiles() { return (await fetch(asset("demo.json"))).json(); } + +// the code editor: the project's files, applied as one outside edit +var C = { path: null, dirty: false }; +function codeFiles() { return Object.keys(P.files).sort(function (a, b) { return (a.indexOf("/") >= 0) - (b.indexOf("/") >= 0) || a.localeCompare(b); }); } +function showCode(path) { + var files = codeFiles(); + path = path || C.path || (files.indexOf("scene.rhai") >= 0 ? "scene.rhai" : "isokit.toml"); + if (!(path in P.files)) path = files[0]; + C.path = path; C.dirty = false; + $("wb-cfiles").innerHTML = files.map(function (p) { return '
    • ' + esc(p) + "
    • "; }).join(""); + $("wb-cpath").textContent = path || ""; + $("wb-ctext").value = path ? P.files[path] : ""; + $("wb-capply").disabled = true; + codeMsg(); + if (!$("wb-codedlg").open) $("wb-codedlg").showModal(); + $("wb-ctext").focus(); +} +/** Errors of the loaded project (red), else `note`. */ +function codeMsg(note) { + var m = $("wb-cmsg"), errs = []; + if (studio) { + var meta = J(studio.meta()); + if (meta.load_error) errs.push(meta.load_error); + var e = meta.script_error; + if (e) errs.push((e.file || "script") + (e.line ? ":" + e.line : "") + (e.col ? ":" + e.col : "") + ": " + e.message); + } + m.textContent = errs.length ? errs.join("\n") : note || "Scripts run when you apply (Ctrl+S). Changes can be undone in the Studio."; + m.classList.toggle("wb-bad", errs.length > 0); +} +function leaveCode() { return !C.dirty || confirm("Discard the changes to " + C.path + "?"); } +async function applyCode() { + if (!C.path) return; + var ch = {}; ch[C.path] = $("wb-ctext").value; + outside(ch, "Code applied"); + await persist(ch); + C.dirty = false; $("wb-capply").disabled = true; + if (!studio) { tryStart(); return; } + codeMsg("Applied: " + C.path); +} +function wireCode() { + var ta = $("wb-ctext"); + ta.addEventListener("input", function () { C.dirty = true; $("wb-capply").disabled = false; }); + ta.addEventListener("keydown", function (e) { + if ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S" || e.key === "Enter")) { e.preventDefault(); applyCode(); return; } + if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); ta.setRangeText(" ", ta.selectionStart, ta.selectionEnd, "end"); C.dirty = true; $("wb-capply").disabled = false; } + e.stopPropagation(); // the Studio's keys (Delete, arrows, R) must not fire while typing + }); + $("wb-cfiles").addEventListener("click", function (e) { var li = e.target.closest("li"); if (li && leaveCode()) showCode(li.getAttribute("data-path")); }); + $("wb-capply").addEventListener("click", applyCode); + $("wb-cclose").addEventListener("click", function () { if (leaveCode()) $("wb-codedlg").close(); }); + $("wb-codedlg").addEventListener("cancel", function (e) { if (!leaveCode()) e.preventDefault(); }); + $("wb-cnew").addEventListener("click", async function () { + if (!leaveCode()) return; + var p = (prompt("New file (relative path), e.g. lib/kiosk.rhai", "lib/new.rhai") || "").trim().replace(/\\/g, "/"); + if (!p) return; + if (!projectFile(p) || /(^|\/)\.\.(\/|$)/.test(p) || p.charAt(0) === "/") { toast("Use a relative path ending in .rhai, .toml or .json", true); return; } + if (p in P.files) { showCode(p); return; } + var body = /\.rhai$/.test(p) ? "// " + p + ": list it in isokit.toml under prelude to load its prefabs\n" : ""; + var ch = {}; ch[p] = body; outside(ch, "File added: " + p); await persist(ch); + showCode(p); + }); + $("wb-cdel").addEventListener("click", async function () { + if (!C.path || C.path === "isokit.toml") { toast("isokit.toml is the project itself and stays", true); return; } + if (!confirm("Delete " + C.path + "?")) return; + var ch = {}; ch[C.path] = null; outside(ch, "File deleted: " + C.path); await persist(ch); + C.path = null; showCode(); + }); +} + +// ------------------------------------------------------------ start +function gate(html) { var g = $("wb-gate"); g.innerHTML = '
      ' + html + "
      "; g.hidden = false; return g; } +function tryStart() { + try { studio = new Studio(isFolder() ? P.rec.name : "browser: " + P.rec.name, JSON.stringify(P.files)); } + catch (e) { + var g = gate("

      " + esc(P.rec.name) + " does not load

      " + esc(String(e)) + "
      " + + '

      Fix the file in the code editor, or open another project.

      ' + + ' '); + g.querySelector("#wb-gcode").onclick = function () { showCode("isokit.toml"); }; + g.querySelector("#wb-gproj").onclick = showProjects; + return; + } + $("wb-gate").hidden = true; + window.IsoKitBackend = backend(); + window.isokit = { + engine: engine_version(), project: P.rec.name, studio: studio, + files: function () { return Object.assign({}, P.files); }, + mcp: mcpCall, call: callTool, webmcp: registerWebMcp() + }; + var s = document.createElement("script"); + s.src = asset("../studio/studio.js").href; + document.body.appendChild(s); +} +async function boot() { + injectUI(); + await init({ module_or_path: asset("isokit_wasm_bg.wasm") }); + var m = /[#&]p=([^&]+)/.exec(location.hash), id = m ? decodeURIComponent(m[1]) : localStorage.getItem("isokit-web-last"); + var rec = id ? await store.get(id) : null; + if (!rec) { + var all = await store.all(); + rec = all.sort(function (a, b) { return b.updated - a.updated; })[0] || await createProject(await demoFiles()); + } + P.rec = rec; + history.replaceState(null, "", "#p=" + encodeURIComponent(rec.id)); + localStorage.setItem("isokit-web-last", rec.id); + window.addEventListener("hashchange", function () { location.reload(); }); + if (isFolder()) { + if (!(await permitted(rec.handle, false))) { + var g = gate("

      Open the folder \u201c" + esc(rec.name) + "\u201d

      The browser asks once per visit before the Studio may edit files on disk.

      " + + ' '); + g.querySelector("#wb-gproj").onclick = showProjects; + await new Promise(function (res) { + g.querySelector("#wb-gallow").onclick = async function () { if (await permitted(rec.handle, true)) res(); }; + }); + } + var got = await readFolder(rec.handle); + P.files = got.files; P.stats = got.stats; + watchFolder(); + } else { + P.files = rec.files || {}; + } + tryStart(); +} +// an engine panic leaves the WASM instance unusable: say so instead of failing silently +function crashed(e) { + if (!(e instanceof WebAssembly.RuntimeError) && !/unsafe aliasing|unreachable/.test(String(e && e.message))) return; + var g = gate("

      The engine stopped

      " + esc(e.message || e) + "

      Your last saved state is kept. Reload to continue; the browser console has the details.

      " + + ''); + g.querySelector("#wb-greload").onclick = function () { location.reload(); }; +} +window.addEventListener("error", function (e) { crashed(e.error); }); +window.addEventListener("unhandledrejection", function (e) { crashed(e.reason); }); +boot().catch(function (e) { + console.error(e); + gate("

      The web Studio could not start

      " + esc(e && e.stack || e) + "

      It needs a current browser (WebAssembly, ES modules, IndexedDB).

      "); +});