diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs index 76b79912..3f3f3a99 100644 --- a/apps/effectcraft-web/src/files.rs +++ b/apps/effectcraft-web/src/files.rs @@ -144,6 +144,10 @@ impl Services for WebServices { let dir = dir.trim_end_matches('/'); list().into_iter().map(|(p, _)| p).filter(|p| p.rsplit_once('/').is_some_and(|(d, _)| d == dir)).collect() } + /// The table's files (the default asks `std::fs`, which has no files on wasm32). + fn exists(&self, path: &str) -> bool { + get(path).is_some() + } fn is_dir(&self, _path: &str) -> bool { false } diff --git a/apps/effectcraft-web/tests/smoke.mjs b/apps/effectcraft-web/tests/smoke.mjs index ee272a67..3a6c1337 100644 --- a/apps/effectcraft-web/tests/smoke.mjs +++ b/apps/effectcraft-web/tests/smoke.mjs @@ -441,6 +441,18 @@ try { const comps = await js(`effectcraft.inspect().then(i => i.activeComp)`); if (!comps) throw new Error("reopened project has no active comp"); report.steps.reopen = { ...opened, activeComp: comps }; + + // Files the page holds (browser storage, no std::fs): a script opens one next to the project, + // and the footage check finds the imported media instead of marking it missing. + const hf = await js(`(async () => { + const p = await effectcraft.addFile(new File(["from the page"], "script-data.txt")); + await effectcraft.saveToBrowser("/host-files.ecproj"); + const run = await effectcraft.execute("script.run", {code: "var f = new File(" + JSON.stringify(p) + "); writeLn(f.open('r') ? f.read() : f.error);"}); + return {output: run.output, footage: await effectcraft.execute("footage.check", {wait: true})}; + })()`); + report.steps.hostFiles = hf; + check(hf.output === "from the page", `a script opens a file the page holds (${hf.output})`); + check(hf.footage.checked > 0 && hf.footage.missing === 0, `stored footage isn't missing (${JSON.stringify(hf.footage)})`); report.steps.final = { screenshot: await shot("02-after") }; // Persistence: the session, imported media, settings and a project saved to browser storage diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index 067b85fd..f2125de9 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -107,4 +107,6 @@ mod tests_ui_more; #[cfg(test)] mod tests_evalfile; #[cfg(test)] +mod tests_services; +#[cfg(test)] mod tests_ui_mouse; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index b94bd80f..21d4f706 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -380,11 +380,14 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Ok(json!(check_access(s, &path, false)?.exists())), + // Through the session's services, like the reads: hosts without a file system (the + // browser's file table) answer for their own files. + "access" => Ok(json!(s.services.exists(&check_access(s, &path, false)?.to_string_lossy()))), "exists" | "isDir" => { // Unreadable locations report "missing" instead of failing. let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) }; - Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() })) + let p = p.to_string_lossy(); + Ok(json!(if op == "exists" { s.services.exists(&p) } else { s.services.is_dir(&p) })) } "read" | "readBinary" => { let p = check_access(s, &path, false)?; diff --git a/crates/script/src/tests_services.rs b/crates/script/src/tests_services.rs new file mode 100644 index 00000000..c0ecfab4 --- /dev/null +++ b/crates/script/src/tests_services.rs @@ -0,0 +1,47 @@ +//! Scripts see the host's files: existence checks go through `Services`, like reads, so a host +//! that keeps files in memory (the web editor's browser storage) answers for its own files. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use effectcraft_engine::{Services, Session}; + +use crate::run_code; + +#[derive(Default)] +struct Mem(Mutex>>); + +impl Services for Mem { + fn read_file(&self, path: &str) -> std::io::Result> { + self.0.lock().unwrap().get(path).cloned().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string())) + } + fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + self.0.lock().unwrap().insert(path.into(), data.into()); + Ok(()) + } + fn exists(&self, path: &str) -> bool { + self.0.lock().unwrap().contains_key(path) + } + fn is_dir(&self, path: &str) -> bool { + path == "/browser/footage" + } +} + +#[test] +fn a_script_opens_a_file_only_the_host_holds() { + let mem = Arc::new(Mem::default()); + mem.write_file("/browser/data.txt", b"hello").unwrap(); + let mut s = Session { services: mem, ..Default::default() }; + crate::install(&mut s); + // A project in a folder that isn't on disk: scripts may read files next to it. + s.path = Some("/browser/project.ecproj".into()); + let out = run_code( + &mut s, + r#"var f = new File("/browser/data.txt"); var ok = f.open("r"); + writeLn(ok + " " + (ok ? f.read() : f.error)); + writeLn(new File("/browser/missing.txt").exists + " " + new Folder("/browser/footage").exists);"#, + "t.jsx", + ); + assert!(out.error.is_none(), "{:?}", out.error); + assert_eq!(out.output, vec!["true hello".to_string(), "false true".to_string()]); +} diff --git a/docs/web.md b/docs/web.md index e73f7a89..23ee1da1 100644 --- a/docs/web.md +++ b/docs/web.md @@ -413,7 +413,8 @@ no npm packages): load, the GPU path (WebGPU → GPU compositor, viewer on the G in the viewer, `render.frame`, Render Queue GIF and PNG sequence (`.zip`) downloads, a background render in a worker while measuring the page's event-loop gaps (must stay under 400 ms) and its progress, the AudioContext starting on a (simulated) key press and a tone playing through it -with the meters moving, Save As and re-opening the saved project, persistence across a reload +with the meters moving, Save As and re-opening the saved project, a script opening a file from +browser storage and the footage check finding stored media, persistence across a reload (session snapshot, a stored project, imported media, a setting, Open Recent) and an offline reload through the service worker. It writes screenshots and `report.json`: