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).

      "); +});