diff --git a/BUILD-WASM.md b/BUILD-WASM.md new file mode 100644 index 000000000..f72157d27 --- /dev/null +++ b/BUILD-WASM.md @@ -0,0 +1,77 @@ +# Building the OneCAD Draft wasm bundle (`static/onecaddraft`) + +The complete source state of this repository (rebrand + Odoo glue + Rust +bridge) is ready to build. **The wasm compile of the app crate (640k lines of +Rust across 789 files) needs ~4.5 GB RAM for rustc alone** — it OOM-kills in +a 4 GB cgroup (verified twice with a memory monitor: rustc peaks at +~3.7 GB and grows past the ceiling during metadata encoding). Any machine +with **8 GB+ free RAM** and ~3 GB free disk completes it. + +## Build on a roomy machine (the one path that works end-to-end) + +```bash +# 0) deps: rustup target add wasm32-unknown-unknown; cargo install --locked trunk +# wasm-bindgen-cli MUST match Cargo.lock (grep wasm-bindgen Cargo.lock) + +# 1) OPTIONAL (recommended for production quality): restore opt level +# In Cargo.toml [profile.release] remove the opt-level = 0 line added for +# the constrained sandbox build. Upstream default (opt 3) is best for CAD. + +# 2) build (trunk reads Trunk.toml: target=web-app.html, dist=dist/app, +# and runs the [[hooks]] post_build script that builds + wasm-binds +# the ocs_web_worker cdylib automatically) +trunk build --release + +# 3) assemble + deploy + version lockstep (compiles web/odoo/*.ts with +# esbuild, strips sw.js/app.webmanifest, injects the odoo boot snippet, +# deploys to core-components/one_cad_draft/static/onecaddraft/) +python3 scripts/assemble_onecaddraft_dist.py # on the one-os-dev sandbox + +# 4) install/upgrade the module +python3 scripts/onecaddraft-install-rpc.py # XML-RPC 127.0.0.1:18070 db=oneos +``` + +`assemble_onecaddraft_dist.py` gates: REQUIRED `index.html`, +`OpenCADStudio.js`, `OpenCADStudio_bg.wasm`, `worker_pkg/ocs_web_worker.js`, +`worker_pkg/ocs_web_worker_bg.wasm`, `ocs-parse-worker.js`, `ocs-control.js`, +`locale-labels.json`, non-empty `fonts/`; FORBIDDEN `sw.js`, +`app.webmanifest` (stripped with a loud log); no absolute `/app/`-prefixed +asset refs; version lockstep across `bundleVersion`, `__manifest__.py` and +the glue `?v=` literals. + +## Sandbox lessons that cost us hours (do not re-learn) + +1. **`?v=` bump per build.** Odoo serves statics with 7-day max-age; the + assembler bumps everything in lockstep. Hard-refresh the browser once. +2. **wasm-bindgen stack OOM** when the Odoo stack is up: `oneos-dev down` + before bindgen, `up` after (the assembler does not build, so this only + matters for manual bindgen runs). +3. **NEVER `wasm-tools strip -a`** (destroys `__wasm_bindgen_unstable`). + Use `wasm-opt --strip-dwarf` then `-O1`/`-O2` if hand-optimizing; trunk + already runs the wasm-opt pass requested in the HTML tag. +4. **Overlay rootfs**: only runtime-layer deletions free space; build + scratch must be deleted eagerly (`target/release/deps` re-fills on + resume — deleting it buys nothing net). +5. **`NO_COLOR=1` breaks trunk's clap parser** (`--no-color` expects + true/false): run trunk with `env -u NO_COLOR`. +6. **`cargo check` creates a full second (dev-profile) tree** — on a + constrained disk it IS the disk-full event. Don't check; build. +7. **Orphan sweep**: profile-era changes leave stale-hash artifacts forever; + `scripts/prune-orphans.py` (in the one-os-dev sandbox) reclaims them. +8. **Background processes die between tool calls** in the z.ai sandbox — + run long builds in ≤9-minute foreground chunks; cargo resumes + incrementally. + +## What is already wired (no further source work needed) + +- `web/odoo/{index.ts,bridge.ts,explorer.js}` — the Documents glue (save + air-gap via `window.openCadStudioSaveFile`, deep-link opens via + `wasmBindings.ocs_web_drop_file`, explorer dialogs, bind storage). +- `src/odoo_bridge.rs` — Rust bridge: `File > Open` in the embedded shell + dispatches `onecaddraft:host-request {method:"odoo.pickOpen"}` and drains + completions in `Message::PollWebControl` (50 ms tick) into + `open_web_bytes`. Never touches `window.onecaddraftHostReady`. +- `src/app/update/file.rs` (intercept) + `src/app/update/mod.rs` (drain) + + `src/lib.rs` (module decl) + `Cargo.toml` (CustomEvent features). +- `core-components/one_cad_draft/` in one-os-dev: the 5-file addon + (storage seq 52, menu seq 37, extensions dxf/dwg, fileExtension .dxf). diff --git a/Cargo.toml b/Cargo.toml index e52c2d179..2f3f30685 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -191,6 +191,9 @@ web-sys = { version = "=0.3.85", features = [ "DataTransfer", "FileList", "Event", + # OneBSpace (OneCAD Draft): Odoo bridge CustomEvents (web/odoo/ glue). + "CustomEvent", + "CustomEventInit", "ReadableStreamDefaultReader", "ReadableStream", # The ?file= boot deep link reads location.search and compares the link's @@ -209,6 +212,8 @@ opt-level = 2 debug = 1 [profile.release] +# OneBSpace (OneCAD Draft): disk-constrained sandbox build; rebuild at opt=3 in a roomy env. +opt-level = 0 strip = true [[bench]] diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 57c5bf577..95a1378f7 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -70,14 +70,14 @@ impl OpenCADStudio { pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option> { match cmd { // OneBSpace (OneCAD Draft): donations disabled — air-gapped build. - "DONATE" => Some(Task::none()), + "DONATE" => (), // OneBSpace (OneCAD Draft): the upstream web-app link is gone. - "WEBVERSION" => Some(Task::none()), + "WEBVERSION" => (), // OneBSpace (OneCAD Draft): external help/discussions link removed — // air-gapped build. Use the in-app help instead. - "HELP" => Some(Task::none()), + "HELP" => (), // ── DWGPROPS — print round-trip-only HeaderVariables ───────── // No UI dialog for these yet; the command surfaces them so @@ -301,7 +301,7 @@ impl OpenCADStudio { // OneBSpace (OneCAD Draft): the upstream GitHub issue tracker is // unreachable from an air-gapped build; feedback is a no-op. - "REPORT" => Some(Task::none()), + "REPORT" => (), "ABOUT" => { return Some(Task::done(Message::AboutOpen)); @@ -313,7 +313,7 @@ impl OpenCADStudio { // OneBSpace (OneCAD Draft): upstream release notes are unreachable // from an air-gapped build. - "CHANGELOG" => Some(Task::none()), + "CHANGELOG" => (), // ── ALIASEDIT — command-alias editor ─────────────────────────── // Opens the command-alias table editor (ocad.pgp). Keyboard diff --git a/src/app/update/file.rs b/src/app/update/file.rs index cfda5a9cc..b2891e1d4 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1619,6 +1619,12 @@ impl OpenCADStudio { } #[cfg(target_arch = "wasm32")] { + // OneBSpace (OneCAD Draft): embedded in the Odoo Documents shell, + // File > Open is served by the host's Documents explorer + // (`odoo.pickOpen`, web/odoo/ glue) instead of the browser picker. + if crate::odoo_bridge::intercept_open() { + return Task::none(); + } // The browser picker + parse happen inside `pick_and_load_web`; // the real name is unknown until then, so show a generic label // meanwhile. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 4ae0e87a6..524a49a16 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -954,7 +954,13 @@ impl OpenCADStudio { for note in crate::scene::model::image_model::take_decode_refusals() { self.command_line.push_error(¬e); } - Task::batch([control, dropped]) + // OneBSpace (OneCAD Draft): drawings picked in the Odoo + // Documents explorer land in the bridge queue; deliver them + // through the same open path as a browser drop. + let odoo_open = crate::odoo_bridge::take_completed_open() + .map(|(name, bytes)| self.open_web_bytes(name, bytes)) + .unwrap_or_else(Task::none); + Task::batch([control, dropped, odoo_open]) } #[cfg(not(target_arch = "wasm32"))] Message::PollWebControl => Task::none(), diff --git a/src/lib.rs b/src/lib.rs index 8c097b908..201b04ab6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,9 @@ pub mod mcp_ops; pub mod modules; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod network; +// OneBSpace (OneCAD Draft): Odoo Documents bridge (web/odoo/ glue, wasm only). +#[cfg(target_arch = "wasm32")] +pub mod odoo_bridge; pub mod par; pub mod patreon; pub mod perf; diff --git a/src/odoo_bridge.rs b/src/odoo_bridge.rs new file mode 100644 index 000000000..918b3942a --- /dev/null +++ b/src/odoo_bridge.rs @@ -0,0 +1,302 @@ +//! OneBSpace (OneCAD Draft): Odoo Documents bridge — Rust side. +//! +//! Wires the app's `File > Open` flow to the OneBSpace Documents explorer +//! while the studio runs embedded in the `one_cad_draft` Odoo addon iframe +//! (wasm_app_framework). The JS half of this bridge lives in `web/odoo/` +//! (see `web/odoo/README-odoo.md` for the full wire contract). +//! +//! Contract summary (README-odoo.md §3.2 / §8.5): +//! - This module DISPATCHES `onecaddraft:host-request` CustomEvents +//! `{id, method, payload}` on the window and LISTENS for +//! `onecaddraft:host-response` `{id, ok, result?, error?}`. +//! - The glue sets `window.onecaddraftHostReady = true` once its response +//! listener is attached. This side polls/retires until the flag shows up; +//! it NEVER sets or clears the flag itself. +//! - Saves/exports are air-gapped WITHOUT this module: `sys.rs::download_bytes` +//! already prefers the page hook `window.openCadStudioSaveFile(name, bytes)` +//! registered by the glue, so bytes can never reach a browser download. +//! Deep-link opens (`?document=`) are staged by the glue through +//! `wasmBindings.ocs_web_drop_file` and drained by `Message::PollWebControl` +//! like any other drop. The ONLY thing Rust must provide is routing the +//! in-app `File > Open` through the host explorer (`odoo.pickOpen`). +//! +//! Call sites (both `#[cfg(target_arch = "wasm32")]`): +//! - `app::update::file::on_open_file` -> [`intercept_open`] +//! - `app::update::Message::PollWebControl` -> [`take_completed_open`] + +use std::cell::{Cell, RefCell}; + +use base64::Engine as _; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; + +/// CustomEvent the glue listens on (Rust -> glue requests). +const REQUEST_EVENT: &str = "onecaddraft:host-request"; +/// CustomEvent this module listens on (glue -> Rust responses). +const RESPONSE_EVENT: &str = "onecaddraft:host-response"; +/// Host method used for `File > Open` (answered by the Documents explorer). +const PICK_METHOD: &str = "odoo.pickOpen"; +/// Extensions offered to the host explorer for open (owned drawing types). +const PICK_EXTENSIONS: &[&str] = &["dxf", "dwg"]; +/// The poll tick (`Message::PollWebControl`) fires every 50 ms; re-dispatch +/// a still-pending pick request every 5th tick (250 ms) until the glue is up. +const RETRY_EVERY_TICKS: u32 = 5; +/// Give the glue ~30 s to appear (600 x 50 ms) before dropping the request. +const HOST_DEADLINE_TICKS: u32 = 600; + +/// A drawing the host explorer handed back, ready to be opened. +struct OpenCompletion { + name: String, + bytes: Vec, +} + +/// A `File > Open` request waiting for the glue (and the host) to answer. +struct PendingPick { + id: u64, + ticks: u32, +} + +thread_local! { + static EMBEDDED: Cell> = const { Cell::new(None) }; + static NEXT_ID: Cell = const { Cell::new(1) }; + static PENDING: RefCell> = const { RefCell::new(None) }; + static COMPLETED: RefCell> = const { RefCell::new(Vec::new()) }; + static LISTENER_INSTALLED: Cell = const { Cell::new(false) }; + static LISTENER_CLOSURE: RefCell>> = + const { RefCell::new(None) }; + static ANNOUNCED: Cell = const { Cell::new(false) }; +} + +/// True while the studio runs inside the OneCAD Draft Odoo iframe. +/// +/// Same-origin frame whose parent lives under the Odoo shell paths, or a +/// `?odoo-host=1` escape hatch for testing. Cached after the first probe. +pub fn embedded() -> bool { + EMBEDDED.with(|cell| { + if let Some(v) = cell.get() { + return v; + } + let v = detect_embedded(); + cell.set(Some(v)); + v + }) +} + +fn detect_embedded() -> bool { + let Some(window) = web_sys::window() else { + return false; + }; + // Forced embedding (testing / unusual host layouts). + if let Ok(search) = window.location().search() { + if search.contains("odoo-host=1") { + return true; + } + } + // Framed? (a top-level window is its own parent) + let Ok(Some(parent)) = window.parent() else { + return false; + }; + let parent_val: &JsValue = parent.as_ref(); + let window_val: &JsValue = window.as_ref(); + let framed = parent_val != window_val; + if !framed { + return false; + } + // Same-origin parent under the Odoo shell? A cross-origin parent throws + // on `location.pathname` access, which reads as "not our host". + let path = match parent.location().pathname() { + Ok(path) => path, + Err(_) => return false, + }; + path.starts_with("/odoo") || path.starts_with("/web") +} + +/// Route an in-app `File > Open` through the host's Documents explorer. +/// +/// Called from `on_open_file`'s wasm32 arm instead of the browser picker. +/// Returns `true` when the request is handled by the host (the caller must +/// then not open the native browser picker), `false` when the app is not +/// embedded and the stock picker should run. +pub fn intercept_open() -> bool { + if !embedded() { + return false; + } + announce(); + install_listener(); + let already_pending = PENDING.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.is_some() { + return true; + } + let id = NEXT_ID.with(|n| { + let id = n.get(); + n.set(id + 1); + id + }); + *slot = Some(PendingPick { id, ticks: 0 }); + false + }); + if already_pending { + // A pick dialog is already open on the host; swallow the repeat. + return true; + } + dispatch_pick_request(PENDING.with(|slot| slot.borrow().as_ref().map(|p| p.id))); + true +} + +/// Poll-side hook: retries pending pick requests while the glue boots and +/// pops the next completed open, if any. Runs once per `PollWebControl` +/// (every 50 ms on web). +pub fn take_completed_open() -> Option<(String, Vec)> { + if !embedded() { + return None; + } + // Retry / expire the pending pick request. + let fire = PENDING.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(pending) = slot.as_mut() else { + return false; + }; + pending.ticks += 1; + if pending.ticks > HOST_DEADLINE_TICKS { + warn_log("OneCAD Draft: Odoo shell never answered the open request; giving up."); + *slot = None; + return false; + } + pending.ticks % RETRY_EVERY_TICKS == 0 + }); + if fire { + dispatch_pick_request(PENDING.with(|slot| slot.borrow().as_ref().map(|p| p.id))); + } + COMPLETED.with(|queue| queue.borrow_mut().pop()).map(|o| (o.name, o.bytes)) +} + +/// Dispatch `odoo.pickOpen` to the glue (silently skipped until the glue +/// signals `window.onecaddraftHostReady`). +fn dispatch_pick_request(id: Option) { + let Some(id) = id else { return }; + let Some(window) = web_sys::window() else { + return; + }; + if !host_ready(&window) { + return; + } + let detail = js_sys::Object::new(); + let _ = js_sys::Reflect::set(&detail, &JsValue::from_str("id"), &JsValue::from(id)); + let _ = js_sys::Reflect::set( + &detail, + &JsValue::from_str("method"), + &JsValue::from_str(PICK_METHOD), + ); + let payload = js_sys::Object::new(); + let extensions = js_sys::Array::new(); + for ext in PICK_EXTENSIONS { + extensions.push(&JsValue::from_str(ext)); + } + let _ = js_sys::Reflect::set(&payload, &JsValue::from_str("extensions"), &extensions.into()); + let _ = js_sys::Reflect::set(&detail, &JsValue::from_str("payload"), &payload.into()); + + let init = web_sys::CustomEventInit::new(); + init.set_detail(&detail.into()); + if let Ok(event) = web_sys::CustomEvent::new_with_event_init_dict(REQUEST_EVENT, &init) { + let _ = window.dispatch_event(&event); + } +} + +/// Attach the `onecaddraft:host-response` listener exactly once. +fn install_listener() { + if LISTENER_INSTALLED.get() { + return; + } + let Some(window) = web_sys::window() else { + return; + }; + let closure = Closure::::new(|event: JsValue| handle_response(event)); + let target: &web_sys::EventTarget = window.unchecked_ref(); + let _ = target.add_event_listener_with_callback( + RESPONSE_EVENT, + closure.as_ref().unchecked_ref(), + ); + LISTENER_CLOSURE.with(|cell| { + cell.borrow_mut().replace(closure); + }); + LISTENER_INSTALLED.set(true); +} + +/// Consume one glue response for an outstanding `odoo.pickOpen` request. +fn handle_response(event: JsValue) { + let Ok(event) = event.dyn_into::() else { + return; + }; + let detail = event.detail(); + let key = JsValue::from_str("id"); + let Some(id) = js_sys::Reflect::get(&detail, &key) + .ok() + .and_then(|v| v.as_f64()) + .map(|f| f as u64) + else { + return; + }; + // Only consume responses matching OUR pending request: the glue answers + // every request exactly once, and nothing else shares this channel. + let ours = PENDING.with(|slot| slot.borrow().as_ref().map(|p| p.id) == Some(id)); + if !ours { + return; + } + PENDING.with(|slot| *slot.borrow_mut() = None); + + let ok = js_sys::Reflect::get(&detail, &JsValue::from_str("ok")) + .map(|v| v.is_truthy()) + .unwrap_or(false); + let result = js_sys::Reflect::get(&detail, &JsValue::from_str("result")) + .unwrap_or(JsValue::UNDEFINED); + if !ok || !result.is_object() { + // `null` result = user cancelled in the Documents explorer. + info_log("OneCAD Draft: open cancelled in the Documents explorer."); + return; + } + let name = js_sys::Reflect::get(&result, &JsValue::from_str("name")) + .ok() + .and_then(|v| v.as_string()); + let bytes_value = + js_sys::Reflect::get(&result, &JsValue::from_str("bytes")).unwrap_or(JsValue::UNDEFINED); + let bytes: Option> = if let Ok(array) = bytes_value.dyn_into::() { + Some(array.to_vec()) + } else { + // Fallback: base64 `content` (kept from the family wire contract). + let content = js_sys::Reflect::get(&result, &JsValue::from_str("content")) + .ok() + .and_then(|v| v.as_string()); + content.and_then(|c| base64::engine::general_purpose::STANDARD.decode(c).ok()) + }; + match (name, bytes) { + (Some(name), Some(bytes)) => { + COMPLETED.with(|queue| { + queue.borrow_mut().push(OpenCompletion { name, bytes }); + }); + } + _ => warn_log("OneCAD Draft: malformed open response from the Odoo shell."), + } +} + +fn host_ready(window: &web_sys::Window) -> bool { + js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("onecaddraftHostReady")) + .map(|v| v.is_truthy()) + .unwrap_or(false) +} + +fn announce() { + if ANNOUNCED.get() { + return; + } + ANNOUNCED.set(true); + info_log("OneCAD Draft: Odoo shell detected — File > Open is served by Documents."); +} + +fn info_log(message: &str) { + web_sys::console::log_1(&JsValue::from_str(message)); +} + +fn warn_log(message: &str) { + web_sys::console::warn_1(&JsValue::from_str(message)); +} diff --git a/web/odoo/README-odoo.md b/web/odoo/README-odoo.md new file mode 100644 index 000000000..49cca56ec --- /dev/null +++ b/web/odoo/README-odoo.md @@ -0,0 +1,205 @@ +# OneCAD Draft — Odoo Documents glue (`web/odoo/`) + +Integration contract for the addon assembler and future maintainers. This trio +is the Odoo Documents host glue for the OpenCADStudio fork (`one_cad_draft` +addon, wasm_app_framework iframe). It is a structural 1:1 port of the proven +FieldCraft glue (`OneCAD-Simulation/apps/fieldcraft-web/src/odoo/` — see +worklog tasks 4-c / 6-b), adapted to this app's iced/wasm seams. The host wire +protocol is **unchanged** from the family contract. + +| File | Role | +|---|---| +| `index.ts` | Boot glue: bridge singleton + explorer wiring, host ping gate, `?document=` deep link, wasm-module resolver, `host-ready` signalling. | +| `bridge.ts` | Bridge client (postMessage to the framework shell) + Rust facade (CustomEvents) + save air-gap hook + control-channel helpers. | +| `explorer.js` | Vanilla-JS Documents explorer dialogs (`save` / `open` / `export`; `asset` kept for the family contract, not surfaced in v1). Plain JS — never compiled. | + +--- + +## 1. Version constants (bump in LOCKSTEP) + +``` +ODOO_GLUE_VERSION (bridge.ts) = "20.0.1.0.0" +?v= literal in index.ts = "./bridge.js?v=20.0.1.0.0", "./explorer.js?v=20.0.1.0.0" +?v= literal in explorer.js = "./bridge.js?v=20.0.1.0.0" +``` + +Law (family precedent — the 7-day Odoo static max-age makes stale-bundle +failures real): every build bumps **all** of + +1. `ODOO_GLUE_VERSION` and the `?v=` literals inside `index.ts` / `explorer.js` + (they cannot reference the const — import specifiers are static); +2. the `?v=` in the index.html injection snippet below; +3. the addon's `bundleVersion` (registration JS) and `__manifest__.py` version. + +The assembler owns the substitution (sed-style, one value everywhere). `bridge.ts` +exports `ODOO_GLUE_VERSION` as the single source of truth for docs/tooling. + +## 2. index.html injection snippet (assembler; exact) + +Inject into the app's `index.html` (built from `web-app.html`) **after the +loading markup** (after the `#loading` div / splash scripts), inside ``: + +```html + +``` + +* The `window.parent !== window` guard keeps standalone/local runs glue-free + (native downloads, native pickers — the glue is inert by design there too, + but not loading it at all is cheaper). +* Replace `` with the current lockstep version (§1). +* The assembler also rewrites the built page for the addon bundle (strip the + service-worker registration, rewrite `/app/`-absolute asset links to + `/one_cad_draft/static/onecaddraft/`); the snippet must survive that rewrite + as a relative `./odoo/index.js` import. +* Compile step: `npx esbuild web/odoo/index.ts web/odoo/bridge.ts --format=esm + --target=es2022` (erasable-syntax-only TS; no tsc needed) → ship + `odoo/index.js`, `odoo/bridge.js`, `odoo/explorer.js` verbatim. + +**Mount-order tolerance (required):** the glue may load after the wasm boots +(iced boots fast) or before it. The bridge handles glue-first (Rust polls +`window.onecaddraftHostReady`); the deep-link resolver in `index.ts` handles +wasm-first/late by polling the wasm entry points for up to 30 s (§5). + +## 3. Window API surface + +### 3.1 Glue ⇄ Odoo shell (postMessage, same-origin parent) + +``` +request : {source:"one-cad-draft", kind:"odoo-documents", id, method, payload} +reply : {source:"one-cad-draft-host", kind:"odoo-documents", id, ok, result|error:{message}} +``` + +Host methods (implemented by `wasm_app_action.js`): `ping list read write +delete usage folders createFolder listFolder saveExport stat`. +Bulk methods (`read write saveExport`) use a 300 s timeout; all others 60 s. +128 MB raw-byte cap enforced client-side before base64 (host mirrors it). + +### 3.2 Rust facade (window CustomEvents — the Rust odoo bridge module) + +| Event | Direction | Detail | +|---|---|---| +| `onecaddraft:host-request` | Rust → glue | `{id:number, method:string, payload?:object}` — the glue also accepts `params` (alias, kept from the FieldCraft wire). Known host methods pass through; orchestration methods: `saveAs`, `open`, `export`, `importAsset`; Rust-only methods: `odoo.pickOpen`, `odoo.pickProgram`, `odoo.pickSave`, `odoo.write` (`/odoo/doc//` in place, `/odoo/pending//` first save), `odoo.saveExport`, `odoo.import`. FIFO, one at a time, every id answered exactly once. | +| `onecaddraft:host-response` | glue → Rust | `{id, ok, result?, error?{message}}` | +| `onecaddraft:host-ready` | glue → Rust | `{host:boolean, documentId, name}` — fired once after the ping gate and the deep-link read. | +| `onecaddraft:document-opened` | glue → Rust | `{documentId, name, dataB64}` — deep-link record read. | + +**Ready flag law (keep exactly):** the GLUE sets +`window.onecaddraftHostReady = true` once its `host-response` listener is +attached and the boot attempt finished (host answered or timed out) — BEFORE +deep-link consumption. The Rust side POLLS/RETRIES until the flag is set; it +must never set or clear it. Late-booting Rust reads the same state +synchronously at `window.onecaddraftHost.{ready, host, documentId, name}` plus +`consumePendingOpen()` (deep-link payload, once → null). + +`window.onecaddraft` (reserved): `request("odoo.document.bytes" | +"odoo.document.info")` facade the bridge calls when a flow arrives without +bytes. v1 does not install it — the bridge falls back to the control channel +(§5). `window.onecaddraftOdoo.bootFile` (reserved): FieldCraft's Rust staging +seam; not installed here, the resolver never depends on it. + +### 3.3 Save air gap — `window.openCadStudioSaveFile(name, bytes)` + +Registered by `bridge.ts` (constructor, in-frame only). The wasm app already +checks it first: `src/sys.rs::download_bytes` (line 723) calls +`save_hook().call2(&window, &name, &Uint8Array)` and returns before the +`` path, and `src/app/update/file.rs:4154` disables the +File System Access picker while the hook exists. So while the glue is loaded +in the iframe, **bytes can never reach a browser download** — Save, Save As, +save-before-close and every export (PDF/CSV/PNG/ZIP/…) arrive here. + +Routing (bytes are ALWAYS consumed; failures are logged, never downloaded): + +| Case | Action | +|---|---| +| `.dxf`/`.dwg` name, bound record (`localStorage` bind) | in-place write-back `write {recordId, name}`; bind refreshed. | +| `.dxf`/`.dwg` name, unbound | explorer SAVE dialog → `write {name, folderId}` (Projects default via the explorer) → bind → future saves take the write-back path. Cancel = bytes dropped with a console note. | +| anything else (obj/stl/pdf/csv/png/zip/…) | `saveExport` → Documents **Exports** folder. | + +Hook saves are serialized through one promise chain (no stacked dialogs). +Over-cap (>128 MB raw) saves are dropped with a warning (family cap law). + +### 3.4 App entry points used by the glue (verified in src) + +| Seam | Where | Used for | +|---|---|---| +| `wasmBindings.ocs_web_drop_file(name, bytes)` — primary | `src/io/web_drop.rs:116` (`#[wasm_bindgen]`); Trunk publishes the module as `window.wasmBindings` (`docs/automation/web.md`) | deep-link open staging. Binary-safe; drained by the 50 ms `Message::PollWebControl` poll guarded by `opening.is_none()` (`src/app/update/mod.rs:877`). A plain `window.ocs_web_drop_file` global is accepted as an alias. | +| `wasmBindings.ocs_control_submit/take` — fallback | `src/app/control/mod.rs:72/89` | deep-link fallback `{"op":"open","name","data_base64"}` (follows `accepted`/`running` via `{"op":"operation","request_id"}`, retries `code:"busy"`; 60 s) and the no-bytes serialization fallback `{"op":"save","allow_lossy":true}` → reply `data_base64` (`mod.rs:1243`). | +| `window.openCadStudioSaveFile(name, bytes)` | `src/sys.rs:757 save_hook()` | save air gap (§3.3). | + +## 4. Deep link + +The framework opens the iframe with `?document=` (plus its `?v=` +cache buster). Boot order (ordering law, unchanged from FieldCraft): + +1. `waitForHost()` (ping ×100 ms, 20 s) → host up or standalone; +2. `markHostReady()` — unblocks the Rust side first; +3. `bridge.openDeepLink()` reads the record (300 s), stores `pendingOpen`, + fires `onecaddraft:document-opened`, refreshes the binding; +4. `host-ready` event + `window.onecaddraftHost` state; +5. the staged bytes go through the resolver (§5: drop entry, else control + channel), then `?document=` is cleared from the iframe URL + (`history.replaceState`; `?v=` and any other params survive). + +## 5. Resolver / mount-order tolerance + +`stageDrawing(name, bytes)` polls for the wasm module (drop entry OR control +pair) every 100 ms up to **30 s** — covers wasm-before-glue and +glue-before-wasm — then: + +1. `wasmBindings.ocs_web_drop_file(name, bytes)` (or the `window.` global + alias) — primary, binary-safe; +2. else control `{"op":"open", name, data_base64}` — base64 path, `busy` + retried until the deadline. + +Bytes are consumed by whichever path wins; the glue logs and gives up (never +downloads) if neither appears within 30 s. + +## 6. localStorage keys + +| Key | Meaning | +|---|---| +| `one-cad-draft:draft:bind:` | document name (`.dxf`/`.dwg` stripped) → `{recordId, name, updatedAt}` — write-back binding. | +| `one-cad-draft:documents:last-folder:` | explorer last folder per mode (`save`/`open`/`asset`/`export`). | + +## 7. Explorer + +Injected one-`