// (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary. // OneCAD Draft Odoo Documents glue (ported from the OneCAD Simulation glue — // apps/fieldcraft-web/src/odoo/index.ts; boot ordering laws are unchanged). /** * OneCAD Draft odoo/index.ts — the boot glue (bridge <-> explorer <-> wasm app). * ---------------------------------------------------------------------- * Entry module injected by the addon shell ONLY inside the * wasm_app_framework iframe (standalone runs never import it — see the * import guard documented in README-odoo.md). Responsibilities: * * 1. instantiate the bridge singleton and wire the explorer dialogs * ("saveAs" / "open" / "export" / "importAsset" host-request methods); * 2. BOOT GATE: waitForHost() (ping every 100 ms, up to 20 s) — then, if * location.search carries ?document=, read that record BEFORE the * app is signaled ready (deep-link open, "onecaddraft:document-opened"); * 3. signal "onecaddraft:host-ready" {host, documentId, name} exactly once. * The same state stays readable synchronously at window.onecaddraftHost * (late-booting Rust) together with consumePendingOpen(), which hands * the deep-link payload {documentId, name, dataB64} over once. * * Deep-link staging (this app's adaptation): instead of a Rust-installed * bootFile seam the record bytes are pushed into the app through its * JS-accessible open entry points, whichever exists first — * primary : wasmBindings.ocs_web_drop_file(name, bytes) (src/io/web_drop.rs; * binary-safe, drained by the 50 ms Message::PollWebControl poll * guarded by opening.is_none()) — with a window.ocs_web_drop_file * global accepted as an alias; * fallback: the control channel {"op":"open","name","data_base64"} via * wasmBindings.ocs_control_submit/take (src/app/control/mod.rs), * following accepted/running replies through op:"operation" and * retrying the "busy" answer while another drawing opens. * The wasm module can boot before OR after this glue (iced boots fast), so * the resolver polls for it up to 30 s before giving up. On success the * ?document= parameter is cleared from the iframe URL (the ?v= cache buster * the framework adds is preserved). * * Standalone dev mode: no host frame — no waiting, no deep link, the ready * signal still fires with host:false so the app boots untouched. * * Build note: erasable-syntax-only TS compiled by the OneCAD Draft addon * build (esbuild) to odoo/index.js next to bridge.js; explorer.js is a * plain-JS module copied verbatim. The "?v=" literals below are the glue * version (ODOO_GLUE_VERSION in bridge.ts) and are bumped in lockstep with * the addon's bundleVersion — see README-odoo.md. Exports nothing. */ // @ts-ignore — the ?v= cache-buster specifier is resolved by the esbuild // build (and by the browser); tsc's bundler resolution cannot see it. import { bytesToBase64, exportForHost, EVT_HOST_READY } from "./bridge.js?v=20.0.1.0.0"; // @ts-ignore — explorer.js is a sibling plain-JS ES module (copied verbatim // into dist; it imports the compiled bridge.js at runtime). import { openDocumentsExplorer } from "./explorer.js?v=20.0.1.0.0"; import type { DocumentOpenedDetail, ExplorerOpener, HostReadyDetail } from "./bridge.js"; const bridge = exportForHost(); bridge.setExplorerOpener(openDocumentsExplorer as ExplorerOpener); /* Late-boot contract surface (Rust polls this synchronously-readable state * until the glue flags itself ready; the glue — not Rust — owns the flag). */ interface OneCadHostWindow { onecaddraftHostReady?: boolean; onecaddraftHost?: { ready: boolean; host: boolean; documentId: number | null; name: string | null; consumePendingOpen: () => DocumentOpenedDetail | null; }; /** Reserved: the bridge's appSerialize/appInfo facade. OneCAD Draft v1 * does not install it on the wasm side (opens stage through the drop * resolver below; saves flow through the bridge's save hook) — the * bridge tolerates its absence. */ onecaddraft?: { request?: (method: string, params?: unknown) => Promise }; /** Reserved: a future Rust-side staging seam (FieldCraft parity). Not * installed by this app; the resolver never depends on it. */ onecaddraftOdoo?: { bootFile?: (recordId: number, name: string, bytes: Uint8Array) => string | undefined; }; /** Trunk publishes the wasm-bindgen module here (docs/automation/web.md). */ wasmBindings?: { ocs_web_drop_file?: (name: string, bytes: Uint8Array) => void; ocs_control_submit?: (request: string) => string; ocs_control_take?: (ticket: string) => string | undefined; }; /** Alias accepted by the resolver: a host page may expose the drop entry * as a plain global instead of on the wasm-bindgen module object. */ ocs_web_drop_file?: (name: string, bytes: Uint8Array) => void; } const hostWindow = window as unknown as OneCadHostWindow; hostWindow.onecaddraftHost = { ready: false, host: false, documentId: null, name: null, consumePendingOpen: () => bridge.consumePendingOpen(), }; /* The Rust odoo bridge polls window.onecaddraftHostReady (a plain boolean) * before dispatching odoo.pickOpen / odoo.pickSave / odoo.write to this glue * and retries until it is set. Set it as soon as the boot attempt finishes * (host answered or not): every Rust picker parks its slot behind this flag, * so an unset flag would wedge the app's file flows for REQUEST_TIMEOUT * (300 s) with no dialog on screen. */ function markHostReady(): void { try { hostWindow.onecaddraftHostReady = true; } catch (error) { console.warn("[onecaddraft-odoo] could not set onecaddraftHostReady:", error); } } /* ------------------------------------------------------------------ */ /* Wasm-module resolver (boots before or after this glue — both fine) */ /* ------------------------------------------------------------------ */ const WASM_WAIT_MS = 30_000; // poll for the wasm module up to 30 s const CONTROL_OPEN_TIMEOUT_MS = 60_000; const POLL_INTERVAL_MS = 100; interface WasmControlApi { ocs_control_submit: (request: string) => string; ocs_control_take: (ticket: string) => string | undefined; } /** The JS-callable open entry points, in resolution order: the drop entry * on the wasm-bindgen module object, then a plain window global. */ function findDropEntry(): ((name: string, bytes: Uint8Array) => void) | null { const bindings = hostWindow.wasmBindings; if (bindings && typeof bindings.ocs_web_drop_file === "function") { return bindings.ocs_web_drop_file; } if (typeof hostWindow.ocs_web_drop_file === "function") { return hostWindow.ocs_web_drop_file; } return null; } function findControlApi(): WasmControlApi | null { const bindings = hostWindow.wasmBindings; return bindings && typeof bindings.ocs_control_submit === "function" && typeof bindings.ocs_control_take === "function" ? (bindings as WasmControlApi) : null; } /** Resolve once either open entry point exists (wasm early OR late). */ async function waitForWasmModule(timeoutMs: number = WASM_WAIT_MS): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (findDropEntry() || findControlApi()) return true; await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } return Boolean(findDropEntry() || findControlApi()); } /** Control-channel open: submit {"op":"open","name","data_base64"} and poll * take() for the reply, following accepted/running through op:"operation" * (docs/automation/web.md). "busy" (another drawing opening) is retried * until the deadline. Returns true only on a final ok reply. */ async function controlOpen(name: string, bytes: Uint8Array): Promise { const api = findControlApi(); if (!api) return false; const submit = (payload: Record): string => api.ocs_control_submit(JSON.stringify(payload)); const dataBase64 = bytesToBase64(bytes); // encode once; retried on busy const deadline = Date.now() + CONTROL_OPEN_TIMEOUT_MS; let ticket = submit({ op: "open", name, data_base64: dataBase64 }); while (Date.now() < deadline) { const raw = api.ocs_control_take(ticket); if (raw === undefined) { await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); continue; } let reply: { ok?: unknown; status?: unknown; code?: unknown; error?: unknown; request_id?: unknown }; try { reply = JSON.parse(raw); } catch { console.warn("[onecaddraft-odoo] control open: unparsable reply"); return false; } const status = typeof reply.status === "string" ? reply.status : ""; if (reply.ok === false) { if (reply.code === "busy") { // Another drawing is still opening; retry once it settles. await new Promise((resolve) => setTimeout(resolve, 500)); ticket = submit({ op: "open", name, data_base64: dataBase64 }); continue; } console.warn( "[onecaddraft-odoo] control open failed:", typeof reply.error === "string" ? reply.error : reply.code, ); return false; } if ((status === "accepted" || status === "running") && typeof reply.request_id === "string") { await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); ticket = api.ocs_control_submit(JSON.stringify({ op: "operation", request_id: reply.request_id })); continue; } return true; // final ok reply } console.warn("[onecaddraft-odoo] control open timed out after " + CONTROL_OPEN_TIMEOUT_MS + " ms"); return false; } /** Stage one drawing's bytes into the running app. Tries the binary-safe * drop entry first, then the control channel; consumes the bytes either * way (the caller drops its copy once this resolves). */ async function stageDrawing(name: string, bytes: Uint8Array): Promise { if (!(await waitForWasmModule())) return false; const drop = findDropEntry(); if (drop) { try { drop(name, bytes); return true; } catch (error) { console.warn( "[onecaddraft-odoo] ocs_web_drop_file failed — trying the control channel:", error instanceof Error ? error.message : error, ); } } return controlOpen(name, bytes); } /* ------------------------------------------------------------------ */ /* Deep link */ /* ------------------------------------------------------------------ */ /** Deep-link opener: stage the record bytes into the app through the wasm * resolver above (the app API appears asynchronously after boot, so the * resolver polls for it). */ async function openStagedDeepLink(entry: DocumentOpenedDetail | null): Promise { if (!entry || typeof entry.dataB64 !== "string") return; const bytes = Uint8Array.from(atob(entry.dataB64), (char) => char.charCodeAt(0)); const staged = await stageDrawing(entry.name || "drawing.dxf", bytes); if (!staged) { console.warn("[onecaddraft-odoo] deep-link open skipped: the wasm module never appeared"); return; } clearDeepLinkQuery(); } /** Drop ?document= from the iframe URL once consumed (?v= and anything else * the framework appended stays). Failure is non-fatal — a stale parameter * would only re-trigger the read on a manual reload. */ function clearDeepLinkQuery(): void { try { const params = new URLSearchParams(window.location.search); if (!params.has("document")) return; params.delete("document"); const query = params.toString(); const url = window.location.pathname + (query ? "?" + query : "") + window.location.hash; window.history.replaceState(window.history.state, "", url); } catch (error) { console.warn("[onecaddraft-odoo] could not clear the deep-link query:", error); } } /* ------------------------------------------------------------------ */ /* Boot */ /* ------------------------------------------------------------------ */ void (async () => { let host = false; if (bridge.inFrame) { try { await bridge.waitForHost(); host = true; } catch (error) { console.warn( "[onecaddraft-odoo] host did not answer in time — continuing without Odoo Documents:", error, ); } } else { console.info("[onecaddraft-odoo] standalone (no host frame) — Odoo Documents disabled"); } /* Unblock the Rust bridge first; the deep-link read continues in the * background and must never keep the file flows hostage. */ markHostReady(); let deepLink = false; if (host) { try { deepLink = await bridge.openDeepLink(); } catch (error) { console.warn("[onecaddraft-odoo] deep-link read failed:", error); } } const binding = bridge.getCurrentBinding(); const detail: HostReadyDetail = { host, documentId: binding.documentId, name: binding.name, }; const state = hostWindow.onecaddraftHost; if (state) { state.ready = true; state.host = host; state.documentId = detail.documentId; state.name = detail.name; } window.dispatchEvent(new CustomEvent(EVT_HOST_READY, { detail })); if (deepLink) { void openStagedDeepLink(bridge.consumePendingOpen()); } })(); export {};