diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 702c5d33..2482521c 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -615,21 +615,26 @@ export async function browseRead(path) { } /// The fallback where folders can't be opened: pick files (or a whole folder, `directory`) with a -/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder). +/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder). A file +/// that can't be read is listed as {path, error} instead (#226); cancelling resolves with []. export function browsePickFiles(directory) { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { const input = document.createElement("input"); input.type = "file"; input.multiple = true; if (directory) input.webkitdirectory = true; + input.oncancel = () => resolve([]); input.onchange = async () => { - try { - const out = []; - for (const f of input.files) { - out.push({ path: (directory && f.webkitRelativePath) || f.name, bytes: new Uint8Array(await f.arrayBuffer()) }); + const out = []; + for (const f of input.files) { + const path = (directory && f.webkitRelativePath) || f.name; + try { + out.push({ path, bytes: new Uint8Array(await f.arrayBuffer()) }); + } catch (e) { + out.push({ path, error: String((e && (e.message || e.name)) || e) }); } - resolve(out); - } catch (e) { reject(e); } + } + resolve(out); }; input.click(); }); diff --git a/apps/effectcraft-web/src/browse.rs b/apps/effectcraft-web/src/browse.rs index aba2eb22..ad7500fe 100644 --- a/apps/effectcraft-web/src/browse.rs +++ b/apps/effectcraft-web/src/browse.rs @@ -105,7 +105,7 @@ fn spawn(p: js_sys::Promise, what: &'static str, then: impl FnOnce(&mut effectcr crate::post(move |app| then(app, v)); } Err(e) => { - let msg = e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_default(); + let msg = crate::persist::js_err(e); // Cancelling a picker is not an error. if !msg.contains("abort") && !msg.contains("AbortError") { crate::post(move |app| app.ui.status = format!("{what}: {msg}")); @@ -234,20 +234,42 @@ impl Browser for WebBrowser { Ok(json!({"pending": true})) } "addFiles" | "uploadFolder" => { + let what = if id == "uploadFolder" { "Upload Folder…" } else { "Add Files…" }; let p = pick_files(id == "uploadFolder"); wasm_bindgen_futures::spawn_local(async move { - let Ok(list) = wasm_bindgen_futures::JsFuture::from(p).await else { return }; - let mut n = 0; - for f in js_sys::Array::from(&list).iter() { - let path = js_sys::Reflect::get(&f, &"path".into()).ok().and_then(|v| v.as_string()); - let bytes = js_sys::Reflect::get(&f, &"bytes".into()).ok().and_then(|b| b.dyn_into::().ok()); - if let (Some(path), Some(bytes)) = (path, bytes) { - crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into()); - n += 1; + let list = match wasm_bindgen_futures::JsFuture::from(p).await { + Ok(list) => js_sys::Array::from(&list), + Err(e) => { + let msg = crate::persist::js_err(e); + crate::post(move |app| app.ui.status = format!("{what}: {msg}")); + return; } + }; + // Files that can't be read are reported, not dropped silently (#226). + let (mut n, mut unread) = (0, vec![]); + for f in list.iter() { + let get = |key: &str| js_sys::Reflect::get(&f, &key.into()).ok(); + let Some(path) = get("path").and_then(|v| v.as_string()) else { continue }; + match get("bytes").and_then(|b| b.dyn_into::().ok()) { + Some(bytes) => { + crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into()); + n += 1; + } + None => unread.push(match get("error").and_then(|e| e.as_string()).filter(|e| !e.is_empty()) { + Some(why) => format!("{path} ({why})"), + None => path, + }), + } + } + // Cancelled: nothing to report. + if n == 0 && unread.is_empty() { + return; } crate::post(move |app| { app.ui.status = format!("{n} file(s) added to Browser Storage"); + if !unread.is_empty() { + app.ui.status += &format!(". Could not read {}: try {what} again", unread.join(", ")); + } go(app, STORAGE); }); }); diff --git a/apps/effectcraft-web/src/persist.rs b/apps/effectcraft-web/src/persist.rs index 93fe8906..a2345cc3 100644 --- a/apps/effectcraft-web/src/persist.rs +++ b/apps/effectcraft-web/src/persist.rs @@ -60,7 +60,8 @@ pub fn last_error() -> Option { LAST_ERROR.with(|e| e.borrow().clone()) } -fn js_err(e: JsValue) -> String { +/// A JavaScript error (a rejected promise's reason) as text. +pub(crate) fn js_err(e: JsValue) -> String { e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}")) } diff --git a/apps/effectcraft-web/tests/page.mjs b/apps/effectcraft-web/tests/page.mjs new file mode 100644 index 00000000..2b8cc9bf --- /dev/null +++ b/apps/effectcraft-web/tests/page.mjs @@ -0,0 +1,70 @@ +// Page-side helpers of the web app (js/host.js) under Node with a fake DOM, no browser or Wasm +// build needed: +// +// node apps/effectcraft-web/tests/page.mjs +// +// - Add Files… / Upload Folder…: a picked file that can't be read is listed with its error +// instead of failing the whole pick silently, and cancelling picks nothing (#226). +// Exits non-zero on failure. +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +globalThis.self = globalThis; + +class FakeElement { + constructor(tag) { + this.tag = tag; + this.children = []; + this.attributes = {}; + this.textContent = ""; + } + setAttribute(k, v) { + this.attributes[k] = v; + } + append(...c) { + this.children.push(...c); + } + *all() { + yield this; + for (const c of this.children) yield* c.all(); + } +} +let picker; +globalThis.document = { + body: new FakeElement("body"), + createElement(tag) { + const el = new FakeElement(tag); + if (tag === "input") { + picker = el; + el.click = () => {}; + } + return el; + }, + getElementById(id) { + return [...this.body.all()].find((e) => e.id === id) ?? null; + }, +}; + +const host = await import(pathToFileURL(join(here, "../js/host.js"))); + +// #226: one readable and one unreadable file. +const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read }); +const picked = host.browsePickFiles(false); +picker.files = [ + file("ok.png", async () => new Uint8Array([1, 2, 3]).buffer), + file("red-corner.png", async () => { + throw new DOMException("Generated unreadable selected file", "NotReadableError"); + }), +]; +await picker.onchange(); +const out = await picked; +assert.equal(out.length, 2); +assert.deepEqual([out[0].path, [...out[0].bytes]], ["ok.png", [1, 2, 3]]); +assert.deepEqual(out[1], { path: "red-corner.png", error: "Generated unreadable selected file" }); +const cancelled = host.browsePickFiles(false); +picker.oncancel(); +assert.deepEqual(await cancelled, [], "cancelling picks nothing"); + +console.log("page: ok"); diff --git a/docs/web.md b/docs/web.md index f3b44306..1b5e0fa7 100644 --- a/docs/web.md +++ b/docs/web.md @@ -230,7 +230,8 @@ Window ▸ Media Browser browses a virtual tree (`src/browse.rs`): - **Browser Storage**: every file in browser storage (imported media, saved projects, files added with **Add Files…** or **Upload Folder…**, plain file inputs that work in every browser). This is - the persistent recent list where folders can't be opened. + the persistent recent list where folders can't be opened. A picked file that can't be read is + named in the status bar with the browser's reason, and the others are still added. - **Folders**: **Open Folder…** (File System Access API, `showDirectoryPicker`, Chromium) lists a folder from the user's disk; only the listing is read, and a file's bytes are read when it is imported (then kept in browser storage). The folder handles are remembered in IndexedDB; after @@ -418,6 +419,10 @@ node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out tar reused worker again even at the same size, and a worker that fails to start fails its job and is terminated (`node apps/effectcraft-web/tests/workers.mjs`). +`apps/effectcraft-web/tests/page.mjs` checks page-side helpers of `js/host.js` the same way with +a fake DOM: a picked file that can't be read is reported while the others are added +(`node apps/effectcraft-web/tests/page.mjs`). + It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that replies when done while the page keeps running, and the Media Browser over browser storage; and