pub const SETTINGS_KEY: &str = "@settings";
pub const FEATURE_PALETTE_DISPLAY_KEY: &str = "@feature_palette_display";
pub const DOCK_LAYOUT_KEY: &str = "@dock_layout";
pub const PINNED_KEY: &str = "@pinned";
pub const RECOVERY_KEY: &str = "@recovery";
pub const KICAD_LIBRARY_KEY: &str = "@kicad_library";
pub const PLM_IMPORT_KEY: &str = "@plm_import";
pub const PLUGINS_KEY: &str = "@plugins";
pub const JAVASCRIPT_DRAFT_KEY: &str = "@javascript_draft";
pub const RECENT_DOCUMENTS_KEY: &str = "@recent_documents";
pub const RECENT_THUMBNAILS_KEY: &str = "@recent_thumbnails";
pub const PREFERENCE_KEYS: &[&str] = &[
SETTINGS_KEY,
DOCK_LAYOUT_KEY,
PINNED_KEY,
FEATURE_PALETTE_DISPLAY_KEY,
KICAD_LIBRARY_KEY,
RECENT_DOCUMENTS_KEY,
];
pub const LOCAL_KEYS: &[&str] = &[
RECOVERY_KEY,
PLM_IMPORT_KEY,
PLUGINS_KEY,
JAVASCRIPT_DRAFT_KEY,
RECENT_THUMBNAILS_KEY,
crate::panels::cam_library::CAM_TOOL_LIBRARY_KEY,
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeySpace {
Documents,
Preferences,
Local,
}
impl KeySpace {
pub fn of(name: &str) -> Self {
if PREFERENCE_KEYS.contains(&name) {
KeySpace::Preferences
} else if LOCAL_KEYS.contains(&name) {
KeySpace::Local
} else {
KeySpace::Documents
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Residency {
Absent,
OnDisk,
Resident,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReadNow {
Ready(String),
Loading,
Absent,
}
pub fn read_now(store: &dyn ModelStore, name: &str) -> ReadNow {
if let Some(contents) = store.read(name) {
return ReadNow::Ready(contents);
}
match store.residency(name) {
Residency::Resident => store.read(name).map_or(ReadNow::Absent, ReadNow::Ready),
Residency::OnDisk => ReadNow::Loading,
Residency::Absent => ReadNow::Absent,
}
}
pub struct ImportedFile {
pub name: String,
pub bytes: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct BrowserEntry {
pub name: String,
pub identity: String,
pub is_dir: bool,
pub size: Option<u64>,
pub modified: Option<f64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BrowserPlace {
pub label: String,
pub location: String,
pub kind: PlaceKind,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlaceKind {
Home,
Documents,
Downloads,
Models,
Root,
}
pub fn media_type(file_name: &str) -> &'static str {
let extension = std::path::Path::new(file_name)
.extension()
.map(|extension| extension.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
match extension.as_str() {
"pdf" => "application/pdf",
"svg" => "image/svg+xml",
"glb" => "model/gltf-binary",
"3mf" => "model/3mf",
"json" => "application/json",
"zip" => "application/zip",
"nbrep" | "fbrep" | "tbrep" => "application/octet-stream",
"step" | "stp" => "model/step",
"iges" | "igs" => "model/iges",
"stl" => "model/stl",
"obj" => "model/obj",
"dxf" => "image/vnd.dxf",
"csv" => "text/csv",
"txt" => "text/plain",
"md" => "text/markdown",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
_ => "application/octet-stream",
}
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn write_atomic(path: &std::path::Path, bytes: &[u8], secret: bool) -> std::io::Result<()> {
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let name = path
.file_name()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "no file name"))?;
let mut temp_name = name.to_os_string();
temp_name.push(format!(".{}-{}.tmp", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
let temp = path.with_file_name(temp_name);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
if secret {
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
#[cfg(not(unix))]
let _ = secret;
let written = options
.open(&temp)
.and_then(|mut file| {
file.write_all(bytes)?;
file.sync_all()
})
.and_then(|()| std::fs::rename(&temp, path));
if written.is_err() {
let _ = std::fs::remove_file(&temp);
}
written
}
pub trait ModelStore {
#[cfg(feature = "automation")]
fn files(&self, _request: &brep_mcp_core::files::Request) -> Result<brep_mcp_core::files::Reply, String> {
Err("files is unsupported by this store backend".into())
}
#[cfg(feature = "automation")]
fn files_settled(&self, _request: &brep_mcp_core::files::Request) -> Option<Result<(), String>> { Some(Ok(())) }
fn backend_label(&self) -> String {
"persistent storage".into()
}
fn list(&self) -> Vec<String> {
Vec::new()
}
fn read(&self, name: &str) -> Option<String>;
fn write(&self, name: &str, contents: &str) -> Result<(), String>;
fn remove(&self, _name: &str) -> Result<(), String> {
Ok(())
}
fn mutation_generation(&self) -> u64;
fn residency(&self, name: &str) -> Residency {
match self.read(name) {
Some(_) => Residency::Resident,
None => Residency::Absent,
}
}
fn identity(&self, identity: &str) -> DocumentIdentity {
DocumentIdentity::Path(identity.to_string())
}
fn display_name(&self, identity: &str) -> String {
model_display_name(identity)
}
fn sibling_of(&self, identity: &str, file_name: &str) -> Option<String> {
Some(sibling_identity(identity, file_name))
}
fn canonical_identity(&self, name: &str) -> String {
name.to_string()
}
fn canonicalizer(&self) -> Canonicalizer {
std::rc::Rc::new(str::to_string)
}
fn take_persistence_errors(&self) -> Vec<String> {
Vec::new()
}
fn take_failed_writes(&self) -> Vec<String> {
Vec::new()
}
fn write_failed(&self, name: &str) -> bool {
let _ = name;
false
}
fn failed_write_base(&self, name: &str) -> Option<String> {
let _ = name;
None
}
fn write_if(&self, name: &str, contents: &str, expected: &str) -> Result<(), String> {
let _ = expected;
self.write(name, contents)
}
fn plm_session(&self) -> Option<crate::plm::connection::Session> {
None
}
fn plm_url_setting(&self) -> Option<crate::plm::connection::UrlSetting> {
None
}
fn save_plm_url(&self, url: &str) -> Result<crate::plm::connection::UrlSaved, String> {
let _ = url;
Err("this session keeps no PLM settings".into())
}
fn plm_client(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> {
None
}
fn plm_revision(&self, key: &str) -> Option<crate::plm::client::IndexEntry> {
let _ = key;
None
}
fn note_checked_out(&self, key: &str) {
let _ = key;
}
fn plm_written_by_us(&self, key: &str) -> bool {
let _ = key;
false
}
fn refresh_plm_index(&self, keys: &[String], stale: bool) {
let _ = (keys, stale);
}
fn remember_saved_document(&self, name: &str, contents: &str) {
let _ = (name, contents);
}
fn local_files(&self) -> Option<&dyn ModelStore> {
None
}
fn begin_pick_file(&self) -> Result<(), String> {
Err("this platform has no file chooser".into())
}
fn take_picked_file(&self) -> Option<ImportedFile> {
None
}
fn pending_writes(&self) -> usize {
0
}
fn pending_saves(&self) -> usize {
self.pending_writes()
}
fn pending_writes_except(&self, names: &[&str]) -> usize {
let _ = names;
self.pending_writes()
}
fn browser_location(&self) -> String {
self.backend_label()
}
fn browser_entries(&self, _extensions: &[&str]) -> Vec<BrowserEntry> {
self.list()
.into_iter()
.map(|name| BrowserEntry {
identity: name.clone(),
name,
is_dir: false,
size: None,
modified: None,
})
.collect()
}
fn browser_enter(&self, _identity: &str) -> Result<(), String> {
Err("this storage backend has no directories".into())
}
fn browser_up(&self) -> Result<(), String> {
Ok(())
}
fn browser_home(&self) -> Result<(), String> {
Ok(())
}
fn browser_root(&self) -> Result<(), String> {
Ok(())
}
fn browser_navigate(&self, _location: &str) -> Result<(), String> {
Err("this storage backend cannot navigate to a path".into())
}
fn browser_places(&self) -> Vec<BrowserPlace> {
Vec::new()
}
fn browser_create_dir(&self, _name: &str) -> Result<(), String> {
Err("this storage backend cannot create directories".into())
}
fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
self.write(name, contents)?;
Ok(name.to_string())
}
fn browser_write_bytes(&self, _file_name: &str, _contents: &[u8]) -> Result<String, String> {
Err("this storage backend cannot write a file at a location".into())
}
fn list_external_files(&self, _extensions: &[&str]) -> Vec<String> {
Vec::new()
}
fn read_external_file(&self, _name: &str) -> Option<Vec<u8>> {
None
}
fn supports_file_interchange(&self) -> bool {
false
}
fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
let _ = (name, contents);
Ok(None)
}
fn begin_import(&self) -> Result<(), String> {
Ok(())
}
fn take_import(&self) -> Option<ImportedFile> {
None
}
fn begin_import_filtered(&self, _filter: (&str, &[&str])) -> Result<(), String> {
self.begin_import()
}
fn export_file_named(&self, _file_name: &str, _contents: &str) -> Result<(), String> {
Ok(())
}
fn export_file_named_bytes(&self, _file_name: &str, _contents: &[u8]) -> Result<(), String> {
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn native_store_at(dir: std::path::PathBuf) -> Box<dyn ModelStore> {
match crate::plm::config::resolve(&dir, &|_| None, &Default::default()) {
Ok(None) => Box::new(native_model::FileModelStore::rooted_at(dir)),
Ok(Some(config)) => boot_native_store(dir, config),
Err(e) => file_store_with_notice(dir, format!("PLM config: {e}")),
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn file_store_with_notice(root: std::path::PathBuf, reason: String) -> Box<dyn ModelStore> {
Box::new(
native_model::FileModelStore::rooted_at(root)
.with_notice(format!("{reason} — this session uses the file stores"))
.with_plm_reason(reason),
)
}
#[cfg(not(target_arch = "wasm32"))]
pub use native_boot::{boot_native_store, check_native_plm, start_native_boot, NativeFileBackend, PendingBoot, BOOT_TIMEOUT};
pub fn default_model_store() -> Box<dyn ModelStore> {
#[cfg(not(target_arch = "wasm32"))]
{
Box::new(native_model::FileModelStore::new())
}
#[cfg(target_arch = "wasm32")]
{
web_model::take_boot_store()
}
}
#[cfg(target_arch = "wasm32")]
pub async fn hydrate_web_store() {
web_model::hydrate().await;
}
#[cfg(target_arch = "wasm32")]
pub use web_model::set_repaint_ctx;
pub(crate) fn wake_ui() {
#[cfg(target_arch = "wasm32")]
web_model::wake_frame_loop();
#[cfg(not(target_arch = "wasm32"))]
crate::plm::native::request_wake();
}
#[cfg(not(target_arch = "wasm32"))]
pub fn flush_writes(limit: std::time::Duration) -> bool {
native_model::write_behind::flush(limit)
}
pub(crate) fn spawn_waking(task: impl std::future::Future<Output = ()> + 'static) {
#[cfg(target_arch = "wasm32")]
wasm_bindgen_futures::spawn_local(web_model::WakeOnPoll(Box::pin(task)));
#[cfg(not(target_arch = "wasm32"))]
crate::plm::native::spawn(task);
}
pub const MODEL_EXT: &str = ".nbrep";
pub const MODEL_EXTENSIONS: &[&str] = &["nbrep", "fbrep", "tbrep"];
pub fn model_file_name(name: &str) -> String {
let class = crate::document_class::DocumentClass::of_name(name).unwrap_or_default();
class.file_name(name)
}
#[cfg(not(target_arch = "wasm32"))]
mod native_boot {
use super::mirror_store::{BackendFuture, MirrorStore, StoreBackend, DIR_PREFIX, PREFIX};
use super::native_model::FileModelStore;
use super::{BrowserEntry, BrowserPlace, ModelStore, Residency};
use crate::plm::config::PlmConfig;
use crate::plm::PlmFuture;
use std::path::{Path, PathBuf};
use std::rc::Rc;
pub const BOOT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
fn reserved() -> impl Iterator<Item = &'static str> {
super::PREFERENCE_KEYS.iter().chain(super::LOCAL_KEYS).copied()
}
pub struct NativeFileBackend {
files: FileModelStore,
}
impl NativeFileBackend {
pub fn rooted_at(root: PathBuf) -> Self {
Self { files: FileModelStore::rooted_at(root) }
}
fn name_of(key: &str) -> Option<String> {
if let Some(name) = reserved().find(|name| MirrorStore::key(name) == key) {
return Some(name.to_string());
}
if key.starts_with(DIR_PREFIX) {
return None;
}
key.strip_prefix(PREFIX).map(str::to_string)
}
}
fn ready<T: 'static>(value: Result<T, String>) -> BackendFuture<T> {
Box::pin(std::future::ready(value))
}
impl StoreBackend for NativeFileBackend {
fn label(&self) -> String {
self.files.backend_label()
}
fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
let reserved = reserved().map(|name| name.to_string());
let documents = self.files.list();
let all = reserved
.chain(documents)
.filter_map(|name| self.files.read(&name).map(|value| (MirrorStore::key(&name), value)))
.collect();
ready(Ok(all))
}
fn orders_writes_per_key(&self) -> bool {
true
}
fn get(&self, key: &str) -> BackendFuture<Option<String>> {
ready(Ok(Self::name_of(key).and_then(|name| self.files.read(&name))))
}
fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
ready(match Self::name_of(key) {
Some(name) => self.files.write(&name, value),
None => Err(format!("{key}: the local store keeps no explorer folders")),
})
}
fn delete(&self, key: &str) -> BackendFuture<()> {
ready(match Self::name_of(key) {
Some(name) => self.files.remove(&name),
None => Ok(()),
})
}
}
fn plm_connect(config: PlmConfig, local: Rc<dyn StoreBackend>) -> PlmFuture<Rc<dyn StoreBackend>> {
crate::plm::client::open_session(config, local)
}
pub fn boot_native_store(root: PathBuf, config: PlmConfig) -> Box<dyn ModelStore> {
let (url, source) = (config.url.clone(), config.url_source);
let booted = boot_with(&root, config, plm_connect, BOOT_TIMEOUT);
boot_outcome(root, url, source, booted)
}
fn boot_outcome(
root: PathBuf,
url: String,
source: crate::plm::config::Source,
booted: Result<NativeMirrorStore, String>,
) -> Box<dyn ModelStore> {
match booted {
Ok(store) => Box::new(store),
Err(reason) => {
let reason = format!("PLM at {url}: {reason}");
Box::new(
FileModelStore::rooted_at(root)
.with_plm_launch(url, source)
.with_notice(format!("{reason} — this session uses the file stores"))
.with_plm_reason(reason),
)
}
}
}
pub fn start_native_boot(root: PathBuf, config: PlmConfig) -> PendingBoot {
start_boot_with(root, config, plm_connect, BOOT_TIMEOUT)
}
pub struct PendingBoot {
state: Rc<std::cell::RefCell<BootState>>,
task: crate::plm::native::Abort,
root: PathBuf,
url: String,
source: crate::plm::config::Source,
timeout: std::time::Duration,
}
struct BootState {
phase_since: std::time::Instant,
booted: Option<Result<NativeMirrorStore, String>>,
}
impl PendingBoot {
pub fn poll(&mut self) -> Option<Box<dyn ModelStore>> {
let finished = self.state.borrow_mut().booted.take();
let booted = match finished {
Some(booted) => booted,
None if self.state.borrow().phase_since.elapsed() > self.timeout => {
self.task.abort();
Err(format!("no answer within {} s", self.timeout.as_secs()))
}
None => return None,
};
Some(boot_outcome(self.root.clone(), self.url.clone(), self.source, booted))
}
}
pub(super) fn start_boot_with(
root: PathBuf,
config: PlmConfig,
connect: impl FnOnce(PlmConfig, Rc<dyn StoreBackend>) -> PlmFuture<Rc<dyn StoreBackend>>,
timeout: std::time::Duration,
) -> PendingBoot {
let (url, source) = (config.url.clone(), config.url_source);
let (files, local) = boot_parts(&root, &config);
let state = Rc::new(std::cell::RefCell::new(BootState { phase_since: std::time::Instant::now(), booted: None }));
let theirs = state.clone();
let connecting = connect(config, local);
let task = crate::plm::native::spawn_abortable(async move {
let booted = async {
let backend = connecting.await?;
theirs.borrow_mut().phase_since = std::time::Instant::now();
let entries = backend.load_index().await.map_err(|e| format!("the store index would not load ({e})"))?;
Ok::<_, String>(mirror_of(backend, entries, files))
}
.await;
theirs.borrow_mut().booted = Some(booted);
});
PendingBoot { state, task, root, url, source, timeout }
}
fn boot_parts(root: &Path, config: &PlmConfig) -> (FileModelStore, Rc<dyn StoreBackend>) {
let files = FileModelStore::rooted_at(root.to_path_buf()).with_plm_launch(config.url.clone(), config.url_source);
let local: Rc<dyn StoreBackend> = Rc::new(NativeFileBackend::rooted_at(root.to_path_buf()));
(files, local)
}
fn mirror_of(backend: Rc<dyn StoreBackend>, entries: Vec<(String, super::mirror_store::Entry)>, files: FileModelStore) -> NativeMirrorStore {
let wake: Rc<dyn Fn()> = Rc::new(crate::plm::native::request_wake);
let core = MirrorStore::from_index(backend, entries, Some(wake));
NativeMirrorStore { core, files }
}
pub fn check_native_plm(root: PathBuf, config: PlmConfig) -> Result<(), String> {
let local: Rc<dyn StoreBackend> = Rc::new(NativeFileBackend::rooted_at(root));
crate::plm::native::block_on_for(plm_connect(config, local), BOOT_TIMEOUT)
.ok_or_else(|| format!("no answer within {} s", BOOT_TIMEOUT.as_secs()))?
.map(|_| ())
}
pub(super) fn boot_with(
root: &Path,
config: PlmConfig,
connect: impl FnOnce(PlmConfig, Rc<dyn StoreBackend>) -> PlmFuture<Rc<dyn StoreBackend>>,
timeout: std::time::Duration,
) -> Result<NativeMirrorStore, String> {
use crate::plm::native::block_on_for;
let late = || format!("no answer within {} s", timeout.as_secs());
let (files, local) = boot_parts(root, &config);
let backend = block_on_for(connect(config, local), timeout).ok_or_else(late)??;
let entries = block_on_for(backend.load_index(), timeout)
.ok_or_else(late)?
.map_err(|e| format!("the store index would not load ({e})"))?;
Ok(mirror_of(backend, entries, files))
}
pub(super) struct NativeMirrorStore {
pub(super) core: MirrorStore,
files: FileModelStore,
}
impl ModelStore for NativeMirrorStore {
#[cfg(feature = "automation")]
fn files(&self, request: &brep_mcp_core::files::Request) -> Result<brep_mcp_core::files::Reply, String> { self.files.files(request) }
fn backend_label(&self) -> String {
self.core.backend_label()
}
fn plm_client(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> {
self.core.plm_client()
}
fn plm_revision(&self, key: &str) -> Option<crate::plm::client::IndexEntry> {
self.core.plm_revision(key)
}
fn note_checked_out(&self, key: &str) {
self.core.note_checked_out(key)
}
fn plm_written_by_us(&self, key: &str) -> bool {
self.core.plm_written_by_us(key)
}
fn refresh_plm_index(&self, keys: &[String], stale: bool) {
self.core.refresh_plm_index(keys, stale)
}
fn remember_saved_document(&self, name: &str, contents: &str) {
self.core.remember_saved_document(name, contents)
}
fn list(&self) -> Vec<String> {
self.core.list()
}
fn read(&self, name: &str) -> Option<String> {
self.core.read(name)
}
fn residency(&self, name: &str) -> Residency {
self.core.residency(name)
}
fn canonical_identity(&self, name: &str) -> String {
self.core.canonical_identity(name)
}
fn canonicalizer(&self) -> super::Canonicalizer {
self.core.canonicalizer()
}
fn write(&self, name: &str, contents: &str) -> Result<(), String> {
self.core.write(name, contents)
}
fn remove(&self, name: &str) -> Result<(), String> {
self.core.remove(name)
}
fn mutation_generation(&self) -> u64 {
self.core.mutation_generation()
}
fn take_persistence_errors(&self) -> Vec<String> {
let mut errors = self.core.take_persistence_errors();
errors.extend(super::native_model::write_behind::take_errors(self.files.root()));
errors
}
fn take_failed_writes(&self) -> Vec<String> {
self.core.take_failed_writes()
}
fn write_failed(&self, name: &str) -> bool {
self.core.write_failed(name)
}
fn failed_write_base(&self, name: &str) -> Option<String> {
self.core.failed_write_base(name)
}
fn write_if(&self, name: &str, contents: &str, expected: &str) -> Result<(), String> {
self.core.write_if(name, contents, expected)
}
fn plm_session(&self) -> Option<crate::plm::connection::Session> {
use crate::plm::connection::{parse_label, Keep, Session, Status};
let (url, username) = parse_label(&self.core.backend_label())?;
Some(Session { status: Status::Connected { url, username }, keep: Keep::Files(self.files.root().to_path_buf()) })
}
fn plm_url_setting(&self) -> Option<crate::plm::connection::UrlSetting> {
self.files.plm_url_setting()
}
fn save_plm_url(&self, url: &str) -> Result<crate::plm::connection::UrlSaved, String> {
self.files.save_plm_url(url)
}
fn pending_writes(&self) -> usize {
self.core.pending_writes() + super::native_model::write_behind::count(self.files.root())
}
fn pending_saves(&self) -> usize {
self.core.pending_writes()
}
fn pending_writes_except(&self, names: &[&str]) -> usize {
self.core.pending_writes_except(names)
}
fn browser_location(&self) -> String {
self.core.browser_location()
}
fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
self.core.browser_entries(extensions)
}
fn browser_enter(&self, identity: &str) -> Result<(), String> {
self.core.browser_enter(identity)
}
fn browser_up(&self) -> Result<(), String> {
self.core.browser_up()
}
fn browser_home(&self) -> Result<(), String> {
self.core.browser_home()
}
fn browser_root(&self) -> Result<(), String> {
self.core.browser_root()
}
fn browser_navigate(&self, location: &str) -> Result<(), String> {
self.core.browser_navigate(location)
}
fn browser_places(&self) -> Vec<BrowserPlace> {
self.core.browser_places()
}
fn browser_create_dir(&self, name: &str) -> Result<(), String> {
self.core.browser_create_dir(name)
}
fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
self.core.browser_write(name, contents)
}
fn browser_write_bytes(&self, file_name: &str, contents: &[u8]) -> Result<String, String> {
self.files.browser_write_bytes(file_name, contents)
}
fn local_files(&self) -> Option<&dyn ModelStore> {
Some(&self.files)
}
fn list_external_files(&self, extensions: &[&str]) -> Vec<String> {
self.files.list_external_files(extensions)
}
fn read_external_file(&self, name: &str) -> Option<Vec<u8>> {
self.files.read_external_file(name)
}
fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
self.files.export_file_named(file_name, contents)
}
fn export_file_named_bytes(&self, file_name: &str, contents: &[u8]) -> Result<(), String> {
self.files.export_file_named_bytes(file_name, contents)
}
}
}
pub type Canonicalizer = std::rc::Rc<dyn Fn(&str) -> String>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DocumentIdentity {
Path(String),
Revision { part: String, revision: String },
}
impl DocumentIdentity {
pub fn parse_revision_key(key: &str) -> Option<Self> {
match key.split('/').collect::<Vec<_>>().as_slice() {
["part", part, "rev", revision] if !part.is_empty() && !revision.is_empty() => {
Some(DocumentIdentity::Revision {
part: crate::plm::identity::unsegment(part)?,
revision: crate::plm::identity::unsegment(revision)?,
})
}
_ => None,
}
}
pub fn key(&self) -> String {
match self {
DocumentIdentity::Path(path) => path.clone(),
DocumentIdentity::Revision { part, revision } => crate::plm::identity::document_key(part, revision),
}
}
}
pub fn sibling_identity(identity: &str, file_name: &str) -> String {
match identity.rfind(['/', '\\']) {
Some(cut) => format!("{}{file_name}", &identity[..=cut]),
None => file_name.to_string(),
}
}
pub fn file_name_of(identity: &str) -> String {
identity.rsplit(['/', '\\']).next().unwrap_or(identity).to_string()
}
pub(crate) fn model_display_name(file_name: &str) -> String {
let base = file_name
.rsplit(['/', '\\'])
.next()
.unwrap_or(file_name);
match crate::document_class::DocumentClass::of_name(base) {
Some(crate::document_class::DocumentClass::Normal) => {
crate::document_class::strip_class_extension(base).to_string()
}
_ => base.to_string(),
}
}
#[cfg(not(target_arch = "wasm32"))]
mod native_model {
use super::{
model_display_name, BrowserEntry, BrowserPlace, ModelStore, PlaceKind, Residency, DOCK_LAYOUT_KEY,
FEATURE_PALETTE_DISPLAY_KEY, KICAD_LIBRARY_KEY, PINNED_KEY, PLM_IMPORT_KEY, RECOVERY_KEY,
SETTINGS_KEY,
};
use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf};
fn configured_plm(root: &std::path::Path) -> Option<crate::plm::connection::Session> {
use crate::plm::connection::{Keep, Session, Status};
let status = match crate::plm::config::resolve(root, &|_| None, &Default::default()) {
Ok(None) => return None,
Ok(Some(config)) => Status::NotConnected { url: config.url, reason: None },
Err(e) if root.join("plm.json").exists() => Status::NotConnected { url: String::new(), reason: Some(e) },
Err(_) => return None,
};
Some(Session { status, keep: Keep::Files(root.to_path_buf()) })
}
pub(crate) mod write_behind {
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
#[derive(Clone)]
enum Op {
Put(Arc<str>),
Remove,
}
struct Entry {
op: Op,
seq: u64,
taken: bool,
}
#[derive(Default)]
struct State {
pending: HashMap<PathBuf, Entry>,
next_seq: u64,
errors: Vec<(PathBuf, String)>,
writers: std::collections::HashSet<PathBuf>,
read_cache: HashMap<PathBuf, (u64, std::time::SystemTime, String)>,
}
struct Lane {
state: Mutex<State>,
changed: Condvar,
}
fn lane() -> &'static Lane {
static LANE: OnceLock<Lane> = OnceLock::new();
LANE.get_or_init(|| Lane { state: Mutex::new(State::default()), changed: Condvar::new() })
}
fn lock() -> MutexGuard<'static, State> {
lane().state.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn enqueue(path: PathBuf, op: Op) {
let mut state = lock();
state.next_seq += 1;
let seq = state.next_seq;
let dir = directory(&path);
state.pending.insert(path, Entry { op, seq, taken: false });
if !state.writers.contains(&dir) {
let for_writer = dir.clone();
let spawned =
std::thread::Builder::new().name("brep-store-writer".into()).spawn(move || writer(for_writer));
match spawned {
Ok(_) => {
state.writers.insert(dir);
}
Err(e) => {
drop(state);
log::warn!("store writer thread did not start ({e}); writing on the caller");
while write_one(&dir) {}
return;
}
}
}
drop(state);
lane().changed.notify_all();
}
pub(crate) fn put(path: PathBuf, contents: &str) {
enqueue(path, Op::Put(Arc::from(contents)));
}
pub(crate) fn remove(path: PathBuf) {
enqueue(path, Op::Remove);
}
pub(crate) fn pending(path: &Path) -> Option<Option<String>> {
lock().pending.get(path).map(|entry| match &entry.op {
Op::Put(text) => Some(text.to_string()),
Op::Remove => None,
})
}
pub(crate) fn read(path: &Path) -> Option<String> {
if let Some(pending) = pending(path) {
return pending;
}
let Ok(meta) = std::fs::metadata(path) else {
lock().read_cache.remove(path);
return None;
};
let stamp = meta.modified().ok().map(|modified| (meta.len(), modified));
if let Some((len, modified)) = stamp {
if let Some((cached_len, cached_modified, text)) = lock().read_cache.get(path) {
if *cached_len == len && *cached_modified == modified {
return Some(text.clone());
}
}
}
let text = std::fs::read_to_string(path).ok()?;
if let Some((len, modified)) = stamp {
let mut state = lock();
if !state.pending.contains_key(path) {
state.read_cache.insert(path.to_path_buf(), (len, modified, text.clone()));
}
}
Some(text)
}
pub(crate) fn pending_put(path: &Path) -> Option<bool> {
lock().pending.get(path).map(|entry| matches!(entry.op, Op::Put(_)))
}
pub(crate) fn count(root: &Path) -> usize {
lock().pending.keys().filter(|path| path.starts_with(root)).count()
}
pub(crate) fn take_errors(root: &Path) -> Vec<String> {
let mut state = lock();
let (mine, others) = std::mem::take(&mut state.errors).into_iter().partition(|(path, _)| path.starts_with(root));
state.errors = others;
mine.into_iter().map(|(_, error)| error).collect()
}
pub(crate) fn flush(limit: Duration) -> bool {
let deadline = Instant::now() + limit;
let mut state = lock();
let upto = state.next_seq;
while state.pending.values().any(|entry| entry.seq <= upto) {
let now = Instant::now();
if now >= deadline {
for (path, entry) in state.pending.iter().filter(|(_, entry)| entry.seq <= upto) {
let at = if entry.taken { "being written" } else { "queued" };
eprintln!("write-behind: {} not landed after {limit:?} ({at})", path.display());
}
return false;
}
state = match lane().changed.wait_timeout(state, deadline - now) {
Ok((state, _)) => state,
Err(poisoned) => poisoned.into_inner().0,
};
}
true
}
fn directory(path: &Path) -> PathBuf {
path.parent().map(Path::to_path_buf).unwrap_or_default()
}
fn writer(dir: PathBuf) {
loop {
if !write_one(&dir) {
let mut state = lock();
let more = state.pending.iter().any(|(path, entry)| !entry.taken && directory(path) == dir);
if !more {
state.writers.remove(&dir);
return;
}
}
}
}
fn write_one(dir: &Path) -> bool {
let job = {
let mut state = lock();
let Some((path, entry)) = state
.pending
.iter_mut()
.filter(|(path, entry)| !entry.taken && directory(path) == dir)
.min_by_key(|(_, entry)| entry.seq)
else {
return false;
};
entry.taken = true;
(path.clone(), entry.op.clone(), entry.seq)
};
let (path, op, seq) = job;
let result = match &op {
Op::Put(text) => {
let parent = path.parent().filter(|parent| !parent.as_os_str().is_empty());
parent
.map_or(Ok(()), std::fs::create_dir_all)
.and_then(|()| super::super::write_atomic(&path, text.as_bytes(), false))
.map_err(|e| format!("write {}: {e}", path.display()))
}
Op::Remove => match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
Err(format!("remove {}: {e}", path.display()))
}
_ => Ok(()),
},
};
{
let mut state = lock();
if state.pending.get(&path).is_some_and(|entry| entry.seq == seq) {
state.pending.remove(&path);
}
state.read_cache.remove(&path);
if let Err(error) = result {
log::warn!("{error}");
state.errors.push((path.clone(), error));
}
}
lane().changed.notify_all();
crate::plm::native::request_wake();
true
}
}
pub struct FileModelStore {
app_dir: PathBuf,
dir: PathBuf,
browser_dir: RefCell<PathBuf>,
mutations: Cell<u64>,
notices: RefCell<Vec<String>>,
plm: RefCell<Option<crate::plm::connection::Session>>,
plm_source: Cell<crate::plm::config::Source>,
listing: RefCell<Option<Listing>>,
names: RefCell<Option<Names>>,
}
struct Names {
dir_modified: Option<std::time::SystemTime>,
generation: u64,
taken: std::time::Instant,
racy: bool,
names: Vec<String>,
}
struct Listing {
dir: PathBuf,
extensions: Vec<String>,
dir_modified: Option<std::time::SystemTime>,
generation: u64,
taken: std::time::Instant,
racy: bool,
entries: Vec<BrowserEntry>,
}
const LISTING_TTL: std::time::Duration = std::time::Duration::from_secs(1);
const RACY_WINDOW: std::time::Duration = std::time::Duration::from_millis(100);
impl FileModelStore {
pub fn new() -> Self {
let app_dir = crate::plm::config::app_config_dir();
let plm = configured_plm(&app_dir);
Self {
dir: app_dir.join("models"),
browser_dir: RefCell::new(app_dir.join("models")),
app_dir,
mutations: Cell::new(0),
notices: RefCell::new(Vec::new()),
plm: RefCell::new(plm),
plm_source: Cell::new(crate::plm::config::Source::File),
listing: RefCell::new(None),
names: RefCell::new(None),
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn with_dir(dir: PathBuf) -> Self {
Self {
app_dir: dir.clone(),
browser_dir: RefCell::new(dir.clone()),
dir,
mutations: Cell::new(0),
notices: RefCell::new(Vec::new()),
plm: RefCell::new(None),
plm_source: Cell::new(crate::plm::config::Source::File),
listing: RefCell::new(None),
names: RefCell::new(None),
}
}
pub fn rooted_at(root: PathBuf) -> Self {
let models = root.join("models");
let _ = std::fs::create_dir_all(&models);
let plm = configured_plm(&root);
Self {
app_dir: root,
browser_dir: RefCell::new(models.clone()),
dir: models,
mutations: Cell::new(0),
notices: RefCell::new(Vec::new()),
plm: RefCell::new(plm),
plm_source: Cell::new(crate::plm::config::Source::File),
listing: RefCell::new(None),
names: RefCell::new(None),
}
}
pub fn with_notice(self, notice: String) -> Self {
self.notices.borrow_mut().push(notice);
self
}
pub fn with_plm_launch(self, url: String, source: crate::plm::config::Source) -> Self {
use crate::plm::connection::{Keep, Session, Status};
*self.plm.borrow_mut() = Some(Session { status: Status::NotConnected { url, reason: None }, keep: Keep::Files(self.app_dir.clone()) });
self.plm_source.set(source);
self
}
pub fn with_plm_reason(self, reason: String) -> Self {
if let Some(session) = self.plm.borrow_mut().as_mut() {
if let crate::plm::connection::Status::NotConnected { url, reason: kept } = &mut session.status {
let own = format!("PLM at {url}: ");
*kept = Some(reason.strip_prefix(&own).map(str::to_string).unwrap_or(reason));
}
}
self
}
pub(crate) fn root(&self) -> &std::path::Path {
&self.app_dir
}
fn bump(&self) {
self.mutations.set(self.mutations.get().wrapping_add(1));
}
fn resolve(&self, name: &str) -> PathBuf {
Self::resolve_in(&self.app_dir, &self.dir, name)
}
fn resolve_in(app_dir: &Path, dir: &Path, name: &str) -> PathBuf {
if let Some(path) = Self::reserved_path(app_dir, name) {
return path;
}
Self::document_path(dir, name)
}
fn reserved_path(app_dir: &Path, name: &str) -> Option<PathBuf> {
let file = match name {
SETTINGS_KEY => "settings.json",
FEATURE_PALETTE_DISPLAY_KEY => "feature_palette_display.json",
DOCK_LAYOUT_KEY => "dock_layout.json",
PINNED_KEY => "pinned.json",
RECOVERY_KEY => "recovery.json",
KICAD_LIBRARY_KEY => "kicad_library.json",
crate::panels::cam_library::CAM_TOOL_LIBRARY_KEY => "cam_tools.json",
super::RECENT_DOCUMENTS_KEY => "recent_documents.json",
super::RECENT_THUMBNAILS_KEY => "recent_thumbnails.json",
PLM_IMPORT_KEY => "plm_import.json",
super::PLUGINS_KEY => "plugins.json",
super::JAVASCRIPT_DRAFT_KEY => "javascript_draft.js",
_ => return None,
};
Some(app_dir.join(file))
}
fn document_path(dir: &Path, name: &str) -> PathBuf {
if name.contains('/') || name.contains('\\') {
return PathBuf::from(name);
}
let class = crate::document_class::DocumentClass::of_name(name).unwrap_or_default();
let safe: String = crate::document_class::strip_class_extension(name)
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect();
dir.join(class.file_name(&safe))
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."))
}
fn matches_extension(path: &Path, extensions: &[&str]) -> bool {
extensions.is_empty()
|| extensions.iter().any(|extension| {
let wanted = extension.trim_start_matches('.').to_ascii_lowercase();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_ascii_lowercase();
name.ends_with(&format!(".{wanted}"))
})
}
}
impl ModelStore for FileModelStore {
#[cfg(feature = "automation")]
fn files(&self, request: &brep_mcp_core::files::Request) -> Result<brep_mcp_core::files::Reply, String> {
std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
let backend = brep_mcp_core::files::native::NativeFiles::new([
("documents".into(), self.dir.clone()), ("workspace".into(), self.dir.clone()),
])?;
let reply = brep_mcp_core::files::execute(&backend, request)?;
if !request.operation.annotations().read_only { self.mutations.set(self.mutations.get().wrapping_add(1)); }
Ok(reply)
}
fn backend_label(&self) -> String {
format!("filesystem: {}", self.dir.display())
}
fn local_files(&self) -> Option<&dyn ModelStore> {
Some(self)
}
fn take_persistence_errors(&self) -> Vec<String> {
let mut errors = std::mem::take(&mut *self.notices.borrow_mut());
errors.extend(write_behind::take_errors(&self.app_dir));
errors
}
fn pending_writes(&self) -> usize {
write_behind::count(&self.app_dir)
}
fn pending_saves(&self) -> usize {
0
}
fn pending_writes_except(&self, _names: &[&str]) -> usize {
0
}
fn plm_session(&self) -> Option<crate::plm::connection::Session> {
self.plm.borrow().clone()
}
fn plm_url_setting(&self) -> Option<crate::plm::connection::UrlSetting> {
use crate::plm::connection::{Status, UrlSetting};
let plm = self.plm.borrow();
let url = plm.as_ref().map(|session| match &session.status {
Status::Connected { url, .. } | Status::NotConnected { url, .. } => url.clone(),
});
Some(UrlSetting {
dir: self.app_dir.clone(),
url: url.filter(|url| !url.is_empty()),
source: plm.as_ref().map(|_| self.plm_source.get()),
})
}
fn save_plm_url(&self, url: &str) -> Result<crate::plm::connection::UrlSaved, String> {
let (url, token_dropped) = crate::plm::config::repoint(&self.app_dir, url)?;
if self.plm_source.get() == crate::plm::config::Source::File {
*self.plm.borrow_mut() = configured_plm(&self.app_dir);
}
Ok(crate::plm::connection::UrlSaved { url, token_dropped })
}
fn list(&self) -> Vec<String> {
let dir_modified = std::fs::metadata(&self.dir).and_then(|meta| meta.modified()).ok();
let generation = self.mutations.get();
if let Some(cached) = self.names.borrow().as_ref() {
if !cached.racy
&& cached.dir_modified == dir_modified
&& cached.generation == generation
&& cached.taken.elapsed() < LISTING_TTL
{
return cached.names.clone();
}
}
let mut names: Vec<String> = std::fs::read_dir(&self.dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
crate::document_class::DocumentClass::of_name(&name)
.is_some()
.then(|| model_display_name(&name))
})
.collect();
names.sort();
*self.names.borrow_mut() = Some(Names {
racy: dir_modified.is_none_or(|modified| {
std::time::SystemTime::now().duration_since(modified).map_or(true, |age| age < RACY_WINDOW)
}),
dir_modified,
generation,
taken: std::time::Instant::now(),
names: names.clone(),
});
names
}
fn read(&self, name: &str) -> Option<String> {
if let Some(path) = Self::reserved_path(&self.app_dir, name) {
return write_behind::read(&path);
}
std::fs::read_to_string(self.resolve(name)).ok()
}
fn residency(&self, name: &str) -> Residency {
let path = self.resolve(name);
let present = write_behind::pending_put(&path).unwrap_or_else(|| path.is_file());
if present { Residency::Resident } else { Residency::Absent }
}
fn write(&self, name: &str, contents: &str) -> Result<(), String> {
if let Some(path) = Self::reserved_path(&self.app_dir, name) {
write_behind::put(path, contents);
self.bump();
return Ok(());
}
let path = self.resolve(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create models dir: {e}"))?;
}
super::write_atomic(&path, contents.as_bytes(), false).map_err(|e| format!("write {}: {e}", path.display()))?;
self.bump();
Ok(())
}
fn mutation_generation(&self) -> u64 {
self.mutations.get()
}
fn remove(&self, name: &str) -> Result<(), String> {
if let Some(path) = Self::reserved_path(&self.app_dir, name) {
if write_behind::pending_put(&path).unwrap_or_else(|| path.is_file()) {
write_behind::remove(path);
self.bump();
}
return Ok(());
}
match std::fs::remove_file(self.resolve(name)) {
Ok(()) => {
self.bump();
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("remove: {e}")),
}
}
fn canonical_identity(&self, name: &str) -> String {
self.resolve(name).to_string_lossy().into_owned()
}
fn canonicalizer(&self) -> super::Canonicalizer {
let (app_dir, dir) = (self.app_dir.clone(), self.dir.clone());
std::rc::Rc::new(move |name| Self::resolve_in(&app_dir, &dir, name).to_string_lossy().into_owned())
}
fn browser_location(&self) -> String {
self.browser_dir.borrow().display().to_string()
}
fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
let dir = self.browser_dir.borrow().clone();
let dir_modified = std::fs::metadata(&dir).and_then(|meta| meta.modified()).ok();
let generation = self.mutations.get();
if let Some(listing) = self.listing.borrow().as_ref() {
if !listing.racy
&& listing.dir == dir
&& listing.extensions.iter().map(String::as_str).eq(extensions.iter().copied())
&& listing.dir_modified == dir_modified
&& listing.generation == generation
&& listing.taken.elapsed() < LISTING_TTL
{
return listing.entries.clone();
}
}
let mut entries: Vec<BrowserEntry> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let path = entry.path();
let is_dir = path.is_dir();
if !(is_dir || (path.is_file() && Self::matches_extension(&path, extensions))) {
return None;
}
let meta = entry.metadata().ok();
let size = meta.as_ref().filter(|m| m.is_file()).map(|m| m.len());
let modified = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs_f64());
Some(BrowserEntry {
name: entry.file_name().to_string_lossy().into_owned(),
identity: path.to_string_lossy().into_owned(),
is_dir,
size,
modified,
})
})
.collect();
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
});
*self.listing.borrow_mut() = Some(Listing {
dir,
extensions: extensions.iter().map(|extension| extension.to_string()).collect(),
racy: dir_modified.is_none_or(|modified| {
std::time::SystemTime::now().duration_since(modified).map_or(true, |age| age < RACY_WINDOW)
}),
dir_modified,
generation,
taken: std::time::Instant::now(),
entries: entries.clone(),
});
entries
}
fn browser_enter(&self, identity: &str) -> Result<(), String> {
let path = PathBuf::from(identity);
if !path.is_dir() {
return Err(format!("not a directory: {}", path.display()));
}
*self.browser_dir.borrow_mut() = path;
Ok(())
}
fn browser_up(&self) -> Result<(), String> {
let parent = self.browser_dir.borrow().parent().map(Path::to_path_buf);
if let Some(parent) = parent {
*self.browser_dir.borrow_mut() = parent;
}
Ok(())
}
fn browser_home(&self) -> Result<(), String> {
*self.browser_dir.borrow_mut() = Self::home_dir();
Ok(())
}
fn browser_root(&self) -> Result<(), String> {
let current = self.browser_dir.borrow().clone();
let root = current
.ancestors()
.last()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from(std::path::MAIN_SEPARATOR.to_string()));
*self.browser_dir.borrow_mut() = root;
Ok(())
}
fn browser_navigate(&self, location: &str) -> Result<(), String> {
let path: PathBuf = PathBuf::from(location).components().collect();
if path.is_dir() {
*self.browser_dir.borrow_mut() = path;
Ok(())
} else {
Err(format!("not a directory: {location}"))
}
}
fn browser_places(&self) -> Vec<BrowserPlace> {
let home = Self::home_dir();
let mut places = vec![BrowserPlace {
label: "Home".into(),
location: home.display().to_string(),
kind: PlaceKind::Home,
}];
for (label, sub, kind) in [
("Documents", "Documents", PlaceKind::Documents),
("Downloads", "Downloads", PlaceKind::Downloads),
] {
let path = home.join(sub);
if path.is_dir() {
places.push(BrowserPlace {
label: label.into(),
location: path.display().to_string(),
kind,
});
}
}
places.push(BrowserPlace {
label: "Models".into(),
location: self.dir.display().to_string(),
kind: PlaceKind::Models,
});
places.push(BrowserPlace {
label: "/".into(),
location: "/".into(),
kind: PlaceKind::Root,
});
places
}
fn browser_create_dir(&self, name: &str) -> Result<(), String> {
let name = PathBuf::from(name);
if name.components().count() != 1 {
return Err("folder name must be one path component".into());
}
let path = self.browser_dir.borrow().join(name);
std::fs::create_dir(&path)
.map_err(|e| format!("create folder {}: {e}", path.display()))
}
fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
let file_name = PathBuf::from(name)
.file_name()
.ok_or("invalid file name")?
.to_string_lossy()
.into_owned();
let file_name = super::model_file_name(&file_name);
let path = self.browser_dir.borrow().join(file_name);
super::write_atomic(&path, contents.as_bytes(), false)
.map_err(|e| format!("write {}: {e}", path.display()))?;
self.bump();
Ok(path.to_string_lossy().into_owned())
}
fn browser_write_bytes(&self, file_name: &str, contents: &[u8]) -> Result<String, String> {
let file_name = PathBuf::from(file_name)
.file_name()
.ok_or("invalid file name")?
.to_owned();
let folder = self.browser_dir.borrow().clone();
std::fs::create_dir_all(&folder).map_err(|e| format!("create {}: {e}", folder.display()))?;
let path = folder.join(file_name);
super::write_atomic(&path, contents, false)
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path.to_string_lossy().into_owned())
}
fn list_external_files(&self, extensions: &[&str]) -> Vec<String> {
let wanted: Vec<String> = extensions
.iter()
.map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
.collect();
let mut names: Vec<String> = std::fs::read_dir(&self.dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let path = entry.path();
path.is_file().then_some(path)
})
.filter_map(|path| {
let extension = path.extension()?.to_string_lossy().to_ascii_lowercase();
wanted.contains(&extension).then(|| {
path.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
})
})
.collect();
names.sort();
names
}
fn read_external_file(&self, name: &str) -> Option<Vec<u8>> {
let path = PathBuf::from(name);
let path = if path.is_absolute() || path.components().count() > 1 {
path
} else {
self.browser_dir.borrow().join(path)
};
std::fs::read(path).ok()
}
fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
self.export_file_named_bytes(file_name, contents.as_bytes())
}
fn export_file_named_bytes(&self, file_name: &str, contents: &[u8]) -> Result<(), String> {
std::fs::create_dir_all(&self.dir)
.map_err(|e| format!("create models dir: {e}"))?;
let name = PathBuf::from(file_name)
.file_name()
.ok_or("invalid export file name")?
.to_owned();
let path = self.dir.join(name);
super::write_atomic(&path, contents, false)
.map_err(|e| format!("write {}: {e}", path.display()))
}
}
}
pub(crate) mod mirror_store {
use super::{
model_display_name, BrowserEntry, BrowserPlace, KeySpace, ModelStore, PlaceKind,
Residency, DOCK_LAYOUT_KEY, FEATURE_PALETTE_DISPLAY_KEY, KICAD_LIBRARY_KEY, PINNED_KEY,
PLM_IMPORT_KEY, RECOVERY_KEY, SETTINGS_KEY,
};
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
pub(crate) const PREFIX: &str = "brep-app:model:";
pub(crate) const DIR_PREFIX: &str = "brep-app:dir:";
pub(crate) const FILE_PREFIX: &str = "brep-app:file:";
#[cfg(feature = "automation")]
mod files { include!("store_files.rs"); }
const MAX_ERRORS: usize = 64;
pub(crate) type BackendFuture<T> = Pin<Box<dyn Future<Output = Result<T, String>>>>;
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) enum Entry {
Resident(String),
OnDisk { bytes: u64 },
}
pub(crate) struct IndexRow {
pub(crate) key: String,
pub(crate) bytes: u64,
pub(crate) content_hash: String,
}
impl Entry {
pub(crate) fn len(&self) -> u64 {
match self {
Entry::Resident(value) => value.len() as u64,
Entry::OnDisk { bytes } => *bytes,
}
}
}
#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn key_space(key: &str) -> Option<KeySpace> {
if key.starts_with(DIR_PREFIX) || key.starts_with(FILE_PREFIX) {
return None;
}
for name in super::PREFERENCE_KEYS.iter().chain(super::LOCAL_KEYS) {
if key == MirrorStore::key(name) {
return Some(KeySpace::of(name));
}
}
Some(KeySpace::Documents)
}
pub(crate) trait StoreBackend {
fn label(&self) -> String;
fn plm_client(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> {
None
}
fn plm_revision(&self, key: &str) -> Option<crate::plm::client::IndexEntry> {
let _ = key;
None
}
fn note_checked_out(&self, key: &str) {
let _ = key;
}
fn plm_written_by_us(&self, key: &str) -> bool {
let _ = key;
false
}
fn refresh_plm_index(&self, keys: &[String], stale: bool) {
let _ = (keys, stale);
}
fn index_rows(&self, keys: &[String]) -> BackendFuture<Vec<IndexRow>> {
let _ = keys;
Box::pin(async { Err("this store keeps no index".to_string()) })
}
fn index_all(&self) -> BackendFuture<Vec<IndexRow>> {
Box::pin(async { Err("this store keeps no index".to_string()) })
}
fn load_all(&self) -> BackendFuture<Vec<(String, String)>>;
fn load_index(&self) -> BackendFuture<Vec<(String, Entry)>> {
let all = self.load_all();
Box::pin(async move {
Ok(all
.await?
.into_iter()
.map(|(key, value)| (key, Entry::Resident(value)))
.collect())
})
}
fn orders_writes_per_key(&self) -> bool {
false
}
fn get(&self, key: &str) -> BackendFuture<Option<String>>;
fn put(&self, key: &str, value: &str) -> BackendFuture<()>;
fn put_if(&self, key: &str, value: &str, expected: &str) -> BackendFuture<()> {
let _ = expected;
self.put(key, value)
}
fn delete(&self, key: &str) -> BackendFuture<()>;
#[cfg(feature = "automation")]
fn move_files(&self, _entries: Vec<(String, String, String)>) -> BackendFuture<()> {
Box::pin(async { Err("atomic file moves are unsupported by this backend".into()) })
}
}
#[cfg_attr(not(test), allow(dead_code))] pub(crate) struct KeySpaceRouter {
pub(crate) documents: Rc<dyn StoreBackend>,
pub(crate) preferences: Rc<dyn StoreBackend>,
pub(crate) local: Rc<dyn StoreBackend>,
}
#[cfg_attr(not(test), allow(dead_code))]
impl KeySpaceRouter {
fn route(&self, key: &str) -> &Rc<dyn StoreBackend> {
match key_space(key) {
Some(KeySpace::Preferences) => &self.preferences,
Some(KeySpace::Local) => &self.local,
Some(KeySpace::Documents) | None => &self.documents,
}
}
fn serves(space: KeySpace, key: &str) -> bool {
match key_space(key) {
Some(found) => found == space,
None => space == KeySpace::Documents,
}
}
fn spaces(&self) -> [(KeySpace, &Rc<dyn StoreBackend>); 3] {
[
(KeySpace::Documents, &self.documents),
(KeySpace::Preferences, &self.preferences),
(KeySpace::Local, &self.local),
]
}
}
impl StoreBackend for KeySpaceRouter {
fn label(&self) -> String {
self.documents.label()
}
fn plm_client(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> {
self.documents.plm_client()
}
fn plm_revision(&self, key: &str) -> Option<crate::plm::client::IndexEntry> {
self.documents.plm_revision(key)
}
fn note_checked_out(&self, key: &str) {
self.documents.note_checked_out(key)
}
fn plm_written_by_us(&self, key: &str) -> bool {
self.documents.plm_written_by_us(key)
}
fn refresh_plm_index(&self, keys: &[String], stale: bool) {
self.documents.refresh_plm_index(keys, stale)
}
fn index_rows(&self, keys: &[String]) -> BackendFuture<Vec<IndexRow>> {
self.documents.index_rows(keys)
}
fn index_all(&self) -> BackendFuture<Vec<IndexRow>> {
self.documents.index_all()
}
fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
let requests: Vec<_> = self
.spaces()
.into_iter()
.map(|(space, backend)| (space, backend.load_all()))
.collect();
Box::pin(async move {
let mut all = Vec::new();
for (space, request) in requests {
all.extend(
request
.await?
.into_iter()
.filter(|(key, _)| Self::serves(space, key)),
);
}
Ok(all)
})
}
fn load_index(&self) -> BackendFuture<Vec<(String, Entry)>> {
let requests: Vec<_> = self
.spaces()
.into_iter()
.map(|(space, backend)| (space, backend.load_index()))
.collect();
Box::pin(async move {
let mut all = Vec::new();
for (space, request) in requests {
all.extend(
request
.await?
.into_iter()
.filter(|(key, _)| Self::serves(space, key)),
);
}
Ok(all)
})
}
fn orders_writes_per_key(&self) -> bool {
self.spaces()
.iter()
.all(|(_, backend)| backend.orders_writes_per_key())
}
fn get(&self, key: &str) -> BackendFuture<Option<String>> {
self.route(key).get(key)
}
fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
self.route(key).put(key, value)
}
fn put_if(&self, key: &str, value: &str, expected: &str) -> BackendFuture<()> {
self.route(key).put_if(key, value, expected)
}
fn delete(&self, key: &str) -> BackendFuture<()> {
self.route(key).delete(key)
}
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) struct UnavailableBackend {
reason: String,
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
impl UnavailableBackend {
pub(crate) fn new(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
}
}
}
impl StoreBackend for UnavailableBackend {
fn label(&self) -> String {
format!("NOT SAVING — {} · use Download to keep your work", self.reason)
}
fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
let reason = self.reason.clone();
Box::pin(async move { Err(reason) })
}
fn get(&self, _key: &str) -> BackendFuture<Option<String>> {
let reason = self.reason.clone();
Box::pin(async move { Err(reason) })
}
fn put(&self, _key: &str, _value: &str) -> BackendFuture<()> {
let reason = self.reason.clone();
Box::pin(async move { Err(reason) })
}
fn delete(&self, _key: &str) -> BackendFuture<()> {
let reason = self.reason.clone();
Box::pin(async move { Err(reason) })
}
}
pub(crate) const CHANGED_ELSEWHERE: &str = "changed elsewhere: ";
pub(crate) struct ErrorLog {
log: RefCell<Vec<String>>,
drained: Cell<usize>,
failed_writes: RefCell<Vec<String>>,
unsynced: RefCell<BTreeMap<String, String>>,
bases: RefCell<BTreeMap<String, String>>,
wake: Option<Rc<dyn Fn()>>,
}
impl ErrorLog {
fn new(wake: Option<Rc<dyn Fn()>>) -> Self {
Self {
log: RefCell::new(Vec::new()),
drained: Cell::new(0),
failed_writes: RefCell::new(Vec::new()),
unsynced: RefCell::new(BTreeMap::new()),
bases: RefCell::new(BTreeMap::new()),
wake,
}
}
pub(crate) fn record(&self, message: String) {
{
let mut log = self.log.borrow_mut();
let undrained = log.len() > self.drained.get();
if undrained && log.last().map(|last| *last == message).unwrap_or(false) {
return;
}
log.push(message);
if log.len() > MAX_ERRORS {
log.remove(0);
self.drained.set(self.drained.get().saturating_sub(1));
}
}
if let Some(wake) = &self.wake {
wake();
}
}
pub(crate) fn record_failed_write(&self, name: &str) {
let mut failed = self.failed_writes.borrow_mut();
if !failed.iter().any(|n| n == name) {
failed.push(name.to_string());
}
}
fn take_failed_writes(&self) -> Vec<String> {
std::mem::take(&mut *self.failed_writes.borrow_mut())
}
fn note_base(&self, key: &str, base: String) {
self.bases.borrow_mut().insert(key.to_string(), base);
}
fn mark_unsynced(&self, key: &str, failed: bool) {
let mut unsynced = self.unsynced.borrow_mut();
if failed {
let base = self.bases.borrow().get(key).cloned().unwrap_or_default();
unsynced.entry(key.to_string()).or_insert(base);
} else {
unsynced.remove(key);
}
}
fn unsynced(&self, key: &str) -> Option<String> {
self.unsynced.borrow().get(key).cloned()
}
fn drain(&self) -> Vec<String> {
let log = self.log.borrow();
let from = self.drained.get().min(log.len());
self.drained.set(log.len());
log[from..].to_vec()
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn all(&self) -> Vec<String> {
self.log.borrow().clone()
}
}
#[cfg(target_arch = "wasm32")]
fn spawn(task: impl Future<Output = ()> + 'static) {
super::spawn_waking(task);
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn(task: impl Future<Output = ()> + 'static) {
crate::plm::native::spawn(task);
}
fn spawn_for(name: &str, task: impl Future<Output = ()> + 'static) {
#[cfg(not(target_arch = "wasm32"))]
crate::plm::native::tagged(name, || spawn(task));
#[cfg(target_arch = "wasm32")]
{
let _ = name;
spawn(task);
}
}
#[derive(Clone)]
pub(crate) struct MirrorStore {
entries: Rc<RefCell<BTreeMap<String, Entry>>>,
browser_dir: Rc<RefCell<String>>,
backend: Rc<dyn StoreBackend>,
errors: Rc<ErrorLog>,
pending: Rc<Cell<usize>>,
mutations: Rc<Cell<u64>>,
in_flight: Rc<RefCell<BTreeMap<String, usize>>>,
wake: Option<Rc<dyn Fn()>>,
loading: Rc<RefCell<BTreeSet<String>>>,
queues: Rc<RefCell<BTreeMap<String, VecDeque<QueuedWrite>>>>,
clock: Rc<Cell<u64>>,
freshness: Rc<RefCell<BTreeMap<String, u64>>>,
full_index_authority: Rc<Cell<u64>>,
listing: Rc<RefCell<Option<(ListingKey, Vec<BrowserEntry>)>>>,
}
#[derive(Clone, PartialEq)]
struct ListingKey {
dir: String,
extensions: Vec<String>,
mutations: u64,
keys: usize,
bytes: u64,
}
type QueuedWrite = (String, Box<dyn FnOnce() -> BackendFuture<()>>);
impl MirrorStore {
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn new(
backend: Rc<dyn StoreBackend>,
entries: Vec<(String, String)>,
wake: Option<Rc<dyn Fn()>>,
) -> Self {
Self::from_index(
backend,
entries
.into_iter()
.map(|(key, value)| (key, Entry::Resident(value)))
.collect(),
wake,
)
}
pub(crate) fn from_index(
backend: Rc<dyn StoreBackend>,
entries: Vec<(String, Entry)>,
wake: Option<Rc<dyn Fn()>>,
) -> Self {
Self {
entries: Rc::new(RefCell::new(entries.into_iter().collect())),
browser_dir: Rc::new(RefCell::new("/models".into())),
backend,
errors: Rc::new(ErrorLog::new(wake.clone())),
pending: Rc::new(Cell::new(0)),
mutations: Rc::new(Cell::new(0)),
in_flight: Rc::new(RefCell::new(BTreeMap::new())),
wake,
loading: Rc::new(RefCell::new(BTreeSet::new())),
queues: Rc::new(RefCell::new(BTreeMap::new())),
clock: Rc::new(Cell::new(0)),
freshness: Rc::new(RefCell::new(BTreeMap::new())),
full_index_authority: Rc::new(Cell::new(0)),
listing: Rc::new(RefCell::new(None)),
}
}
fn tick(&self) -> u64 {
let next = self.clock.get() + 1;
self.clock.set(next);
next
}
fn stamp_local(&self, key: &str) {
let tick = self.tick();
self.freshness.borrow_mut().insert(key.to_string(), tick);
}
pub(crate) fn key(name: &str) -> String {
match name {
SETTINGS_KEY => "brep-app:settings".into(),
FEATURE_PALETTE_DISPLAY_KEY => "brep-app:feature_palette_display".into(),
DOCK_LAYOUT_KEY => "brep-app:dock_layout".into(),
PINNED_KEY => "brep-app:pinned".into(),
RECOVERY_KEY => "brep-app:recovery".into(),
KICAD_LIBRARY_KEY => "brep-app:kicad_library".into(),
crate::panels::cam_library::CAM_TOOL_LIBRARY_KEY => "brep-app:cam_tools".into(),
super::RECENT_DOCUMENTS_KEY => "brep-app:recent_documents".into(),
super::RECENT_THUMBNAILS_KEY => "brep-app:recent_thumbnails".into(),
PLM_IMPORT_KEY => "brep-app:plm_import".into(),
super::PLUGINS_KEY => "brep-app:plugins".into(),
super::JAVASCRIPT_DRAFT_KEY => "brep-app:javascript_draft".into(),
_ => format!("{PREFIX}{}", Self::model_relative(name)),
}
}
fn model_relative(name: &str) -> String {
let name = name
.trim_start_matches('/')
.strip_prefix("models/")
.unwrap_or_else(|| name.trim_start_matches('/'));
let name = name.trim_matches('/');
match crate::document_class::DocumentClass::of_name(name) {
Some(crate::document_class::DocumentClass::Normal) => {
crate::document_class::strip_class_extension(name).to_string()
}
Some(_) => super::model_file_name(name),
None => name.to_string(),
}
}
fn virtual_model_path(relative: &str) -> String {
format!("/models/{}", super::model_file_name(relative.trim_matches('/')))
}
fn child_path(parent: &str, child: &str) -> String {
if parent == "/" {
format!("/{child}")
} else {
format!("{}/{child}", parent.trim_end_matches('/'))
}
}
fn push(
&self,
name: &str,
key: &str,
request: impl FnOnce() -> BackendFuture<()> + 'static,
) {
self.pending.set(self.pending.get() + 1);
*self.in_flight.borrow_mut().entry(key.to_string()).or_insert(0) += 1;
if self.backend.orders_writes_per_key() {
let settle = self.settler(key);
let tag = name;
let name = name.to_string();
let request = request();
spawn_for(tag, async move { settle(&name, request.await) });
return;
}
{
let mut queues = self.queues.borrow_mut();
if let Some(queue) = queues.get_mut(key) {
queue.push_back((name.to_string(), Box::new(request)));
return;
}
queues.insert(key.to_string(), VecDeque::new());
}
let settle = self.settler(key);
let queues = self.queues.clone();
let key = key.to_string();
let first = (name.to_string(), request());
spawn_for(name, async move {
let (name, request) = first;
settle(&name, request.await);
loop {
let next = {
let mut queues = queues.borrow_mut();
let next = queues.get_mut(&key).and_then(VecDeque::pop_front);
if next.is_none() {
queues.remove(&key);
}
next
};
let Some((name, request)) = next else {
break;
};
settle(&name, request().await);
}
});
}
fn settler(&self, key: &str) -> impl Fn(&str, Result<(), String>) + 'static {
let pending = self.pending.clone();
let in_flight = self.in_flight.clone();
let errors = self.errors.clone();
let entries = self.entries.clone();
let key = key.to_string();
move |name, outcome| {
pending.set(pending.get().saturating_sub(1));
{
let mut in_flight = in_flight.borrow_mut();
if let Some(count) = in_flight.get_mut(&key) {
*count -= 1;
if *count == 0 {
in_flight.remove(&key);
}
}
}
match outcome {
Err(message) if message.starts_with(CHANGED_ELSEWHERE) => {
errors.mark_unsynced(&key, false);
if let Some(entry) = entries.borrow_mut().get_mut(&key) {
let bytes = match entry {
Entry::Resident(text) => text.len() as u64,
Entry::OnDisk { bytes } => *bytes,
};
*entry = Entry::OnDisk { bytes };
}
errors.record_failed_write(name);
errors.record(format!("'{name}' was NOT saved: {}", &message[CHANGED_ELSEWHERE.len()..]));
}
Err(message) => {
errors.record_failed_write(name);
errors.mark_unsynced(&key, true);
errors.record(format!("'{name}' was NOT saved: {message}"));
}
Ok(()) => errors.mark_unsynced(&key, false),
}
}
}
pub(crate) fn reconcile(&self, asked: Option<&[String]>, rows: Vec<IndexRow>, issued: u64) -> bool {
let busy = |key: &str| {
self.in_flight.borrow().contains_key(key)
|| self.queues.borrow().contains_key(key)
|| self.full_index_authority.get() >= issued
|| self.freshness.borrow().get(key).is_some_and(|&tick| tick >= issued)
};
let listed: BTreeSet<&str> = rows.iter().map(|row| row.key.as_str()).collect();
let mut changed = false;
{
let mut entries = self.entries.borrow_mut();
let gone: Vec<String> = match asked {
Some(asked) => asked.iter().filter(|key| !listed.contains(key.as_str())).cloned().collect(),
None => entries
.keys()
.filter(|key| key.starts_with(PREFIX) && !listed.contains(key.as_str()))
.cloned()
.collect(),
};
for key in gone {
if busy(&key) {
continue;
}
self.freshness.borrow_mut().insert(key.clone(), issued);
changed |= entries.remove(&key).is_some();
}
for row in rows {
if busy(&row.key) {
continue;
}
self.freshness.borrow_mut().insert(row.key.clone(), issued);
let same = match entries.get(&row.key) {
Some(Entry::Resident(held)) => {
crate::plm::content_hash(held.as_bytes()) == row.content_hash
}
Some(Entry::OnDisk { bytes }) => *bytes == row.bytes,
None => false,
};
if same {
continue;
}
entries.insert(row.key, Entry::OnDisk { bytes: row.bytes });
changed = true;
}
}
if asked.is_none() {
self.full_index_authority.set(self.full_index_authority.get().max(issued));
}
if changed {
self.mutations.set(self.mutations.get().wrapping_add(1));
if let Some(wake) = &self.wake {
wake();
}
}
changed
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn apply_external(&self, key: &str, value: Option<String>) {
self.stamp_local(key);
let changed = {
let mut entries = self.entries.borrow_mut();
match value {
Some(value) => {
let same = matches!(entries.get(key), Some(Entry::Resident(held)) if *held == value);
if same {
false
} else {
entries.insert(key.to_string(), Entry::Resident(value));
true
}
}
None => entries.remove(key).is_some(),
}
};
if !changed {
return;
}
self.mutations.set(self.mutations.get().wrapping_add(1));
if let Some(wake) = &self.wake {
wake();
}
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn refresh_key(&self, key: String) {
if self.in_flight.borrow().contains_key(&key) {
return;
}
let store = self.clone();
let request = self.backend.get(&key);
spawn(async move {
match request.await {
Ok(value) => store.apply_external(&key, value),
Err(_) => {}
}
});
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn report(&self, message: String) {
self.errors.record(message);
}
pub(crate) fn pending(&self) -> usize {
self.pending.get()
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn error_history(&self) -> Vec<String> {
self.errors.all()
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn len_of(&self, name: &str) -> Option<usize> {
self.entries.borrow().get(&Self::key(name)).map(|v| v.len() as usize)
}
fn load(&self, key: &str) {
if !self.loading.borrow_mut().insert(key.to_string()) {
return;
}
let store = self.clone();
let key = key.to_string();
let issued = self.tick();
let request = self.backend.get(&key);
spawn(async move {
let outcome = request.await;
store.loading.borrow_mut().remove(&key);
store.resolve(&key, outcome, issued);
});
}
fn resolve(&self, key: &str, outcome: Result<Option<String>, String>, issued: u64) {
let Ok(value) = outcome else {
return;
};
if self.full_index_authority.get() > issued
|| self.freshness.borrow().get(key).is_some_and(|&tick| tick > issued) {
return;
}
{
let mut entries = self.entries.borrow_mut();
if !matches!(entries.get(key), Some(Entry::OnDisk { .. })) {
return;
}
self.stamp_local(key);
match value {
Some(value) => entries.insert(key.to_string(), Entry::Resident(value)),
None => entries.remove(key),
};
}
self.mutations.set(self.mutations.get().wrapping_add(1));
if let Some(wake) = &self.wake {
wake();
}
}
pub(crate) fn prefetch(&self) {
let keys: Vec<String> = self
.entries
.borrow()
.iter()
.filter(|(_, entry)| matches!(entry, Entry::OnDisk { .. }))
.map(|(key, _)| key.clone())
.collect();
if keys.is_empty() {
return;
}
let store = self.clone();
spawn(async move {
for key in keys {
let wanted = matches!(store.entries.borrow().get(&key), Some(Entry::OnDisk { .. }));
if !wanted || !store.loading.borrow_mut().insert(key.clone()) {
continue;
}
let issued = store.tick();
let outcome = store.backend.get(&key).await;
store.loading.borrow_mut().remove(&key);
store.resolve(&key, outcome, issued);
}
});
}
}
impl MirrorStore {
fn list_folder(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
let current = self.browser_dir.borrow().clone();
if current == "/" {
return vec![BrowserEntry {
name: "models".into(),
identity: "/models".into(),
is_dir: true,
size: None,
modified: None,
}];
}
let relative_dir = current
.strip_prefix("/models")
.unwrap_or("")
.trim_matches('/');
let prefix = if relative_dir.is_empty() {
String::new()
} else {
format!("{relative_dir}/")
};
let wanted: Vec<String> = extensions
.iter()
.map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
.collect();
let mut entries: BTreeMap<String, BrowserEntry> = BTreeMap::new();
for (key, value) in self.entries.borrow().iter() {
let (item, is_model) = if let Some(item) = key.strip_prefix(PREFIX) {
(item, true)
} else if let Some(item) = key.strip_prefix(DIR_PREFIX) {
(item.trim_end_matches('/'), false)
} else {
continue;
};
let Some(rest) = item.strip_prefix(&prefix) else {
continue;
};
if rest.is_empty() {
continue;
}
if let Some((child, _)) = rest.split_once('/') {
entries
.entry(child.to_string())
.or_insert_with(|| BrowserEntry {
name: child.to_string(),
identity: Self::child_path(¤t, child),
is_dir: true,
size: None,
modified: None,
});
} else if is_model {
let name = super::model_file_name(rest);
let lower = name.to_ascii_lowercase();
if wanted.is_empty()
|| wanted
.iter()
.any(|extension| lower.ends_with(&format!(".{extension}")))
{
entries.insert(
name.clone(),
BrowserEntry {
name,
identity: Self::virtual_model_path(item),
is_dir: false,
size: Some(value.len()),
modified: None,
},
);
}
} else {
entries
.entry(rest.to_string())
.or_insert_with(|| BrowserEntry {
name: rest.to_string(),
identity: Self::child_path(¤t, rest),
is_dir: true,
size: None,
modified: None,
});
}
}
let mut entries: Vec<_> = entries.into_values().collect();
entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
entries
}
}
impl ModelStore for MirrorStore {
#[cfg(feature = "automation")]
fn files(&self, request: &brep_mcp_core::files::Request) -> Result<brep_mcp_core::files::Reply, String> {
brep_mcp_core::files::execute(self, request)
}
#[cfg(feature = "automation")]
fn files_settled(&self, request: &brep_mcp_core::files::Request) -> Option<Result<(), String>> { self.file_settlement(request) }
fn backend_label(&self) -> String {
self.backend.label()
}
fn plm_client(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> {
self.backend.plm_client()
}
fn plm_revision(&self, key: &str) -> Option<crate::plm::client::IndexEntry> {
self.backend.plm_revision(key)
}
fn note_checked_out(&self, key: &str) {
self.backend.note_checked_out(key)
}
fn plm_written_by_us(&self, key: &str) -> bool {
self.backend.plm_written_by_us(key)
}
fn refresh_plm_index(&self, keys: &[String], stale: bool) {
if !stale && keys.is_empty() {
return;
}
let store = self.clone();
let issued = self.tick();
if stale {
let request = self.backend.index_all();
spawn(async move {
if let Ok(rows) = request.await {
store.reconcile(None, rows, issued);
}
});
} else {
let asked: Vec<String> = keys.iter().map(|key| format!("{PREFIX}{key}")).collect();
let request = self.backend.index_rows(keys);
spawn(async move {
if let Ok(rows) = request.await {
store.reconcile(Some(&asked), rows, issued);
}
});
}
}
fn remember_saved_document(&self, name: &str, contents: &str) {
let key = Self::key(name);
self.stamp_local(&key);
self.entries.borrow_mut().insert(key, Entry::Resident(contents.into()));
self.mutations.set(self.mutations.get().wrapping_add(1));
}
fn list(&self) -> Vec<String> {
self.entries
.borrow()
.keys()
.filter_map(|key| key.strip_prefix(PREFIX).map(str::to_string))
.collect()
}
fn read(&self, name: &str) -> Option<String> {
let key = Self::key(name);
let held = self.entries.borrow().get(&key).cloned();
match held {
Some(Entry::Resident(value)) => Some(value),
Some(Entry::OnDisk { .. }) => {
self.load(&key);
None
}
None => None,
}
}
fn canonical_identity(&self, name: &str) -> String {
match Self::key(name).strip_prefix(PREFIX) {
Some(relative) => Self::virtual_model_path(relative),
None => name.to_string(),
}
}
fn canonicalizer(&self) -> super::Canonicalizer {
std::rc::Rc::new(|name| match Self::key(name).strip_prefix(PREFIX) {
Some(relative) => Self::virtual_model_path(relative),
None => name.to_string(),
})
}
fn residency(&self, name: &str) -> Residency {
match self.entries.borrow().get(&Self::key(name)) {
Some(Entry::Resident(_)) => Residency::Resident,
Some(Entry::OnDisk { .. }) => Residency::OnDisk,
None => Residency::Absent,
}
}
fn write(&self, name: &str, contents: &str) -> Result<(), String> {
let key = Self::key(name);
let base = crate::plm::bom::revision_of_document(name)
.and_then(|(part, revision)| self.plm_revision(&crate::plm::identity::document_key(&part, &revision)))
.map(|row| row.content_hash)
.unwrap_or_default();
self.errors.note_base(&key, base);
self.stamp_local(&key);
self.entries
.borrow_mut()
.insert(key.clone(), Entry::Resident(contents.to_string()));
let backend = self.backend.clone();
let (put_key, contents) = (key.clone(), contents.to_string());
self.push(name, &key, move || backend.put(&put_key, &contents));
self.mutations.set(self.mutations.get().wrapping_add(1));
Ok(())
}
fn write_if(&self, name: &str, contents: &str, expected: &str) -> Result<(), String> {
let key = Self::key(name);
self.errors.note_base(&key, expected.to_string());
self.stamp_local(&key);
self.entries
.borrow_mut()
.insert(key.clone(), Entry::Resident(contents.to_string()));
let backend = self.backend.clone();
let (put_key, contents, expected) = (key.clone(), contents.to_string(), expected.to_string());
self.push(name, &key, move || backend.put_if(&put_key, &contents, &expected));
self.mutations.set(self.mutations.get().wrapping_add(1));
Ok(())
}
fn remove(&self, name: &str) -> Result<(), String> {
let key = Self::key(name);
self.stamp_local(&key);
let existed = self.entries.borrow_mut().remove(&key).is_some();
let backend = self.backend.clone();
let delete_key = key.clone();
self.push(name, &key, move || backend.delete(&delete_key));
if existed {
self.mutations.set(self.mutations.get().wrapping_add(1));
}
Ok(())
}
fn mutation_generation(&self) -> u64 {
self.mutations.get()
}
fn pending_writes(&self) -> usize {
self.pending()
}
fn pending_writes_except(&self, names: &[&str]) -> usize {
let skip: Vec<String> = names.iter().map(|name| Self::key(name)).collect();
self.in_flight.borrow().iter().filter(|(key, _)| !skip.contains(key)).map(|(_, count)| *count).sum()
}
fn take_persistence_errors(&self) -> Vec<String> {
self.errors.drain()
}
fn take_failed_writes(&self) -> Vec<String> {
self.errors.take_failed_writes()
}
fn write_failed(&self, name: &str) -> bool {
self.errors.unsynced(&Self::key(name)).is_some()
}
fn failed_write_base(&self, name: &str) -> Option<String> {
self.errors.unsynced(&Self::key(name))
}
fn browser_location(&self) -> String {
self.browser_dir.borrow().clone()
}
fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
let key = {
let entries = self.entries.borrow();
ListingKey {
dir: self.browser_dir.borrow().clone(),
extensions: extensions.iter().map(|e| e.to_string()).collect(),
mutations: self.mutations.get(),
keys: entries.len(),
bytes: entries.values().map(Entry::len).sum(),
}
};
if let Some((cached, listing)) = self.listing.borrow().as_ref() {
if *cached == key {
return listing.clone();
}
}
let listing = self.list_folder(extensions);
*self.listing.borrow_mut() = Some((key, listing.clone()));
listing
}
fn browser_enter(&self, identity: &str) -> Result<(), String> {
if identity == "/models" || identity.starts_with("/models/") {
*self.browser_dir.borrow_mut() = identity.trim_end_matches('/').to_string();
Ok(())
} else {
Err("the browser virtual filesystem is rooted at /models".into())
}
}
fn browser_up(&self) -> Result<(), String> {
let current = self.browser_dir.borrow().clone();
if current == "/" {
return Ok(());
}
let parent = current
.rsplit_once('/')
.map(|(parent, _)| parent)
.unwrap_or("");
*self.browser_dir.borrow_mut() = if parent.is_empty() {
"/".into()
} else {
parent.into()
};
Ok(())
}
fn browser_home(&self) -> Result<(), String> {
*self.browser_dir.borrow_mut() = "/models".into();
Ok(())
}
fn browser_root(&self) -> Result<(), String> {
*self.browser_dir.borrow_mut() = "/".into();
Ok(())
}
fn browser_navigate(&self, location: &str) -> Result<(), String> {
let loc = location.trim_end_matches('/');
let loc = if loc.is_empty() { "/" } else { loc };
if loc == "/" || loc == "/models" || loc.starts_with("/models/") {
*self.browser_dir.borrow_mut() = loc.to_string();
Ok(())
} else {
Err("the browser virtual filesystem is rooted at /models".into())
}
}
fn browser_places(&self) -> Vec<BrowserPlace> {
vec![
BrowserPlace {
label: "Models".into(),
location: "/models".into(),
kind: PlaceKind::Models,
},
BrowserPlace {
label: "/".into(),
location: "/".into(),
kind: PlaceKind::Root,
},
]
}
fn browser_create_dir(&self, name: &str) -> Result<(), String> {
if name.is_empty()
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
{
return Err("folder name must be one path component".into());
}
let current = self.browser_dir.borrow().clone();
if !current.starts_with("/models") {
return Err("folders can only be created under /models".into());
}
let relative = Self::child_path(¤t, name)
.trim_start_matches("/models/")
.to_string();
let key = format!("{DIR_PREFIX}{relative}/");
self.entries
.borrow_mut()
.insert(key.clone(), Entry::Resident(String::new()));
let backend = self.backend.clone();
let put_key = key.clone();
self.push(name, &key, move || backend.put(&put_key, ""));
Ok(())
}
fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
let file = model_display_name(name);
if file.is_empty() {
return Err("invalid file name".into());
}
let current = self.browser_dir.borrow().clone();
if !current.starts_with("/models") {
return Err("select a folder under /models".into());
}
let identity = Self::child_path(¤t, &super::model_file_name(&file));
self.write(&identity, contents)?;
Ok(identity)
}
}
}
#[cfg(target_arch = "wasm32")]
#[path = "store_host.rs"]
mod host;
#[cfg(target_arch = "wasm32")]
mod web_model {
use super::mirror_store::{
BackendFuture, MirrorStore, StoreBackend, UnavailableBackend, DIR_PREFIX, FILE_PREFIX, PREFIX,
};
use super::{
model_display_name, BrowserEntry, BrowserPlace, ImportedFile, ModelStore,
};
use std::cell::RefCell;
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Context, Poll, Waker};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
const DB_NAME: &str = "brep-app";
const DB_VERSION: u32 = 1;
const STORE_NAME: &str = "kv";
const CHANNEL_NAME: &str = "brep-app:store";
thread_local! {
static IMPORT_INPUT: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
static IMPORTED: RefCell<Option<ImportedFile>> = const { RefCell::new(None) };
static PICK_INPUT: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
static PICKED: RefCell<Option<ImportedFile>> = const { RefCell::new(None) };
static REPAINT_CTX: RefCell<Option<eframe::egui::Context>> = const { RefCell::new(None) };
static BOOT_STORE: RefCell<Option<IdbModelStore>> = const { RefCell::new(None) };
static BOOT_NOTICE: RefCell<Option<String>> = const { RefCell::new(None) };
static BOOT_REASON: RefCell<Option<String>> = const { RefCell::new(None) };
}
fn set_boot_notice(notice: String) {
BOOT_REASON.with(|r| *r.borrow_mut() = Some(notice.clone()));
BOOT_NOTICE.with(|n| *n.borrow_mut() = Some(format!("{notice} — this page uses the browser's storage")));
}
pub fn set_repaint_ctx(ctx: eframe::egui::Context) {
REPAINT_CTX.with(|c| *c.borrow_mut() = Some(ctx));
}
pub(crate) struct WakeOnPoll(pub(crate) std::pin::Pin<Box<dyn Future<Output = ()>>>);
impl Future for WakeOnPoll {
type Output = ();
fn poll(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<()> {
let poll = self.0.as_mut().poll(cx);
wake_frame_loop();
poll
}
}
pub(crate) fn wake_frame_loop() {
REPAINT_CTX.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.request_repaint();
}
});
}
#[derive(Default)]
struct Settled {
outcome: Option<Result<JsValue, String>>,
waker: Option<Waker>,
}
enum EventSource {
Request(web_sys::IdbRequest),
Transaction(web_sys::IdbTransaction),
}
struct IdbFuture {
source: EventSource,
settled: Rc<RefCell<Settled>>,
_handlers: Vec<Closure<dyn FnMut(web_sys::Event)>>,
}
fn settle(settled: &Rc<RefCell<Settled>>, outcome: Result<JsValue, String>) {
let waker = {
let mut settled = settled.borrow_mut();
if settled.outcome.is_none() {
settled.outcome = Some(outcome);
}
settled.waker.take()
};
if let Some(waker) = waker {
waker.wake();
}
}
fn request_error(request: &web_sys::IdbRequest) -> String {
request
.error()
.ok()
.flatten()
.map(|error| format!("{}: {}", error.name(), error.message()))
.unwrap_or_else(|| "IndexedDB request failed".into())
}
fn js_error(value: &JsValue) -> String {
value
.as_string()
.or_else(|| js_sys::Reflect::get(value, &JsValue::from_str("message")).ok()?.as_string())
.unwrap_or_else(|| format!("{value:?}"))
}
fn on_request(request: web_sys::IdbRequest) -> IdbFuture {
let settled = Rc::new(RefCell::new(Settled::default()));
let success = {
let settled = settled.clone();
let request = request.clone();
Closure::wrap(Box::new(move |_event: web_sys::Event| {
let value = request.result().unwrap_or(JsValue::UNDEFINED);
settle(&settled, Ok(value));
}) as Box<dyn FnMut(web_sys::Event)>)
};
let failure = {
let settled = settled.clone();
let request = request.clone();
Closure::wrap(Box::new(move |_event: web_sys::Event| {
settle(&settled, Err(request_error(&request)));
}) as Box<dyn FnMut(web_sys::Event)>)
};
request.set_onsuccess(Some(success.as_ref().unchecked_ref()));
request.set_onerror(Some(failure.as_ref().unchecked_ref()));
IdbFuture {
source: EventSource::Request(request),
settled,
_handlers: vec![success, failure],
}
}
fn on_transaction(transaction: web_sys::IdbTransaction) -> IdbFuture {
let settled = Rc::new(RefCell::new(Settled::default()));
let complete = {
let settled = settled.clone();
Closure::wrap(Box::new(move |_event: web_sys::Event| {
settle(&settled, Ok(JsValue::UNDEFINED));
}) as Box<dyn FnMut(web_sys::Event)>)
};
let describe = {
let transaction = transaction.clone();
move |fallback: &str| {
transaction
.error()
.map(|error| format!("{}: {}", error.name(), error.message()))
.unwrap_or_else(|| fallback.to_string())
}
};
let failure = {
let settled = settled.clone();
let describe = describe.clone();
Closure::wrap(Box::new(move |_event: web_sys::Event| {
settle(&settled, Err(describe("IndexedDB transaction failed")));
}) as Box<dyn FnMut(web_sys::Event)>)
};
let abort = {
let settled = settled.clone();
Closure::wrap(Box::new(move |_event: web_sys::Event| {
settle(
&settled,
Err(describe("IndexedDB transaction aborted (quota?)")),
);
}) as Box<dyn FnMut(web_sys::Event)>)
};
transaction.set_oncomplete(Some(complete.as_ref().unchecked_ref()));
transaction.set_onerror(Some(failure.as_ref().unchecked_ref()));
transaction.set_onabort(Some(abort.as_ref().unchecked_ref()));
IdbFuture {
source: EventSource::Transaction(transaction),
settled,
_handlers: vec![complete, failure, abort],
}
}
impl Future for IdbFuture {
type Output = Result<JsValue, String>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let mut settled = self.settled.borrow_mut();
if let Some(outcome) = settled.outcome.take() {
return Poll::Ready(outcome);
}
settled.waker = Some(cx.waker().clone());
Poll::Pending
}
}
impl Drop for IdbFuture {
fn drop(&mut self) {
match &self.source {
EventSource::Request(request) => {
request.set_onsuccess(None);
request.set_onerror(None);
}
EventSource::Transaction(transaction) => {
transaction.set_oncomplete(None);
transaction.set_onerror(None);
transaction.set_onabort(None);
}
}
}
}
pub(super) struct IdbBackend {
db: web_sys::IdbDatabase,
channel: Option<web_sys::BroadcastChannel>,
}
impl IdbBackend {
async fn open(channel: Option<web_sys::BroadcastChannel>) -> Result<Self, String> {
let factory = web_sys::window()
.ok_or("no window")?
.indexed_db()
.map_err(|e| js_error(&e))?
.ok_or("no indexedDB on this window")?;
let request = factory
.open_with_u32(DB_NAME, DB_VERSION)
.map_err(|e| js_error(&e))?;
let upgrading = request.clone();
let upgrade = Closure::wrap(Box::new(move |_event: web_sys::Event| {
if let Ok(value) = upgrading.result() {
if let Ok(db) = value.dyn_into::<web_sys::IdbDatabase>() {
let _ = db.create_object_store(STORE_NAME);
}
}
}) as Box<dyn FnMut(web_sys::Event)>);
request.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref()));
let opened = on_request(request.clone().unchecked_into::<web_sys::IdbRequest>()).await;
request.set_onupgradeneeded(None);
drop(upgrade);
let db = opened?
.dyn_into::<web_sys::IdbDatabase>()
.map_err(|_| "IndexedDB open returned no database".to_string())?;
Ok(Self { db, channel })
}
fn announce(channel: &Option<web_sys::BroadcastChannel>, key: &str) {
if !key.starts_with(PREFIX) && !key.starts_with(DIR_PREFIX) && !key.starts_with(FILE_PREFIX) {
return;
}
if let Some(channel) = channel {
let _ = channel.post_message(&JsValue::from_str(key));
}
}
fn transact(
&self,
mode: web_sys::IdbTransactionMode,
) -> Result<(web_sys::IdbTransaction, web_sys::IdbObjectStore), String> {
let transaction = self
.db
.transaction_with_str_and_mode(STORE_NAME, mode)
.map_err(|e| js_error(&e))?;
let store = transaction.object_store(STORE_NAME).map_err(|e| js_error(&e))?;
Ok((transaction, store))
}
}
impl StoreBackend for IdbBackend {
fn label(&self) -> String {
"browser storage (IndexedDB) · download/upload for files".into()
}
fn orders_writes_per_key(&self) -> bool {
true
}
fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
let started = (|| -> Result<(IdbFuture, IdbFuture), String> {
let (_transaction, store) = self.transact(web_sys::IdbTransactionMode::Readonly)?;
let keys = store.get_all_keys().map_err(|e| js_error(&e))?;
let values = store.get_all().map_err(|e| js_error(&e))?;
Ok((on_request(keys), on_request(values)))
})();
Box::pin(async move {
let (keys, values) = started?;
let keys = js_sys::Array::from(&keys.await?);
let values = js_sys::Array::from(&values.await?);
if keys.length() != values.length() {
return Err("IndexedDB returned mismatched keys and values".into());
}
let mut entries = Vec::with_capacity(keys.length() as usize);
for i in 0..keys.length() {
let (Some(key), Some(value)) =
(keys.get(i).as_string(), values.get(i).as_string())
else {
continue;
};
entries.push((key, value));
}
Ok(entries)
})
}
#[cfg(feature = "automation")]
fn move_files(&self, entries: Vec<(String, String, String)>) -> BackendFuture<()> {
let started = (|| -> Result<IdbFuture, String> {
let (transaction, store) = self.transact(web_sys::IdbTransactionMode::Readwrite)?;
for (old, new, value) in &entries {
let put = store.put_with_key(&JsValue::from_str(value), &JsValue::from_str(new));
let delete = put.and_then(|_| store.delete(&JsValue::from_str(old)));
if let Err(error) = delete {
let _ = transaction.abort();
return Err(js_error(&error));
}
}
Ok(on_transaction(transaction))
})();
let channel = self.channel.clone();
Box::pin(async move {
started?.await?;
for (old, new, _) in entries {
Self::announce(&channel, &old);
Self::announce(&channel, &new);
}
Ok(())
})
}
fn get(&self, key: &str) -> BackendFuture<Option<String>> {
let started = (|| -> Result<IdbFuture, String> {
let (_transaction, store) = self.transact(web_sys::IdbTransactionMode::Readonly)?;
let request = store.get(&JsValue::from_str(key)).map_err(|e| js_error(&e))?;
Ok(on_request(request))
})();
Box::pin(async move {
Ok(started?.await?.as_string())
})
}
fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
let started = (|| -> Result<IdbFuture, String> {
let (transaction, store) =
self.transact(web_sys::IdbTransactionMode::Readwrite)?;
store
.put_with_key(&JsValue::from_str(value), &JsValue::from_str(key))
.map_err(|e| js_error(&e))?;
Ok(on_transaction(transaction))
})();
let channel = self.channel.clone();
let key = key.to_string();
Box::pin(async move {
started?.await?;
Self::announce(&channel, &key);
Ok(())
})
}
fn delete(&self, key: &str) -> BackendFuture<()> {
let started = (|| -> Result<IdbFuture, String> {
let (transaction, store) =
self.transact(web_sys::IdbTransactionMode::Readwrite)?;
store
.delete(&JsValue::from_str(key))
.map_err(|e| js_error(&e))?;
Ok(on_transaction(transaction))
})();
let channel = self.channel.clone();
let key = key.to_string();
Box::pin(async move {
started?.await?;
Self::announce(&channel, &key);
Ok(())
})
}
}
pub(super) async fn hydrate() {
let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
if super::host::available() {
let backend: Rc<dyn StoreBackend> = Rc::new(super::host::HostBackend);
let core = match backend.load_index().await {
Ok(entries) => MirrorStore::from_index(backend, entries, Some(wake)),
Err(error) => unavailable(format!("OrcaSlicer storage: {error}"), Some(wake)),
};
install_verification_hooks(&core);
BOOT_STORE.with(|c| *c.borrow_mut() = Some(IdbModelStore { core }));
return;
}
let channel = web_sys::BroadcastChannel::new(CHANNEL_NAME).ok();
let core = match IdbBackend::open(channel.clone()).await {
Ok(backend) => {
let mut backend: Rc<dyn StoreBackend> = Rc::new(backend);
match crate::plm::backend::open_web_session(backend.clone()).await {
Ok(Some(plm)) => backend = plm,
Ok(None) => {}
Err(sentence) => set_boot_notice(format!("PLM: {sentence}")),
}
match backend.load_index().await {
Ok(entries) => {
let core = MirrorStore::from_index(backend, entries, Some(wake));
listen_for_other_tabs(&core, channel);
core
}
Err(message) => unavailable(
format!("IndexedDB could not be read ({message})"),
Some(wake),
),
}
}
Err(message) => unavailable(format!("IndexedDB unavailable ({message})"), Some(wake)),
};
install_verification_hooks(&core);
BOOT_STORE.with(|c| *c.borrow_mut() = Some(IdbModelStore { core }));
}
fn listen_for_other_tabs(core: &MirrorStore, channel: Option<web_sys::BroadcastChannel>) {
let Some(channel) = channel else {
return;
};
let core = core.clone();
let on_message = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| {
if let Some(key) = event.data().as_string() {
core.refresh_key(key);
}
}) as Box<dyn FnMut(web_sys::MessageEvent)>);
channel.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
on_message.forget();
}
fn unavailable(reason: String, wake: Option<Rc<dyn Fn()>>) -> MirrorStore {
let core = MirrorStore::new(
Rc::new(UnavailableBackend::new(reason.clone())),
Vec::new(),
wake,
);
core.report(if super::host::available() {
format!("{reason} — this session will not be saved; check OrcaSlicer plugin permissions and reload")
} else {
format!("{reason} — this session will not be saved; use Download to keep your work")
});
core
}
pub(super) fn take_boot_store() -> Box<dyn ModelStore> {
let store = BOOT_STORE.with(|c| c.borrow_mut().take()).unwrap_or_else(|| {
let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
IdbModelStore {
core: unavailable("storage was never initialised".into(), Some(wake)),
}
});
Box::new(store)
}
fn install_verification_hooks(core: &MirrorStore) {
let Some(window) = web_sys::window() else {
return;
};
let publish = |name: &str, value: &JsValue| {
let _ = js_sys::Reflect::set(&window, &JsValue::from_str(name), value);
};
let store = core.clone();
let write = Closure::wrap(Box::new(move |name: String, contents: String| -> JsValue {
match store.write(&name, &contents) {
Ok(()) => JsValue::NULL,
Err(message) => JsValue::from_str(&message),
}
}) as Box<dyn FnMut(String, String) -> JsValue>);
publish("__brepStoreWrite", write.as_ref());
write.forget();
let store = core.clone();
let len = Closure::wrap(Box::new(move |name: String| -> f64 {
store.len_of(&name).map(|n| n as f64).unwrap_or(-1.0)
}) as Box<dyn FnMut(String) -> f64>);
publish("__brepStoreLen", len.as_ref());
len.forget();
let store = core.clone();
let read = Closure::wrap(Box::new(move |name: String| -> JsValue {
match store.read(&name) {
Some(contents) => JsValue::from_str(&contents),
None => JsValue::NULL,
}
}) as Box<dyn FnMut(String) -> JsValue>);
publish("__brepStoreRead", read.as_ref());
read.forget();
let store = core.clone();
let list = Closure::wrap(Box::new(move || -> JsValue {
JsValue::from_str(&serde_json::to_string(&store.list()).unwrap_or_default())
}) as Box<dyn FnMut() -> JsValue>);
publish("__brepStoreList", list.as_ref());
list.forget();
let store = core.clone();
let errors = Closure::wrap(Box::new(move || -> JsValue {
JsValue::from_str(&serde_json::to_string(&store.error_history()).unwrap_or_default())
}) as Box<dyn FnMut() -> JsValue>);
publish("__brepStoreErrors", errors.as_ref());
errors.forget();
let store = core.clone();
let remove = Closure::wrap(Box::new(move |name: String| -> JsValue {
match store.remove(&name) {
Ok(()) => JsValue::NULL,
Err(message) => JsValue::from_str(&message),
}
}) as Box<dyn FnMut(String) -> JsValue>);
publish("__brepStoreRemove", remove.as_ref());
remove.forget();
let store = core.clone();
let pending = Closure::wrap(Box::new(move || -> f64 { store.pending() as f64 })
as Box<dyn FnMut() -> f64>);
publish("__brepStorePending", pending.as_ref());
pending.forget();
}
fn pick_input() -> Option<web_sys::HtmlInputElement> {
if let Some(existing) = PICK_INPUT.with(|c| c.borrow().clone()) {
return Some(existing);
}
let document = web_sys::window()?.document()?;
let input: web_sys::HtmlInputElement = document.create_element("input").ok()?.dyn_into().ok()?;
input.set_type("file");
input.set_hidden(true);
let input_for_cb = input.clone();
let onchange = Closure::wrap(Box::new(move |_e: web_sys::Event| {
let Some(file) = input_for_cb.files().and_then(|files| files.get(0)) else { return };
let name = file.name();
let Ok(reader) = web_sys::FileReader::new() else { return };
let reader_for_load = reader.clone();
let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| {
if let Ok(value) = reader_for_load.result() {
let bytes = js_sys::Uint8Array::new(&value).to_vec();
PICKED.with(|c| *c.borrow_mut() = Some(ImportedFile { name: name.clone(), bytes }));
wake_frame_loop();
}
}) as Box<dyn FnMut(web_sys::Event)>);
reader.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
let _ = reader.read_as_array_buffer(&file);
}) as Box<dyn FnMut(web_sys::Event)>);
input.set_onchange(Some(onchange.as_ref().unchecked_ref()));
onchange.forget();
if let Some(body) = document.body() {
let _ = body.append_child(&input);
}
PICK_INPUT.with(|c| *c.borrow_mut() = Some(input.clone()));
Some(input)
}
fn ensure_input(accept: &str) -> Option<web_sys::HtmlInputElement> {
if let Some(existing) = IMPORT_INPUT.with(|c| c.borrow().clone()) {
existing.set_accept(accept);
return Some(existing);
}
let document = web_sys::window()?.document()?;
let input: web_sys::HtmlInputElement =
document.create_element("input").ok()?.dyn_into().ok()?;
input.set_type("file");
input.set_accept(accept);
input.set_hidden(true);
let input_for_cb = input.clone();
let onchange = Closure::wrap(Box::new(move |_e: web_sys::Event| {
let Some(files) = input_for_cb.files() else { return };
let Some(file) = files.get(0) else { return };
let name = file.name();
let Ok(reader) = web_sys::FileReader::new() else { return };
let reader_for_load = reader.clone();
let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| {
if let Ok(value) = reader_for_load.result() {
let bytes = js_sys::Uint8Array::new(&value).to_vec();
IMPORTED.with(|c| *c.borrow_mut() = Some(ImportedFile {
name: model_display_name(&name),
bytes,
}));
wake_frame_loop();
}
}) as Box<dyn FnMut(web_sys::Event)>);
reader.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
let _ = reader.read_as_array_buffer(&file);
}) as Box<dyn FnMut(web_sys::Event)>);
input.set_onchange(Some(onchange.as_ref().unchecked_ref()));
onchange.forget();
if let Some(body) = document.body() {
let _ = body.append_child(&input);
}
IMPORT_INPUT.with(|c| *c.borrow_mut() = Some(input.clone()));
Some(input)
}
fn download(file_name: &str, mime: &str, contents: &str) -> Result<(), String> {
let parts = js_sys::Array::of1(&JsValue::from_str(contents));
let options = web_sys::BlobPropertyBag::new();
options.set_type(mime);
let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
.map_err(|_| "blob create failed".to_string())?;
click_download(file_name, &blob)
}
fn download_bytes(file_name: &str, mime: &str, contents: &[u8]) -> Result<(), String> {
let array = js_sys::Uint8Array::new_with_length(contents.len() as u32);
array.copy_from(contents);
let parts = js_sys::Array::of1(&array);
let options = web_sys::BlobPropertyBag::new();
options.set_type(mime);
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options)
.map_err(|_| "blob create failed".to_string())?;
click_download(file_name, &blob)
}
fn click_download(file_name: &str, blob: &web_sys::Blob) -> Result<(), String> {
if super::host::available() {
let name = file_name.to_owned();
let blob = blob.clone();
wasm_bindgen_futures::spawn_local(async move {
let result = async {
let buffer = wasm_bindgen_futures::JsFuture::from(blob.array_buffer()).await
.map_err(|e| format!("export bytes: {e:?}"))?;
let bytes = js_sys::Uint8Array::new(&buffer).to_vec();
let binary: String = bytes.into_iter().map(char::from).collect();
let encoded = web_sys::window().ok_or("no window")?.btoa(&binary)
.map_err(|e| format!("export encoding: {e:?}"))?;
super::host::request(serde_json::json!({"op":"export", "name":name,
"base64":encoded})).await
}.await;
if let Err(error) = result {
BOOT_NOTICE.with(|n| *n.borrow_mut() = Some(format!("Local export failed: {error}")));
wake_frame_loop();
}
});
return Ok(());
}
let document = web_sys::window()
.and_then(|w| w.document())
.ok_or("no document")?;
let url = web_sys::Url::create_object_url_with_blob(blob)
.map_err(|_| "object url failed".to_string())?;
let anchor: web_sys::HtmlAnchorElement = document
.create_element("a")
.map_err(|_| "anchor create failed".to_string())?
.dyn_into()
.map_err(|_| "anchor cast failed".to_string())?;
anchor.set_href(&url);
anchor.set_download(file_name);
anchor.click();
let _ = web_sys::Url::revoke_object_url(&url);
Ok(())
}
pub(super) struct IdbModelStore {
core: MirrorStore,
}
impl ModelStore for IdbModelStore {
#[cfg(feature = "automation")]
fn files(&self, request: &brep_mcp_core::files::Request) -> Result<brep_mcp_core::files::Reply, String> {
self.core.files(request)
}
#[cfg(feature = "automation")]
fn files_settled(&self, request: &brep_mcp_core::files::Request) -> Option<Result<(), String>> { self.core.files_settled(request) }
fn backend_label(&self) -> String {
self.core.backend_label()
}
fn plm_client(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> {
self.core.plm_client()
}
fn plm_revision(&self, key: &str) -> Option<crate::plm::client::IndexEntry> {
self.core.plm_revision(key)
}
fn note_checked_out(&self, key: &str) {
self.core.note_checked_out(key)
}
fn plm_written_by_us(&self, key: &str) -> bool {
self.core.plm_written_by_us(key)
}
fn refresh_plm_index(&self, keys: &[String], stale: bool) {
self.core.refresh_plm_index(keys, stale)
}
fn remember_saved_document(&self, name: &str, contents: &str) {
self.core.remember_saved_document(name, contents)
}
fn list(&self) -> Vec<String> {
self.core.list()
}
fn read(&self, name: &str) -> Option<String> {
self.core.read(name)
}
fn residency(&self, name: &str) -> super::Residency {
self.core.residency(name)
}
fn canonical_identity(&self, name: &str) -> String {
self.core.canonical_identity(name)
}
fn canonicalizer(&self) -> super::Canonicalizer {
self.core.canonicalizer()
}
fn write(&self, name: &str, contents: &str) -> Result<(), String> {
self.core.write(name, contents)
}
fn remove(&self, name: &str) -> Result<(), String> {
self.core.remove(name)
}
fn mutation_generation(&self) -> u64 {
self.core.mutation_generation()
}
fn take_persistence_errors(&self) -> Vec<String> {
let mut errors: Vec<String> = BOOT_NOTICE.with(|n| n.borrow_mut().take()).into_iter().collect();
errors.extend(self.core.take_persistence_errors());
errors
}
fn take_failed_writes(&self) -> Vec<String> {
self.core.take_failed_writes()
}
fn write_failed(&self, name: &str) -> bool {
self.core.write_failed(name)
}
fn failed_write_base(&self, name: &str) -> Option<String> {
self.core.failed_write_base(name)
}
fn write_if(&self, name: &str, contents: &str, expected: &str) -> Result<(), String> {
self.core.write_if(name, contents, expected)
}
fn plm_session(&self) -> Option<crate::plm::connection::Session> {
use crate::plm::connection::{parse_label, Keep, Session, Status};
if let Some((url, username)) = parse_label(&self.core.backend_label()) {
return Some(Session { status: Status::Connected { url, username }, keep: Keep::BrowserSession });
}
let location = web_sys::window()?.location();
if !crate::plm::backend::served_by_plm(&location.pathname().ok()?) {
return None;
}
let reason = BOOT_REASON.with(|r| r.borrow().clone());
Some(Session { status: Status::NotConnected { url: location.origin().ok()?, reason }, keep: Keep::BrowserSession })
}
fn pending_writes(&self) -> usize {
self.core.pending_writes()
}
fn pending_writes_except(&self, names: &[&str]) -> usize {
self.core.pending_writes_except(names)
}
fn browser_location(&self) -> String {
self.core.browser_location()
}
fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
self.core.browser_entries(extensions)
}
fn browser_enter(&self, identity: &str) -> Result<(), String> {
self.core.browser_enter(identity)
}
fn browser_up(&self) -> Result<(), String> {
self.core.browser_up()
}
fn browser_home(&self) -> Result<(), String> {
self.core.browser_home()
}
fn browser_root(&self) -> Result<(), String> {
self.core.browser_root()
}
fn browser_navigate(&self, location: &str) -> Result<(), String> {
self.core.browser_navigate(location)
}
fn browser_places(&self) -> Vec<BrowserPlace> {
self.core.browser_places()
}
fn browser_create_dir(&self, name: &str) -> Result<(), String> {
self.core.browser_create_dir(name)
}
fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
self.core.browser_write(name, contents)
}
fn supports_file_interchange(&self) -> bool {
true
}
fn begin_pick_file(&self) -> Result<(), String> {
let input = pick_input().ok_or("the browser's file chooser is unavailable")?;
input.set_value("");
input.click();
Ok(())
}
fn take_picked_file(&self) -> Option<ImportedFile> {
PICKED.with(|c| c.borrow_mut().take())
}
fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
let name = model_display_name(name);
let file_name = super::model_file_name(&name);
download(&file_name, super::media_type(&file_name), contents)?;
Ok(Some(name))
}
fn begin_import(&self) -> Result<(), String> {
let input =
ensure_input(".nbrep,.fbrep,.tbrep").ok_or("file input unavailable")?;
input.set_value("");
input.click();
Ok(())
}
fn take_import(&self) -> Option<ImportedFile> {
IMPORTED.with(|c| c.borrow_mut().take())
}
fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
let accept = filter
.1
.iter()
.map(|ext| format!(".{ext}"))
.collect::<Vec<_>>()
.join(",");
let input = ensure_input(&accept).ok_or("file input unavailable")?;
input.set_value("");
input.click();
Ok(())
}
fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
download(file_name, super::media_type(file_name), contents)
}
fn export_file_named_bytes(&self, file_name: &str, contents: &[u8]) -> Result<(), String> {
download_bytes(file_name, super::media_type(file_name), contents)
}
}
}