[ADD] OneCAD Draft Odoo integration: Rust odoo_bridge (File>Open via Documents explorer, PollWebControl drain), web/odoo glue trio (save air-gap via openCadStudioSaveFile hook, deep-link via ocs_web_drop_file), CustomEvent web-sys features, BUILD-WASM.md build guide; sandbox-constrained opt-level=0 note in release profile

This commit is contained in:
1b-agent committed 2026-10-19 00:04:16 +00:00
1 parent 193f2ae9ab
commit 62babcb71e
11 files changed
+2823 -6

No files matched your search

+205
View File
@@ -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=<VERSION>` 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 `<body>`:
```html
<script type="module">
try { if (window.parent !== window) { import("./odoo/index.js?v=<VERSION>").catch((e) => console.warn("OneCAD Draft odoo bridge:", e)); } } catch (e) { /* standalone */ }
</script>
```
* 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 `<VERSION>` 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/<rid>/<name>` in place, `/odoo/pending/<token>/<name>` 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
`<a download>` 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=<recordId>` (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:<stem>` | document name (`.dxf`/`.dwg` stripped) → `{recordId, name, updatedAt}` — write-back binding. |
| `one-cad-draft:documents:last-folder:<mode>` | explorer last folder per mode (`save`/`open`/`asset`/`export`). |
## 7. Explorer
Injected one-`<style>` dark CAD palette (`ocd-explorer-style`, `--ocd-*`
vars, `ocd-*` classes), dialog over the canvas. Modes: `save` (tree + name,
`.dxf` chip), `open` (whole drive, files filtered to `dxf`/`dwg`),
`export` (name → Exports). `asset` stays in code for the family contract but
is not surfaced in v1 (its filter set mirrors the Rust asset exts:
`obj`, `landxml` — LANDXMLIMPORT is wired in `src/app/commands/display.rs`).
## 8. Assembler checklist
1. esbuild-compile `index.ts`/`bridge.ts` (esm, es2022) next to `explorer.js`.
2. Bump `ODOO_GLUE_VERSION` + `?v=` literals + injection snippet +
`bundleVersion` + manifest version (§1).
3. Rewrite the built `index.html` (strip SW registration, `/app/` links) and
inject the §2 snippet after the loading markup.
4. Verify gate: no absolute refs, no SW, `odoo/index.js` + `odoo/bridge.js` +
`odoo/explorer.js` present in `static/onecaddraft/odoo/`.
5. Rust side (main agent's odoo bridge module) must: dispatch
`onecaddraft:host-request` (payload or params), listen for
`onecaddraft:host-response`, poll `window.onecaddraftHostReady` until true
before dispatching `odoo.*` methods, and never set the flag itself.
## 9. Deviations from the FieldCraft glue (all intentional)
1. **Save flow replaces `services_odoo.rs`'s take_writes drain**: this app has
no Rust services layer; `sys.rs::download_bytes` already honors a page
hook, so the air gap is the `openCadStudioSaveFile` bridge hook (§3.3).
2. **Deep-link staging replaces `onecadsimOdoo.bootFile` + `ui.file`**: no
such Rust seams exist here; the resolver uses `ocs_web_drop_file`
(primary) / control `open` (fallback) and clears `?document=` afterwards.
3. **`appSerialize` control-channel fallback**: `window.onecaddraft` is not
installed in v1, so the no-bytes save/export path serializes via control
`{"op":"save","allow_lossy":true}` (reply `data_base64`).
4. **Extensions/identity**: `.fieldcraft`→`.dxf`/`.dwg` ownership,
`RUST_ASSET_EXTS = ["obj","landxml"]` (landxml import is wired in src),
bind prefix `one-cad-draft:draft:bind:`, tags `one-cad-draft(-host)`,
events `onecaddraft:*`, style prefix `ocd-`.
5. **`ODOO_GLUE_VERSION` const** added (docs/tooling anchor; import
specifiers stay literal for the assembler's lockstep sed).