1pub const SETTINGS_KEY: &str = "@settings";
10pub const DOCK_LAYOUT_KEY: &str = "@dock_layout";
11pub const SESSION_KEY: &str = "@session";
14pub const PINNED_KEY: &str = "@pinned";
18
19pub struct ImportedFile {
22 pub name: String,
23 pub bytes: Vec<u8>,
24}
25
26#[derive(Clone, Debug, PartialEq)]
28pub struct BrowserEntry {
29 pub name: String,
30 pub identity: String,
31 pub is_dir: bool,
32 pub size: Option<u64>,
35 pub modified: Option<f64>,
38}
39
40#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct BrowserPlace {
43 pub label: String,
45 pub location: String,
47 pub kind: PlaceKind,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum PlaceKind {
54 Home,
55 Documents,
56 Downloads,
57 Models,
58 Root,
59}
60pub trait ModelStore {
83 fn backend_label(&self) -> String {
86 "persistent storage".into()
87 }
88
89 fn list(&self) -> Vec<String> {
93 Vec::new()
94 }
95
96 fn read(&self, name: &str) -> Option<String>;
98
99 fn write(&self, name: &str, contents: &str) -> Result<(), String>;
102
103 fn remove(&self, _name: &str) -> Result<(), String> {
105 Ok(())
106 }
107
108 fn take_persistence_errors(&self) -> Vec<String> {
116 Vec::new()
117 }
118
119 fn browser_location(&self) -> String {
123 self.backend_label()
124 }
125
126 fn browser_entries(&self, _extensions: &[&str]) -> Vec<BrowserEntry> {
130 self.list()
131 .into_iter()
132 .map(|name| BrowserEntry {
133 identity: name.clone(),
134 name,
135 is_dir: false,
136 size: None,
137 modified: None,
138 })
139 .collect()
140 }
141
142 fn browser_enter(&self, _identity: &str) -> Result<(), String> {
143 Err("this storage backend has no directories".into())
144 }
145
146 fn browser_up(&self) -> Result<(), String> {
147 Ok(())
148 }
149
150 fn browser_home(&self) -> Result<(), String> {
151 Ok(())
152 }
153
154 fn browser_root(&self) -> Result<(), String> {
155 Ok(())
156 }
157
158 fn browser_navigate(&self, _location: &str) -> Result<(), String> {
164 Err("this storage backend cannot navigate to a path".into())
165 }
166
167 fn browser_places(&self) -> Vec<BrowserPlace> {
170 Vec::new()
171 }
172
173 fn browser_create_dir(&self, _name: &str) -> Result<(), String> {
175 Err("this storage backend cannot create directories".into())
176 }
177
178 fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
181 self.write(name, contents)?;
182 Ok(name.to_string())
183 }
184
185 fn list_external_files(&self, _extensions: &[&str]) -> Vec<String> {
189 Vec::new()
190 }
191
192 fn read_external_file(&self, _name: &str) -> Option<Vec<u8>> {
194 None
195 }
196
197 fn supports_file_interchange(&self) -> bool {
206 false
207 }
208
209 fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
215 let _ = (name, contents);
216 Ok(None)
217 }
218
219 fn begin_import(&self) -> Result<(), String> {
223 Ok(())
224 }
225
226 fn take_import(&self) -> Option<ImportedFile> {
229 None
230 }
231
232 fn begin_import_filtered(&self, _filter: (&str, &[&str])) -> Result<(), String> {
244 self.begin_import()
245 }
246
247 fn export_file_named(&self, _file_name: &str, _contents: &str) -> Result<(), String> {
251 Ok(())
252 }
253}
254
255pub fn default_model_store() -> Box<dyn ModelStore> {
257 #[cfg(not(target_arch = "wasm32"))]
258 {
259 Box::new(native_model::FileModelStore::new())
260 }
261 #[cfg(target_arch = "wasm32")]
262 {
263 web_model::take_boot_store()
266 }
267}
268
269#[cfg(target_arch = "wasm32")]
274pub async fn hydrate_web_store() {
275 web_model::hydrate().await;
276}
277
278#[cfg(target_arch = "wasm32")]
282pub use web_model::set_repaint_ctx;
283
284pub const MODEL_EXT: &str = ".BREP.json";
286
287#[cfg(all(test, not(target_arch = "wasm32")))]
290pub fn native_test_store(dir: std::path::PathBuf) -> Box<dyn ModelStore> {
291 Box::new(native_model::FileModelStore::with_dir(dir))
292}
293
294#[cfg(all(test, not(target_arch = "wasm32")))]
299pub(crate) struct MemModelStore {
300 docs: std::cell::RefCell<std::collections::BTreeMap<String, String>>,
301 reads: std::cell::Cell<usize>,
302}
303
304#[cfg(all(test, not(target_arch = "wasm32")))]
305impl MemModelStore {
306 pub fn new() -> Self {
307 Self {
308 docs: std::cell::RefCell::new(std::collections::BTreeMap::new()),
309 reads: std::cell::Cell::new(0),
310 }
311 }
312
313 pub fn put(&self, name: &str, contents: &str) {
315 self.docs
316 .borrow_mut()
317 .insert(name.to_string(), contents.to_string());
318 }
319
320 pub fn reads(&self) -> usize {
322 self.reads.get()
323 }
324}
325
326#[cfg(all(test, not(target_arch = "wasm32")))]
327impl ModelStore for MemModelStore {
328 fn backend_label(&self) -> String {
329 "in-memory test store".into()
330 }
331 fn list(&self) -> Vec<String> {
332 self.docs.borrow().keys().cloned().collect()
333 }
334 fn read(&self, name: &str) -> Option<String> {
335 self.reads.set(self.reads.get() + 1);
336 self.docs.borrow().get(name).cloned()
337 }
338 fn write(&self, name: &str, contents: &str) -> Result<(), String> {
339 self.put(name, contents);
340 Ok(())
341 }
342 fn remove(&self, name: &str) -> Result<(), String> {
343 self.docs.borrow_mut().remove(name);
344 Ok(())
345 }
346}
347
348pub(crate) fn model_display_name(file_name: &str) -> String {
352 let base = file_name
353 .rsplit(['/', '\\'])
354 .next()
355 .unwrap_or(file_name);
356 base.strip_suffix(MODEL_EXT)
357 .or_else(|| base.strip_suffix(".json"))
358 .unwrap_or(base)
359 .to_string()
360}
361
362#[cfg(not(target_arch = "wasm32"))]
364mod native_model {
365 use super::{
366 model_display_name, BrowserEntry, BrowserPlace, ModelStore, PlaceKind, DOCK_LAYOUT_KEY,
367 MODEL_EXT, PINNED_KEY, SESSION_KEY, SETTINGS_KEY,
368 };
369 use std::cell::RefCell;
370 use std::path::{Path, PathBuf};
371
372 pub struct FileModelStore {
379 app_dir: PathBuf,
380 dir: PathBuf,
381 browser_dir: RefCell<PathBuf>,
382 }
383
384 impl FileModelStore {
385 pub fn new() -> Self {
386 let base = std::env::var_os("XDG_CONFIG_HOME")
387 .map(PathBuf::from)
388 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
389 .unwrap_or_else(|| PathBuf::from("."));
390 let app_dir = base.join("brep-app");
391 Self {
392 dir: app_dir.join("models"),
393 browser_dir: RefCell::new(app_dir.join("models")),
394 app_dir,
395 }
396 }
397
398 #[cfg_attr(not(test), allow(dead_code))]
401 pub fn with_dir(dir: PathBuf) -> Self {
402 Self {
403 app_dir: dir.clone(),
404 browser_dir: RefCell::new(dir.clone()),
405 dir,
406 }
407 }
408
409 fn resolve(&self, name: &str) -> PathBuf {
412 match name {
413 SETTINGS_KEY => return self.app_dir.join("settings.json"),
414 DOCK_LAYOUT_KEY => return self.app_dir.join("dock_layout.json"),
415 SESSION_KEY => return self.app_dir.join("session.json"),
416 PINNED_KEY => return self.app_dir.join("pinned.json"),
417 _ => {}
418 }
419 if name.contains('/') || name.contains('\\') {
420 return PathBuf::from(name);
421 }
422 let safe: String = name
423 .strip_suffix(MODEL_EXT)
424 .unwrap_or(name)
425 .chars()
426 .map(|c| {
427 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
428 c
429 } else {
430 '_'
431 }
432 })
433 .collect();
434 self.dir.join(format!("{safe}{MODEL_EXT}"))
435 }
436
437 fn home_dir() -> PathBuf {
438 std::env::var_os("HOME")
439 .map(PathBuf::from)
440 .or_else(|| std::env::current_dir().ok())
441 .unwrap_or_else(|| PathBuf::from("."))
442 }
443
444 fn matches_extension(path: &Path, extensions: &[&str]) -> bool {
445 extensions.is_empty()
446 || extensions.iter().any(|extension| {
447 let wanted = extension.trim_start_matches('.').to_ascii_lowercase();
448 let name = path
449 .file_name()
450 .unwrap_or_default()
451 .to_string_lossy()
452 .to_ascii_lowercase();
453 name.ends_with(&format!(".{wanted}"))
454 })
455 }
456 }
457
458 impl ModelStore for FileModelStore {
459 fn backend_label(&self) -> String {
460 format!("filesystem: {}", self.dir.display())
461 }
462
463 fn list(&self) -> Vec<String> {
464 let mut names: Vec<String> = std::fs::read_dir(&self.dir)
465 .into_iter()
466 .flatten()
467 .flatten()
468 .filter_map(|entry| {
469 let name = entry.file_name().to_string_lossy().into_owned();
470 name.ends_with(MODEL_EXT).then(|| model_display_name(&name))
471 })
472 .collect();
473 names.sort();
474 names
475 }
476
477 fn read(&self, name: &str) -> Option<String> {
478 std::fs::read_to_string(self.resolve(name)).ok()
479 }
480
481 fn write(&self, name: &str, contents: &str) -> Result<(), String> {
482 let path = self.resolve(name);
483 if let Some(parent) = path.parent() {
484 std::fs::create_dir_all(parent).map_err(|e| format!("create models dir: {e}"))?;
485 }
486 std::fs::write(&path, contents).map_err(|e| format!("write {}: {e}", path.display()))
487 }
488
489 fn remove(&self, name: &str) -> Result<(), String> {
490 match std::fs::remove_file(self.resolve(name)) {
491 Ok(()) => Ok(()),
492 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
493 Err(e) => Err(format!("remove: {e}")),
494 }
495 }
496
497 fn browser_location(&self) -> String {
498 self.browser_dir.borrow().display().to_string()
499 }
500
501 fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
502 let mut entries: Vec<BrowserEntry> = std::fs::read_dir(&*self.browser_dir.borrow())
503 .into_iter()
504 .flatten()
505 .flatten()
506 .filter_map(|entry| {
507 let path = entry.path();
508 let is_dir = path.is_dir();
509 if !(is_dir || (path.is_file() && Self::matches_extension(&path, extensions))) {
510 return None;
511 }
512 let meta = entry.metadata().ok();
513 let size = meta.as_ref().filter(|m| m.is_file()).map(|m| m.len());
514 let modified = meta
515 .as_ref()
516 .and_then(|m| m.modified().ok())
517 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
518 .map(|d| d.as_secs_f64());
519 Some(BrowserEntry {
520 name: entry.file_name().to_string_lossy().into_owned(),
521 identity: path.to_string_lossy().into_owned(),
522 is_dir,
523 size,
524 modified,
525 })
526 })
527 .collect();
528 entries.sort_by(|a, b| {
529 b.is_dir
530 .cmp(&a.is_dir)
531 .then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
532 });
533 entries
534 }
535
536 fn browser_enter(&self, identity: &str) -> Result<(), String> {
537 let path = PathBuf::from(identity);
538 if !path.is_dir() {
539 return Err(format!("not a directory: {}", path.display()));
540 }
541 *self.browser_dir.borrow_mut() = path;
542 Ok(())
543 }
544
545 fn browser_up(&self) -> Result<(), String> {
546 let parent = self.browser_dir.borrow().parent().map(Path::to_path_buf);
547 if let Some(parent) = parent {
548 *self.browser_dir.borrow_mut() = parent;
549 }
550 Ok(())
551 }
552
553 fn browser_home(&self) -> Result<(), String> {
554 *self.browser_dir.borrow_mut() = Self::home_dir();
555 Ok(())
556 }
557
558 fn browser_root(&self) -> Result<(), String> {
559 let current = self.browser_dir.borrow().clone();
560 let root = current
561 .ancestors()
562 .last()
563 .map(Path::to_path_buf)
564 .unwrap_or_else(|| PathBuf::from(std::path::MAIN_SEPARATOR.to_string()));
565 *self.browser_dir.borrow_mut() = root;
566 Ok(())
567 }
568
569 fn browser_navigate(&self, location: &str) -> Result<(), String> {
570 let path = PathBuf::from(location);
571 if path.is_dir() {
572 *self.browser_dir.borrow_mut() = path;
573 Ok(())
574 } else {
575 Err(format!("not a directory: {location}"))
576 }
577 }
578
579 fn browser_places(&self) -> Vec<BrowserPlace> {
580 let home = Self::home_dir();
581 let mut places = vec![BrowserPlace {
582 label: "Home".into(),
583 location: home.display().to_string(),
584 kind: PlaceKind::Home,
585 }];
586 for (label, sub, kind) in [
587 ("Documents", "Documents", PlaceKind::Documents),
588 ("Downloads", "Downloads", PlaceKind::Downloads),
589 ] {
590 let path = home.join(sub);
591 if path.is_dir() {
592 places.push(BrowserPlace {
593 label: label.into(),
594 location: path.display().to_string(),
595 kind,
596 });
597 }
598 }
599 places.push(BrowserPlace {
600 label: "Models".into(),
601 location: self.dir.display().to_string(),
602 kind: PlaceKind::Models,
603 });
604 places.push(BrowserPlace {
605 label: "/".into(),
606 location: "/".into(),
607 kind: PlaceKind::Root,
608 });
609 places
610 }
611
612 fn browser_create_dir(&self, name: &str) -> Result<(), String> {
613 let name = PathBuf::from(name);
614 if name.components().count() != 1 {
615 return Err("folder name must be one path component".into());
616 }
617 let path = self.browser_dir.borrow().join(name);
618 std::fs::create_dir(&path)
619 .map_err(|e| format!("create folder {}: {e}", path.display()))
620 }
621
622 fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
623 let mut file_name = PathBuf::from(name)
624 .file_name()
625 .ok_or("invalid file name")?
626 .to_os_string();
627 if !name.to_ascii_lowercase().ends_with(".json") {
628 file_name.push(MODEL_EXT);
629 }
630 let path = self.browser_dir.borrow().join(file_name);
631 std::fs::write(&path, contents)
632 .map_err(|e| format!("write {}: {e}", path.display()))?;
633 Ok(path.to_string_lossy().into_owned())
634 }
635
636 fn list_external_files(&self, extensions: &[&str]) -> Vec<String> {
637 let wanted: Vec<String> = extensions
638 .iter()
639 .map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
640 .collect();
641 let mut names: Vec<String> = std::fs::read_dir(&self.dir)
642 .into_iter()
643 .flatten()
644 .flatten()
645 .filter_map(|entry| {
646 let path = entry.path();
647 path.is_file().then_some(path)
648 })
649 .filter_map(|path| {
650 let extension = path.extension()?.to_string_lossy().to_ascii_lowercase();
651 wanted.contains(&extension).then(|| {
652 path.file_name()
653 .unwrap_or_default()
654 .to_string_lossy()
655 .into_owned()
656 })
657 })
658 .collect();
659 names.sort();
660 names
661 }
662
663 fn read_external_file(&self, name: &str) -> Option<Vec<u8>> {
664 let path = PathBuf::from(name);
665 let path = if path.is_absolute() || path.components().count() > 1 {
666 path
667 } else {
668 self.browser_dir.borrow().join(path)
669 };
670 std::fs::read(path).ok()
671 }
672
673 fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
674 std::fs::create_dir_all(&self.dir)
675 .map_err(|e| format!("create models dir: {e}"))?;
676 let name = PathBuf::from(file_name)
677 .file_name()
678 .ok_or("invalid export file name")?
679 .to_owned();
680 let path = self.dir.join(name);
681 std::fs::write(&path, contents)
682 .map_err(|e| format!("write {}: {e}", path.display()))
683 }
684 }
685}
686
687#[cfg(any(target_arch = "wasm32", test))]
706pub(crate) mod mirror_store {
707 use super::{
708 model_display_name, BrowserEntry, BrowserPlace, ModelStore, PlaceKind, DOCK_LAYOUT_KEY,
709 MODEL_EXT, PINNED_KEY, SESSION_KEY, SETTINGS_KEY,
710 };
711 use std::cell::{Cell, RefCell};
712 use std::collections::BTreeMap;
713 use std::future::Future;
714 use std::pin::Pin;
715 use std::rc::Rc;
716
717 pub(crate) const PREFIX: &str = "brep-app:model:";
723 pub(crate) const DIR_PREFIX: &str = "brep-app:dir:";
724
725 const MAX_ERRORS: usize = 64;
728
729 pub(crate) type BackendFuture<T> = Pin<Box<dyn Future<Output = Result<T, String>>>>;
734
735 pub(crate) trait StoreBackend {
772 fn label(&self) -> String;
775
776 fn load_all(&self) -> BackendFuture<Vec<(String, String)>>;
779
780 fn put(&self, key: &str, value: &str) -> BackendFuture<()>;
782
783 fn delete(&self, key: &str) -> BackendFuture<()>;
785 }
786
787 pub(crate) struct UnavailableBackend {
796 reason: String,
797 }
798
799 impl UnavailableBackend {
800 pub(crate) fn new(reason: impl Into<String>) -> Self {
801 Self {
802 reason: reason.into(),
803 }
804 }
805 }
806
807 impl StoreBackend for UnavailableBackend {
808 fn label(&self) -> String {
809 format!("NOT SAVING — {} · use Download to keep your work", self.reason)
810 }
811
812 fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
813 let reason = self.reason.clone();
814 Box::pin(async move { Err(reason) })
815 }
816
817 fn put(&self, _key: &str, _value: &str) -> BackendFuture<()> {
818 let reason = self.reason.clone();
819 Box::pin(async move { Err(reason) })
820 }
821
822 fn delete(&self, _key: &str) -> BackendFuture<()> {
823 let reason = self.reason.clone();
824 Box::pin(async move { Err(reason) })
825 }
826 }
827
828 pub(crate) struct ErrorLog {
837 log: RefCell<Vec<String>>,
838 drained: Cell<usize>,
839 wake: Option<Rc<dyn Fn()>>,
842 }
843
844 impl ErrorLog {
845 fn new(wake: Option<Rc<dyn Fn()>>) -> Self {
846 Self {
847 log: RefCell::new(Vec::new()),
848 drained: Cell::new(0),
849 wake,
850 }
851 }
852
853 pub(crate) fn record(&self, message: String) {
858 {
859 let mut log = self.log.borrow_mut();
860 let undrained = log.len() > self.drained.get();
861 if undrained && log.last().map(|last| *last == message).unwrap_or(false) {
862 return;
863 }
864 log.push(message);
865 if log.len() > MAX_ERRORS {
866 log.remove(0);
867 self.drained.set(self.drained.get().saturating_sub(1));
868 }
869 }
870 if let Some(wake) = &self.wake {
871 wake();
872 }
873 }
874
875 fn drain(&self) -> Vec<String> {
877 let log = self.log.borrow();
878 let from = self.drained.get().min(log.len());
879 self.drained.set(log.len());
880 log[from..].to_vec()
881 }
882
883 fn all(&self) -> Vec<String> {
885 self.log.borrow().clone()
886 }
887 }
888
889 #[cfg(target_arch = "wasm32")]
898 fn spawn(task: impl Future<Output = ()> + 'static) {
899 wasm_bindgen_futures::spawn_local(task);
900 }
901
902 #[cfg(not(target_arch = "wasm32"))]
903 fn spawn(task: impl Future<Output = ()> + 'static) {
904 let mut task = Box::pin(task);
905 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
906 let _ = task.as_mut().poll(&mut cx);
907 }
908
909 #[derive(Clone)]
914 pub(crate) struct MirrorStore {
915 entries: Rc<RefCell<BTreeMap<String, String>>>,
917 browser_dir: Rc<RefCell<String>>,
919 backend: Rc<dyn StoreBackend>,
920 errors: Rc<ErrorLog>,
921 pending: Rc<Cell<usize>>,
925 }
926
927 impl MirrorStore {
928 pub(crate) fn new(
932 backend: Rc<dyn StoreBackend>,
933 entries: Vec<(String, String)>,
934 wake: Option<Rc<dyn Fn()>>,
935 ) -> Self {
936 Self {
937 entries: Rc::new(RefCell::new(entries.into_iter().collect())),
938 browser_dir: Rc::new(RefCell::new("/models".into())),
939 backend,
940 errors: Rc::new(ErrorLog::new(wake)),
941 pending: Rc::new(Cell::new(0)),
942 }
943 }
944
945 pub(crate) fn key(name: &str) -> String {
949 match name {
950 SETTINGS_KEY => "brep-app:settings".into(),
951 DOCK_LAYOUT_KEY => "brep-app:dock_layout".into(),
952 SESSION_KEY => "brep-app:session".into(),
953 PINNED_KEY => "brep-app:pinned".into(),
954 _ => format!("{PREFIX}{}", Self::model_relative(name)),
955 }
956 }
957
958 fn model_relative(name: &str) -> String {
961 let name = name
962 .trim_start_matches('/')
963 .strip_prefix("models/")
964 .unwrap_or_else(|| name.trim_start_matches('/'));
965 name.strip_suffix(MODEL_EXT)
966 .or_else(|| name.strip_suffix(".json"))
967 .unwrap_or(name)
968 .trim_matches('/')
969 .to_string()
970 }
971
972 fn virtual_model_path(relative: &str) -> String {
973 format!("/models/{}{MODEL_EXT}", relative.trim_matches('/'))
974 }
975
976 fn child_path(parent: &str, child: &str) -> String {
977 if parent == "/" {
978 format!("/{child}")
979 } else {
980 format!("{}/{child}", parent.trim_end_matches('/'))
981 }
982 }
983
984 fn push(&self, name: &str, request: BackendFuture<()>) {
987 self.pending.set(self.pending.get() + 1);
988 let pending = self.pending.clone();
989 let errors = self.errors.clone();
990 let name = name.to_string();
991 spawn(async move {
992 let outcome = request.await;
993 pending.set(pending.get().saturating_sub(1));
994 if let Err(message) = outcome {
995 errors.record(format!("'{name}' was NOT saved: {message}"));
996 }
997 });
998 }
999
1000 pub(crate) fn report(&self, message: String) {
1002 self.errors.record(message);
1003 }
1004
1005 pub(crate) fn pending(&self) -> usize {
1007 self.pending.get()
1008 }
1009
1010 pub(crate) fn error_history(&self) -> Vec<String> {
1012 self.errors.all()
1013 }
1014
1015 pub(crate) fn len_of(&self, name: &str) -> Option<usize> {
1019 self.entries.borrow().get(&Self::key(name)).map(|v| v.len())
1020 }
1021 }
1022
1023 impl ModelStore for MirrorStore {
1024 fn backend_label(&self) -> String {
1025 self.backend.label()
1026 }
1027
1028 fn list(&self) -> Vec<String> {
1029 self.entries
1031 .borrow()
1032 .keys()
1033 .filter_map(|key| key.strip_prefix(PREFIX).map(str::to_string))
1034 .collect()
1035 }
1036
1037 fn read(&self, name: &str) -> Option<String> {
1038 self.entries.borrow().get(&Self::key(name)).cloned()
1039 }
1040
1041 fn write(&self, name: &str, contents: &str) -> Result<(), String> {
1042 let key = Self::key(name);
1043 self.entries
1044 .borrow_mut()
1045 .insert(key.clone(), contents.to_string());
1046 self.push(name, self.backend.put(&key, contents));
1050 Ok(())
1051 }
1052
1053 fn remove(&self, name: &str) -> Result<(), String> {
1054 let key = Self::key(name);
1055 self.entries.borrow_mut().remove(&key);
1056 self.push(name, self.backend.delete(&key));
1057 Ok(())
1058 }
1059
1060 fn take_persistence_errors(&self) -> Vec<String> {
1061 self.errors.drain()
1062 }
1063
1064 fn browser_location(&self) -> String {
1065 self.browser_dir.borrow().clone()
1069 }
1070
1071 fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
1072 let current = self.browser_dir.borrow().clone();
1073 if current == "/" {
1074 return vec![BrowserEntry {
1075 name: "models".into(),
1076 identity: "/models".into(),
1077 is_dir: true,
1078 size: None,
1079 modified: None,
1080 }];
1081 }
1082 let relative_dir = current
1083 .strip_prefix("/models")
1084 .unwrap_or("")
1085 .trim_matches('/');
1086 let prefix = if relative_dir.is_empty() {
1087 String::new()
1088 } else {
1089 format!("{relative_dir}/")
1090 };
1091 let wanted: Vec<String> = extensions
1092 .iter()
1093 .map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
1094 .collect();
1095 let mut entries: BTreeMap<String, BrowserEntry> = BTreeMap::new();
1096 for (key, value) in self.entries.borrow().iter() {
1097 let (item, is_model) = if let Some(item) = key.strip_prefix(PREFIX) {
1098 (item, true)
1099 } else if let Some(item) = key.strip_prefix(DIR_PREFIX) {
1100 (item.trim_end_matches('/'), false)
1101 } else {
1102 continue;
1103 };
1104 let Some(rest) = item.strip_prefix(&prefix) else {
1105 continue;
1106 };
1107 if rest.is_empty() {
1108 continue;
1109 }
1110 if let Some((child, _)) = rest.split_once('/') {
1111 entries
1112 .entry(child.to_string())
1113 .or_insert_with(|| BrowserEntry {
1114 name: child.to_string(),
1115 identity: Self::child_path(¤t, child),
1116 is_dir: true,
1117 size: None,
1118 modified: None,
1119 });
1120 } else if is_model {
1121 let name = format!("{rest}{MODEL_EXT}");
1122 let lower = name.to_ascii_lowercase();
1123 if wanted.is_empty()
1124 || wanted
1125 .iter()
1126 .any(|extension| lower.ends_with(&format!(".{extension}")))
1127 {
1128 entries.insert(
1131 name.clone(),
1132 BrowserEntry {
1133 name,
1134 identity: Self::virtual_model_path(item),
1135 is_dir: false,
1136 size: Some(value.len() as u64),
1137 modified: None,
1138 },
1139 );
1140 }
1141 } else {
1142 entries
1143 .entry(rest.to_string())
1144 .or_insert_with(|| BrowserEntry {
1145 name: rest.to_string(),
1146 identity: Self::child_path(¤t, rest),
1147 is_dir: true,
1148 size: None,
1149 modified: None,
1150 });
1151 }
1152 }
1153 let mut entries: Vec<_> = entries.into_values().collect();
1154 entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
1155 entries
1156 }
1157
1158 fn browser_enter(&self, identity: &str) -> Result<(), String> {
1159 if identity == "/models" || identity.starts_with("/models/") {
1160 *self.browser_dir.borrow_mut() = identity.trim_end_matches('/').to_string();
1161 Ok(())
1162 } else {
1163 Err("the browser virtual filesystem is rooted at /models".into())
1164 }
1165 }
1166
1167 fn browser_up(&self) -> Result<(), String> {
1168 let current = self.browser_dir.borrow().clone();
1169 if current == "/" {
1170 return Ok(());
1171 }
1172 let parent = current
1173 .rsplit_once('/')
1174 .map(|(parent, _)| parent)
1175 .unwrap_or("");
1176 *self.browser_dir.borrow_mut() = if parent.is_empty() {
1177 "/".into()
1178 } else {
1179 parent.into()
1180 };
1181 Ok(())
1182 }
1183
1184 fn browser_home(&self) -> Result<(), String> {
1185 *self.browser_dir.borrow_mut() = "/models".into();
1186 Ok(())
1187 }
1188
1189 fn browser_root(&self) -> Result<(), String> {
1190 *self.browser_dir.borrow_mut() = "/".into();
1191 Ok(())
1192 }
1193
1194 fn browser_navigate(&self, location: &str) -> Result<(), String> {
1195 let loc = location.trim_end_matches('/');
1196 let loc = if loc.is_empty() { "/" } else { loc };
1197 if loc == "/" || loc == "/models" || loc.starts_with("/models/") {
1198 *self.browser_dir.borrow_mut() = loc.to_string();
1199 Ok(())
1200 } else {
1201 Err("the browser virtual filesystem is rooted at /models".into())
1202 }
1203 }
1204
1205 fn browser_places(&self) -> Vec<BrowserPlace> {
1206 vec![
1207 BrowserPlace {
1208 label: "Models".into(),
1209 location: "/models".into(),
1210 kind: PlaceKind::Models,
1211 },
1212 BrowserPlace {
1213 label: "/".into(),
1214 location: "/".into(),
1215 kind: PlaceKind::Root,
1216 },
1217 ]
1218 }
1219
1220 fn browser_create_dir(&self, name: &str) -> Result<(), String> {
1221 if name.is_empty()
1222 || name == "."
1223 || name == ".."
1224 || name.contains('/')
1225 || name.contains('\\')
1226 {
1227 return Err("folder name must be one path component".into());
1228 }
1229 let current = self.browser_dir.borrow().clone();
1230 if !current.starts_with("/models") {
1231 return Err("folders can only be created under /models".into());
1232 }
1233 let relative = Self::child_path(¤t, name)
1234 .trim_start_matches("/models/")
1235 .to_string();
1236 let key = format!("{DIR_PREFIX}{relative}/");
1239 self.entries.borrow_mut().insert(key.clone(), String::new());
1240 self.push(name, self.backend.put(&key, ""));
1241 Ok(())
1242 }
1243
1244 fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
1245 let file = model_display_name(name);
1246 if file.is_empty() {
1247 return Err("invalid file name".into());
1248 }
1249 let current = self.browser_dir.borrow().clone();
1250 if !current.starts_with("/models") {
1251 return Err("select a folder under /models".into());
1252 }
1253 let identity = format!("{}{MODEL_EXT}", Self::child_path(¤t, &file));
1254 self.write(&identity, contents)?;
1255 Ok(identity)
1256 }
1257 }
1258}
1259
1260#[cfg(target_arch = "wasm32")]
1262mod web_model {
1263 use super::mirror_store::{BackendFuture, MirrorStore, StoreBackend, UnavailableBackend};
1264 use super::{
1265 model_display_name, BrowserEntry, BrowserPlace, ImportedFile, ModelStore, MODEL_EXT,
1266 };
1267 use std::cell::RefCell;
1268 use std::future::Future;
1269 use std::pin::Pin;
1270 use std::rc::Rc;
1271 use std::task::{Context, Poll, Waker};
1272 use wasm_bindgen::prelude::*;
1273 use wasm_bindgen::JsCast;
1274
1275 const DB_NAME: &str = "brep-app";
1284 const DB_VERSION: u32 = 1;
1285 const STORE_NAME: &str = "kv";
1287
1288 thread_local! {
1289 static IMPORT_INPUT: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
1291 static IMPORTED: RefCell<Option<ImportedFile>> = const { RefCell::new(None) };
1293 static REPAINT_CTX: RefCell<Option<eframe::egui::Context>> = const { RefCell::new(None) };
1299 static BOOT_STORE: RefCell<Option<IdbModelStore>> = const { RefCell::new(None) };
1305 }
1306
1307 pub fn set_repaint_ctx(ctx: eframe::egui::Context) {
1311 REPAINT_CTX.with(|c| *c.borrow_mut() = Some(ctx));
1312 }
1313
1314 fn wake_frame_loop() {
1318 REPAINT_CTX.with(|c| {
1319 if let Some(ctx) = c.borrow().as_ref() {
1320 ctx.request_repaint();
1321 }
1322 });
1323 }
1324
1325 #[derive(Default)]
1334 struct Settled {
1335 outcome: Option<Result<JsValue, String>>,
1336 waker: Option<Waker>,
1337 }
1338
1339 enum EventSource {
1342 Request(web_sys::IdbRequest),
1343 Transaction(web_sys::IdbTransaction),
1344 }
1345
1346 struct IdbFuture {
1348 source: EventSource,
1349 settled: Rc<RefCell<Settled>>,
1350 _handlers: Vec<Closure<dyn FnMut(web_sys::Event)>>,
1352 }
1353
1354 fn settle(settled: &Rc<RefCell<Settled>>, outcome: Result<JsValue, String>) {
1355 let waker = {
1356 let mut settled = settled.borrow_mut();
1357 if settled.outcome.is_none() {
1358 settled.outcome = Some(outcome);
1359 }
1360 settled.waker.take()
1361 };
1362 if let Some(waker) = waker {
1363 waker.wake();
1364 }
1365 }
1366
1367 fn request_error(request: &web_sys::IdbRequest) -> String {
1368 request
1369 .error()
1370 .ok()
1371 .flatten()
1372 .map(|error| format!("{}: {}", error.name(), error.message()))
1373 .unwrap_or_else(|| "IndexedDB request failed".into())
1374 }
1375
1376 fn js_error(value: &JsValue) -> String {
1377 value
1378 .as_string()
1379 .or_else(|| js_sys::Reflect::get(value, &JsValue::from_str("message")).ok()?.as_string())
1380 .unwrap_or_else(|| format!("{value:?}"))
1381 }
1382
1383 fn on_request(request: web_sys::IdbRequest) -> IdbFuture {
1385 let settled = Rc::new(RefCell::new(Settled::default()));
1386 let success = {
1387 let settled = settled.clone();
1388 let request = request.clone();
1389 Closure::wrap(Box::new(move |_event: web_sys::Event| {
1390 let value = request.result().unwrap_or(JsValue::UNDEFINED);
1391 settle(&settled, Ok(value));
1392 }) as Box<dyn FnMut(web_sys::Event)>)
1393 };
1394 let failure = {
1395 let settled = settled.clone();
1396 let request = request.clone();
1397 Closure::wrap(Box::new(move |_event: web_sys::Event| {
1398 settle(&settled, Err(request_error(&request)));
1399 }) as Box<dyn FnMut(web_sys::Event)>)
1400 };
1401 request.set_onsuccess(Some(success.as_ref().unchecked_ref()));
1402 request.set_onerror(Some(failure.as_ref().unchecked_ref()));
1403 IdbFuture {
1404 source: EventSource::Request(request),
1405 settled,
1406 _handlers: vec![success, failure],
1407 }
1408 }
1409
1410 fn on_transaction(transaction: web_sys::IdbTransaction) -> IdbFuture {
1418 let settled = Rc::new(RefCell::new(Settled::default()));
1419 let complete = {
1420 let settled = settled.clone();
1421 Closure::wrap(Box::new(move |_event: web_sys::Event| {
1422 settle(&settled, Ok(JsValue::UNDEFINED));
1423 }) as Box<dyn FnMut(web_sys::Event)>)
1424 };
1425 let describe = {
1426 let transaction = transaction.clone();
1427 move |fallback: &str| {
1428 transaction
1429 .error()
1430 .map(|error| format!("{}: {}", error.name(), error.message()))
1431 .unwrap_or_else(|| fallback.to_string())
1432 }
1433 };
1434 let failure = {
1435 let settled = settled.clone();
1436 let describe = describe.clone();
1437 Closure::wrap(Box::new(move |_event: web_sys::Event| {
1438 settle(&settled, Err(describe("IndexedDB transaction failed")));
1439 }) as Box<dyn FnMut(web_sys::Event)>)
1440 };
1441 let abort = {
1442 let settled = settled.clone();
1443 Closure::wrap(Box::new(move |_event: web_sys::Event| {
1444 settle(
1445 &settled,
1446 Err(describe("IndexedDB transaction aborted (quota?)")),
1447 );
1448 }) as Box<dyn FnMut(web_sys::Event)>)
1449 };
1450 transaction.set_oncomplete(Some(complete.as_ref().unchecked_ref()));
1451 transaction.set_onerror(Some(failure.as_ref().unchecked_ref()));
1452 transaction.set_onabort(Some(abort.as_ref().unchecked_ref()));
1453 IdbFuture {
1454 source: EventSource::Transaction(transaction),
1455 settled,
1456 _handlers: vec![complete, failure, abort],
1457 }
1458 }
1459
1460 impl Future for IdbFuture {
1461 type Output = Result<JsValue, String>;
1462
1463 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
1464 let mut settled = self.settled.borrow_mut();
1465 if let Some(outcome) = settled.outcome.take() {
1466 return Poll::Ready(outcome);
1467 }
1468 settled.waker = Some(cx.waker().clone());
1469 Poll::Pending
1470 }
1471 }
1472
1473 impl Drop for IdbFuture {
1474 fn drop(&mut self) {
1475 match &self.source {
1478 EventSource::Request(request) => {
1479 request.set_onsuccess(None);
1480 request.set_onerror(None);
1481 }
1482 EventSource::Transaction(transaction) => {
1483 transaction.set_oncomplete(None);
1484 transaction.set_onerror(None);
1485 transaction.set_onabort(None);
1486 }
1487 }
1488 }
1489 }
1490
1491 pub(super) struct IdbBackend {
1496 db: web_sys::IdbDatabase,
1497 }
1498
1499 impl IdbBackend {
1500 async fn open() -> Result<Self, String> {
1504 let factory = web_sys::window()
1505 .ok_or("no window")?
1506 .indexed_db()
1507 .map_err(|e| js_error(&e))?
1508 .ok_or("no indexedDB on this window")?;
1509 let request = factory
1510 .open_with_u32(DB_NAME, DB_VERSION)
1511 .map_err(|e| js_error(&e))?;
1512
1513 let upgrading = request.clone();
1517 let upgrade = Closure::wrap(Box::new(move |_event: web_sys::Event| {
1518 if let Ok(value) = upgrading.result() {
1519 if let Ok(db) = value.dyn_into::<web_sys::IdbDatabase>() {
1520 let _ = db.create_object_store(STORE_NAME);
1522 }
1523 }
1524 }) as Box<dyn FnMut(web_sys::Event)>);
1525 request.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref()));
1526
1527 let opened = on_request(request.clone().unchecked_into::<web_sys::IdbRequest>()).await;
1528 request.set_onupgradeneeded(None);
1529 drop(upgrade);
1530
1531 let db = opened?
1532 .dyn_into::<web_sys::IdbDatabase>()
1533 .map_err(|_| "IndexedDB open returned no database".to_string())?;
1534 Ok(Self { db })
1535 }
1536
1537 fn transact(
1540 &self,
1541 mode: web_sys::IdbTransactionMode,
1542 ) -> Result<(web_sys::IdbTransaction, web_sys::IdbObjectStore), String> {
1543 let transaction = self
1544 .db
1545 .transaction_with_str_and_mode(STORE_NAME, mode)
1546 .map_err(|e| js_error(&e))?;
1547 let store = transaction.object_store(STORE_NAME).map_err(|e| js_error(&e))?;
1548 Ok((transaction, store))
1549 }
1550 }
1551
1552 impl StoreBackend for IdbBackend {
1553 fn label(&self) -> String {
1554 "browser storage (IndexedDB) · download/upload for files".into()
1555 }
1556
1557 fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
1558 let started = (|| -> Result<(IdbFuture, IdbFuture), String> {
1562 let (_transaction, store) = self.transact(web_sys::IdbTransactionMode::Readonly)?;
1563 let keys = store.get_all_keys().map_err(|e| js_error(&e))?;
1564 let values = store.get_all().map_err(|e| js_error(&e))?;
1565 Ok((on_request(keys), on_request(values)))
1566 })();
1567 Box::pin(async move {
1568 let (keys, values) = started?;
1569 let keys = js_sys::Array::from(&keys.await?);
1570 let values = js_sys::Array::from(&values.await?);
1571 if keys.length() != values.length() {
1572 return Err("IndexedDB returned mismatched keys and values".into());
1573 }
1574 let mut entries = Vec::with_capacity(keys.length() as usize);
1575 for i in 0..keys.length() {
1576 let (Some(key), Some(value)) =
1577 (keys.get(i).as_string(), values.get(i).as_string())
1578 else {
1579 continue;
1582 };
1583 entries.push((key, value));
1584 }
1585 Ok(entries)
1586 })
1587 }
1588
1589 fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
1590 let started = (|| -> Result<IdbFuture, String> {
1595 let (transaction, store) =
1596 self.transact(web_sys::IdbTransactionMode::Readwrite)?;
1597 store
1598 .put_with_key(&JsValue::from_str(value), &JsValue::from_str(key))
1599 .map_err(|e| js_error(&e))?;
1600 Ok(on_transaction(transaction))
1601 })();
1602 Box::pin(async move {
1603 started?.await?;
1604 Ok(())
1605 })
1606 }
1607
1608 fn delete(&self, key: &str) -> BackendFuture<()> {
1609 let started = (|| -> Result<IdbFuture, String> {
1610 let (transaction, store) =
1611 self.transact(web_sys::IdbTransactionMode::Readwrite)?;
1612 store
1613 .delete(&JsValue::from_str(key))
1614 .map_err(|e| js_error(&e))?;
1615 Ok(on_transaction(transaction))
1616 })();
1617 Box::pin(async move {
1618 started?.await?;
1619 Ok(())
1620 })
1621 }
1622 }
1623
1624 pub(super) async fn hydrate() {
1636 let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
1637 let core = match IdbBackend::open().await {
1638 Ok(backend) => {
1639 let backend: Rc<dyn StoreBackend> = Rc::new(backend);
1640 match backend.load_all().await {
1641 Ok(entries) => MirrorStore::new(backend, entries, Some(wake)),
1642 Err(message) => unavailable(
1646 format!("IndexedDB could not be read ({message})"),
1647 Some(wake),
1648 ),
1649 }
1650 }
1651 Err(message) => unavailable(format!("IndexedDB unavailable ({message})"), Some(wake)),
1652 };
1653 install_verification_hooks(&core);
1654 BOOT_STORE.with(|c| *c.borrow_mut() = Some(IdbModelStore { core }));
1655 }
1656
1657 fn unavailable(reason: String, wake: Option<Rc<dyn Fn()>>) -> MirrorStore {
1659 let core = MirrorStore::new(
1660 Rc::new(UnavailableBackend::new(reason.clone())),
1661 Vec::new(),
1662 wake,
1663 );
1664 core.report(format!(
1667 "{reason} — this session will not be saved; use Download to keep your work"
1668 ));
1669 core
1670 }
1671
1672 pub(super) fn take_boot_store() -> Box<dyn ModelStore> {
1675 let store = BOOT_STORE.with(|c| c.borrow_mut().take()).unwrap_or_else(|| {
1676 let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
1677 IdbModelStore {
1678 core: unavailable("storage was never initialised".into(), Some(wake)),
1679 }
1680 });
1681 Box::new(store)
1682 }
1683
1684 fn install_verification_hooks(core: &MirrorStore) {
1694 let Some(window) = web_sys::window() else {
1695 return;
1696 };
1697 let publish = |name: &str, value: &JsValue| {
1698 let _ = js_sys::Reflect::set(&window, &JsValue::from_str(name), value);
1699 };
1700
1701 let store = core.clone();
1702 let write = Closure::wrap(Box::new(move |name: String, contents: String| -> JsValue {
1703 match store.write(&name, &contents) {
1704 Ok(()) => JsValue::NULL,
1705 Err(message) => JsValue::from_str(&message),
1706 }
1707 }) as Box<dyn FnMut(String, String) -> JsValue>);
1708 publish("__brepStoreWrite", write.as_ref());
1709 write.forget();
1710
1711 let store = core.clone();
1712 let len = Closure::wrap(Box::new(move |name: String| -> f64 {
1713 store.len_of(&name).map(|n| n as f64).unwrap_or(-1.0)
1714 }) as Box<dyn FnMut(String) -> f64>);
1715 publish("__brepStoreLen", len.as_ref());
1716 len.forget();
1717
1718 let store = core.clone();
1723 let read = Closure::wrap(Box::new(move |name: String| -> JsValue {
1724 match store.read(&name) {
1725 Some(contents) => JsValue::from_str(&contents),
1726 None => JsValue::NULL,
1727 }
1728 }) as Box<dyn FnMut(String) -> JsValue>);
1729 publish("__brepStoreRead", read.as_ref());
1730 read.forget();
1731
1732 let store = core.clone();
1733 let list = Closure::wrap(Box::new(move || -> JsValue {
1734 JsValue::from_str(&serde_json::to_string(&store.list()).unwrap_or_default())
1735 }) as Box<dyn FnMut() -> JsValue>);
1736 publish("__brepStoreList", list.as_ref());
1737 list.forget();
1738
1739 let store = core.clone();
1740 let errors = Closure::wrap(Box::new(move || -> JsValue {
1741 JsValue::from_str(&serde_json::to_string(&store.error_history()).unwrap_or_default())
1742 }) as Box<dyn FnMut() -> JsValue>);
1743 publish("__brepStoreErrors", errors.as_ref());
1744 errors.forget();
1745
1746 let store = core.clone();
1747 let pending = Closure::wrap(Box::new(move || -> f64 { store.pending() as f64 })
1748 as Box<dyn FnMut() -> f64>);
1749 publish("__brepStorePending", pending.as_ref());
1750 pending.forget();
1751 }
1752
1753 fn ensure_input(accept: &str) -> Option<web_sys::HtmlInputElement> {
1763 if let Some(existing) = IMPORT_INPUT.with(|c| c.borrow().clone()) {
1764 existing.set_accept(accept);
1765 return Some(existing);
1766 }
1767 let document = web_sys::window()?.document()?;
1768 let input: web_sys::HtmlInputElement =
1769 document.create_element("input").ok()?.dyn_into().ok()?;
1770 input.set_type("file");
1771 input.set_accept(accept);
1772 input.set_hidden(true);
1773
1774 let input_for_cb = input.clone();
1776 let onchange = Closure::wrap(Box::new(move |_e: web_sys::Event| {
1777 let Some(files) = input_for_cb.files() else { return };
1778 let Some(file) = files.get(0) else { return };
1779 let name = file.name();
1780 let Ok(reader) = web_sys::FileReader::new() else { return };
1781 let reader_for_load = reader.clone();
1782 let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| {
1783 if let Ok(value) = reader_for_load.result() {
1784 let bytes = js_sys::Uint8Array::new(&value).to_vec();
1785 IMPORTED.with(|c| *c.borrow_mut() = Some(ImportedFile {
1786 name: model_display_name(&name),
1787 bytes,
1788 }));
1789 wake_frame_loop();
1792 }
1793 }) as Box<dyn FnMut(web_sys::Event)>);
1794 reader.set_onload(Some(onload.as_ref().unchecked_ref()));
1795 onload.forget();
1797 let _ = reader.read_as_array_buffer(&file);
1798 }) as Box<dyn FnMut(web_sys::Event)>);
1799 input.set_onchange(Some(onchange.as_ref().unchecked_ref()));
1800 onchange.forget(); if let Some(body) = document.body() {
1803 let _ = body.append_child(&input);
1804 }
1805 IMPORT_INPUT.with(|c| *c.borrow_mut() = Some(input.clone()));
1806 Some(input)
1807 }
1808
1809 fn download(file_name: &str, mime: &str, contents: &str) -> Result<(), String> {
1812 let document = web_sys::window()
1813 .and_then(|w| w.document())
1814 .ok_or("no document")?;
1815 let parts = js_sys::Array::of1(&JsValue::from_str(contents));
1816 let options = web_sys::BlobPropertyBag::new();
1817 options.set_type(mime);
1818 let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
1819 .map_err(|_| "blob create failed".to_string())?;
1820 let url = web_sys::Url::create_object_url_with_blob(&blob)
1821 .map_err(|_| "object url failed".to_string())?;
1822 let anchor: web_sys::HtmlAnchorElement = document
1823 .create_element("a")
1824 .map_err(|_| "anchor create failed".to_string())?
1825 .dyn_into()
1826 .map_err(|_| "anchor cast failed".to_string())?;
1827 anchor.set_href(&url);
1828 anchor.set_download(file_name);
1829 anchor.click();
1830 let _ = web_sys::Url::revoke_object_url(&url);
1831 Ok(())
1832 }
1833
1834 pub(super) struct IdbModelStore {
1837 core: MirrorStore,
1838 }
1839
1840 impl ModelStore for IdbModelStore {
1841 fn backend_label(&self) -> String {
1843 self.core.backend_label()
1844 }
1845 fn list(&self) -> Vec<String> {
1846 self.core.list()
1847 }
1848 fn read(&self, name: &str) -> Option<String> {
1849 self.core.read(name)
1850 }
1851 fn write(&self, name: &str, contents: &str) -> Result<(), String> {
1852 self.core.write(name, contents)
1853 }
1854 fn remove(&self, name: &str) -> Result<(), String> {
1855 self.core.remove(name)
1856 }
1857 fn take_persistence_errors(&self) -> Vec<String> {
1858 self.core.take_persistence_errors()
1859 }
1860 fn browser_location(&self) -> String {
1861 self.core.browser_location()
1862 }
1863 fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
1864 self.core.browser_entries(extensions)
1865 }
1866 fn browser_enter(&self, identity: &str) -> Result<(), String> {
1867 self.core.browser_enter(identity)
1868 }
1869 fn browser_up(&self) -> Result<(), String> {
1870 self.core.browser_up()
1871 }
1872 fn browser_home(&self) -> Result<(), String> {
1873 self.core.browser_home()
1874 }
1875 fn browser_root(&self) -> Result<(), String> {
1876 self.core.browser_root()
1877 }
1878 fn browser_navigate(&self, location: &str) -> Result<(), String> {
1879 self.core.browser_navigate(location)
1880 }
1881 fn browser_places(&self) -> Vec<BrowserPlace> {
1882 self.core.browser_places()
1883 }
1884 fn browser_create_dir(&self, name: &str) -> Result<(), String> {
1885 self.core.browser_create_dir(name)
1886 }
1887 fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
1888 self.core.browser_write(name, contents)
1889 }
1890
1891 fn supports_file_interchange(&self) -> bool {
1893 true
1894 }
1895
1896 fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
1899 let name = model_display_name(name);
1900 download(&format!("{name}{MODEL_EXT}"), "application/json", contents)?;
1901 Ok(Some(name))
1902 }
1903
1904 fn begin_import(&self) -> Result<(), String> {
1905 let input =
1906 ensure_input(".json,.BREP.json,application/json").ok_or("file input unavailable")?;
1907 input.set_value("");
1909 input.click();
1910 Ok(())
1911 }
1912
1913 fn take_import(&self) -> Option<ImportedFile> {
1914 IMPORTED.with(|c| c.borrow_mut().take())
1915 }
1916
1917 fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
1922 let accept = filter
1923 .1
1924 .iter()
1925 .map(|ext| format!(".{ext}"))
1926 .collect::<Vec<_>>()
1927 .join(",");
1928 let input = ensure_input(&accept).ok_or("file input unavailable")?;
1929 input.set_value("");
1930 input.click();
1931 Ok(())
1932 }
1933
1934 fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
1937 download(file_name, "application/octet-stream", contents)
1938 }
1939 }
1940}
1941
1942
1943#[cfg(all(test, not(target_arch = "wasm32")))]
1944mod tests {
1945 use super::native_model::FileModelStore;
1946 use super::{ModelStore, DOCK_LAYOUT_KEY, SETTINGS_KEY};
1947
1948 #[test]
1949 fn native_model_store_round_trips_named_documents() {
1950 let dir = std::env::temp_dir().join(format!("brep-app-models-{}", std::process::id()));
1951 let _ = std::fs::remove_dir_all(&dir);
1952 let store = FileModelStore::with_dir(dir.clone());
1953
1954 assert!(store.list().is_empty());
1956 assert_eq!(store.read("missing"), None);
1957
1958 let doc_a = r#"{"features":[{"type":"P.CU"}]}"#;
1960 let doc_b = r#"{"features":[]}"#;
1961 store.write("alpha", doc_a).unwrap();
1962 store.write("beta", doc_b).unwrap();
1963 assert_eq!(store.read("alpha").as_deref(), Some(doc_a));
1964 assert_eq!(store.read("beta").as_deref(), Some(doc_b));
1965 assert_eq!(store.list(), vec!["alpha".to_string(), "beta".to_string()]);
1966
1967 assert_eq!(store.read("alpha.BREP.json").as_deref(), Some(doc_a));
1969
1970 store.write("alpha", doc_b).unwrap();
1972 assert_eq!(store.read("alpha").as_deref(), Some(doc_b));
1973 store.remove("alpha").unwrap();
1974 assert_eq!(store.read("alpha"), None);
1975 assert_eq!(store.list(), vec!["beta".to_string()]);
1976 store.remove("alpha").unwrap(); let _ = std::fs::remove_dir_all(&dir);
1979 }
1980
1981 #[test]
1982 fn native_store_uses_the_same_read_write_api_for_app_state_and_models() {
1983 let dir = std::env::temp_dir().join(format!(
1984 "brep-app-unified-store-{}",
1985 std::process::id()
1986 ));
1987 let _ = std::fs::remove_dir_all(&dir);
1988 let store = FileModelStore::with_dir(dir.clone());
1989
1990 store.write(SETTINGS_KEY, r#"{"theme":"dark"}"#).unwrap();
1991 store.write(DOCK_LAYOUT_KEY, r#"{"tiles":[]}"#).unwrap();
1992 store.write("part", r#"{"features":[]}"#).unwrap();
1993
1994 assert_eq!(
1995 store.read(SETTINGS_KEY).as_deref(),
1996 Some(r#"{"theme":"dark"}"#)
1997 );
1998 assert_eq!(
1999 store.read(DOCK_LAYOUT_KEY).as_deref(),
2000 Some(r#"{"tiles":[]}"#)
2001 );
2002 assert_eq!(store.list(), vec!["part".to_string()]);
2003 assert!(dir.join("settings.json").is_file());
2004 assert!(dir.join("dock_layout.json").is_file());
2005 assert!(dir.join("part.BREP.json").is_file());
2006
2007 let _ = std::fs::remove_dir_all(&dir);
2008 }
2009
2010 #[test]
2011 fn native_browser_navigates_and_saves_outside_the_model_directory() {
2012 let root = std::env::temp_dir().join(format!(
2013 "brep-app-browser-navigation-{}",
2014 std::process::id()
2015 ));
2016 let models = root.join("models");
2017 let sibling = root.join("projects");
2018 let _ = std::fs::remove_dir_all(&root);
2019 std::fs::create_dir_all(&models).unwrap();
2020 std::fs::create_dir_all(&sibling).unwrap();
2021 let store = FileModelStore::with_dir(models.clone());
2022
2023 store.browser_up().unwrap();
2024 let projects = store
2025 .browser_entries(&["BREP.json"])
2026 .into_iter()
2027 .find(|entry| entry.name == "projects")
2028 .expect("sibling directory should be visible");
2029 assert!(projects.is_dir);
2030 store.browser_enter(&projects.identity).unwrap();
2031 let identity = store
2032 .browser_write("assembly", r#"{"features":[]}"#)
2033 .unwrap();
2034
2035 assert_eq!(std::path::PathBuf::from(&identity), sibling.join("assembly.BREP.json"));
2036 assert_eq!(store.read(&identity).as_deref(), Some(r#"{"features":[]}"#));
2037 assert_eq!(store.browser_location(), sibling.display().to_string());
2038
2039 let _ = std::fs::remove_dir_all(&root);
2040 }
2041
2042 #[test]
2043 fn native_store_exposes_foreign_files_to_the_common_explorer() {
2044 let dir = std::env::temp_dir().join(format!(
2045 "brep-app-explorer-store-{}",
2046 std::process::id()
2047 ));
2048 let _ = std::fs::remove_dir_all(&dir);
2049 let store = FileModelStore::with_dir(dir.clone());
2050
2051 store.export_file_named("bracket.step", "STEP DATA").unwrap();
2052 store.export_file_named("preview.stl", "solid preview").unwrap();
2053
2054 assert_eq!(
2055 store.list_external_files(&["step", "stp"]),
2056 vec!["bracket.step".to_string()]
2057 );
2058 assert_eq!(
2059 store.read_external_file("bracket.step").as_deref(),
2060 Some(b"STEP DATA".as_slice())
2061 );
2062 assert!(dir.join("bracket.step").is_file());
2063
2064 let _ = std::fs::remove_dir_all(&dir);
2065 }
2066}
2067
2068#[cfg(all(test, not(target_arch = "wasm32")))]
2075mod mirror_tests {
2076 use super::mirror_store::{BackendFuture, MirrorStore, StoreBackend, UnavailableBackend};
2077 use super::{ModelStore, DOCK_LAYOUT_KEY, PINNED_KEY, SETTINGS_KEY};
2078 use std::cell::RefCell;
2079 use std::rc::Rc;
2080
2081 #[derive(Default)]
2084 struct RecordingBackend {
2085 puts: RefCell<Vec<(String, String)>>,
2086 deletes: RefCell<Vec<String>>,
2087 fail_with: RefCell<Option<String>>,
2088 }
2089
2090 impl RecordingBackend {
2091 fn shared() -> Rc<Self> {
2092 Rc::new(Self::default())
2093 }
2094
2095 fn put_keys(&self) -> Vec<String> {
2096 self.puts.borrow().iter().map(|(k, _)| k.clone()).collect()
2097 }
2098
2099 fn outcome(&self) -> BackendFuture<()> {
2100 let failure = self.fail_with.borrow().clone();
2101 Box::pin(async move {
2102 match failure {
2103 Some(message) => Err(message),
2104 None => Ok(()),
2105 }
2106 })
2107 }
2108 }
2109
2110 impl StoreBackend for RecordingBackend {
2111 fn label(&self) -> String {
2112 "recording test backend".into()
2113 }
2114
2115 fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
2116 Box::pin(async move { Ok(Vec::new()) })
2117 }
2118
2119 fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
2120 self.puts
2121 .borrow_mut()
2122 .push((key.to_string(), value.to_string()));
2123 self.outcome()
2124 }
2125
2126 fn delete(&self, key: &str) -> BackendFuture<()> {
2127 self.deletes.borrow_mut().push(key.to_string());
2128 self.outcome()
2129 }
2130 }
2131
2132 fn store(backend: &Rc<RecordingBackend>) -> MirrorStore {
2133 MirrorStore::new(backend.clone(), Vec::new(), None)
2134 }
2135
2136 #[test]
2137 fn mirror_serves_reads_synchronously_and_pushes_writes_behind() {
2138 let backend = RecordingBackend::shared();
2139 let store = store(&backend);
2140
2141 assert!(store.list().is_empty());
2142 assert_eq!(store.read("alpha"), None);
2143
2144 let doc = r#"{"features":[{"type":"P.CU"}]}"#;
2145 store.write("alpha", doc).unwrap();
2148 assert_eq!(store.read("alpha").as_deref(), Some(doc));
2149 assert_eq!(store.list(), vec!["alpha".to_string()]);
2150 assert_eq!(
2152 backend.puts.borrow().as_slice(),
2153 [("brep-app:model:alpha".to_string(), doc.to_string())]
2154 );
2155 assert_eq!(store.pending(), 0);
2157 assert!(store.take_persistence_errors().is_empty());
2158
2159 assert_eq!(store.read("alpha.BREP.json").as_deref(), Some(doc));
2161
2162 store.remove("alpha").unwrap();
2163 assert_eq!(store.read("alpha"), None);
2164 assert!(store.list().is_empty());
2165 assert_eq!(
2166 backend.deletes.borrow().as_slice(),
2167 ["brep-app:model:alpha".to_string()]
2168 );
2169 }
2170
2171 #[test]
2172 fn key_scheme_is_verbatim_from_the_local_storage_era() {
2173 assert_eq!(MirrorStore::key(SETTINGS_KEY), "brep-app:settings");
2174 assert_eq!(MirrorStore::key(DOCK_LAYOUT_KEY), "brep-app:dock_layout");
2175 assert_eq!(MirrorStore::key(PINNED_KEY), "brep-app:pinned");
2176 assert_eq!(MirrorStore::key("part"), "brep-app:model:part");
2178 assert_eq!(MirrorStore::key("/models/part"), "brep-app:model:part");
2179 assert_eq!(
2180 MirrorStore::key("/models/sub/part.BREP.json"),
2181 "brep-app:model:sub/part"
2182 );
2183 assert_eq!(MirrorStore::key("part.json"), "brep-app:model:part");
2184 }
2185
2186 #[test]
2187 fn reserved_application_blobs_share_the_document_api_and_never_list() {
2188 let backend = RecordingBackend::shared();
2189 let store = store(&backend);
2190
2191 store.write(SETTINGS_KEY, r#"{"theme":"dark"}"#).unwrap();
2192 store.write(DOCK_LAYOUT_KEY, r#"{"tiles":[]}"#).unwrap();
2193 store.write("part", r#"{"features":[]}"#).unwrap();
2194
2195 assert_eq!(
2196 store.read(SETTINGS_KEY).as_deref(),
2197 Some(r#"{"theme":"dark"}"#)
2198 );
2199 assert_eq!(store.list(), vec!["part".to_string()]);
2201 assert_eq!(
2202 backend.put_keys(),
2203 vec![
2204 "brep-app:settings".to_string(),
2205 "brep-app:dock_layout".to_string(),
2206 "brep-app:model:part".to_string(),
2207 ]
2208 );
2209 }
2210
2211 #[test]
2212 fn hydrated_entries_are_visible_to_the_first_synchronous_read() {
2213 let store = MirrorStore::new(
2215 RecordingBackend::shared(),
2216 vec![
2217 ("brep-app:model:alpha".into(), "A".into()),
2218 ("brep-app:model:sub/beta".into(), "B".into()),
2219 ("brep-app:settings".into(), r#"{"theme":"dark"}"#.into()),
2220 ],
2221 None,
2222 );
2223 assert_eq!(
2224 store.list(),
2225 vec!["alpha".to_string(), "sub/beta".to_string()]
2226 );
2227 assert_eq!(store.read("alpha").as_deref(), Some("A"));
2228 assert_eq!(store.read("/models/sub/beta").as_deref(), Some("B"));
2229 assert_eq!(
2230 store.read(SETTINGS_KEY).as_deref(),
2231 Some(r#"{"theme":"dark"}"#)
2232 );
2233 }
2234
2235 #[test]
2236 fn explorer_walks_the_virtual_tree_the_key_prefixes_describe() {
2237 let backend = RecordingBackend::shared();
2238 let store = store(&backend);
2239 store.write("alpha", "AAAA").unwrap();
2240 store.browser_create_dir("sub").unwrap();
2241 store.write("/models/sub/beta", "BB").unwrap();
2242
2243 assert!(backend.put_keys().contains(&"brep-app:dir:sub/".to_string()));
2245
2246 let entries = store.browser_entries(&["BREP.json"]);
2248 let names: Vec<_> = entries.iter().map(|e| e.name.clone()).collect();
2249 assert_eq!(names, vec!["sub".to_string(), "alpha.BREP.json".to_string()]);
2250 assert!(entries[0].is_dir);
2251 assert_eq!(entries[1].size, Some(4));
2252 assert_eq!(entries[1].identity, "/models/alpha.BREP.json");
2253
2254 store.browser_enter("/models/sub").unwrap();
2256 assert_eq!(store.browser_location(), "/models/sub");
2257 let entries = store.browser_entries(&["BREP.json"]);
2258 assert_eq!(entries.len(), 1);
2259 assert_eq!(entries[0].name, "beta.BREP.json");
2260 assert_eq!(entries[0].identity, "/models/sub/beta.BREP.json");
2261
2262 let identity = store.browser_write("gamma", "G").unwrap();
2264 assert_eq!(identity, "/models/sub/gamma.BREP.json");
2265 assert_eq!(store.read(&identity).as_deref(), Some("G"));
2266
2267 store.browser_root().unwrap();
2269 assert_eq!(
2270 store
2271 .browser_entries(&["BREP.json"])
2272 .into_iter()
2273 .map(|e| e.identity)
2274 .collect::<Vec<_>>(),
2275 vec!["/models".to_string()]
2276 );
2277 assert!(store.browser_navigate("/etc").is_err());
2278 }
2279
2280 #[test]
2281 fn a_write_behind_failure_reaches_the_user_exactly_once() {
2282 let backend = RecordingBackend::shared();
2283 *backend.fail_with.borrow_mut() = Some("QuotaExceededError: out of room".into());
2284 let store = store(&backend);
2285
2286 store.write("alpha", "A").unwrap();
2288 assert_eq!(store.read("alpha").as_deref(), Some("A"));
2289
2290 let drained = store.take_persistence_errors();
2292 assert_eq!(drained.len(), 1);
2293 assert!(drained[0].contains("alpha"), "{drained:?}");
2294 assert!(drained[0].contains("QuotaExceededError"), "{drained:?}");
2295 assert!(store.take_persistence_errors().is_empty());
2297
2298 store.write("alpha", "AA").unwrap();
2301 assert_eq!(store.take_persistence_errors().len(), 1);
2302 assert_eq!(store.error_history().len(), 2);
2304 }
2305
2306 #[test]
2307 fn an_unavailable_backend_is_usable_in_memory_and_loud_about_not_saving() {
2308 let backend = Rc::new(UnavailableBackend::new("IndexedDB unavailable (blocked)"));
2309 let store = MirrorStore::new(backend, Vec::new(), None);
2310
2311 assert!(store.backend_label().contains("NOT SAVING"));
2312 assert!(store.backend_label().contains("blocked"));
2313
2314 store.write("alpha", "A").unwrap();
2316 assert_eq!(store.read("alpha").as_deref(), Some("A"));
2317 assert_eq!(store.len_of("alpha"), Some(1));
2318 let drained = store.take_persistence_errors();
2320 assert_eq!(drained.len(), 1);
2321 assert!(drained[0].contains("alpha"), "{drained:?}");
2322 }
2323
2324 #[test]
2325 fn recording_a_failure_wakes_the_frame_loop() {
2326 let woken = Rc::new(std::cell::Cell::new(0usize));
2329 let wake = {
2330 let woken = woken.clone();
2331 Rc::new(move || woken.set(woken.get() + 1)) as Rc<dyn Fn()>
2332 };
2333 let backend = RecordingBackend::shared();
2334 *backend.fail_with.borrow_mut() = Some("boom".into());
2335 let store = MirrorStore::new(backend, Vec::new(), Some(wake));
2336
2337 store.write("alpha", "A").unwrap();
2338 assert_eq!(woken.get(), 1);
2339 }
2340}