Files
OneCAD-Draft/web/odoo/index.ts
T

313 lines
13 KiB
TypeScript

// (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 {};