Files
OneCAD-Simulation/apps/fieldcraft-web/src/services_odoo.rs
T

709 lines
32 KiB
Rust

// OneBSpace (OneCAD Simulation): Odoo Documents host integration.
//! OneBSpace (OneCAD Simulation): Odoo Documents host integration for the wasm32 build
//! (the `mod services_odoo;` declaration in `main.rs` carries the
//! `#[cfg(target_arch = "wasm32")]` gate).
//!
//! Inside the wasm_app_framework shell every host seam of
//! [`fieldcraft_ui_egui::Services`] is rerouted through the one-cad-simulation JS
//! bridge instead of the browser/device. FieldCraft's pickers are *synchronous*
//! (`Fn(&[Filter]) -> Option<String>`), so unlike the OneCAD port this module does
//! not return futures: the closure fires the bridge request and answers `None`
//! (the UI sees "cancelled"), and the real completion is queued here and drained
//! once per frame by the web shell:
//!
//! - an *open* pick stages the record's bytes in the page filesystem at
//! `/odoo/doc/<recordId>/<name>` and queues `ModelApp::open_path` (the same
//! entry `web.rs::open_arrived` uses, so statuses and worker opens behave);
//! - a *save* pick queues `file.save_as {"path": "/odoo/pending/<token>/<name>"}`;
//! the engine write is air-gapped by [`handle_write`] and the bridge binds the
//! pending token to the Documents record, so the model's file becomes the
//! record and later Ctrl+S writes back in place.
//! - writes drained from the page filesystem under `/odoo/…` (or model files
//! anywhere else) go to the bound Documents record; every other write becomes
//! an export into the "Exports" folder — the air gap keeps bytes off the local
//! device ([`handle_write`]); the web shell checks the autosave/recovery folder
//! *first*, so the localStorage recovery mirror keeps working as upstream.
//!
//! Everything is inert standalone: when the page is not framed by the Odoo shell
//! ([`embedded`]) nothing is overridden and the upstream behavior is untouched.
//!
//! # WIRE PROTOCOL (matches the one-cad-simulation JS bridge: CustomEvents)
//!
//! Rust → JS: `window.dispatchEvent(new CustomEvent("onecadsim:host-request",
//! { detail }))` with `detail = { id: number, method: string, params: object }`.
//!
//! JS → Rust: `window.dispatchEvent(new CustomEvent("onecadsim:host-response",
//! { detail }))` with `detail = { id, ok: boolean, result?: any, error?: string }`.
//!
//! The glue must attach its `onecadsim:host-response` listener *before* the app
//! boots (index.html loads the bridge module ahead of the wasm) and set
//! `window.onecadsimHostReady = true` when listening; until the flag appears
//! requests are retried every [`RETRY_MS`] for [`HOST_DEADLINE`].
//!
//! | method | params | result (on `ok`) |
//! |--------------------|------------------------------------------|------------------|
//! | `odoo.pickOpen` | `{ extensions: string[] }` | `{ recordId, name, bytes: Uint8Array }` or `{ content: <base64> }` → bytes staged at `/odoo/doc/<recordId>/<name>` |
//! | `odoo.pickSave` | `{ name: string, design: boolean }` | `{ token, name }` → `/odoo/pending/<token>/<name>`, or `{ path: "/odoo/…" }` |
//! | `odoo.pickProgram` | `{}` (asset pick from the "Assets" folder) | same shape as `odoo.pickOpen` |
//! | `odoo.write` | `{ path: string, bytes: Uint8Array }` | ack only (`ok`) — Documents record write-back |
//! | `odoo.saveExport` | `{ name: string, bytes: Uint8Array }` | ack only — lands in the "Exports" folder |
//! | `odoo.import` | `{ name: string, bytes: Uint8Array }` | ack only — best-effort mirror into "Assets" |
//!
//! A `null`/absent `result` with `ok: true` means the user cancelled the
//! explorer; nothing is queued and the UI stays as it was.
//!
//! For JS→Rust staging (deep links `?document=<id>`) the glue calls
//! `window.onecadsimOdoo.bootFile(recordId, name, uint8array) -> path`, which
//! inserts the bytes into the page filesystem at `/odoo/doc/<id>/<name>`, then
//! asks the app to open the returned path via `window.onecadsim.request`.
//!
//! `window.onecadsim` is a small promise-based API the glue drives (the OneCAD
//! fork's `api.rs` equivalent, kept minimal):
//!
//! - `request("ui.file", { path })` → queues the open; resolves immediately.
//! - `request("odoo.document.bytes")` → resolves with the current model's
//! `.fieldcraft` bytes ([`Session::file_bytes`], via the shell's frame drain).
//! - `request("odoo.document.info")` → resolves with `{ name }` (the current
//! model's file name, if any).
//!
//! # HOW TO WIRE (exact locations in this tree)
//!
//! 1. `apps/fieldcraft-web/src/main.rs`, module block (tagged
//! `OneBSpace (OneCAD Simulation)`): `#[cfg(target_arch = "wasm32")] mod services_odoo;`
//! 2. `apps/fieldcraft-web/src/web.rs`, the startup closure — immediately after
//! the `services(inbox, ctx)` literal and before `with_services`:
//! `let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());`
//! 3. `apps/fieldcraft-web/src/web.rs` `Shell::logic` (tagged line):
//! `crate::services_odoo::drain(&mut self.app);` — completes queued picks.
//! 4. `apps/fieldcraft-web/src/web.rs` `Shell::hand_out_writes` (tagged): the
//! `else if crate::services_odoo::handle_write(&path, &bytes) { continue; }`
//! branch between the recovery-folder branch and the download branch.
//! 5. `apps/fieldcraft-web/src/web.rs` `Shell::open_arrived` (tagged):
//! `crate::services_odoo::mirror_import(&name, …)` mirrors computer imports
//! into the Documents "Assets" folder.
// OneBSpace (OneCAD Simulation) — this entire file is downstream; keep it out of
// upstream syncs (greppable tag above).
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::JsFuture;
/// An existing Documents record inside the emulated filesystem.
const ODOC: &str = "/odoo/doc/";
/// A Save-as destination chosen in the explorer, not yet written.
const OPENDING: &str = "/odoo/pending/";
/// Scratch area whose writes the shell swallows (never a record, never a download).
const OTMP: &str = "/odoo/tmp/";
/// Rust → JS request event (see the WIRE PROTOCOL block above).
const REQUEST_EVENT: &str = "onecadsim:host-request";
/// JS → Rust response event (see the WIRE PROTOCOL block above).
const RESPONSE_EVENT: &str = "onecadsim:host-response";
const PICK_OPEN: &str = "odoo.pickOpen";
const PICK_SAVE: &str = "odoo.pickSave";
/// Supported by the JS bridge; FieldCraft's `Services` has no program picker,
/// so the Rust side never emits it today.
#[allow(dead_code)]
const PICK_PROGRAM: &str = "odoo.pickProgram";
const WRITE: &str = "odoo.write";
const SAVE_EXPORT: &str = "odoo.saveExport";
const IMPORT: &str = "odoo.import";
/// At most this many bridge requests parked at once.
const MAX_PENDING: usize = 64;
/// Retry cadence while `window.onecadsimHostReady` has not appeared yet.
const RETRY_MS: i32 = 250;
/// How long to wait for the JS bridge module to become ready per request
/// (in retry ticks of [`RETRY_MS`]: 30 s).
const HOST_DEADLINE_TICKS: u32 = 120;
/// CAE/model formats the app ingests beyond its own (mirrors the JS bridge's
/// asset list). Imported copies land in the Documents "Assets" folder.
const ASSET_EXTS: [&str; 12] = ["step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf", "dxf", "gltf", "glb", "ply"];
thread_local! {
/// Decided once: does this page run inside the Odoo shell?
static EMBEDDED: Cell<Option<bool>> = const { Cell::new(None) };
/// Pending picker requests, keyed by request id.
static PENDING: RefCell<HashMap<u64, Arc<Slot>>> = RefCell::new(HashMap::new());
/// Completions waiting for the shell's next frame ([`drain`]).
static COMPLETED: RefCell<Vec<Completion>> = RefCell::new(Vec::new());
/// Promise resolvers parked for `odoo.document.bytes`, keyed by request id.
static BYTES_WAITING: RefCell<HashMap<u64, Function>> = RefCell::new(HashMap::new());
/// Promise resolvers parked for `odoo.document.info`, keyed by request id.
static INFO_WAITING: RefCell<HashMap<u64, Function>> = RefCell::new(HashMap::new());
/// The request-id counter.
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
/// The `onecadsim:host-response` listener is installed exactly once.
static LISTENER: Cell<bool> = const { Cell::new(false) };
/// The `window.onecadsimOdoo` boot seam is installed exactly once.
static BOOT: Cell<bool> = const { Cell::new(false) };
/// The `window.onecadsim` API is installed exactly once.
static API: Cell<bool> = const { Cell::new(false) };
/// Asset imports already mirrored this session (name, size).
static MIRRORED: RefCell<HashSet<(String, usize)>> = RefCell::new(HashSet::new());
}
/// A picker request parked in flight: the response listener removes it from
/// [`PENDING`] and pushes the queued completion for [`drain`] (the sync
/// `Services` design has no future to poll a slot, so the queue is the handoff).
struct Slot {
done: Cell<bool>,
}
/// Work the shell performs on its next frame.
enum Completion {
/// Open the emulated path (`ModelApp::open_path`).
Open(String),
/// Save the model to the emulated path (`file.save_as`).
SaveAs(String),
/// Resolve `odoo.document.bytes` with the model's bytes.
Bytes(u64),
/// Resolve `odoo.document.info` with the model's file name.
Info(u64),
}
/// Is this page running inside the wasm_app_framework shell? Decided once,
/// the family rule (one-cut-pro env.ts): framed and the same-origin parent
/// is the Odoo web client (`/odoo` or `/web`). `?odoo-host=1` forces it on
/// for local testing against a stub shell.
pub fn embedded() -> bool {
if let Some(decided) = EMBEDDED.with(Cell::get) {
return decided;
}
let decided = embedded_uncached();
EMBEDDED.with(|e| e.set(Some(decided)));
decided
}
fn embedded_uncached() -> bool {
let Some(window) = web_sys::window() else { return false };
let Ok(search) = window.location().search() else { return false };
if search.contains("odoo-host=1") {
return true;
}
let Ok(parent) = window.parent() else { return false };
// Top window: standalone run, upstream behavior everywhere.
let parent_top = parent
.as_ref()
.map(|p| {
AsRef::<JsValue>::as_ref(p) == AsRef::<JsValue>::as_ref(&window)
})
.unwrap_or(false);
if parent_top {
return false;
}
// Same-origin shell: the parent path is readable; anything else that is
// framed counts as embedded (the shell never cross-origins the iframe).
let pathname = parent
.as_ref()
.and_then(|p| p.location().pathname().ok());
match pathname {
Some(path) => path.starts_with("/odoo") || path.starts_with("/web"),
None => true,
}
}
/// Install the integration: the seams and the response listener always
/// (harmless standalone), the Services overrides only inside the shell.
/// Call once at app start, right after the `services(inbox, ctx)` literal in
/// `web.rs` and before `with_services` (see HOW TO WIRE above).
pub fn install_odoo_services(services: Services, ctx: egui::Context) -> Services {
install_response_listener(ctx.clone());
install_seams(ctx.clone());
if !embedded() {
return services;
}
log::info!("OneCAD Simulation: Odoo shell detected — pickers and writes route through the Documents bridge");
let open_ctx = ctx.clone();
let save_ctx = ctx;
Services {
// Open/Insert pickers browse Odoo Documents: fire the explorer request
// and answer `None` (the shell completes the open on a later frame).
pick_open: Some(Box::new(move |filters: &[Filter]| {
let params = Object::new();
let list = Array::new();
for ext in filter_extensions(filters) {
list.push(&JsValue::from_str(&ext));
}
let _ = Reflect::set(&params, &"extensions".into(), &list.into());
pick(PICK_OPEN, params, open_ctx.clone());
None
})),
// Saves ask the explorer once; the returned pending token binds the
// model to its record (the queued `file.save_as` lands it, later
// Ctrl+S writes back in place).
pick_save: Some(Box::new(move |name: &str, filters: &[Filter]| {
let params = Object::new();
let _ = Reflect::set(&params, &"name".into(), &JsValue::from_str(name));
// A model save (the owned `.fieldcraft` type) drives the owned save flow.
let design = filters.iter().any(|f| f.extensions.iter().any(|e| e.eq_ignore_ascii_case("fieldcraft")));
let _ = Reflect::set(&params, &"design".into(), &JsValue::from_bool(design));
pick(PICK_SAVE, params, save_ctx.clone());
None
})),
// Air gap: no external links from the shell.
open_url: Some(Box::new(|_: &str| Ok(()))),
..services
}
}
/// The union of a picker filter set's extensions, lowercase, deduped, in order.
fn filter_extensions(filters: &[Filter]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for filter in filters {
for ext in filter.extensions {
let ext = ext.to_ascii_lowercase();
if !out.iter().any(|seen| *seen == ext) {
out.push(ext);
}
}
}
out
}
/// Fire a bridge request (retried until the glue is ready); the answer arrives
/// out-of-band via `onecadsim:host-response` and completes a queued pick.
fn pick(method: &'static str, params: Object, ctx: egui::Context) {
if PENDING.with(|p| p.borrow().len()) >= MAX_PENDING {
log::error!("OneCAD Simulation: too many pending bridge requests (maximum {MAX_PENDING})");
return;
}
let id = next_id();
let slot = Arc::new(Slot { done: Cell::new(false) });
PENDING.with(|p| p.borrow_mut().insert(id, slot));
deliver(method, id, params, 0);
// Nudge the UI so the shell's frame drain runs promptly.
ctx.request_repaint();
}
fn next_id() -> u64 {
NEXT_ID.with(|n| {
let id = n.get();
n.set(id + 1);
id
})
}
/// Deliver a request once `window.onecadsimHostReady` appears (the glue sets it
/// after attaching its response listener), then cancel the slot if it never
/// does. Answers arrive out-of-band via `onecadsim:host-response`.
fn deliver(method: &'static str, id: u64, params: Object, tick: u32) {
wasm_bindgen_futures::spawn_local(async move {
let Some(window) = web_sys::window() else { return };
let slot_done = PENDING.with(|p| p.borrow().get(&id).map(|s| s.done.get()).unwrap_or(true));
if slot_done {
return; // answered or cancelled while we were waiting
}
if host_ready() && dispatch_event(method, id, &params) {
return; // the shell owns the answer now
}
if tick >= HOST_DEADLINE_TICKS {
log::error!("OneCAD Simulation: the Odoo bridge never became ready; cancelling \"{method}\"");
PENDING.with(|p| p.borrow_mut().remove(&id));
return;
}
let promise = js_sys::Promise::new(&mut |resolve, _| {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, RETRY_MS);
});
let _ = JsFuture::from(promise).await;
deliver(method, id, params, tick + 1);
});
}
fn host_ready() -> bool {
web_sys::window()
.map(|w| Reflect::get(&w, &"onecadsimHostReady".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false))
.unwrap_or(false)
}
/// `window.dispatchEvent(new CustomEvent(method, { detail: {id, method, params} }))`.
/// Built through `js_sys` (no extra web-sys feature gates). `false` when the
/// event could not be created or dispatched (no shell listening).
fn dispatch_event(method: &str, id: u64, params: &Object) -> bool {
let Some(window) = web_sys::window() else { return false };
let Ok(ctor) = Reflect::get(&window, &"CustomEvent".into()) else { return false };
let Ok(ctor) = ctor.dyn_into::<js_sys::Function>() else { return false };
let detail = Object::new();
let _ = Reflect::set(&detail, &"id".into(), &JsValue::from(id as f64));
let _ = Reflect::set(&detail, &"method".into(), &JsValue::from_str(method));
let _ = Reflect::set(&detail, &"params".into(), params);
let init = Object::new();
let _ = Reflect::set(&init, &"detail".into(), &detail.into());
let args = Array::of2(&JsValue::from_str(REQUEST_EVENT), &init.into());
let Ok(event) = Reflect::construct(&ctor, &args) else { return false };
let Ok(dispatch) = Reflect::get(&window, &"dispatchEvent".into()) else { return false };
let Ok(dispatch) = dispatch.dyn_into::<js_sys::Function>() else { return false };
dispatch.call1(&window, &event).is_ok()
}
/// Install the `onecadsim:host-response` listener once: responses complete the
/// parked picker slots (queued work for [`drain`]) or log write/import failures.
fn install_response_listener(ctx: egui::Context) {
if LISTENER.with(Cell::get) {
return;
}
let Some(window) = web_sys::window() else { return };
let listener = Closure::<dyn Fn(JsValue)>::new(move |event: JsValue| {
let detail = Reflect::get(&event, &"detail".into()).unwrap_or(JsValue::UNDEFINED);
let id = field_u64(&detail, "id");
let ok = Reflect::get(&detail, &"ok".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
let error = field_string(&detail, "error").unwrap_or_default();
let result = Reflect::get(&detail, &"result".into()).unwrap_or(JsValue::UNDEFINED);
let slot = PENDING.with(|p| p.borrow_mut().remove(&id));
match slot {
Some(_slot) => {
if ok {
// OneBSpace (OneCAD Simulation): the completion goes on the
// queue the shell's frame drain consumes ([`drain`]).
if let Some(completion) = completion_from_result(&result) {
COMPLETED.with(|c| c.borrow_mut().push(completion));
}
} else {
log::warn!("OneCAD Simulation: the shell refused the request: {error}");
}
ctx.request_repaint();
}
// Writes, exports and imports are acknowledged without a parked
// slot; only their failures are interesting here.
None if !ok => log::warn!("OneCAD Simulation: the shell refused a write/export/import: {error}"),
None => {}
}
});
let Ok(add) = Reflect::get(&window, &"addEventListener".into()) else { return };
let Ok(add) = add.dyn_into::<js_sys::Function>() else { return };
if add.call2(&window, &JsValue::from_str(RESPONSE_EVENT), &listener.into_js_value()).is_ok() {
LISTENER.with(|l| l.set(true));
log::info!("OneCAD Simulation: response listener installed");
} else {
log::warn!("OneCAD Simulation: response listener install FAILED");
}
}
/// Map a `onecadsim:host-response` result onto the queued completion the shell
/// drains (see the WIRE PROTOCOL table above).
fn completion_from_result(result: &JsValue) -> Option<Completion> {
if !result.is_object() {
return None; // the user cancelled the explorer
}
let name = field_string(result, "name");
// An opened record: bytes come as a Uint8Array (preferred) or base64.
if let Ok(bytes) = Reflect::get(result, &"bytes".into())
&& bytes.is_instance_of::<Uint8Array>()
{
let Ok(bytes) = bytes.dyn_into::<Uint8Array>() else { return None };
return stage_record(field_u64(result, "recordId"), name.as_deref(), bytes.to_vec()).map(Completion::Open);
}
if let Some(content) = field_string(result, "content")
&& let Some(bytes) = base64_decode(&content)
{
return stage_record(field_u64(result, "recordId"), name.as_deref(), bytes).map(Completion::Open);
}
// A Save-as destination: the pending token names the path the engine
// will write to (the glue binds the token to the record).
let token = field_string(result, "token");
if let (Some(token), Some(name)) = (token, name) {
return Some(Completion::SaveAs(format!("{OPENDING}{token}/{}", sanitize(&name))));
}
// Or the glue resolved an explicit emulated path itself.
if let Some(path) = field_string(result, "path")
&& path.starts_with("/odoo/")
{
return Some(Completion::Open(path));
}
None
}
/// Stage a picked record's bytes in the page filesystem at `/odoo/doc/<id>/<name>`.
fn stage_record(record_id: u64, name: Option<&str>, bytes: Vec<u8>) -> Option<String> {
if record_id == 0 {
return None;
}
let path = format!("{ODOC}{record_id}/{}", sanitize(name.unwrap_or("document")));
match fieldcraft_io::fs::insert(Path::new(&path), bytes) {
Ok(()) => Some(path),
Err(e) => {
log::error!("OneCAD Simulation: could not stage the document ({e})");
None
}
}
}
fn field_u64(value: &JsValue, key: &str) -> u64 {
Reflect::get(value, &key.into()).ok().and_then(|v| v.as_f64()).map(|f| f as u64).unwrap_or(0)
}
fn field_string(value: &JsValue, key: &str) -> Option<String> {
Reflect::get(value, &key.into()).ok().and_then(|v| v.as_string())
}
/// File names only key the filesystem: keep them single-segment.
fn sanitize(name: &str) -> String {
let cleaned: String = name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect();
let trimmed = cleaned.trim();
if trimmed.is_empty() {
"file".into()
} else {
trimmed.to_string()
}
}
/// Tiny base64 (standard alphabet, padding optional) decoder for bridge
/// payloads that arrive as `content` strings instead of `Uint8Array`s.
fn base64_decode(input: &str) -> Option<Vec<u8>> {
fn value(byte: u8) -> Option<u32> {
match byte {
b'A'..=b'Z' => Some((byte - b'A') as u32),
b'a'..=b'z' => Some((byte - b'a') as u32 + 26),
b'0'..=b'9' => Some((byte - b'0') as u32 + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let clean: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace() && *b != b'=').collect();
let mut out = Vec::with_capacity(clean.len() * 3 / 4);
for chunk in clean.chunks(4) {
if chunk.len() < 2 {
return None;
}
let mut acc: u32 = 0;
for (i, byte) in chunk.iter().enumerate() {
acc |= value(*byte)? << (18 - 6 * i);
}
out.push((acc >> 16) as u8);
if chunk.len() > 2 {
out.push((acc >> 8) as u8);
}
if chunk.len() > 3 {
out.push(acc as u8);
}
}
Some(out)
}
/// Drain hook (tagged call site in `web.rs::hand_out_writes`, right after the
/// recovery-folder branch): consume writes the shell must not turn into device
/// downloads. `/odoo/…` writes go to the bound Documents record (or the pending
/// token's destination on first write); model files elsewhere are write-backs
/// by name; everything else is an export into the "Exports" folder. Returns
/// true when the write was consumed (standalone: always false, upstream
/// behavior untouched).
pub fn handle_write(path: &Path, bytes: &[u8]) -> bool {
if !embedded() {
return false;
}
let path_str = path.to_string_lossy().into_owned();
// Scratch writes (never a record): swallowed, nothing leaves the page.
if path_str.starts_with(OTMP) {
return true;
}
let params = Object::new();
let method = if path_str.starts_with(ODOC) || path_str.starts_with(OPENDING) {
let _ = Reflect::set(&params, &"path".into(), &JsValue::from_str(&path_str));
WRITE
} else if is_model_path(&path_str) {
// Model files written outside the emulated tree (New model saved to the
// page's folder): the bridge resolves the record by name or creates one.
let _ = Reflect::set(&params, &"path".into(), &JsValue::from_str(&path_str));
WRITE
} else {
// Decks, reports, results: an export into the "Exports" folder.
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "export".into());
let _ = Reflect::set(&params, &"name".into(), &JsValue::from_str(&name));
SAVE_EXPORT
};
let _ = Reflect::set(&params, &"bytes".into(), &Uint8Array::from(bytes).into());
if !dispatch_event(method, next_id(), &params) {
log::error!("OneCAD Simulation: the Odoo bridge is unavailable; \"{path_str}\" stays in the page (the Documents copy was not updated)");
}
// Consumed either way: the air gap wins even if the bridge module is
// gone — bytes never reach the local device from the shell (a copy
// survives in the page filesystem and the localStorage autosave until
// the save succeeds).
true
}
/// `.fieldcraft`/`.solidcraft`/`.3mf` are the model-ish types the shell writes
/// back to Documents; everything else counts as an export.
fn is_model_path(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ["fieldcraft", "solidcraft", "3mf"].iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
}
/// Mirror hook (tagged call site in `web.rs::open_arrived`): CAE assets brought
/// in from the computer get a copy in the Documents "Assets" folder (family
/// storage rule). Deduplicated per (name, size) this session; the JS side
/// additionally dedupes by signature across reloads.
pub fn mirror_import(name: &str, bytes: Option<&[u8]>) {
if !embedded() {
return;
}
let Some(bytes) = bytes else { return };
if !is_asset_name(name) {
return;
}
if !MIRRORED.with(|m| m.borrow_mut().insert((name.to_string(), bytes.len()))) {
return;
}
let params = Object::new();
let _ = Reflect::set(&params, &"name".into(), &JsValue::from_str(name));
let _ = Reflect::set(&params, &"bytes".into(), &Uint8Array::from(bytes).into());
if !dispatch_event(IMPORT, next_id(), &params) {
log::warn!("OneCAD Simulation: could not mirror \"{name}\" into the Assets folder (bridge not ready)");
}
}
fn is_asset_name(name: &str) -> bool {
Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ASSET_EXTS.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
}
/// The shell's per-frame drain (tagged call site in `web.rs::Shell::logic`):
/// completes queued picks and answers the glue's API requests.
pub fn drain(app: &mut ModelApp) {
let work: Vec<Completion> = COMPLETED.with(|c| std::mem::take(&mut *c.borrow_mut()));
if !work.is_empty() {
log::info!("OneCAD Simulation: drain {} queued item(s)", work.len());
}
for completion in work {
match completion {
Completion::Open(path) => app.open_path(&path),
Completion::SaveAs(path) => {
if let Err(e) = app.session.execute("file.save_as", &serde_json::json!({"path": path})) {
log::warn!("OneCAD Simulation: the save-as to \"{path}\" failed: {e}");
}
}
Completion::Bytes(id) => match app.session.file_bytes(None) {
Ok(bytes) => complete_bytes(id, &bytes),
Err(e) => BYTES_WAITING.with(|b| {
if let Some(reject) = b.borrow_mut().remove(&id) {
let _ = reject.call1(&JsValue::UNDEFINED, &JsValue::from_str(&format!("{e}")));
}
}),
},
Completion::Info(id) => {
let name = app
.session
.path
.as_deref()
.map(|p| sanitize(p.rsplit('/').next().unwrap_or(p)))
.filter(|n| !n.is_empty());
complete_info(id, name);
}
}
}
}
fn complete_bytes(id: u64, bytes: &[u8]) {
if let Some(resolve) = BYTES_WAITING.with(|b| b.borrow_mut().remove(&id)) {
let _ = resolve.call1(&JsValue::UNDEFINED, &Uint8Array::from(bytes).into());
}
}
fn complete_info(id: u64, name: Option<String>) {
if let Some(resolve) = INFO_WAITING.with(|b| b.borrow_mut().remove(&id)) {
let info = Object::new();
let _ = Reflect::set(&info, &"name".into(), &name.as_deref().map(JsValue::from_str).unwrap_or(JsValue::NULL));
let _ = resolve.call1(&JsValue::UNDEFINED, &info.into());
}
}
/// Install the JS→Rust seams once:
///
/// - `window.onecadsimOdoo = { bootFile }` — the staging seam for deep links
/// (`?document=<id>`): the glue inserts the record's bytes into the page
/// filesystem, then opens the returned path via `window.onecadsim.request`.
/// - `window.onecadsim = { request }` — the promise-based API the glue drives
/// (the OneCAD fork's `api.rs` equivalent, kept minimal).
fn install_seams(ctx: egui::Context) {
install_boot_seam();
install_api(ctx);
}
fn install_boot_seam() {
if BOOT.with(Cell::get) {
return;
}
let Some(window) = web_sys::window() else { return };
let boot_file = Closure::<dyn Fn(JsValue, JsValue, JsValue) -> JsValue>::new(
|record_id: JsValue, name: JsValue, bytes: JsValue| {
let id = record_id.as_f64().map(|f| f as u64).unwrap_or(0);
if id == 0 {
return JsValue::UNDEFINED;
}
let name = name.as_string().unwrap_or_else(|| "document".into());
let Ok(bytes) = bytes.dyn_into::<Uint8Array>() else { return JsValue::UNDEFINED };
match stage_record(id, Some(&name), bytes.to_vec()) {
Some(path) => JsValue::from_str(&path),
None => JsValue::UNDEFINED,
}
},
);
let glue = Object::new();
let _ = Reflect::set(&glue, &"bootFile".into(), &boot_file.into_js_value());
if Reflect::set(&window, &"onecadsimOdoo".into(), &glue).is_ok() {
BOOT.with(|b| b.set(true));
}
}
fn install_api(ctx: egui::Context) {
if API.with(Cell::get) {
return;
}
let Some(window) = web_sys::window() else { return };
let request = Closure::<dyn Fn(JsValue, JsValue) -> JsValue>::new(move |method: JsValue, params: JsValue| {
let method = method.as_string().unwrap_or_default();
let ctx = ctx.clone();
js_sys::Promise::new(&mut move |resolve, reject| match method.as_str() {
// Open the staged deep-link path on the shell's next frame.
"ui.file" => {
let path = Reflect::get(&params, &"path".into()).ok().and_then(|v| v.as_string());
match path {
Some(path) if path.starts_with("/odoo/") => {
COMPLETED.with(|c| c.borrow_mut().push(Completion::Open(path)));
ctx.request_repaint();
let _ = resolve.call1(&JsValue::UNDEFINED, &JsValue::NULL);
}
_ => {
let _ = reject.call1(&JsValue::UNDEFINED, &JsValue::from_str("ui.file: an /odoo/ path is required"));
}
}
}
// The current model's bytes, resolved from the shell's frame drain.
"odoo.document.bytes" => {
let id = next_id();
BYTES_WAITING.with(|b| b.borrow_mut().insert(id, resolve));
COMPLETED.with(|c| c.borrow_mut().push(Completion::Bytes(id)));
ctx.request_repaint();
}
// The current model's file name (best effort), same drain.
"odoo.document.info" => {
let id = next_id();
INFO_WAITING.with(|b| b.borrow_mut().insert(id, resolve));
COMPLETED.with(|c| c.borrow_mut().push(Completion::Info(id)));
ctx.request_repaint();
}
other => {
let _ = reject.call1(&JsValue::UNDEFINED, &JsValue::from_str(format!("onecadsim: unknown method \"{other}\"").as_str()));
}
})
.into()
});
let api = Object::new();
let _ = Reflect::set(&api, &"request".into(), &request.into_js_value());
if Reflect::set(&window, &"onecadsim".into(), &api).is_ok() {
API.with(|a| a.set(true));
}
}