//! Files in the browser. Designs are kept in the origin private file system (OPFS): read into //! the engine's in-memory files (`solvecraft_engine::io::vfs`) at start, written back whenever a //! command writes one. Files come in from the computer through a file picker (the File System //! Access API where the browser has it, an `` otherwise) or a drop on the page, //! and go out as downloads (exports, and "Download design"). An autosave of unsaved work is kept //! in OPFS too and offered again after a reload or a crash. use std::cell::{Cell, RefCell}; use std::rc::Rc; use js_sys::{Array, Function, Object, Reflect, Uint8Array}; use solvecraft_engine::io::vfs; use wasm_bindgen::{JsCast as _, JsValue, closure::Closure}; use wasm_bindgen_futures::{JsFuture, spawn_local}; use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream}; /// Where OPFS files appear to the engine. pub const OPFS: &str = "/opfs/"; /// Paths written here are handed to the user as downloads. pub const DOWNLOADS: &str = "/downloads/"; /// Files brought in from the computer. pub const UPLOAD: &str = "/upload/"; /// The autosave of unsaved work (in OPFS: `recovery.solvecraft` and `recovery.json`). const RECOVERY: &str = "recovery.solvecraft"; const RECOVERY_META: &str = "recovery.json"; /// What to do with a file brought in from the computer. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Intent { /// Open it (a design, or STEP/mesh as a new design). Open, /// Add its bodies to the open design (STEP, 3MF, STL). Insert, } /// A stored design, as the file panel lists it. #[derive(Clone, Debug)] pub struct Entry { pub name: String, pub size: u64, /// Last written (ms since 1970; 0 when unknown). pub modified: f64, } /// State shared between the async browser calls and the app's frames. #[derive(Default)] pub struct Files { pub entries: RefCell>, /// Files read from the computer: (name, bytes, what to do). pub incoming: RefCell, Intent)>>, /// Messages for the status line (errors and confirmations). pub messages: RefCell>, /// The stored designs are loaded (the app may open them). pub ready: Cell, /// The file panel is open. pub panel: Cell, /// An autosave left by an earlier session: its design's name and file. pub recovery: RefCell)>>, /// The app's context, to wake it when a browser call finishes. pub ctx: RefCell>, } impl Files { fn say(&self, text: impl Into, error: bool) { self.messages.borrow_mut().push((text.into(), error)); self.wake(); } /// A file arrived from the computer. fn arrive(&self, name: String, bytes: Vec, intent: Intent) { self.incoming.borrow_mut().push((name, bytes, intent)); self.wake(); } /// Run a frame (the app sleeps until something happens). fn wake(&self) { if let Some(c) = self.ctx.borrow().as_ref() { c.request_repaint(); } } } thread_local! { /// The private file system isn't there (an insecure page: http on a network address, or an /// old browser): designs are kept in local storage instead. static LOCAL: Cell = const { Cell::new(false) }; } /// Local storage keys of stored files. const LOCAL_PREFIX: &str = "solvecraft.file."; fn local() -> Result { web_sys::window().and_then(|w| w.local_storage().ok().flatten()).ok_or_else(|| "this browser keeps no local storage".to_string()) } /// Text as is, anything else as hex (local storage holds strings). fn encode(bytes: &[u8]) -> String { match std::str::from_utf8(bytes) { Ok(t) => format!("t{t}"), Err(_) => { let mut s = String::with_capacity(bytes.len() * 2 + 1); s.push('x'); for b in bytes { s.push_str(&format!("{b:02x}")); } s } } } fn decode(s: &str) -> Vec { if let Some(t) = s.strip_prefix('t') { return t.as_bytes().to_vec(); } let h = s.strip_prefix('x').unwrap_or(""); (0..h.len() / 2).filter_map(|i| h.get(2 * i..2 * i + 2).and_then(|x| u8::from_str_radix(x, 16).ok())).collect() } /// Is the browser's storage local storage (no OPFS here)? pub fn local_only() -> bool { LOCAL.with(Cell::get) } fn err(e: JsValue) -> String { e.as_string().or_else(|| Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}")) } /// The browser's storage manager: missing on insecure pages (http on a network address) and in /// old browsers, where calling into it would throw. fn storage_manager() -> Option { let nav = web_sys::window()?.navigator(); let sm = Reflect::get(&nav, &"storage".into()).ok().filter(|v| v.is_object())?; Some(sm.unchecked_into()) } /// Does the storage manager have `method`? fn has(sm: &web_sys::StorageManager, method: &str) -> bool { Reflect::get(sm, &method.into()).is_ok_and(|f| f.is_function()) } async fn root() -> Result { let sm = storage_manager().filter(|sm| has(sm, "getDirectory")).ok_or("this page has no private file storage (it needs https or localhost)")?; let dir = JsFuture::from(sm.get_directory()).await.map_err(err)?; dir.dyn_into::().map_err(|_| "this browser has no private file storage (OPFS)".to_string()) } async fn read_handle(h: &FileSystemFileHandle) -> Result, String> { let file: web_sys::File = JsFuture::from(h.get_file()).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())?; blob_bytes(&file).await } async fn blob_bytes(b: &web_sys::Blob) -> Result, String> { let buf = JsFuture::from(b.array_buffer()).await.map_err(err)?; Ok(Uint8Array::new(&buf).to_vec()) } async fn file_handle(dir: &FileSystemDirectoryHandle, name: &str, create: bool) -> Result { let opts = FileSystemGetFileOptions::new(); opts.set_create(create); JsFuture::from(dir.get_file_handle_with_options(name, &opts)).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string()) } async fn write_file(name: &str, bytes: &[u8]) -> Result<(), String> { if local_only() { return local()? .set_item(&format!("{LOCAL_PREFIX}{name}"), &encode(bytes)) .map_err(|e| format!("local storage is full or blocked: {}", err(e))); } let dir = root().await?; let h = file_handle(&dir, name, true).await?; let w: FileSystemWritableFileStream = JsFuture::from(h.create_writable()).await.map_err(err)?.dyn_into().map_err(|_| "no writable stream".to_string())?; JsFuture::from(w.write_with_u8_array(bytes).map_err(err)?).await.map_err(err)?; JsFuture::from(w.close()).await.map_err(err)?; Ok(()) } async fn remove_file(name: &str) -> Result<(), String> { if local_only() { return local()?.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err); } let dir = root().await?; JsFuture::from(dir.remove_entry(name)).await.map_err(err)?; Ok(()) } /// Every stored file in local storage. fn read_local() -> Result, f64)>, String> { let st = local()?; let mut out = Vec::new(); for i in 0..st.length().unwrap_or(0).min(10_000) { let Some(k) = st.key(i).ok().flatten() else { continue }; let Some(name) = k.strip_prefix(LOCAL_PREFIX) else { continue }; if let Some(v) = st.get_item(&k).ok().flatten() { out.push((name.to_string(), decode(&v), 0.0)); } } Ok(out) } /// Every stored file: (name, bytes). Without OPFS, local storage takes over from here on. async fn read_all() -> Result, f64)>, String> { let dir = match root().await { Ok(d) => d, Err(e) => { log::warn!("no OPFS ({e}): designs go to local storage"); LOCAL.with(|l| l.set(true)); return read_local(); } }; let iter: JsValue = dir.values().into(); let next: Function = Reflect::get(&iter, &"next".into()).map_err(err)?.dyn_into().map_err(|_| "no iterator".to_string())?; let mut out = Vec::new(); for _ in 0..10_000 { let step = JsFuture::from(js_sys::Promise::resolve(&next.call0(&iter).map_err(err)?)).await.map_err(err)?; if Reflect::get(&step, &"done".into()).ok().and_then(|d| d.as_bool()).unwrap_or(true) { break; } let Ok(h) = Reflect::get(&step, &"value".into()).map_err(err)?.dyn_into::() else { continue }; let file = match JsFuture::from(h.get_file()).await.map_err(err).and_then(|f| f.dyn_into::().map_err(|_| "not a file".to_string())) { Ok(f) => f, Err(e) => { log::warn!("{}: {e}", h.name()); continue; } }; match blob_bytes(&file).await { Ok(b) => out.push((h.name(), b, file.last_modified())), Err(e) => log::warn!("{}: {e}", h.name()), } } Ok(out) } /// Ask the browser to keep the storage (not evict it under pressure), then load every stored /// file into the engine's files. pub fn start(files: &Rc) { if let Some(sm) = storage_manager().filter(|sm| has(sm, "persist")) && let Ok(p) = sm.persist() { spawn_local(async move { let _ = JsFuture::from(p).await; }); } let files = files.clone(); spawn_local(async move { match read_all().await { Ok(all) => { if local_only() { files.say("This page can't use the browser's file storage (it needs https or localhost): designs are kept in local storage, a few MB at most.", false); } let mut entries = Vec::new(); for (name, bytes, modified) in all { if name == RECOVERY_META { let meta: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default(); let n = meta["name"].as_str().unwrap_or("Untitled").to_string(); *files.recovery.borrow_mut() = Some((n, meta["path"].as_str().map(str::to_string))); continue; } if name != RECOVERY { entries.push(Entry { name: name.clone(), size: bytes.len() as u64, modified }); } vfs::insert(&format!("{OPFS}{name}"), bytes); } entries.sort_by_key(|a| a.name.to_lowercase()); *files.entries.borrow_mut() = entries; // An autosave without its design file is nothing to recover. if !vfs::exists(&format!("{OPFS}{RECOVERY}")) { *files.recovery.borrow_mut() = None; } } Err(e) => files.say(format!("browser storage: {e}"), true), } files.ready.set(true); files.wake(); }); } /// Store what commands wrote: OPFS paths into OPFS, download paths as downloads. pub fn flush(files: &Rc) { for (path, bytes) in vfs::take_writes() { if let Some(name) = path.strip_prefix(OPFS) { let name = name.to_string(); { let mut e = files.entries.borrow_mut(); e.retain(|x| x.name != name); if name != RECOVERY && name != RECOVERY_META { e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() }); e.sort_by_key(|a| a.name.to_lowercase()); } } let files = files.clone(); spawn_local(async move { if let Err(e) = write_file(&name, &bytes).await { files.say(format!("{name}: {e}"), true); } }); } else if let Some(name) = path.strip_prefix(DOWNLOADS) && let Err(e) = download(name, &bytes) { files.say(format!("{name}: {e}"), true); } } } /// Hand `bytes` to the user as a download named `name`. pub fn download(name: &str, bytes: &[u8]) -> Result<(), String> { let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; let parts = Array::new(); parts.push(&Uint8Array::from(bytes)); let opts = web_sys::BlobPropertyBag::new(); opts.set_type("application/octet-stream"); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?; let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?; let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(err)?.dyn_into().map_err(|_| "no anchor".to_string())?; a.set_href(&url); a.set_download(name); a.click(); // Let the browser start the download before the URL goes. let revoke = Closure::once_into_js(move || { let _ = web_sys::Url::revoke_object_url(&url); }); if let Some(w) = web_sys::window() { let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000); } Ok(()) } /// Delete a stored design. pub fn delete(files: &Rc, name: &str) { let _ = vfs::remove(&format!("{OPFS}{name}")); files.entries.borrow_mut().retain(|e| e.name != name); let (files, name) = (files.clone(), name.to_string()); spawn_local(async move { if let Err(e) = remove_file(&name).await { files.say(format!("{name}: {e}"), true); } }); } /// Rename a stored design (a copy under the new name, then the old one goes: not every browser /// can move OPFS files). pub fn rename(files: &Rc, from: &str, to: &str) -> Result<(), String> { let to = to.trim(); if to.is_empty() || to.contains('/') || to.contains('\\') || to.len() > 200 { return Err("give a file name without slashes".into()); } if files.entries.borrow().iter().any(|e| e.name == to) { return Err(format!("there is already a file named {to}")); } let bytes = vfs::read(&format!("{OPFS}{from}")).map_err(|e| e.to_string())?; vfs::write(&format!("{OPFS}{to}"), &bytes).map_err(|e| e.to_string())?; delete(files, from); flush(files); Ok(()) } /// Keep (or drop) the autosave of the open design. pub fn autosave(files: &Rc, doc: Option<(&solvecraft_engine::doc::Document, Option<&str>)>) { let files = files.clone(); match doc { Some((d, path)) => { let bytes = solvecraft_engine::io::write_design(d); let meta = serde_json::json!({"name": d.name, "path": path, "saved_at": js_sys::Date::now()}).to_string(); spawn_local(async move { let r = async { write_file(RECOVERY, &bytes).await?; write_file(RECOVERY_META, meta.as_bytes()).await }; if let Err(e) = r.await { files.say(format!("autosave: {e}"), true); } }); } None => spawn_local(async move { let _ = remove_file(RECOVERY).await; let _ = remove_file(RECOVERY_META).await; }), } } /// A stored path for `name` that no stored file has: `name`, else `name (2)`, `name (3)`… pub fn free_name(name: &str) -> String { let (stem, ext) = name.rsplit_once('.').map(|(s, e)| (s.to_string(), format!(".{e}"))).unwrap_or((name.to_string(), String::new())); for i in 1..1000 { let n = if i == 1 { name.to_string() } else { format!("{stem} ({i}){ext}") }; let p = format!("{OPFS}{n}"); if !vfs::exists(&p) { return p; } } format!("{OPFS}{stem} ({}){ext}", js_sys::Date::now() as u64) } /// The autosaved design's path (in the engine's files) for opening it. pub fn recovery_path() -> String { format!("{OPFS}{RECOVERY}") } /// Pick files on the computer: the File System Access picker where there is one, else an /// ``. Read files arrive in `files.incoming`. pub fn pick_from_disk(files: &Rc, intent: Intent) { let Some(window) = web_sys::window() else { return }; let picker = Reflect::get(&window, &"showOpenFilePicker".into()).ok().and_then(|f| f.dyn_into::().ok()); match picker { Some(f) => { let opts = Object::new(); let _ = Reflect::set(&opts, &"multiple".into(), &JsValue::FALSE); let promise = f.call1(&window, &opts); let files = files.clone(); spawn_local(async move { let r = async { let p: js_sys::Promise = promise.map_err(err)?.dyn_into().map_err(|_| "no promise".to_string())?; let handles: Array = JsFuture::from(p).await.map_err(err)?.dyn_into().map_err(|_| "no files".to_string())?; for h in handles.iter() { let h: FileSystemFileHandle = h.dyn_into().map_err(|_| "not a file".to_string())?; let bytes = read_handle(&h).await?; files.arrive(h.name(), bytes, intent); } Ok::<(), String>(()) }; match r.await { Ok(()) => {} // Closing the picker is no error. Err(e) if e.contains("abort") || e.contains("Abort") => {} Err(e) => files.say(format!("open: {e}"), true), } }); } None => input_picker(files, intent), } } fn input_picker(files: &Rc, intent: Intent) { let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return }; let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::()) else { return }; input.set_type("file"); input.set_accept(".solvecraft,.step,.stp,.3mf,.stl"); // Attached (hidden) while it is open: some browsers ignore a click on a detached input. let _ = input.set_attribute("style", "display:none"); if let Some(body) = doc.body() { let _ = body.append_child(&input); } let (files, el) = (files.clone(), input.clone()); let on_change = Closure::once_into_js(move || { el.remove(); let Some(list) = el.files() else { return }; for i in 0..list.length() { let Some(f) = list.get(i) else { continue }; let files = files.clone(); spawn_local(async move { match blob_bytes(&f).await { Ok(b) => files.arrive(f.name(), b, intent), Err(e) => files.say(format!("{}: {e}", f.name()), true), } }); } }); input.set_onchange(Some(on_change.unchecked_ref())); input.click(); } /// Read files dropped on the page. pub fn take_drops(files: &Rc, raw: &mut egui::RawInput) { for f in std::mem::take(&mut raw.dropped_files) { let files = files.clone(); spawn_local(async move { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); match f.bytes_async().await { Ok(b) => files.arrive(name, b, Intent::Insert), Err(e) => files.say(format!("{name}: {e}"), true), } }); } }