forked from 1b-agent/opencadstudio-upstream
313 lines
13 KiB
TypeScript
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 {};
|