Rename SolveCraft to SolidCraft everywhere, with compatibility shims (#389)
* WIP deep rename (not for push) * WIP2 * WIP3 * WIP4 wxs fix * rustfmt * Rename the remaining identifiers in merged crash-report code Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
27415e4dd7
commit
296683229d
464 files changed
+3408
-2956
No files matched your search
@@ -0,0 +1,492 @@
|
||||
//! Files in the browser. Designs are kept in the origin private file system (OPFS): read into
|
||||
//! the engine's in-memory files (`solidcraft_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 `<input type=file>` 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 solidcraft_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.solidcraft` and `recovery.json`).
|
||||
const RECOVERY: &str = "recovery.solidcraft";
|
||||
/// The autosave earlier releases kept (compatibility shim, docs/compatibility.md): recovered
|
||||
/// when there is no current one.
|
||||
const LEGACY_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<Vec<Entry>>,
|
||||
/// Files read from the computer: (name, bytes, what to do).
|
||||
pub incoming: RefCell<Vec<(String, Vec<u8>, Intent)>>,
|
||||
/// Messages for the status line (errors and confirmations).
|
||||
pub messages: RefCell<Vec<(String, bool)>>,
|
||||
/// The stored designs are loaded (the app may open them).
|
||||
pub ready: Cell<bool>,
|
||||
/// The file panel is open.
|
||||
pub panel: Cell<bool>,
|
||||
/// An autosave left by an earlier session: its design's name and file.
|
||||
pub recovery: RefCell<Option<(String, Option<String>)>>,
|
||||
/// The app's context, to wake it when a browser call finishes.
|
||||
pub ctx: RefCell<Option<egui::Context>>,
|
||||
}
|
||||
|
||||
impl Files {
|
||||
fn say(&self, text: impl Into<String>, 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<u8>, 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<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Local storage keys of stored files.
|
||||
const LOCAL_PREFIX: &str = "solidcraft.file.";
|
||||
/// Local-storage files kept by earlier releases (compatibility shim, docs/compatibility.md):
|
||||
/// still listed and opened; removing one removes either key.
|
||||
const LEGACY_LOCAL_PREFIX: &str = "solvecraft.file.";
|
||||
|
||||
fn local() -> Result<web_sys::Storage, String> {
|
||||
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<u8> {
|
||||
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<web_sys::StorageManager> {
|
||||
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<FileSystemDirectoryHandle, String> {
|
||||
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::<FileSystemDirectoryHandle>().map_err(|_| "this browser has no private file storage (OPFS)".to_string())
|
||||
}
|
||||
|
||||
async fn read_handle(h: &FileSystemFileHandle) -> Result<Vec<u8>, 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<Vec<u8>, 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<FileSystemFileHandle, String> {
|
||||
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() {
|
||||
let st = local()?;
|
||||
st.remove_item(&format!("{LEGACY_LOCAL_PREFIX}{name}")).map_err(err)?;
|
||||
return st.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<Vec<(String, Vec<u8>, 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).or_else(|| k.strip_prefix(LEGACY_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<Vec<(String, Vec<u8>, 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::<FileSystemFileHandle>() else { continue };
|
||||
let file =
|
||||
match JsFuture::from(h.get_file()).await.map_err(err).and_then(|f| f.dyn_into::<web_sys::File>().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<Files>) {
|
||||
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();
|
||||
let has_recovery = all.iter().any(|(n, _, _)| n == RECOVERY);
|
||||
for (name, bytes, modified) in all {
|
||||
if name == LEGACY_RECOVERY {
|
||||
if !has_recovery {
|
||||
vfs::insert(&format!("{OPFS}{RECOVERY}"), bytes);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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<Files>) {
|
||||
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<Files>, 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<Files>, 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}"), solidcraft_engine::io::MAX_DESIGN_BYTES).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<Files>, doc: Option<(&solidcraft_engine::doc::Document, Option<&str>)>) {
|
||||
let files = files.clone();
|
||||
match doc {
|
||||
Some((d, path)) => {
|
||||
let bytes = solidcraft_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(LEGACY_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
|
||||
/// `<input type=file>`. Read files arrive in `files.incoming`.
|
||||
pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let picker = Reflect::get(&window, &"showOpenFilePicker".into()).ok().and_then(|f| f.dyn_into::<Function>().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<Files>, 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::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
input.set_accept(".solidcraft,.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<Files>, 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),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
//! SolidCraft in the browser.
|
||||
//!
|
||||
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the
|
||||
//! sample part.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod files;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::files::{self, Files, Intent};
|
||||
|
||||
const CANVAS_ID: &str = "solidcraft_canvas";
|
||||
const LOADING_ID: &str = "solidcraft_loading";
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
|
||||
/// Seconds between autosaves of unsaved work.
|
||||
const AUTOSAVE_S: f64 = 30.0;
|
||||
|
||||
thread_local! {
|
||||
/// An open design has unsaved changes: leaving the page asks first.
|
||||
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
|
||||
/// unsaved changes (the page can't show its own dialog when it is being closed).
|
||||
fn ask_before_unload(window: &web_sys::Window) {
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
|
||||
if UNSAVED.with(std::cell::Cell::get) {
|
||||
e.prevent_default();
|
||||
e.set_return_value("unsaved");
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
|
||||
|
||||
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
|
||||
|
||||
struct Shell {
|
||||
app: SolveApp,
|
||||
files: Rc<Files>,
|
||||
/// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
|
||||
autosaved_at: f64,
|
||||
autosaved: Option<u64>,
|
||||
/// A stored design being renamed: (its name, the new name).
|
||||
renaming: Option<(String, String)>,
|
||||
/// The rename field takes the keyboard when it appears.
|
||||
rename_focus: bool,
|
||||
/// The start page's recent designs were filled from the stored ones.
|
||||
recents_synced: bool,
|
||||
/// On-demand CJK font: fetched only when a CJK language is selected.
|
||||
font: solidcraft_ui_egui::fonts::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
/// Files from the computer, writes to store, messages and the autosave.
|
||||
fn files_tick(&mut self, ctx: &egui::Context) {
|
||||
if self.files.ctx.borrow().is_none() {
|
||||
*self.files.ctx.borrow_mut() = Some(ctx.clone());
|
||||
}
|
||||
files::flush(&self.files);
|
||||
for (text, error) in self.files.messages.borrow_mut().drain(..) {
|
||||
self.app.set_status(text, error);
|
||||
}
|
||||
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
|
||||
for (name, bytes, intent) in incoming {
|
||||
let design = solidcraft_engine::io::is_design_path(&name);
|
||||
let path = if design && intent == Intent::Open {
|
||||
// A design from the computer is kept in the browser (a copy), so Save works.
|
||||
let path = files::free_name(&name);
|
||||
if let Err(e) = vfs::write(&path, &bytes) {
|
||||
self.app.set_status(e.to_string(), true);
|
||||
}
|
||||
files::flush(&self.files);
|
||||
path
|
||||
} else {
|
||||
let path = format!("{}{name}", files::UPLOAD);
|
||||
vfs::insert(&path, bytes);
|
||||
path
|
||||
};
|
||||
match intent {
|
||||
Intent::Open => self.app.open_path(&path),
|
||||
Intent::Insert => self.app.drop_path(&path),
|
||||
}
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
if self.files.ready.get() && !self.recents_synced {
|
||||
self.recents_synced = true;
|
||||
self.sync_recents();
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
// Unsaved work is kept (at most every AUTOSAVE_S); saved work needs no autosave.
|
||||
if self.files.ready.get() && self.files.recovery.borrow().is_none() {
|
||||
let s = &self.app.session;
|
||||
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
|
||||
let wait = AUTOSAVE_S - (now - self.autosaved_at);
|
||||
if changed && wait <= 0.0 {
|
||||
files::autosave(&self.files, Some((&s.doc, s.path.as_deref())));
|
||||
self.autosaved = Some(s.revision);
|
||||
self.autosaved_at = now;
|
||||
} else if changed {
|
||||
// Come back for it even when nothing else happens.
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||||
} else if !s.is_dirty() && self.autosaved.is_some() {
|
||||
files::autosave(&self.files, None);
|
||||
self.autosaved = None;
|
||||
}
|
||||
}
|
||||
if !self.files.ready.get() || !self.files.incoming.borrow().is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// The start page's recent designs are the stored ones: the remembered order first (those
|
||||
/// still stored), then the others, newest first.
|
||||
fn sync_recents(&mut self) {
|
||||
let mut entries = self.files.entries.borrow().clone();
|
||||
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
|
||||
let recent = &mut self.app.home.recent;
|
||||
recent.retain(|p| vfs::exists(p));
|
||||
for e in entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)) {
|
||||
let p = format!("{}{}", files::OPFS, e.name);
|
||||
if !recent.contains(&p) {
|
||||
recent.push(p);
|
||||
}
|
||||
}
|
||||
recent.truncate(solidcraft_ui_egui::home::MAX_RECENT);
|
||||
}
|
||||
|
||||
/// The stored designs: open, insert, rename, delete, download; open from the computer.
|
||||
fn file_panel(&mut self, ctx: &egui::Context) {
|
||||
if !self.files.panel.get() {
|
||||
return;
|
||||
}
|
||||
let mut open = true;
|
||||
let mut act: Option<(&'static str, String)> = None;
|
||||
egui::Window::new("Files in this browser")
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
.default_width(520.0)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.default_pos(ctx.content_rect().center())
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Open from computer…").clicked() {
|
||||
files::pick_from_disk(&self.files, Intent::Open);
|
||||
}
|
||||
if ui.button("Insert from computer…").on_hover_text("STEP, 3MF or STL into the open design").clicked() {
|
||||
files::pick_from_disk(&self.files, Intent::Insert);
|
||||
}
|
||||
if ui.button("Download open design").clicked() {
|
||||
act = Some(("download_open", String::new()));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if !self.files.ready.get() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Reading the browser's storage…");
|
||||
});
|
||||
return;
|
||||
}
|
||||
let entries = self.files.entries.borrow().clone();
|
||||
if entries.is_empty() {
|
||||
ui.label("Designs you save appear here. They stay in this browser.");
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
egui::Grid::new("opfs_files").num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| {
|
||||
for e in &entries {
|
||||
match &mut self.renaming {
|
||||
Some((from, to)) if *from == e.name => {
|
||||
let r = ui.add(egui::TextEdit::singleline(to).desired_width(200.0));
|
||||
if std::mem::take(&mut self.rename_focus) {
|
||||
r.request_focus();
|
||||
}
|
||||
if r.lost_focus() {
|
||||
act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
("rename", to.clone())
|
||||
} else {
|
||||
("rename_cancel", String::new())
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.label(&e.name);
|
||||
}
|
||||
}
|
||||
ui.label(format!("{:.1} KB", e.size as f64 / 1024.0));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button("Open").clicked() {
|
||||
act = Some(("open", e.name.clone()));
|
||||
}
|
||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||
if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() {
|
||||
act = Some(("insert", e.name.clone()));
|
||||
}
|
||||
if ui.small_button("Rename").clicked() {
|
||||
self.renaming = Some((e.name.clone(), e.name.clone()));
|
||||
self.rename_focus = true;
|
||||
}
|
||||
if ui.small_button("Download").clicked() {
|
||||
act = Some(("download", e.name.clone()));
|
||||
}
|
||||
if ui.small_button("Delete").clicked() {
|
||||
act = Some(("delete", e.name.clone()));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if !open {
|
||||
self.files.panel.set(false);
|
||||
self.renaming = None;
|
||||
}
|
||||
let Some((what, name)) = act else { return };
|
||||
let path = format!("{}{name}", files::OPFS);
|
||||
match what {
|
||||
"open" => {
|
||||
self.app.open_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
"insert" => {
|
||||
self.app.insert_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
"download" => match vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES) {
|
||||
Ok(b) => {
|
||||
if let Err(e) = files::download(&name, &b) {
|
||||
self.app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
Err(e) => self.app.set_status(e.to_string(), true),
|
||||
},
|
||||
"download_open" => {
|
||||
let d = &self.app.session.doc;
|
||||
let bytes = solidcraft_engine::io::write_design(d);
|
||||
if let Err(e) = files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes) {
|
||||
self.app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
"delete" => {
|
||||
files::delete(&self.files, &name);
|
||||
self.app.home.recent.retain(|r| *r != path);
|
||||
}
|
||||
"rename_cancel" => self.renaming = None,
|
||||
"rename" => {
|
||||
if let Some((from, _)) = self.renaming.take() {
|
||||
match files::rename(&self.files, &from, &name) {
|
||||
Ok(()) => {
|
||||
// Recent designs follow the new name.
|
||||
let (old, new) = (format!("{}{from}", files::OPFS), format!("{}{name}", files::OPFS));
|
||||
for r in &mut self.app.home.recent {
|
||||
if *r == old {
|
||||
*r = new.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.app.set_status(e, true),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer the autosave an earlier session left.
|
||||
fn recovery_prompt(&mut self, ctx: &egui::Context) {
|
||||
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
|
||||
let mut answer = None;
|
||||
egui::Window::new("Recover unsaved work?").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
ui.label(format!("“{name}” had unsaved changes when this page last closed."));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Recover").clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
if ui.button("Discard").clicked() {
|
||||
answer = Some(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
let Some(recover) = answer else { return };
|
||||
*self.files.recovery.borrow_mut() = None;
|
||||
if recover {
|
||||
self.app.open_path(&files::recovery_path());
|
||||
// It belongs to its own file again (saving goes there); it is unsaved.
|
||||
self.app.session.path = path;
|
||||
self.app.session.doc_mut().name = name;
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
}
|
||||
files::autosave(&self.files, None);
|
||||
}
|
||||
|
||||
/// Fetch and install the CJK font when a CJK language is selected. If that fails, this
|
||||
/// session falls back to English (no tofu); the saved language preference is kept.
|
||||
fn tick_font(&mut self, ctx: &egui::Context) {
|
||||
let done = self.font_result.borrow_mut().take();
|
||||
if let Some(res) = done {
|
||||
let installed = match res {
|
||||
Ok(bytes) => solidcraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
|
||||
Err(err) => {
|
||||
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
|
||||
false
|
||||
}
|
||||
};
|
||||
if installed {
|
||||
log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}");
|
||||
} else {
|
||||
log::warn!("CJK font unavailable; showing English for this session");
|
||||
}
|
||||
self.font.finished(installed, &mut self.app.preferences.language);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.font.tick(&mut self.app.preferences.language) {
|
||||
let slot = self.font_result.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let res = fetch_font_bytes(JAPANESE_FONT_PATH).await;
|
||||
*slot.borrow_mut() = Some(res);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.tick_font(ctx);
|
||||
self.app.logic(ctx);
|
||||
self.files_tick(ctx);
|
||||
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
files::take_drops(&self.files, raw);
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
let ctx = ui.ctx().clone();
|
||||
self.file_panel(&ctx);
|
||||
self.recovery_prompt(&ctx);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
// Persist the user's own language, not a session-only English fallback.
|
||||
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
|
||||
let live = std::mem::replace(&mut self.app.preferences.language, saved);
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.app.prefs());
|
||||
self.app.preferences.language = live;
|
||||
}
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fetch font bytes asynchronously through the browser.
|
||||
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let resp_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await.map_err(|e| format!("{e:?}"))?;
|
||||
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "response cast failed")?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let ab_val = wasm_bindgen_futures::JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
|
||||
let uint8_arr = js_sys::Uint8Array::new(&ab_val);
|
||||
Ok(uint8_arr.to_vec())
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
ask_before_unload(&window);
|
||||
let Some(document) = window.document() else { return };
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
|
||||
if query().contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let files = Rc::new(Files::default());
|
||||
files::start(&files);
|
||||
let panel = files.clone();
|
||||
let services = Services {
|
||||
// Open shows the browser's files (and the computer's, from there).
|
||||
pick_open: Some(Box::new(move || {
|
||||
panel.panel.set(true);
|
||||
None
|
||||
})),
|
||||
// Designs are kept in the browser; everything else is downloaded.
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
|
||||
Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SolveApp::new(Session::default(), services);
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
// eframe's web painter has no stencil buffer.
|
||||
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
|
||||
}
|
||||
if query().contains("sample") {
|
||||
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
|
||||
app.set_status(format!("sample: {e}"), true);
|
||||
}
|
||||
app.session.doc_mut().name = "Sample Plate".into();
|
||||
app.session.mark_saved();
|
||||
}
|
||||
if !query().contains("sample") {
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
Ok(Box::new(Shell {
|
||||
app,
|
||||
files,
|
||||
autosaved_at: f64::NEG_INFINITY,
|
||||
autosaved: None,
|
||||
renaming: None,
|
||||
rename_focus: false,
|
||||
recents_synced: false,
|
||||
font: Default::default(),
|
||||
font_result: Rc::new(std::cell::RefCell::new(None)),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
Err(e) => {
|
||||
el.set_inner_html(&format!("<p>SolidCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("solidcraft-web only runs in the browser: build it with `trunk build --release` in apps/solidcraft-web");
|
||||
}
|
||||
Reference in new issue
Block a user