forked from 1b-agent/fieldcraft-upstream
709 lines
32 KiB
Rust
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(¶ms, &"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(¶ms, &"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(¶ms, &"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, ¶ms) {
|
|
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(¶ms, &"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(¶ms, &"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(¶ms, &"name".into(), &JsValue::from_str(&name));
|
|
SAVE_EXPORT
|
|
};
|
|
let _ = Reflect::set(¶ms, &"bytes".into(), &Uint8Array::from(bytes).into());
|
|
if !dispatch_event(method, next_id(), ¶ms) {
|
|
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(¶ms, &"name".into(), &JsValue::from_str(name));
|
|
let _ = Reflect::set(¶ms, &"bytes".into(), &Uint8Array::from(bytes).into());
|
|
if !dispatch_event(IMPORT, next_id(), ¶ms) {
|
|
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(¶ms, &"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));
|
|
}
|
|
}
|
|
|