forked from 1b-agent/opencadstudio-upstream
[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:
1 parent
193f2ae9ab
commit
62babcb71e
11 files changed
+2823
-6
No files matched your search
@@ -0,0 +1,312 @@
|
||||
// (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=<id>, 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<unknown> };
|
||||
/** 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<boolean> {
|
||||
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<boolean> {
|
||||
const api = findControlApi();
|
||||
if (!api) return false;
|
||||
const submit = (payload: Record<string, unknown>): 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<boolean> {
|
||||
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<void> {
|
||||
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<HostReadyDetail>(EVT_HOST_READY, { detail }));
|
||||
if (deepLink) {
|
||||
void openStagedDeepLink(bridge.consumePendingOpen());
|
||||
}
|
||||
})();
|
||||
|
||||
export {};
|
||||
Reference in new issue
Block a user