1pub const SETTINGS_KEY: &str = "@settings";
10pub const FEATURE_PALETTE_DISPLAY_KEY: &str = "@feature_palette_display";
11pub const DOCK_LAYOUT_KEY: &str = "@dock_layout";
12pub const SESSION_KEY: &str = "@session";
15pub const PINNED_KEY: &str = "@pinned";
19pub const RECOVERY_KEY: &str = "@recovery";
24
25pub struct ImportedFile {
28 pub name: String,
29 pub bytes: Vec<u8>,
30}
31
32#[derive(Clone, Debug, PartialEq)]
34pub struct BrowserEntry {
35 pub name: String,
36 pub identity: String,
37 pub is_dir: bool,
38 pub size: Option<u64>,
41 pub modified: Option<f64>,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct BrowserPlace {
49 pub label: String,
51 pub location: String,
53 pub kind: PlaceKind,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum PlaceKind {
60 Home,
61 Documents,
62 Downloads,
63 Models,
64 Root,
65}
66pub trait ModelStore {
89 fn backend_label(&self) -> String {
92 "persistent storage".into()
93 }
94
95 fn list(&self) -> Vec<String> {
99 Vec::new()
100 }
101
102 fn read(&self, name: &str) -> Option<String>;
104
105 fn write(&self, name: &str, contents: &str) -> Result<(), String>;
108
109 fn remove(&self, _name: &str) -> Result<(), String> {
111 Ok(())
112 }
113
114 fn take_persistence_errors(&self) -> Vec<String> {
122 Vec::new()
123 }
124
125 fn browser_location(&self) -> String {
129 self.backend_label()
130 }
131
132 fn browser_entries(&self, _extensions: &[&str]) -> Vec<BrowserEntry> {
136 self.list()
137 .into_iter()
138 .map(|name| BrowserEntry {
139 identity: name.clone(),
140 name,
141 is_dir: false,
142 size: None,
143 modified: None,
144 })
145 .collect()
146 }
147
148 fn browser_enter(&self, _identity: &str) -> Result<(), String> {
149 Err("this storage backend has no directories".into())
150 }
151
152 fn browser_up(&self) -> Result<(), String> {
153 Ok(())
154 }
155
156 fn browser_home(&self) -> Result<(), String> {
157 Ok(())
158 }
159
160 fn browser_root(&self) -> Result<(), String> {
161 Ok(())
162 }
163
164 fn browser_navigate(&self, _location: &str) -> Result<(), String> {
170 Err("this storage backend cannot navigate to a path".into())
171 }
172
173 fn browser_places(&self) -> Vec<BrowserPlace> {
176 Vec::new()
177 }
178
179 fn browser_create_dir(&self, _name: &str) -> Result<(), String> {
181 Err("this storage backend cannot create directories".into())
182 }
183
184 fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
187 self.write(name, contents)?;
188 Ok(name.to_string())
189 }
190
191 fn list_external_files(&self, _extensions: &[&str]) -> Vec<String> {
195 Vec::new()
196 }
197
198 fn read_external_file(&self, _name: &str) -> Option<Vec<u8>> {
200 None
201 }
202
203 fn supports_file_interchange(&self) -> bool {
212 false
213 }
214
215 fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
221 let _ = (name, contents);
222 Ok(None)
223 }
224
225 fn begin_import(&self) -> Result<(), String> {
229 Ok(())
230 }
231
232 fn take_import(&self) -> Option<ImportedFile> {
235 None
236 }
237
238 fn begin_import_filtered(&self, _filter: (&str, &[&str])) -> Result<(), String> {
250 self.begin_import()
251 }
252
253 fn export_file_named(&self, _file_name: &str, _contents: &str) -> Result<(), String> {
257 Ok(())
258 }
259}
260
261pub fn default_model_store() -> Box<dyn ModelStore> {
263 #[cfg(not(target_arch = "wasm32"))]
264 {
265 Box::new(native_model::FileModelStore::new())
266 }
267 #[cfg(target_arch = "wasm32")]
268 {
269 web_model::take_boot_store()
272 }
273}
274
275#[cfg(target_arch = "wasm32")]
280pub async fn hydrate_web_store() {
281 web_model::hydrate().await;
282}
283
284#[cfg(target_arch = "wasm32")]
288pub use web_model::set_repaint_ctx;
289
290pub const MODEL_EXT: &str = ".BREP.json";
292
293#[cfg(all(test, not(target_arch = "wasm32")))]
296pub fn native_test_store(dir: std::path::PathBuf) -> Box<dyn ModelStore> {
297 Box::new(native_model::FileModelStore::with_dir(dir))
298}
299
300#[cfg(all(test, not(target_arch = "wasm32")))]
305pub(crate) struct MemModelStore {
306 docs: std::cell::RefCell<std::collections::BTreeMap<String, String>>,
307 reads: std::cell::Cell<usize>,
308 writes: std::cell::Cell<usize>,
309}
310
311#[cfg(all(test, not(target_arch = "wasm32")))]
312impl MemModelStore {
313 pub fn new() -> Self {
314 Self {
315 docs: std::cell::RefCell::new(std::collections::BTreeMap::new()),
316 reads: std::cell::Cell::new(0),
317 writes: std::cell::Cell::new(0),
318 }
319 }
320
321 pub fn put(&self, name: &str, contents: &str) {
323 self.docs
324 .borrow_mut()
325 .insert(name.to_string(), contents.to_string());
326 }
327
328 pub fn reads(&self) -> usize {
330 self.reads.get()
331 }
332
333 pub fn writes(&self) -> usize {
336 self.writes.get()
337 }
338}
339
340#[cfg(all(test, not(target_arch = "wasm32")))]
341impl ModelStore for MemModelStore {
342 fn backend_label(&self) -> String {
343 "in-memory test store".into()
344 }
345 fn list(&self) -> Vec<String> {
346 self.docs.borrow().keys().cloned().collect()
347 }
348 fn read(&self, name: &str) -> Option<String> {
349 self.reads.set(self.reads.get() + 1);
350 self.docs.borrow().get(name).cloned()
351 }
352 fn write(&self, name: &str, contents: &str) -> Result<(), String> {
353 self.writes.set(self.writes.get() + 1);
354 self.put(name, contents);
355 Ok(())
356 }
357 fn remove(&self, name: &str) -> Result<(), String> {
358 self.docs.borrow_mut().remove(name);
359 Ok(())
360 }
361}
362
363pub(crate) fn model_display_name(file_name: &str) -> String {
367 let base = file_name
368 .rsplit(['/', '\\'])
369 .next()
370 .unwrap_or(file_name);
371 base.strip_suffix(MODEL_EXT)
372 .or_else(|| base.strip_suffix(".json"))
373 .unwrap_or(base)
374 .to_string()
375}
376
377#[cfg(not(target_arch = "wasm32"))]
379mod native_model {
380 use super::{
381 model_display_name, BrowserEntry, BrowserPlace, ModelStore, PlaceKind, DOCK_LAYOUT_KEY,
382 FEATURE_PALETTE_DISPLAY_KEY, MODEL_EXT, PINNED_KEY, RECOVERY_KEY, SESSION_KEY, SETTINGS_KEY,
383 };
384 use std::cell::RefCell;
385 use std::path::{Path, PathBuf};
386
387 pub struct FileModelStore {
394 app_dir: PathBuf,
395 dir: PathBuf,
396 browser_dir: RefCell<PathBuf>,
397 }
398
399 impl FileModelStore {
400 pub fn new() -> Self {
401 let base = std::env::var_os("XDG_CONFIG_HOME")
402 .map(PathBuf::from)
403 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
404 .unwrap_or_else(|| PathBuf::from("."));
405 let app_dir = base.join("brep-app");
406 Self {
407 dir: app_dir.join("models"),
408 browser_dir: RefCell::new(app_dir.join("models")),
409 app_dir,
410 }
411 }
412
413 #[cfg_attr(not(test), allow(dead_code))]
416 pub fn with_dir(dir: PathBuf) -> Self {
417 Self {
418 app_dir: dir.clone(),
419 browser_dir: RefCell::new(dir.clone()),
420 dir,
421 }
422 }
423
424 fn resolve(&self, name: &str) -> PathBuf {
427 match name {
428 SETTINGS_KEY => return self.app_dir.join("settings.json"),
429 FEATURE_PALETTE_DISPLAY_KEY => return self.app_dir.join("feature_palette_display.json"),
430 DOCK_LAYOUT_KEY => return self.app_dir.join("dock_layout.json"),
431 SESSION_KEY => return self.app_dir.join("session.json"),
432 PINNED_KEY => return self.app_dir.join("pinned.json"),
433 RECOVERY_KEY => return self.app_dir.join("recovery.json"),
434 _ => {}
435 }
436 if name.contains('/') || name.contains('\\') {
437 return PathBuf::from(name);
438 }
439 let safe: String = name
440 .strip_suffix(MODEL_EXT)
441 .unwrap_or(name)
442 .chars()
443 .map(|c| {
444 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
445 c
446 } else {
447 '_'
448 }
449 })
450 .collect();
451 self.dir.join(format!("{safe}{MODEL_EXT}"))
452 }
453
454 fn home_dir() -> PathBuf {
455 std::env::var_os("HOME")
456 .map(PathBuf::from)
457 .or_else(|| std::env::current_dir().ok())
458 .unwrap_or_else(|| PathBuf::from("."))
459 }
460
461 fn matches_extension(path: &Path, extensions: &[&str]) -> bool {
462 extensions.is_empty()
463 || extensions.iter().any(|extension| {
464 let wanted = extension.trim_start_matches('.').to_ascii_lowercase();
465 let name = path
466 .file_name()
467 .unwrap_or_default()
468 .to_string_lossy()
469 .to_ascii_lowercase();
470 name.ends_with(&format!(".{wanted}"))
471 })
472 }
473 }
474
475 impl ModelStore for FileModelStore {
476 fn backend_label(&self) -> String {
477 format!("filesystem: {}", self.dir.display())
478 }
479
480 fn list(&self) -> Vec<String> {
481 let mut names: Vec<String> = std::fs::read_dir(&self.dir)
482 .into_iter()
483 .flatten()
484 .flatten()
485 .filter_map(|entry| {
486 let name = entry.file_name().to_string_lossy().into_owned();
487 name.ends_with(MODEL_EXT).then(|| model_display_name(&name))
488 })
489 .collect();
490 names.sort();
491 names
492 }
493
494 fn read(&self, name: &str) -> Option<String> {
495 std::fs::read_to_string(self.resolve(name)).ok()
496 }
497
498 fn write(&self, name: &str, contents: &str) -> Result<(), String> {
499 let path = self.resolve(name);
500 if let Some(parent) = path.parent() {
501 std::fs::create_dir_all(parent).map_err(|e| format!("create models dir: {e}"))?;
502 }
503 std::fs::write(&path, contents).map_err(|e| format!("write {}: {e}", path.display()))
504 }
505
506 fn remove(&self, name: &str) -> Result<(), String> {
507 match std::fs::remove_file(self.resolve(name)) {
508 Ok(()) => Ok(()),
509 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
510 Err(e) => Err(format!("remove: {e}")),
511 }
512 }
513
514 fn browser_location(&self) -> String {
515 self.browser_dir.borrow().display().to_string()
516 }
517
518 fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
519 let mut entries: Vec<BrowserEntry> = std::fs::read_dir(&*self.browser_dir.borrow())
520 .into_iter()
521 .flatten()
522 .flatten()
523 .filter_map(|entry| {
524 let path = entry.path();
525 let is_dir = path.is_dir();
526 if !(is_dir || (path.is_file() && Self::matches_extension(&path, extensions))) {
527 return None;
528 }
529 let meta = entry.metadata().ok();
530 let size = meta.as_ref().filter(|m| m.is_file()).map(|m| m.len());
531 let modified = meta
532 .as_ref()
533 .and_then(|m| m.modified().ok())
534 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
535 .map(|d| d.as_secs_f64());
536 Some(BrowserEntry {
537 name: entry.file_name().to_string_lossy().into_owned(),
538 identity: path.to_string_lossy().into_owned(),
539 is_dir,
540 size,
541 modified,
542 })
543 })
544 .collect();
545 entries.sort_by(|a, b| {
546 b.is_dir
547 .cmp(&a.is_dir)
548 .then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
549 });
550 entries
551 }
552
553 fn browser_enter(&self, identity: &str) -> Result<(), String> {
554 let path = PathBuf::from(identity);
555 if !path.is_dir() {
556 return Err(format!("not a directory: {}", path.display()));
557 }
558 *self.browser_dir.borrow_mut() = path;
559 Ok(())
560 }
561
562 fn browser_up(&self) -> Result<(), String> {
563 let parent = self.browser_dir.borrow().parent().map(Path::to_path_buf);
564 if let Some(parent) = parent {
565 *self.browser_dir.borrow_mut() = parent;
566 }
567 Ok(())
568 }
569
570 fn browser_home(&self) -> Result<(), String> {
571 *self.browser_dir.borrow_mut() = Self::home_dir();
572 Ok(())
573 }
574
575 fn browser_root(&self) -> Result<(), String> {
576 let current = self.browser_dir.borrow().clone();
577 let root = current
578 .ancestors()
579 .last()
580 .map(Path::to_path_buf)
581 .unwrap_or_else(|| PathBuf::from(std::path::MAIN_SEPARATOR.to_string()));
582 *self.browser_dir.borrow_mut() = root;
583 Ok(())
584 }
585
586 fn browser_navigate(&self, location: &str) -> Result<(), String> {
587 let path = PathBuf::from(location);
588 if path.is_dir() {
589 *self.browser_dir.borrow_mut() = path;
590 Ok(())
591 } else {
592 Err(format!("not a directory: {location}"))
593 }
594 }
595
596 fn browser_places(&self) -> Vec<BrowserPlace> {
597 let home = Self::home_dir();
598 let mut places = vec![BrowserPlace {
599 label: "Home".into(),
600 location: home.display().to_string(),
601 kind: PlaceKind::Home,
602 }];
603 for (label, sub, kind) in [
604 ("Documents", "Documents", PlaceKind::Documents),
605 ("Downloads", "Downloads", PlaceKind::Downloads),
606 ] {
607 let path = home.join(sub);
608 if path.is_dir() {
609 places.push(BrowserPlace {
610 label: label.into(),
611 location: path.display().to_string(),
612 kind,
613 });
614 }
615 }
616 places.push(BrowserPlace {
617 label: "Models".into(),
618 location: self.dir.display().to_string(),
619 kind: PlaceKind::Models,
620 });
621 places.push(BrowserPlace {
622 label: "/".into(),
623 location: "/".into(),
624 kind: PlaceKind::Root,
625 });
626 places
627 }
628
629 fn browser_create_dir(&self, name: &str) -> Result<(), String> {
630 let name = PathBuf::from(name);
631 if name.components().count() != 1 {
632 return Err("folder name must be one path component".into());
633 }
634 let path = self.browser_dir.borrow().join(name);
635 std::fs::create_dir(&path)
636 .map_err(|e| format!("create folder {}: {e}", path.display()))
637 }
638
639 fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
640 let mut file_name = PathBuf::from(name)
641 .file_name()
642 .ok_or("invalid file name")?
643 .to_os_string();
644 if !name.to_ascii_lowercase().ends_with(".json") {
645 file_name.push(MODEL_EXT);
646 }
647 let path = self.browser_dir.borrow().join(file_name);
648 std::fs::write(&path, contents)
649 .map_err(|e| format!("write {}: {e}", path.display()))?;
650 Ok(path.to_string_lossy().into_owned())
651 }
652
653 fn list_external_files(&self, extensions: &[&str]) -> Vec<String> {
654 let wanted: Vec<String> = extensions
655 .iter()
656 .map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
657 .collect();
658 let mut names: Vec<String> = std::fs::read_dir(&self.dir)
659 .into_iter()
660 .flatten()
661 .flatten()
662 .filter_map(|entry| {
663 let path = entry.path();
664 path.is_file().then_some(path)
665 })
666 .filter_map(|path| {
667 let extension = path.extension()?.to_string_lossy().to_ascii_lowercase();
668 wanted.contains(&extension).then(|| {
669 path.file_name()
670 .unwrap_or_default()
671 .to_string_lossy()
672 .into_owned()
673 })
674 })
675 .collect();
676 names.sort();
677 names
678 }
679
680 fn read_external_file(&self, name: &str) -> Option<Vec<u8>> {
681 let path = PathBuf::from(name);
682 let path = if path.is_absolute() || path.components().count() > 1 {
683 path
684 } else {
685 self.browser_dir.borrow().join(path)
686 };
687 std::fs::read(path).ok()
688 }
689
690 fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
691 std::fs::create_dir_all(&self.dir)
692 .map_err(|e| format!("create models dir: {e}"))?;
693 let name = PathBuf::from(file_name)
694 .file_name()
695 .ok_or("invalid export file name")?
696 .to_owned();
697 let path = self.dir.join(name);
698 std::fs::write(&path, contents)
699 .map_err(|e| format!("write {}: {e}", path.display()))
700 }
701 }
702}
703
704#[cfg(any(target_arch = "wasm32", test))]
723pub(crate) mod mirror_store {
724 use super::{
725 model_display_name, BrowserEntry, BrowserPlace, ModelStore, PlaceKind, DOCK_LAYOUT_KEY,
726 FEATURE_PALETTE_DISPLAY_KEY, MODEL_EXT, PINNED_KEY, RECOVERY_KEY, SESSION_KEY, SETTINGS_KEY,
727 };
728 use std::cell::{Cell, RefCell};
729 use std::collections::BTreeMap;
730 use std::future::Future;
731 use std::pin::Pin;
732 use std::rc::Rc;
733
734 pub(crate) const PREFIX: &str = "brep-app:model:";
740 pub(crate) const DIR_PREFIX: &str = "brep-app:dir:";
741
742 const MAX_ERRORS: usize = 64;
745
746 pub(crate) type BackendFuture<T> = Pin<Box<dyn Future<Output = Result<T, String>>>>;
751
752 pub(crate) trait StoreBackend {
789 fn label(&self) -> String;
792
793 fn load_all(&self) -> BackendFuture<Vec<(String, String)>>;
796
797 fn put(&self, key: &str, value: &str) -> BackendFuture<()>;
799
800 fn delete(&self, key: &str) -> BackendFuture<()>;
802 }
803
804 pub(crate) struct UnavailableBackend {
813 reason: String,
814 }
815
816 impl UnavailableBackend {
817 pub(crate) fn new(reason: impl Into<String>) -> Self {
818 Self {
819 reason: reason.into(),
820 }
821 }
822 }
823
824 impl StoreBackend for UnavailableBackend {
825 fn label(&self) -> String {
826 format!("NOT SAVING — {} · use Download to keep your work", self.reason)
827 }
828
829 fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
830 let reason = self.reason.clone();
831 Box::pin(async move { Err(reason) })
832 }
833
834 fn put(&self, _key: &str, _value: &str) -> BackendFuture<()> {
835 let reason = self.reason.clone();
836 Box::pin(async move { Err(reason) })
837 }
838
839 fn delete(&self, _key: &str) -> BackendFuture<()> {
840 let reason = self.reason.clone();
841 Box::pin(async move { Err(reason) })
842 }
843 }
844
845 pub(crate) struct ErrorLog {
854 log: RefCell<Vec<String>>,
855 drained: Cell<usize>,
856 wake: Option<Rc<dyn Fn()>>,
859 }
860
861 impl ErrorLog {
862 fn new(wake: Option<Rc<dyn Fn()>>) -> Self {
863 Self {
864 log: RefCell::new(Vec::new()),
865 drained: Cell::new(0),
866 wake,
867 }
868 }
869
870 pub(crate) fn record(&self, message: String) {
875 {
876 let mut log = self.log.borrow_mut();
877 let undrained = log.len() > self.drained.get();
878 if undrained && log.last().map(|last| *last == message).unwrap_or(false) {
879 return;
880 }
881 log.push(message);
882 if log.len() > MAX_ERRORS {
883 log.remove(0);
884 self.drained.set(self.drained.get().saturating_sub(1));
885 }
886 }
887 if let Some(wake) = &self.wake {
888 wake();
889 }
890 }
891
892 fn drain(&self) -> Vec<String> {
894 let log = self.log.borrow();
895 let from = self.drained.get().min(log.len());
896 self.drained.set(log.len());
897 log[from..].to_vec()
898 }
899
900 fn all(&self) -> Vec<String> {
902 self.log.borrow().clone()
903 }
904 }
905
906 #[cfg(target_arch = "wasm32")]
915 fn spawn(task: impl Future<Output = ()> + 'static) {
916 wasm_bindgen_futures::spawn_local(task);
917 }
918
919 #[cfg(not(target_arch = "wasm32"))]
920 fn spawn(task: impl Future<Output = ()> + 'static) {
921 let mut task = Box::pin(task);
922 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
923 let _ = task.as_mut().poll(&mut cx);
924 }
925
926 #[derive(Clone)]
931 pub(crate) struct MirrorStore {
932 entries: Rc<RefCell<BTreeMap<String, String>>>,
934 browser_dir: Rc<RefCell<String>>,
936 backend: Rc<dyn StoreBackend>,
937 errors: Rc<ErrorLog>,
938 pending: Rc<Cell<usize>>,
942 }
943
944 impl MirrorStore {
945 pub(crate) fn new(
949 backend: Rc<dyn StoreBackend>,
950 entries: Vec<(String, String)>,
951 wake: Option<Rc<dyn Fn()>>,
952 ) -> Self {
953 Self {
954 entries: Rc::new(RefCell::new(entries.into_iter().collect())),
955 browser_dir: Rc::new(RefCell::new("/models".into())),
956 backend,
957 errors: Rc::new(ErrorLog::new(wake)),
958 pending: Rc::new(Cell::new(0)),
959 }
960 }
961
962 pub(crate) fn key(name: &str) -> String {
966 match name {
967 SETTINGS_KEY => "brep-app:settings".into(),
968 FEATURE_PALETTE_DISPLAY_KEY => "brep-app:feature_palette_display".into(),
969 DOCK_LAYOUT_KEY => "brep-app:dock_layout".into(),
970 SESSION_KEY => "brep-app:session".into(),
971 PINNED_KEY => "brep-app:pinned".into(),
972 RECOVERY_KEY => "brep-app:recovery".into(),
973 _ => format!("{PREFIX}{}", Self::model_relative(name)),
974 }
975 }
976
977 fn model_relative(name: &str) -> String {
980 let name = name
981 .trim_start_matches('/')
982 .strip_prefix("models/")
983 .unwrap_or_else(|| name.trim_start_matches('/'));
984 name.strip_suffix(MODEL_EXT)
985 .or_else(|| name.strip_suffix(".json"))
986 .unwrap_or(name)
987 .trim_matches('/')
988 .to_string()
989 }
990
991 fn virtual_model_path(relative: &str) -> String {
992 format!("/models/{}{MODEL_EXT}", relative.trim_matches('/'))
993 }
994
995 fn child_path(parent: &str, child: &str) -> String {
996 if parent == "/" {
997 format!("/{child}")
998 } else {
999 format!("{}/{child}", parent.trim_end_matches('/'))
1000 }
1001 }
1002
1003 fn push(&self, name: &str, request: BackendFuture<()>) {
1006 self.pending.set(self.pending.get() + 1);
1007 let pending = self.pending.clone();
1008 let errors = self.errors.clone();
1009 let name = name.to_string();
1010 spawn(async move {
1011 let outcome = request.await;
1012 pending.set(pending.get().saturating_sub(1));
1013 if let Err(message) = outcome {
1014 errors.record(format!("'{name}' was NOT saved: {message}"));
1015 }
1016 });
1017 }
1018
1019 pub(crate) fn report(&self, message: String) {
1021 self.errors.record(message);
1022 }
1023
1024 pub(crate) fn pending(&self) -> usize {
1026 self.pending.get()
1027 }
1028
1029 pub(crate) fn error_history(&self) -> Vec<String> {
1031 self.errors.all()
1032 }
1033
1034 pub(crate) fn len_of(&self, name: &str) -> Option<usize> {
1038 self.entries.borrow().get(&Self::key(name)).map(|v| v.len())
1039 }
1040 }
1041
1042 impl ModelStore for MirrorStore {
1043 fn backend_label(&self) -> String {
1044 self.backend.label()
1045 }
1046
1047 fn list(&self) -> Vec<String> {
1048 self.entries
1050 .borrow()
1051 .keys()
1052 .filter_map(|key| key.strip_prefix(PREFIX).map(str::to_string))
1053 .collect()
1054 }
1055
1056 fn read(&self, name: &str) -> Option<String> {
1057 self.entries.borrow().get(&Self::key(name)).cloned()
1058 }
1059
1060 fn write(&self, name: &str, contents: &str) -> Result<(), String> {
1061 let key = Self::key(name);
1062 self.entries
1063 .borrow_mut()
1064 .insert(key.clone(), contents.to_string());
1065 self.push(name, self.backend.put(&key, contents));
1069 Ok(())
1070 }
1071
1072 fn remove(&self, name: &str) -> Result<(), String> {
1073 let key = Self::key(name);
1074 self.entries.borrow_mut().remove(&key);
1075 self.push(name, self.backend.delete(&key));
1076 Ok(())
1077 }
1078
1079 fn take_persistence_errors(&self) -> Vec<String> {
1080 self.errors.drain()
1081 }
1082
1083 fn browser_location(&self) -> String {
1084 self.browser_dir.borrow().clone()
1088 }
1089
1090 fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
1091 let current = self.browser_dir.borrow().clone();
1092 if current == "/" {
1093 return vec![BrowserEntry {
1094 name: "models".into(),
1095 identity: "/models".into(),
1096 is_dir: true,
1097 size: None,
1098 modified: None,
1099 }];
1100 }
1101 let relative_dir = current
1102 .strip_prefix("/models")
1103 .unwrap_or("")
1104 .trim_matches('/');
1105 let prefix = if relative_dir.is_empty() {
1106 String::new()
1107 } else {
1108 format!("{relative_dir}/")
1109 };
1110 let wanted: Vec<String> = extensions
1111 .iter()
1112 .map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
1113 .collect();
1114 let mut entries: BTreeMap<String, BrowserEntry> = BTreeMap::new();
1115 for (key, value) in self.entries.borrow().iter() {
1116 let (item, is_model) = if let Some(item) = key.strip_prefix(PREFIX) {
1117 (item, true)
1118 } else if let Some(item) = key.strip_prefix(DIR_PREFIX) {
1119 (item.trim_end_matches('/'), false)
1120 } else {
1121 continue;
1122 };
1123 let Some(rest) = item.strip_prefix(&prefix) else {
1124 continue;
1125 };
1126 if rest.is_empty() {
1127 continue;
1128 }
1129 if let Some((child, _)) = rest.split_once('/') {
1130 entries
1131 .entry(child.to_string())
1132 .or_insert_with(|| BrowserEntry {
1133 name: child.to_string(),
1134 identity: Self::child_path(¤t, child),
1135 is_dir: true,
1136 size: None,
1137 modified: None,
1138 });
1139 } else if is_model {
1140 let name = format!("{rest}{MODEL_EXT}");
1141 let lower = name.to_ascii_lowercase();
1142 if wanted.is_empty()
1143 || wanted
1144 .iter()
1145 .any(|extension| lower.ends_with(&format!(".{extension}")))
1146 {
1147 entries.insert(
1150 name.clone(),
1151 BrowserEntry {
1152 name,
1153 identity: Self::virtual_model_path(item),
1154 is_dir: false,
1155 size: Some(value.len() as u64),
1156 modified: None,
1157 },
1158 );
1159 }
1160 } else {
1161 entries
1162 .entry(rest.to_string())
1163 .or_insert_with(|| BrowserEntry {
1164 name: rest.to_string(),
1165 identity: Self::child_path(¤t, rest),
1166 is_dir: true,
1167 size: None,
1168 modified: None,
1169 });
1170 }
1171 }
1172 let mut entries: Vec<_> = entries.into_values().collect();
1173 entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
1174 entries
1175 }
1176
1177 fn browser_enter(&self, identity: &str) -> Result<(), String> {
1178 if identity == "/models" || identity.starts_with("/models/") {
1179 *self.browser_dir.borrow_mut() = identity.trim_end_matches('/').to_string();
1180 Ok(())
1181 } else {
1182 Err("the browser virtual filesystem is rooted at /models".into())
1183 }
1184 }
1185
1186 fn browser_up(&self) -> Result<(), String> {
1187 let current = self.browser_dir.borrow().clone();
1188 if current == "/" {
1189 return Ok(());
1190 }
1191 let parent = current
1192 .rsplit_once('/')
1193 .map(|(parent, _)| parent)
1194 .unwrap_or("");
1195 *self.browser_dir.borrow_mut() = if parent.is_empty() {
1196 "/".into()
1197 } else {
1198 parent.into()
1199 };
1200 Ok(())
1201 }
1202
1203 fn browser_home(&self) -> Result<(), String> {
1204 *self.browser_dir.borrow_mut() = "/models".into();
1205 Ok(())
1206 }
1207
1208 fn browser_root(&self) -> Result<(), String> {
1209 *self.browser_dir.borrow_mut() = "/".into();
1210 Ok(())
1211 }
1212
1213 fn browser_navigate(&self, location: &str) -> Result<(), String> {
1214 let loc = location.trim_end_matches('/');
1215 let loc = if loc.is_empty() { "/" } else { loc };
1216 if loc == "/" || loc == "/models" || loc.starts_with("/models/") {
1217 *self.browser_dir.borrow_mut() = loc.to_string();
1218 Ok(())
1219 } else {
1220 Err("the browser virtual filesystem is rooted at /models".into())
1221 }
1222 }
1223
1224 fn browser_places(&self) -> Vec<BrowserPlace> {
1225 vec![
1226 BrowserPlace {
1227 label: "Models".into(),
1228 location: "/models".into(),
1229 kind: PlaceKind::Models,
1230 },
1231 BrowserPlace {
1232 label: "/".into(),
1233 location: "/".into(),
1234 kind: PlaceKind::Root,
1235 },
1236 ]
1237 }
1238
1239 fn browser_create_dir(&self, name: &str) -> Result<(), String> {
1240 if name.is_empty()
1241 || name == "."
1242 || name == ".."
1243 || name.contains('/')
1244 || name.contains('\\')
1245 {
1246 return Err("folder name must be one path component".into());
1247 }
1248 let current = self.browser_dir.borrow().clone();
1249 if !current.starts_with("/models") {
1250 return Err("folders can only be created under /models".into());
1251 }
1252 let relative = Self::child_path(¤t, name)
1253 .trim_start_matches("/models/")
1254 .to_string();
1255 let key = format!("{DIR_PREFIX}{relative}/");
1258 self.entries.borrow_mut().insert(key.clone(), String::new());
1259 self.push(name, self.backend.put(&key, ""));
1260 Ok(())
1261 }
1262
1263 fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
1264 let file = model_display_name(name);
1265 if file.is_empty() {
1266 return Err("invalid file name".into());
1267 }
1268 let current = self.browser_dir.borrow().clone();
1269 if !current.starts_with("/models") {
1270 return Err("select a folder under /models".into());
1271 }
1272 let identity = format!("{}{MODEL_EXT}", Self::child_path(¤t, &file));
1273 self.write(&identity, contents)?;
1274 Ok(identity)
1275 }
1276 }
1277}
1278
1279#[cfg(target_arch = "wasm32")]
1281mod web_model {
1282 use super::mirror_store::{BackendFuture, MirrorStore, StoreBackend, UnavailableBackend};
1283 use super::{
1284 model_display_name, BrowserEntry, BrowserPlace, ImportedFile, ModelStore, MODEL_EXT,
1285 };
1286 use std::cell::RefCell;
1287 use std::future::Future;
1288 use std::pin::Pin;
1289 use std::rc::Rc;
1290 use std::task::{Context, Poll, Waker};
1291 use wasm_bindgen::prelude::*;
1292 use wasm_bindgen::JsCast;
1293
1294 const DB_NAME: &str = "brep-app";
1303 const DB_VERSION: u32 = 1;
1304 const STORE_NAME: &str = "kv";
1306
1307 thread_local! {
1308 static IMPORT_INPUT: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
1310 static IMPORTED: RefCell<Option<ImportedFile>> = const { RefCell::new(None) };
1312 static REPAINT_CTX: RefCell<Option<eframe::egui::Context>> = const { RefCell::new(None) };
1318 static BOOT_STORE: RefCell<Option<IdbModelStore>> = const { RefCell::new(None) };
1324 }
1325
1326 pub fn set_repaint_ctx(ctx: eframe::egui::Context) {
1330 REPAINT_CTX.with(|c| *c.borrow_mut() = Some(ctx));
1331 }
1332
1333 fn wake_frame_loop() {
1337 REPAINT_CTX.with(|c| {
1338 if let Some(ctx) = c.borrow().as_ref() {
1339 ctx.request_repaint();
1340 }
1341 });
1342 }
1343
1344 #[derive(Default)]
1353 struct Settled {
1354 outcome: Option<Result<JsValue, String>>,
1355 waker: Option<Waker>,
1356 }
1357
1358 enum EventSource {
1361 Request(web_sys::IdbRequest),
1362 Transaction(web_sys::IdbTransaction),
1363 }
1364
1365 struct IdbFuture {
1367 source: EventSource,
1368 settled: Rc<RefCell<Settled>>,
1369 _handlers: Vec<Closure<dyn FnMut(web_sys::Event)>>,
1371 }
1372
1373 fn settle(settled: &Rc<RefCell<Settled>>, outcome: Result<JsValue, String>) {
1374 let waker = {
1375 let mut settled = settled.borrow_mut();
1376 if settled.outcome.is_none() {
1377 settled.outcome = Some(outcome);
1378 }
1379 settled.waker.take()
1380 };
1381 if let Some(waker) = waker {
1382 waker.wake();
1383 }
1384 }
1385
1386 fn request_error(request: &web_sys::IdbRequest) -> String {
1387 request
1388 .error()
1389 .ok()
1390 .flatten()
1391 .map(|error| format!("{}: {}", error.name(), error.message()))
1392 .unwrap_or_else(|| "IndexedDB request failed".into())
1393 }
1394
1395 fn js_error(value: &JsValue) -> String {
1396 value
1397 .as_string()
1398 .or_else(|| js_sys::Reflect::get(value, &JsValue::from_str("message")).ok()?.as_string())
1399 .unwrap_or_else(|| format!("{value:?}"))
1400 }
1401
1402 fn on_request(request: web_sys::IdbRequest) -> IdbFuture {
1404 let settled = Rc::new(RefCell::new(Settled::default()));
1405 let success = {
1406 let settled = settled.clone();
1407 let request = request.clone();
1408 Closure::wrap(Box::new(move |_event: web_sys::Event| {
1409 let value = request.result().unwrap_or(JsValue::UNDEFINED);
1410 settle(&settled, Ok(value));
1411 }) as Box<dyn FnMut(web_sys::Event)>)
1412 };
1413 let failure = {
1414 let settled = settled.clone();
1415 let request = request.clone();
1416 Closure::wrap(Box::new(move |_event: web_sys::Event| {
1417 settle(&settled, Err(request_error(&request)));
1418 }) as Box<dyn FnMut(web_sys::Event)>)
1419 };
1420 request.set_onsuccess(Some(success.as_ref().unchecked_ref()));
1421 request.set_onerror(Some(failure.as_ref().unchecked_ref()));
1422 IdbFuture {
1423 source: EventSource::Request(request),
1424 settled,
1425 _handlers: vec![success, failure],
1426 }
1427 }
1428
1429 fn on_transaction(transaction: web_sys::IdbTransaction) -> IdbFuture {
1437 let settled = Rc::new(RefCell::new(Settled::default()));
1438 let complete = {
1439 let settled = settled.clone();
1440 Closure::wrap(Box::new(move |_event: web_sys::Event| {
1441 settle(&settled, Ok(JsValue::UNDEFINED));
1442 }) as Box<dyn FnMut(web_sys::Event)>)
1443 };
1444 let describe = {
1445 let transaction = transaction.clone();
1446 move |fallback: &str| {
1447 transaction
1448 .error()
1449 .map(|error| format!("{}: {}", error.name(), error.message()))
1450 .unwrap_or_else(|| fallback.to_string())
1451 }
1452 };
1453 let failure = {
1454 let settled = settled.clone();
1455 let describe = describe.clone();
1456 Closure::wrap(Box::new(move |_event: web_sys::Event| {
1457 settle(&settled, Err(describe("IndexedDB transaction failed")));
1458 }) as Box<dyn FnMut(web_sys::Event)>)
1459 };
1460 let abort = {
1461 let settled = settled.clone();
1462 Closure::wrap(Box::new(move |_event: web_sys::Event| {
1463 settle(
1464 &settled,
1465 Err(describe("IndexedDB transaction aborted (quota?)")),
1466 );
1467 }) as Box<dyn FnMut(web_sys::Event)>)
1468 };
1469 transaction.set_oncomplete(Some(complete.as_ref().unchecked_ref()));
1470 transaction.set_onerror(Some(failure.as_ref().unchecked_ref()));
1471 transaction.set_onabort(Some(abort.as_ref().unchecked_ref()));
1472 IdbFuture {
1473 source: EventSource::Transaction(transaction),
1474 settled,
1475 _handlers: vec![complete, failure, abort],
1476 }
1477 }
1478
1479 impl Future for IdbFuture {
1480 type Output = Result<JsValue, String>;
1481
1482 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
1483 let mut settled = self.settled.borrow_mut();
1484 if let Some(outcome) = settled.outcome.take() {
1485 return Poll::Ready(outcome);
1486 }
1487 settled.waker = Some(cx.waker().clone());
1488 Poll::Pending
1489 }
1490 }
1491
1492 impl Drop for IdbFuture {
1493 fn drop(&mut self) {
1494 match &self.source {
1497 EventSource::Request(request) => {
1498 request.set_onsuccess(None);
1499 request.set_onerror(None);
1500 }
1501 EventSource::Transaction(transaction) => {
1502 transaction.set_oncomplete(None);
1503 transaction.set_onerror(None);
1504 transaction.set_onabort(None);
1505 }
1506 }
1507 }
1508 }
1509
1510 pub(super) struct IdbBackend {
1515 db: web_sys::IdbDatabase,
1516 }
1517
1518 impl IdbBackend {
1519 async fn open() -> Result<Self, String> {
1523 let factory = web_sys::window()
1524 .ok_or("no window")?
1525 .indexed_db()
1526 .map_err(|e| js_error(&e))?
1527 .ok_or("no indexedDB on this window")?;
1528 let request = factory
1529 .open_with_u32(DB_NAME, DB_VERSION)
1530 .map_err(|e| js_error(&e))?;
1531
1532 let upgrading = request.clone();
1536 let upgrade = Closure::wrap(Box::new(move |_event: web_sys::Event| {
1537 if let Ok(value) = upgrading.result() {
1538 if let Ok(db) = value.dyn_into::<web_sys::IdbDatabase>() {
1539 let _ = db.create_object_store(STORE_NAME);
1541 }
1542 }
1543 }) as Box<dyn FnMut(web_sys::Event)>);
1544 request.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref()));
1545
1546 let opened = on_request(request.clone().unchecked_into::<web_sys::IdbRequest>()).await;
1547 request.set_onupgradeneeded(None);
1548 drop(upgrade);
1549
1550 let db = opened?
1551 .dyn_into::<web_sys::IdbDatabase>()
1552 .map_err(|_| "IndexedDB open returned no database".to_string())?;
1553 Ok(Self { db })
1554 }
1555
1556 fn transact(
1559 &self,
1560 mode: web_sys::IdbTransactionMode,
1561 ) -> Result<(web_sys::IdbTransaction, web_sys::IdbObjectStore), String> {
1562 let transaction = self
1563 .db
1564 .transaction_with_str_and_mode(STORE_NAME, mode)
1565 .map_err(|e| js_error(&e))?;
1566 let store = transaction.object_store(STORE_NAME).map_err(|e| js_error(&e))?;
1567 Ok((transaction, store))
1568 }
1569 }
1570
1571 impl StoreBackend for IdbBackend {
1572 fn label(&self) -> String {
1573 "browser storage (IndexedDB) · download/upload for files".into()
1574 }
1575
1576 fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
1577 let started = (|| -> Result<(IdbFuture, IdbFuture), String> {
1581 let (_transaction, store) = self.transact(web_sys::IdbTransactionMode::Readonly)?;
1582 let keys = store.get_all_keys().map_err(|e| js_error(&e))?;
1583 let values = store.get_all().map_err(|e| js_error(&e))?;
1584 Ok((on_request(keys), on_request(values)))
1585 })();
1586 Box::pin(async move {
1587 let (keys, values) = started?;
1588 let keys = js_sys::Array::from(&keys.await?);
1589 let values = js_sys::Array::from(&values.await?);
1590 if keys.length() != values.length() {
1591 return Err("IndexedDB returned mismatched keys and values".into());
1592 }
1593 let mut entries = Vec::with_capacity(keys.length() as usize);
1594 for i in 0..keys.length() {
1595 let (Some(key), Some(value)) =
1596 (keys.get(i).as_string(), values.get(i).as_string())
1597 else {
1598 continue;
1601 };
1602 entries.push((key, value));
1603 }
1604 Ok(entries)
1605 })
1606 }
1607
1608 fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
1609 let started = (|| -> Result<IdbFuture, String> {
1614 let (transaction, store) =
1615 self.transact(web_sys::IdbTransactionMode::Readwrite)?;
1616 store
1617 .put_with_key(&JsValue::from_str(value), &JsValue::from_str(key))
1618 .map_err(|e| js_error(&e))?;
1619 Ok(on_transaction(transaction))
1620 })();
1621 Box::pin(async move {
1622 started?.await?;
1623 Ok(())
1624 })
1625 }
1626
1627 fn delete(&self, key: &str) -> BackendFuture<()> {
1628 let started = (|| -> Result<IdbFuture, String> {
1629 let (transaction, store) =
1630 self.transact(web_sys::IdbTransactionMode::Readwrite)?;
1631 store
1632 .delete(&JsValue::from_str(key))
1633 .map_err(|e| js_error(&e))?;
1634 Ok(on_transaction(transaction))
1635 })();
1636 Box::pin(async move {
1637 started?.await?;
1638 Ok(())
1639 })
1640 }
1641 }
1642
1643 pub(super) async fn hydrate() {
1655 let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
1656 let core = match IdbBackend::open().await {
1657 Ok(backend) => {
1658 let backend: Rc<dyn StoreBackend> = Rc::new(backend);
1659 match backend.load_all().await {
1660 Ok(entries) => MirrorStore::new(backend, entries, Some(wake)),
1661 Err(message) => unavailable(
1665 format!("IndexedDB could not be read ({message})"),
1666 Some(wake),
1667 ),
1668 }
1669 }
1670 Err(message) => unavailable(format!("IndexedDB unavailable ({message})"), Some(wake)),
1671 };
1672 install_verification_hooks(&core);
1673 BOOT_STORE.with(|c| *c.borrow_mut() = Some(IdbModelStore { core }));
1674 }
1675
1676 fn unavailable(reason: String, wake: Option<Rc<dyn Fn()>>) -> MirrorStore {
1678 let core = MirrorStore::new(
1679 Rc::new(UnavailableBackend::new(reason.clone())),
1680 Vec::new(),
1681 wake,
1682 );
1683 core.report(format!(
1686 "{reason} — this session will not be saved; use Download to keep your work"
1687 ));
1688 core
1689 }
1690
1691 pub(super) fn take_boot_store() -> Box<dyn ModelStore> {
1694 let store = BOOT_STORE.with(|c| c.borrow_mut().take()).unwrap_or_else(|| {
1695 let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
1696 IdbModelStore {
1697 core: unavailable("storage was never initialised".into(), Some(wake)),
1698 }
1699 });
1700 Box::new(store)
1701 }
1702
1703 fn install_verification_hooks(core: &MirrorStore) {
1713 let Some(window) = web_sys::window() else {
1714 return;
1715 };
1716 let publish = |name: &str, value: &JsValue| {
1717 let _ = js_sys::Reflect::set(&window, &JsValue::from_str(name), value);
1718 };
1719
1720 let store = core.clone();
1721 let write = Closure::wrap(Box::new(move |name: String, contents: String| -> JsValue {
1722 match store.write(&name, &contents) {
1723 Ok(()) => JsValue::NULL,
1724 Err(message) => JsValue::from_str(&message),
1725 }
1726 }) as Box<dyn FnMut(String, String) -> JsValue>);
1727 publish("__brepStoreWrite", write.as_ref());
1728 write.forget();
1729
1730 let store = core.clone();
1731 let len = Closure::wrap(Box::new(move |name: String| -> f64 {
1732 store.len_of(&name).map(|n| n as f64).unwrap_or(-1.0)
1733 }) as Box<dyn FnMut(String) -> f64>);
1734 publish("__brepStoreLen", len.as_ref());
1735 len.forget();
1736
1737 let store = core.clone();
1742 let read = Closure::wrap(Box::new(move |name: String| -> JsValue {
1743 match store.read(&name) {
1744 Some(contents) => JsValue::from_str(&contents),
1745 None => JsValue::NULL,
1746 }
1747 }) as Box<dyn FnMut(String) -> JsValue>);
1748 publish("__brepStoreRead", read.as_ref());
1749 read.forget();
1750
1751 let store = core.clone();
1752 let list = Closure::wrap(Box::new(move || -> JsValue {
1753 JsValue::from_str(&serde_json::to_string(&store.list()).unwrap_or_default())
1754 }) as Box<dyn FnMut() -> JsValue>);
1755 publish("__brepStoreList", list.as_ref());
1756 list.forget();
1757
1758 let store = core.clone();
1759 let errors = Closure::wrap(Box::new(move || -> JsValue {
1760 JsValue::from_str(&serde_json::to_string(&store.error_history()).unwrap_or_default())
1761 }) as Box<dyn FnMut() -> JsValue>);
1762 publish("__brepStoreErrors", errors.as_ref());
1763 errors.forget();
1764
1765 let store = core.clone();
1766 let pending = Closure::wrap(Box::new(move || -> f64 { store.pending() as f64 })
1767 as Box<dyn FnMut() -> f64>);
1768 publish("__brepStorePending", pending.as_ref());
1769 pending.forget();
1770 }
1771
1772 fn ensure_input(accept: &str) -> Option<web_sys::HtmlInputElement> {
1782 if let Some(existing) = IMPORT_INPUT.with(|c| c.borrow().clone()) {
1783 existing.set_accept(accept);
1784 return Some(existing);
1785 }
1786 let document = web_sys::window()?.document()?;
1787 let input: web_sys::HtmlInputElement =
1788 document.create_element("input").ok()?.dyn_into().ok()?;
1789 input.set_type("file");
1790 input.set_accept(accept);
1791 input.set_hidden(true);
1792
1793 let input_for_cb = input.clone();
1795 let onchange = Closure::wrap(Box::new(move |_e: web_sys::Event| {
1796 let Some(files) = input_for_cb.files() else { return };
1797 let Some(file) = files.get(0) else { return };
1798 let name = file.name();
1799 let Ok(reader) = web_sys::FileReader::new() else { return };
1800 let reader_for_load = reader.clone();
1801 let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| {
1802 if let Ok(value) = reader_for_load.result() {
1803 let bytes = js_sys::Uint8Array::new(&value).to_vec();
1804 IMPORTED.with(|c| *c.borrow_mut() = Some(ImportedFile {
1805 name: model_display_name(&name),
1806 bytes,
1807 }));
1808 wake_frame_loop();
1811 }
1812 }) as Box<dyn FnMut(web_sys::Event)>);
1813 reader.set_onload(Some(onload.as_ref().unchecked_ref()));
1814 onload.forget();
1816 let _ = reader.read_as_array_buffer(&file);
1817 }) as Box<dyn FnMut(web_sys::Event)>);
1818 input.set_onchange(Some(onchange.as_ref().unchecked_ref()));
1819 onchange.forget(); if let Some(body) = document.body() {
1822 let _ = body.append_child(&input);
1823 }
1824 IMPORT_INPUT.with(|c| *c.borrow_mut() = Some(input.clone()));
1825 Some(input)
1826 }
1827
1828 fn download(file_name: &str, mime: &str, contents: &str) -> Result<(), String> {
1831 let document = web_sys::window()
1832 .and_then(|w| w.document())
1833 .ok_or("no document")?;
1834 let parts = js_sys::Array::of1(&JsValue::from_str(contents));
1835 let options = web_sys::BlobPropertyBag::new();
1836 options.set_type(mime);
1837 let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
1838 .map_err(|_| "blob create failed".to_string())?;
1839 let url = web_sys::Url::create_object_url_with_blob(&blob)
1840 .map_err(|_| "object url failed".to_string())?;
1841 let anchor: web_sys::HtmlAnchorElement = document
1842 .create_element("a")
1843 .map_err(|_| "anchor create failed".to_string())?
1844 .dyn_into()
1845 .map_err(|_| "anchor cast failed".to_string())?;
1846 anchor.set_href(&url);
1847 anchor.set_download(file_name);
1848 anchor.click();
1849 let _ = web_sys::Url::revoke_object_url(&url);
1850 Ok(())
1851 }
1852
1853 pub(super) struct IdbModelStore {
1856 core: MirrorStore,
1857 }
1858
1859 impl ModelStore for IdbModelStore {
1860 fn backend_label(&self) -> String {
1862 self.core.backend_label()
1863 }
1864 fn list(&self) -> Vec<String> {
1865 self.core.list()
1866 }
1867 fn read(&self, name: &str) -> Option<String> {
1868 self.core.read(name)
1869 }
1870 fn write(&self, name: &str, contents: &str) -> Result<(), String> {
1871 self.core.write(name, contents)
1872 }
1873 fn remove(&self, name: &str) -> Result<(), String> {
1874 self.core.remove(name)
1875 }
1876 fn take_persistence_errors(&self) -> Vec<String> {
1877 self.core.take_persistence_errors()
1878 }
1879 fn browser_location(&self) -> String {
1880 self.core.browser_location()
1881 }
1882 fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
1883 self.core.browser_entries(extensions)
1884 }
1885 fn browser_enter(&self, identity: &str) -> Result<(), String> {
1886 self.core.browser_enter(identity)
1887 }
1888 fn browser_up(&self) -> Result<(), String> {
1889 self.core.browser_up()
1890 }
1891 fn browser_home(&self) -> Result<(), String> {
1892 self.core.browser_home()
1893 }
1894 fn browser_root(&self) -> Result<(), String> {
1895 self.core.browser_root()
1896 }
1897 fn browser_navigate(&self, location: &str) -> Result<(), String> {
1898 self.core.browser_navigate(location)
1899 }
1900 fn browser_places(&self) -> Vec<BrowserPlace> {
1901 self.core.browser_places()
1902 }
1903 fn browser_create_dir(&self, name: &str) -> Result<(), String> {
1904 self.core.browser_create_dir(name)
1905 }
1906 fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
1907 self.core.browser_write(name, contents)
1908 }
1909
1910 fn supports_file_interchange(&self) -> bool {
1912 true
1913 }
1914
1915 fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
1918 let name = model_display_name(name);
1919 download(&format!("{name}{MODEL_EXT}"), "application/json", contents)?;
1920 Ok(Some(name))
1921 }
1922
1923 fn begin_import(&self) -> Result<(), String> {
1924 let input =
1925 ensure_input(".json,.BREP.json,application/json").ok_or("file input unavailable")?;
1926 input.set_value("");
1928 input.click();
1929 Ok(())
1930 }
1931
1932 fn take_import(&self) -> Option<ImportedFile> {
1933 IMPORTED.with(|c| c.borrow_mut().take())
1934 }
1935
1936 fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
1941 let accept = filter
1942 .1
1943 .iter()
1944 .map(|ext| format!(".{ext}"))
1945 .collect::<Vec<_>>()
1946 .join(",");
1947 let input = ensure_input(&accept).ok_or("file input unavailable")?;
1948 input.set_value("");
1949 input.click();
1950 Ok(())
1951 }
1952
1953 fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
1956 download(file_name, "application/octet-stream", contents)
1957 }
1958 }
1959}
1960
1961
1962#[cfg(all(test, not(target_arch = "wasm32")))]
1963mod tests {
1964 use super::native_model::FileModelStore;
1965 use super::{ModelStore, DOCK_LAYOUT_KEY, FEATURE_PALETTE_DISPLAY_KEY, SETTINGS_KEY};
1966
1967 #[test]
1968 fn native_model_store_round_trips_named_documents() {
1969 let dir = std::env::temp_dir().join(format!("brep-app-models-{}", std::process::id()));
1970 let _ = std::fs::remove_dir_all(&dir);
1971 let store = FileModelStore::with_dir(dir.clone());
1972
1973 assert!(store.list().is_empty());
1975 assert_eq!(store.read("missing"), None);
1976
1977 let doc_a = r#"{"features":[{"type":"P.CU"}]}"#;
1979 let doc_b = r#"{"features":[]}"#;
1980 store.write("alpha", doc_a).unwrap();
1981 store.write("beta", doc_b).unwrap();
1982 assert_eq!(store.read("alpha").as_deref(), Some(doc_a));
1983 assert_eq!(store.read("beta").as_deref(), Some(doc_b));
1984 assert_eq!(store.list(), vec!["alpha".to_string(), "beta".to_string()]);
1985
1986 assert_eq!(store.read("alpha.BREP.json").as_deref(), Some(doc_a));
1988
1989 store.write("alpha", doc_b).unwrap();
1991 assert_eq!(store.read("alpha").as_deref(), Some(doc_b));
1992 store.remove("alpha").unwrap();
1993 assert_eq!(store.read("alpha"), None);
1994 assert_eq!(store.list(), vec!["beta".to_string()]);
1995 store.remove("alpha").unwrap(); let _ = std::fs::remove_dir_all(&dir);
1998 }
1999
2000 #[test]
2001 fn native_store_uses_the_same_read_write_api_for_app_state_and_models() {
2002 let dir = std::env::temp_dir().join(format!(
2003 "brep-app-unified-store-{}",
2004 std::process::id()
2005 ));
2006 let _ = std::fs::remove_dir_all(&dir);
2007 let store = FileModelStore::with_dir(dir.clone());
2008
2009 store.write(SETTINGS_KEY, r#"{"theme":"dark"}"#).unwrap();
2010 store.write(DOCK_LAYOUT_KEY, r#"{"tiles":[]}"#).unwrap();
2011 store.write(super::FEATURE_PALETTE_DISPLAY_KEY, "\"medium_icons\"").unwrap();
2012 store.write("part", r#"{"features":[]}"#).unwrap();
2013
2014 assert_eq!(
2015 store.read(SETTINGS_KEY).as_deref(),
2016 Some(r#"{"theme":"dark"}"#)
2017 );
2018 assert_eq!(
2019 store.read(DOCK_LAYOUT_KEY).as_deref(),
2020 Some(r#"{"tiles":[]}"#)
2021 );
2022 assert_eq!(store.list(), vec!["part".to_string()]);
2023 assert!(dir.join("settings.json").is_file());
2024 assert!(dir.join("dock_layout.json").is_file());
2025 assert_eq!(store.read(FEATURE_PALETTE_DISPLAY_KEY).as_deref(), Some("\"medium_icons\""));
2026 assert!(dir.join("feature_palette_display.json").is_file());
2027 assert!(dir.join("part.BREP.json").is_file());
2028
2029 let _ = std::fs::remove_dir_all(&dir);
2030 }
2031
2032 #[test]
2033 fn native_browser_navigates_and_saves_outside_the_model_directory() {
2034 let root = std::env::temp_dir().join(format!(
2035 "brep-app-browser-navigation-{}",
2036 std::process::id()
2037 ));
2038 let models = root.join("models");
2039 let sibling = root.join("projects");
2040 let _ = std::fs::remove_dir_all(&root);
2041 std::fs::create_dir_all(&models).unwrap();
2042 std::fs::create_dir_all(&sibling).unwrap();
2043 let store = FileModelStore::with_dir(models.clone());
2044
2045 store.browser_up().unwrap();
2046 let projects = store
2047 .browser_entries(&["BREP.json"])
2048 .into_iter()
2049 .find(|entry| entry.name == "projects")
2050 .expect("sibling directory should be visible");
2051 assert!(projects.is_dir);
2052 store.browser_enter(&projects.identity).unwrap();
2053 let identity = store
2054 .browser_write("assembly", r#"{"features":[]}"#)
2055 .unwrap();
2056
2057 assert_eq!(std::path::PathBuf::from(&identity), sibling.join("assembly.BREP.json"));
2058 assert_eq!(store.read(&identity).as_deref(), Some(r#"{"features":[]}"#));
2059 assert_eq!(store.browser_location(), sibling.display().to_string());
2060
2061 let _ = std::fs::remove_dir_all(&root);
2062 }
2063
2064 #[test]
2065 fn native_store_exposes_foreign_files_to_the_common_explorer() {
2066 let dir = std::env::temp_dir().join(format!(
2067 "brep-app-explorer-store-{}",
2068 std::process::id()
2069 ));
2070 let _ = std::fs::remove_dir_all(&dir);
2071 let store = FileModelStore::with_dir(dir.clone());
2072
2073 store.export_file_named("bracket.step", "STEP DATA").unwrap();
2074 store.export_file_named("preview.stl", "solid preview").unwrap();
2075
2076 assert_eq!(
2077 store.list_external_files(&["step", "stp"]),
2078 vec!["bracket.step".to_string()]
2079 );
2080 assert_eq!(
2081 store.read_external_file("bracket.step").as_deref(),
2082 Some(b"STEP DATA".as_slice())
2083 );
2084 assert!(dir.join("bracket.step").is_file());
2085
2086 let _ = std::fs::remove_dir_all(&dir);
2087 }
2088}
2089
2090#[cfg(all(test, not(target_arch = "wasm32")))]
2097mod mirror_tests {
2098 use super::mirror_store::{BackendFuture, MirrorStore, StoreBackend, UnavailableBackend};
2099 use super::{ModelStore, DOCK_LAYOUT_KEY, PINNED_KEY, SETTINGS_KEY};
2100 use std::cell::RefCell;
2101 use std::rc::Rc;
2102
2103 #[derive(Default)]
2106 struct RecordingBackend {
2107 puts: RefCell<Vec<(String, String)>>,
2108 deletes: RefCell<Vec<String>>,
2109 fail_with: RefCell<Option<String>>,
2110 }
2111
2112 impl RecordingBackend {
2113 fn shared() -> Rc<Self> {
2114 Rc::new(Self::default())
2115 }
2116
2117 fn put_keys(&self) -> Vec<String> {
2118 self.puts.borrow().iter().map(|(k, _)| k.clone()).collect()
2119 }
2120
2121 fn outcome(&self) -> BackendFuture<()> {
2122 let failure = self.fail_with.borrow().clone();
2123 Box::pin(async move {
2124 match failure {
2125 Some(message) => Err(message),
2126 None => Ok(()),
2127 }
2128 })
2129 }
2130 }
2131
2132 impl StoreBackend for RecordingBackend {
2133 fn label(&self) -> String {
2134 "recording test backend".into()
2135 }
2136
2137 fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
2138 Box::pin(async move { Ok(Vec::new()) })
2139 }
2140
2141 fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
2142 self.puts
2143 .borrow_mut()
2144 .push((key.to_string(), value.to_string()));
2145 self.outcome()
2146 }
2147
2148 fn delete(&self, key: &str) -> BackendFuture<()> {
2149 self.deletes.borrow_mut().push(key.to_string());
2150 self.outcome()
2151 }
2152 }
2153
2154 fn store(backend: &Rc<RecordingBackend>) -> MirrorStore {
2155 MirrorStore::new(backend.clone(), Vec::new(), None)
2156 }
2157
2158 #[test]
2159 fn mirror_serves_reads_synchronously_and_pushes_writes_behind() {
2160 let backend = RecordingBackend::shared();
2161 let store = store(&backend);
2162
2163 assert!(store.list().is_empty());
2164 assert_eq!(store.read("alpha"), None);
2165
2166 let doc = r#"{"features":[{"type":"P.CU"}]}"#;
2167 store.write("alpha", doc).unwrap();
2170 assert_eq!(store.read("alpha").as_deref(), Some(doc));
2171 assert_eq!(store.list(), vec!["alpha".to_string()]);
2172 assert_eq!(
2174 backend.puts.borrow().as_slice(),
2175 [("brep-app:model:alpha".to_string(), doc.to_string())]
2176 );
2177 assert_eq!(store.pending(), 0);
2179 assert!(store.take_persistence_errors().is_empty());
2180
2181 assert_eq!(store.read("alpha.BREP.json").as_deref(), Some(doc));
2183
2184 store.remove("alpha").unwrap();
2185 assert_eq!(store.read("alpha"), None);
2186 assert!(store.list().is_empty());
2187 assert_eq!(
2188 backend.deletes.borrow().as_slice(),
2189 ["brep-app:model:alpha".to_string()]
2190 );
2191 }
2192
2193 #[test]
2194 fn key_scheme_is_verbatim_from_the_local_storage_era() {
2195 assert_eq!(MirrorStore::key(SETTINGS_KEY), "brep-app:settings");
2196 assert_eq!(MirrorStore::key(DOCK_LAYOUT_KEY), "brep-app:dock_layout");
2197 assert_eq!(MirrorStore::key(PINNED_KEY), "brep-app:pinned");
2198 assert_eq!(MirrorStore::key(super::FEATURE_PALETTE_DISPLAY_KEY), "brep-app:feature_palette_display");
2199 assert_eq!(MirrorStore::key("part"), "brep-app:model:part");
2201 assert_eq!(MirrorStore::key("/models/part"), "brep-app:model:part");
2202 assert_eq!(
2203 MirrorStore::key("/models/sub/part.BREP.json"),
2204 "brep-app:model:sub/part"
2205 );
2206 assert_eq!(MirrorStore::key("part.json"), "brep-app:model:part");
2207 }
2208
2209 #[test]
2210 fn reserved_application_blobs_share_the_document_api_and_never_list() {
2211 let backend = RecordingBackend::shared();
2212 let store = store(&backend);
2213
2214 store.write(SETTINGS_KEY, r#"{"theme":"dark"}"#).unwrap();
2215 store.write(DOCK_LAYOUT_KEY, r#"{"tiles":[]}"#).unwrap();
2216 store.write(super::FEATURE_PALETTE_DISPLAY_KEY, "\"medium_icons\"").unwrap();
2217 store.write(super::RECOVERY_KEY, r#"{"schema":1,"documents":[]}"#).unwrap();
2218 store.write("part", r#"{"features":[]}"#).unwrap();
2219
2220 assert_eq!(
2221 store.read(SETTINGS_KEY).as_deref(),
2222 Some(r#"{"theme":"dark"}"#)
2223 );
2224 assert_eq!(store.list(), vec!["part".to_string()]);
2226 assert_eq!(
2227 backend.put_keys(),
2228 vec![
2229 "brep-app:settings".to_string(),
2230 "brep-app:dock_layout".to_string(),
2231 "brep-app:feature_palette_display".to_string(),
2232 "brep-app:recovery".to_string(),
2233 "brep-app:model:part".to_string(),
2234 ]
2235 );
2236 }
2237
2238 #[test]
2239 fn hydrated_entries_are_visible_to_the_first_synchronous_read() {
2240 let store = MirrorStore::new(
2242 RecordingBackend::shared(),
2243 vec![
2244 ("brep-app:model:alpha".into(), "A".into()),
2245 ("brep-app:model:sub/beta".into(), "B".into()),
2246 ("brep-app:settings".into(), r#"{"theme":"dark"}"#.into()),
2247 ],
2248 None,
2249 );
2250 assert_eq!(
2251 store.list(),
2252 vec!["alpha".to_string(), "sub/beta".to_string()]
2253 );
2254 assert_eq!(store.read("alpha").as_deref(), Some("A"));
2255 assert_eq!(store.read("/models/sub/beta").as_deref(), Some("B"));
2256 assert_eq!(
2257 store.read(SETTINGS_KEY).as_deref(),
2258 Some(r#"{"theme":"dark"}"#)
2259 );
2260 }
2261
2262 #[test]
2263 fn explorer_walks_the_virtual_tree_the_key_prefixes_describe() {
2264 let backend = RecordingBackend::shared();
2265 let store = store(&backend);
2266 store.write("alpha", "AAAA").unwrap();
2267 store.browser_create_dir("sub").unwrap();
2268 store.write("/models/sub/beta", "BB").unwrap();
2269
2270 assert!(backend.put_keys().contains(&"brep-app:dir:sub/".to_string()));
2272
2273 let entries = store.browser_entries(&["BREP.json"]);
2275 let names: Vec<_> = entries.iter().map(|e| e.name.clone()).collect();
2276 assert_eq!(names, vec!["sub".to_string(), "alpha.BREP.json".to_string()]);
2277 assert!(entries[0].is_dir);
2278 assert_eq!(entries[1].size, Some(4));
2279 assert_eq!(entries[1].identity, "/models/alpha.BREP.json");
2280
2281 store.browser_enter("/models/sub").unwrap();
2283 assert_eq!(store.browser_location(), "/models/sub");
2284 let entries = store.browser_entries(&["BREP.json"]);
2285 assert_eq!(entries.len(), 1);
2286 assert_eq!(entries[0].name, "beta.BREP.json");
2287 assert_eq!(entries[0].identity, "/models/sub/beta.BREP.json");
2288
2289 let identity = store.browser_write("gamma", "G").unwrap();
2291 assert_eq!(identity, "/models/sub/gamma.BREP.json");
2292 assert_eq!(store.read(&identity).as_deref(), Some("G"));
2293
2294 store.browser_root().unwrap();
2296 assert_eq!(
2297 store
2298 .browser_entries(&["BREP.json"])
2299 .into_iter()
2300 .map(|e| e.identity)
2301 .collect::<Vec<_>>(),
2302 vec!["/models".to_string()]
2303 );
2304 assert!(store.browser_navigate("/etc").is_err());
2305 }
2306
2307 #[test]
2308 fn a_write_behind_failure_reaches_the_user_exactly_once() {
2309 let backend = RecordingBackend::shared();
2310 *backend.fail_with.borrow_mut() = Some("QuotaExceededError: out of room".into());
2311 let store = store(&backend);
2312
2313 store.write("alpha", "A").unwrap();
2315 assert_eq!(store.read("alpha").as_deref(), Some("A"));
2316
2317 let drained = store.take_persistence_errors();
2319 assert_eq!(drained.len(), 1);
2320 assert!(drained[0].contains("alpha"), "{drained:?}");
2321 assert!(drained[0].contains("QuotaExceededError"), "{drained:?}");
2322 assert!(store.take_persistence_errors().is_empty());
2324
2325 store.write("alpha", "AA").unwrap();
2328 assert_eq!(store.take_persistence_errors().len(), 1);
2329 assert_eq!(store.error_history().len(), 2);
2331 }
2332
2333 #[test]
2334 fn an_unavailable_backend_is_usable_in_memory_and_loud_about_not_saving() {
2335 let backend = Rc::new(UnavailableBackend::new("IndexedDB unavailable (blocked)"));
2336 let store = MirrorStore::new(backend, Vec::new(), None);
2337
2338 assert!(store.backend_label().contains("NOT SAVING"));
2339 assert!(store.backend_label().contains("blocked"));
2340
2341 store.write("alpha", "A").unwrap();
2343 assert_eq!(store.read("alpha").as_deref(), Some("A"));
2344 assert_eq!(store.len_of("alpha"), Some(1));
2345 let drained = store.take_persistence_errors();
2347 assert_eq!(drained.len(), 1);
2348 assert!(drained[0].contains("alpha"), "{drained:?}");
2349 }
2350
2351 #[test]
2352 fn recording_a_failure_wakes_the_frame_loop() {
2353 let woken = Rc::new(std::cell::Cell::new(0usize));
2356 let wake = {
2357 let woken = woken.clone();
2358 Rc::new(move || woken.set(woken.get() + 1)) as Rc<dyn Fn()>
2359 };
2360 let backend = RecordingBackend::shared();
2361 *backend.fail_with.borrow_mut() = Some("boom".into());
2362 let store = MirrorStore::new(backend, Vec::new(), Some(wake));
2363
2364 store.write("alpha", "A").unwrap();
2365 assert_eq!(woken.get(), 1);
2366 }
2367}