// (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/bridge.ts; the host wire protocol is unchanged). /** * OneCAD Draft odoo/bridge.ts — the Odoo Documents bridge client + Rust facade. * ----------------------------------------------------------------------- * Port of the family adapter (one-cut-pro apps/web/src/odoo/bridge.ts, * vanilla twin: onepixel frontend/src/odoo-host/bridge.ts) onto the * wasm_app_framework host protocol — authoritative implementation: * one-os-dev/core-components/wasm_app_framework/static/src/wasm_app_action.js * * request : {source: "one-cad-draft", kind: "odoo-documents", id, method, payload} * posted on window.parent (same-origin Odoo shell iframe) * reply : {source: "one-cad-draft-host", kind: "odoo-documents", id, ok, * result | error:{message}} * * Host methods and payload shapes (confirmed against the framework): * ping {} -> {user, folder, app:{root, projects?, assets?, exports?}} * list {} -> {documents:[{id,name,updatedAt,size}]} * read {recordId} -> {name, dataB64} * write {recordId?, name?, dataB64, folderId?} -> {id} * delete {recordId} -> {deleted} * usage {} -> {bytesUsed, documentCount} * folders {} -> {tree:[{id,name,parentId,system}], app:{...app, appsFilesRoot}} * createFolder {name, parentId?} -> {id} * listFolder {folderId} -> {folders:[{id,name,system}], files:[{id,name,ext,mimetype,size,updatedAt}]} * saveExport {name, dataB64, folderId?} -> {id} (folderId omitted -> the app's "exports" subfolder) * stat {recordId} -> {id, name, mimetype} * * Family laws: Documents is the ONLY storage; 60 s calls / 300 s writes; * one correlation id per call; air-gapped (no external fetch ever). * The 128 MB per-record cap is enforced HERE, on the raw bytes, before any * base64 conversion (the host mirrors it on base64 length * 3/4). * * RUST EVENT CONTRACT (window CustomEvents; the Rust odoo bridge module is * built separately and consumes host responses through these): * * LISTEN "onecaddraft:host-request" detail {id:number, method:string, * (Rust -> JS) payload?:object} * One door for everything. Known host * methods (ping/list/read/write/delete/ * usage/folders/createFolder/listFolder/ * saveExport/stat) pass straight through * to the host with their own payload. * Orchestration methods run dialogs + * writes in JS and never hit the host: * "saveAs" {name?, dataB64?, recordId?, bytes?} * "open" {} * "export" {name?, dataB64?, bytes?} * "importAsset" {name?, dataB64?, bytes?} * Bytes may arrive as dataB64 (JSON) or a * Uint8Array in payload.bytes; when absent * the bridge asks the app to serialize * itself via window.onecaddraft (below). * REQUEST QUEUE: entries are processed * strictly FIFO, one at a time, and each * id is answered exactly once. * * EMIT "onecaddraft:host-response" detail {id, ok, result?, error?{message}} * (JS -> Rust) correlate with the request id. Timeouts * surface as ok:false errors (60 s calls, * 300 s write/read/saveExport). * * EMIT "onecaddraft:host-ready" detail {host:boolean, documentId, name} * (JS -> Rust) fired once by the boot glue (odoo/ * index.ts) after waitForHost() and any * ?document= deep-link read. Late-booting * Rust polls the same state sync at * window.onecaddraftHost.{ready,host,documentId,name} * and retries until the glue's boolean * window.onecaddraftHostReady flips true * (the glue — not Rust — sets it, once its * host-response listener is attached). * * EMIT "onecaddraft:document-opened" detail {documentId, name, dataB64} * (JS -> Rust) fired after the deep-link record was * read. The full payload stays available * for late listeners via * window.onecaddraftHost.consumePendingOpen() * (returns the detail once, then null). * * JS -> APP window.onecaddraft.request("odoo.document.bytes") * -> Promise — serialize * the current drawing (used when a save/export carries no * bytes). request("odoo.document.info") -> Promise<{name?}> — * suggested document name; failures are ignored. * OneCAD Draft v1 installs NEITHER on the wasm side: the boot * glue instead stages opens through ocs_web_drop_file (see * index.ts) and saves flow through the save hook (below), so * these two request names stay reserved contract surface. * * SAVE AIR GAP (the key adaptation from the FieldCraft port — this app has * no services_odoo.rs take_writes drain; instead src/sys.rs::download_bytes * already honors a page-installed save hook): * * window.openCadStudioSaveFile(name, bytes) * Called BY the wasm app (sys.rs:723 download_bytes -> save_hook() * -> hook.call2(window, name, Uint8Array)) for EVERY saved or exported * file whenever the hook is defined — Save, Save As, save-before-close * and all exports — and ONLY then (no file picker, no : * bytes can never reach a browser download while the hook exists). * The hook is installed HERE, from the bridge constructor, only when * the app runs inside the framework iframe (standalone keeps native * downloads). Routing: * a. owned extension (.dxf/.dwg) AND a Documents binding exists for * the name -> in-place write-back (odoo write on the bound record). * b. owned extension, NOT bound -> explorer dialog (SAVE mode) -> * create the record in the chosen folder -> bind -> write. * c. any other extension (obj/stl/pdf/csv/…) -> saveExport into the * Documents "Exports" folder. * Every path consumes the bytes (base64-encodes them or drops them with * a console warning); the hook never falls back to a browser download. * Hook saves are serialized through one promise chain so two quick * exports cannot stack two explorer dialogs. * * STANDALONE DEV MODE — when the app is not inside the framework iframe, * the facade goes inert: waitForHost() resolves immediately (host:false), * every host call rejects with a clear "standalone" error, host-request * events are answered ok:false and the save hook is NOT installed — the * app still boots, nothing hangs, downloads keep working. * * This file is erasable-syntax-only TypeScript with ZERO dependencies: the * addon build (esbuild) compiles it to bridge.js next to this file. */ const DEFAULT_TIMEOUT_MS = 60_000; const WRITE_TIMEOUT_MS = 300_000; // large .dxf/.dwg drawings over the bridge const POLL_INTERVAL_MS = 100; const HOST_WAIT_MS = 20_000; // ping every 100 ms for up to 20 s /** Family cap (wasm_app_action.js MAX_DOCUMENT_BYTES), mirrored client-side. */ export const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024; export const SOURCE_TAG = "one-cad-draft"; export const HOST_SOURCE_TAG = "one-cad-draft-host"; export const BRIDGE_KIND = "odoo-documents"; /** Glue bundle version — MUST be bumped in lockstep with the "?v=" literals * in the static import specifiers of index.ts and explorer.js and with the * addon's bundleVersion/manifest version (see README-odoo.md). */ export const ODOO_GLUE_VERSION = "20.0.1.0.0"; /** Window CustomEvent names of the Rust facade contract (see header). */ export const EVT_HOST_REQUEST = "onecaddraft:host-request"; export const EVT_HOST_RESPONSE = "onecaddraft:host-response"; export const EVT_HOST_READY = "onecaddraft:host-ready"; export const EVT_DOCUMENT_OPENED = "onecaddraft:document-opened"; /** window.onecaddraft request names the bridge calls INTO the app. */ export const APP_SERIALIZE_METHOD = "odoo.document.bytes"; export const APP_INFO_METHOD = "odoo.document.info"; export interface OdooAppTree { root: number; projects?: number; assets?: number; exports?: number; appsFilesRoot?: number; } export interface PingResult { user: string; folder: number; app: OdooAppTree; } export interface FolderRow { id: number; name: string; parentId: number | null; system?: boolean; } export interface FileRow { id: number; name: string; ext?: string; mimetype?: string; size: number; updatedAt?: string; } export interface WriteResult { id: number; name?: string; } export interface ReadResult { name: string; dataB64: string; } export interface StatResult { id: number; name: string; mimetype: string; } export interface UsageResult { bytesUsed: number; documentCount: number; } export interface FoldersResult { tree: FolderRow[]; app: OdooAppTree; } export interface ListResult { documents: FileRow[]; } export interface CallOptions { timeoutMs?: number; } export interface BindEntry { recordId: number; name: string; updatedAt: string; } /** The document the app is currently working on (deep link / last save). */ export interface CurrentBinding { documentId: number | null; name: string | null; } export interface ExplorerChoice { name?: string; folderId?: number; recordId?: number; recordName?: string; file?: File; } export type ExplorerOpener = (opts: { mode: "save" | "open" | "asset" | "export"; defaultName?: string; extensions?: string[]; }) => Promise; export interface HostRequestDetail { id: number; method: string; payload?: Record | null; /** The Rust odoo bridge may name the request object `params`; payload is * the glue spelling. runRequest accepts either. */ params?: Record | null; } export interface HostResponseDetail { id: number; ok: boolean; result?: unknown; error?: { message: string } | null; } export interface HostReadyDetail { host: boolean; documentId: number | null; name: string | null; } export interface DocumentOpenedDetail { documentId: number; name: string; dataB64: string; } export interface SaveAsResult { id: number; name: string; folderId: number | null; } export interface OpenResult { id: number; name: string; dataB64: string; } export interface SavedResult { id: number; name: string; } /** Host method names accepted on "onecaddraft:host-request" (pass-through). */ const HOST_METHODS = new Set([ "ping", "list", "read", "write", "delete", "usage", "folders", "createFolder", "listFolder", "saveExport", "stat", ]); /** Host methods that move bulk bytes — family law says 300 s. */ const BULK_METHODS = new Set(["read", "write", "saveExport"]); /** Orchestration methods served by this bridge (dialogs + writes). */ const UI_METHODS = new Set(["saveAs", "open", "export", "importAsset"]); const BIND_KEY_PREFIX = "one-cad-draft:draft:bind:"; const OWNED_EXTENSION = ".dxf"; /** Extensions the app treats as its drawings: click/URL ownership and the * save hook routing key off this pair, open pickers default to it and * write-back binding keys are names with either stripped. */ const OWNED_EXTENSIONS = new Set(["dxf", "dwg"]); /** Rust odoo bridge ASSET_EXTS — odoo.pickProgram offers these from * Documents. v1: obj only, plus landxml (the LANDXMLIMPORT command is * wired in src/app/commands/display.rs). odoo.import is implemented below * but the app does not call it yet. */ const RUST_ASSET_EXTS = ["obj", "landxml"]; /** Emulated pending paths must sanitize exactly like a Rust-side writer. */ function sanitizeName(name: unknown): string { const cleaned = String(name || "").replace(/[/\\]/g, "_").trim(); return cleaned || "file"; } /** Pull the Uint8Array payload out of an odoo.write / odoo.import event. */ function bytesOf(payload: Record): Uint8Array | null { const raw = payload ? payload.bytes : undefined; if (!raw) return null; if (raw instanceof Uint8Array) return raw; // Cross-realm payloads (tests, odd hosts): isView works across realms. if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(raw)) { return new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength); } if (typeof ArrayBuffer !== "undefined" && raw instanceof ArrayBuffer) return new Uint8Array(raw); return normalizeBytes(raw); } function newToken(): string { if (window.crypto && typeof window.crypto.randomUUID === "function") { return "t" + window.crypto.randomUUID().replace(/-/g, ""); } return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2); } interface OnecaddraftApi { request?: (method: string, params?: unknown) => Promise; } interface PendingTokenBinding { recordId: number | null; name: string; folderId: number | null; } interface PendingEntry { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType; } interface QueueEntry { id: number; method: string; payload: Record; } /* ------------------------------------------------------------------ */ /* Codec + guard helpers (exported for the explorer and the app glue) */ /* ------------------------------------------------------------------ */ /** Chunked btoa so multi-megabyte CAD drawings never blow the arg stack. */ export function bytesToBase64(bytes: Uint8Array): string { let binary = ""; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { const slice = bytes.subarray(i, i + chunk); binary += String.fromCharCode.apply(null, Array.from(slice) as unknown as number[]); } return btoa(binary); } export function base64ToBytes(dataB64: string): Uint8Array { const binary = atob(dataB64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } /** Family 128 MB cap, enforced on RAW bytes before base64 conversion. */ export function guardCap(bytes: Uint8Array): void { if (bytes.byteLength > MAX_DOCUMENT_BYTES) { throw new Error( "OneCAD Draft odoo bridge: document exceeds the 128 MB bridge limit (" + bytes.byteLength + " raw bytes)", ); } } function normalizeBytes(value: unknown): Uint8Array | null { if (value instanceof Uint8Array) return value; if (value instanceof ArrayBuffer) return new Uint8Array(value); if (value && typeof value === "object") { const holder = value as { dataB64?: unknown }; if (typeof holder.dataB64 === "string") return base64ToBytes(holder.dataB64); } return null; } function ensureExtension(name: string, extension: string): string { const trimmed = (name || "").trim() || "Untitled"; const dot = trimmed.lastIndexOf("."); if (dot > 0) return trimmed; return trimmed + extension; } /** A name is "owned" when it carries one of the app's drawing extensions. */ function extensionOf(name: string): string { const dot = (name || "").lastIndexOf("."); return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase(); } function stripOwnedExtension(name: string): string { return (name || "").replace(/\.(dxf|dwg)$/i, ""); } function detectInFrame(): boolean { try { return window.parent !== window; } catch { return false; } } function onecaddraftApi(): OnecaddraftApi | null { const holder = window as unknown as { onecaddraft?: OnecaddraftApi }; const api = holder.onecaddraft; return api && typeof api.request === "function" ? api : null; } function messageOf(error: unknown): string { return error && typeof error === "object" && "message" in error ? String((error as { message?: unknown }).message) : String(error); } /* ------------------------------------------------------------------ */ /* The bridge */ /* ------------------------------------------------------------------ */ class OneCadBridge { inFrame: boolean; standalone: boolean = false; nextId: number = 1; pending: Map = new Map(); queue: QueueEntry[] = []; draining: boolean = false; opener: ExplorerOpener | null = null; binding: CurrentBinding = { documentId: null, name: null }; pendingOpen: DocumentOpenedDetail | null = null; /** odoo.pickSave tokens -> binding (the Rust odoo bridge writes to * /odoo/pending//; the first write creates the record and * every later Ctrl+S writes back in place). */ pendingTokens = new Map(); /** Save-hook saves are serialized through this chain: one explorer * dialog at a time, bytes routed strictly in arrival order. */ saveHookChain: Promise = Promise.resolve(); constructor() { this.inFrame = detectInFrame(); // The save hook MUST exist before the app's first save: sys.rs checks // window.openCadStudioSaveFile on every download, and the wasm boot can // win the race against later boot steps. Registered only in-frame so a // standalone dev session keeps its normal browser downloads. if (this.inFrame) this.installSaveHook(); window.addEventListener("message", (event) => this.onMessage(event)); window.addEventListener(EVT_HOST_REQUEST, ((event: Event) => { const detail = (event as CustomEvent).detail as HostRequestDetail | undefined; if (!detail || typeof detail.id !== "number" || typeof detail.method !== "string") return; // The Rust odoo bridge may dispatch the request object as `params`; // the orchestration glue historically used `payload`. Accept both. const raw = detail.payload !== undefined ? detail.payload : detail.params; this.enqueue({ id: detail.id, method: detail.method, payload: (raw && typeof raw === "object" ? raw : {}) as Record, }); }) as EventListener); } /* ---------------- host plumbing ---------------- */ isHostAvailable(): boolean { return this.inFrame && !this.standalone; } private onMessage(event: MessageEvent): void { const message = event.data as | { source?: string; kind?: string; id?: number; ok?: boolean; result?: unknown; error?: { message?: string } | null } | null; if ( !message || typeof message !== "object" || message.source !== HOST_SOURCE_TAG || message.kind !== BRIDGE_KIND || typeof message.id !== "number" ) { return; } const pending = this.pending.get(message.id); if (!pending) return; this.pending.delete(message.id); clearTimeout(pending.timer); if (message.ok) pending.resolve(message.result); else pending.reject(new Error(message.error?.message || "Odoo Documents bridge call failed")); } /** One correlation id per call; 60 s default / 300 s for writes. */ call(method: string, payload?: Record, options?: CallOptions): Promise { if (!this.inFrame) { this.standalone = true; return Promise.reject( new Error(`OneCAD Draft odoo bridge: standalone (no Odoo host) — "${method}" unavailable`), ); } const timeoutMs = (options && options.timeoutMs) || DEFAULT_TIMEOUT_MS; const id = this.nextId++; const envelope = { source: SOURCE_TAG, kind: BRIDGE_KIND, id, method, payload: payload || {}, }; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`OneCAD Draft odoo bridge: "${method}" timed out after ${timeoutMs} ms`)); }, timeoutMs); this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer }); // The framework shell is our parent window (same origin). window.parent.postMessage(envelope, window.location.origin); }); } /** Ping every 100 ms until the host answers (mount-order independence). */ async waitForHost(timeoutMs: number = HOST_WAIT_MS): Promise { if (!this.inFrame) { this.standalone = true; return { user: "", folder: 0, app: { root: 0 } }; } const deadline = Date.now() + timeoutMs; let lastError: Error | null = null; while (Date.now() < deadline) { try { const pong = await this.ping(); this.standalone = false; return pong; } catch (error) { lastError = error as Error; await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } } throw new Error( `OneCAD Draft odoo bridge: host did not answer ping within ${timeoutMs} ms (${messageOf(lastError)})`, ); } /* ---------------- raw host calls ---------------- */ ping(): Promise { return this.call("ping"); } list(): Promise { return this.call("list"); } stat(recordId: number): Promise { return this.call("stat", { recordId }); } read(recordId: number): Promise { return this.call("read", { recordId }, { timeoutMs: WRITE_TIMEOUT_MS }); } write(payload: { recordId?: number | null; name?: string; dataB64: string; folderId?: number | null }, timeoutMs?: number): Promise { return this.call("write", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS }); } delete(recordId: number): Promise<{ deleted: boolean }> { return this.call<{ deleted: boolean }>("delete", { recordId }); } usage(): Promise { return this.call("usage"); } folders(): Promise { return this.call("folders"); } createFolder(payload: { name: string; parentId?: number }): Promise<{ id: number }> { return this.call<{ id: number }>("createFolder", payload); } listFolder(folderId: number): Promise<{ folders: FolderRow[]; files: FileRow[] }> { return this.call<{ folders: FolderRow[]; files: FileRow[] }>("listFolder", { folderId }); } saveExport(payload: { name: string; dataB64: string; folderId?: number }, timeoutMs?: number): Promise { return this.call("saveExport", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS }); } /* ---------------- binding cache (localStorage) ---------------- */ getCurrentBinding(): CurrentBinding { return this.binding; } /** document name -> Odoo record id, keyed by app + document name. */ bindLookup(name: string): BindEntry | null { try { const raw = window.localStorage.getItem(BIND_KEY_PREFIX + stripOwnedExtension(name)); if (!raw) return null; const entry = JSON.parse(raw) as BindEntry; return entry && typeof entry.recordId === "number" ? entry : null; } catch { return null; } } bindSet(name: string, recordId: number): void { this.binding = { documentId: recordId, name: stripOwnedExtension(name) }; try { const entry: BindEntry = { recordId, name: stripOwnedExtension(name), updatedAt: new Date().toISOString(), }; window.localStorage.setItem(BIND_KEY_PREFIX + entry.name, JSON.stringify(entry)); } catch { /* private mode — memory-only binding still works for this session */ } } /* ---------------- document flows ---------------- */ /** Ctrl+S style write-back / create. Guard runs BEFORE base64. */ async saveDocument(bytes: Uint8Array | ArrayBuffer, name: string, recordId?: number): Promise { const raw = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); guardCap(raw); const dataB64 = bytesToBase64(raw); const clean = ensureExtension(name, OWNED_EXTENSION); const result = await this.write({ recordId, name: clean, dataB64 }); this.bindSet(result.name || clean, result.id); return result; } /** Deep-link read; also refreshes the current binding. */ async readDocument(recordId: number): Promise { const result = await this.read(recordId); this.bindSet(result.name, recordId); return result; } /** * ?document= boot deep link. Never throws (boot must proceed); * on success stores pendingOpen, fires "onecaddraft:document-opened" and * refreshes the binding so host-ready can carry the metadata. */ async openDeepLink(search?: string): Promise { let params: URLSearchParams; try { params = new URLSearchParams(search === undefined ? window.location.search : search); } catch { return false; } const rawId = params.get("document"); if (!rawId) return false; const id = Number(rawId); if (!Number.isInteger(id) || id <= 0) { console.warn("[onecaddraft-odoo] ignoring malformed deep link ?document=" + rawId); return false; } try { const opened = await this.readDocument(id); this.pendingOpen = { documentId: id, name: opened.name, dataB64: opened.dataB64 }; window.dispatchEvent( new CustomEvent(EVT_DOCUMENT_OPENED, { detail: this.pendingOpen }), ); return true; } catch (error) { console.warn("[onecaddraft-odoo] deep-link read failed for document " + id + ":", messageOf(error)); return false; } } /** Late-boot consumers fetch the deep-link payload once, then null. */ consumePendingOpen(): DocumentOpenedDetail | null { const pending = this.pendingOpen; this.pendingOpen = null; return pending; } setExplorerOpener(opener: ExplorerOpener): void { this.opener = opener; } private async askExplorer(options: { mode: "save" | "open" | "asset" | "export"; defaultName?: string; extensions?: string[]; }): Promise { if (!this.isHostAvailable()) { throw new Error("OneCAD Draft odoo bridge: standalone (no Odoo host) — explorer unavailable"); } if (!this.opener) { throw new Error("OneCAD Draft odoo bridge: no explorer dialog wired (setExplorerOpener)"); } return this.opener(options); } private async appSerialize(): Promise { const api = onecaddraftApi(); if (api && api.request) { const out = await api.request(APP_SERIALIZE_METHOD); const bytes = normalizeBytes(out); if (bytes) return bytes; throw new Error("OneCAD Draft odoo bridge: window.onecaddraft did not return drawing bytes"); } // OneCAD Draft fallback: the control channel's "save" op serializes the // active drawing without any download (src/app/control/mod.rs "save") — // used when a save/export flow arrives without bytes and no // window.onecaddraft facade is installed. allow_lossy is acknowledged // because a host save must never wedge on the lossy prompt. const bindings = wasmControlApi(); if (!bindings) { throw new Error( "OneCAD Draft odoo bridge: no drawing bytes given and neither window.onecaddraft nor the control channel is available", ); } const reply = (await controlExchange( bindings, { op: "save", allow_lossy: true }, WRITE_TIMEOUT_MS, )) as { dataB64?: unknown; data_base64?: unknown; ok?: unknown; error?: unknown }; if (reply && reply.ok === false) { throw new Error("OneCAD Draft odoo bridge: control save failed (" + messageOf(reply.error) + ")"); } const raw = typeof reply?.dataB64 === "string" ? reply.dataB64 : typeof reply?.data_base64 === "string" ? reply.data_base64 : ""; const bytes = raw ? base64ToBytes(raw) : null; if (!bytes) { throw new Error("OneCAD Draft odoo bridge: control save returned no drawing bytes"); } return bytes; } private async appInfo(): Promise<{ name?: string }> { const api = onecaddraftApi(); if (!api || !api.request) return {}; try { const info = (await api.request(APP_INFO_METHOD)) as { name?: unknown } | null; return info && typeof info.name === "string" ? { name: info.name } : {}; } catch { return {}; } } /** Save-as flow: bytes -> explorer (tree + name) -> write -> bind. */ async saveAs(options?: { bytes?: Uint8Array | ArrayBuffer; dataB64?: string; name?: string; recordId?: number; }): Promise { const opts = options || {}; let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : ""; if (!dataB64) { const raw = opts.bytes ? (opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes)) : await this.appSerialize(); guardCap(raw); dataB64 = bytesToBase64(raw); } const info = await this.appInfo(); const suggested = opts.name || info.name || "untitled"; const choice = await this.askExplorer({ mode: "save", defaultName: ensureExtension(suggested, OWNED_EXTENSION), }); if (!choice || !choice.name) return null; // cancelled const name = ensureExtension(choice.name, OWNED_EXTENSION); const result = await this.write({ recordId: opts.recordId, name, dataB64, folderId: choice.folderId, }); this.bindSet(result.name || name, result.id); return { id: result.id, name: result.name || name, folderId: choice.folderId || null }; } /** Open flow: explorer (.dxf/.dwg filter) -> read 300 s. */ async openFromDocuments(): Promise { const choice = await this.askExplorer({ mode: "open", extensions: ["dxf", "dwg"] }); if (!choice || typeof choice.recordId !== "number") return null; // cancelled const opened = await this.readDocument(choice.recordId); return { id: choice.recordId, name: opened.name, dataB64: opened.dataB64 }; } /** Export flow: bytes -> (optional name dialog) -> saveExport -> Exports. */ async exportTo(options?: { bytes?: Uint8Array | ArrayBuffer; dataB64?: string; name?: string; }): Promise { const opts = options || {}; let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : ""; if (!dataB64) { const raw = opts.bytes ? (opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes)) : await this.appSerialize(); guardCap(raw); dataB64 = bytesToBase64(raw); } let name = (opts.name || "").trim(); let folderId: number | undefined; if (!name) { const info = await this.appInfo(); const choice = await this.askExplorer({ mode: "export", defaultName: (info.name || "export") + ".dxf", }); if (!choice || !choice.name) return null; // cancelled name = choice.name; folderId = choice.folderId; } const result = await this.saveExport({ name, dataB64, folderId }); return { id: result.id, name: result.name || name }; } /** Import flow: file picker (or given bytes) -> write into Assets. */ async importAsset(options?: { file?: File; bytes?: Uint8Array | ArrayBuffer; dataB64?: string; name?: string; }): Promise { const opts = options || {}; let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : ""; let name = (opts.name || "").trim(); let folderId: number | undefined; if (!dataB64 && !opts.file && !opts.bytes) { const choice = await this.askExplorer({ mode: "asset" }); if (!choice || !(choice.file instanceof File)) return null; // cancelled folderId = choice.folderId; const raw = new Uint8Array(await choice.file.arrayBuffer()); guardCap(raw); dataB64 = bytesToBase64(raw); name = name || choice.file.name; } else if (!dataB64) { const raw = opts.bytes ? opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes) : new Uint8Array(await (opts.file as File).arrayBuffer()); guardCap(raw); dataB64 = bytesToBase64(raw); name = name || (opts.file ? opts.file.name : "asset"); } if (!folderId) { const app = (await this.ping()).app; folderId = app.assets || app.root; } const result = await this.write({ name: name || "asset", dataB64, folderId }); return { id: result.id, name: result.name || name || "asset" }; } /* ---------------- save air gap (window.openCadStudioSaveFile) ---------------- */ /** * Install the page save hook the wasm app checks on every download * (src/sys.rs::download_bytes -> save_hook()). While the hook exists the * app routes EVERY saved/exported file here and never opens a file picker * or a browser download — the bytes are consumed below or dropped with a * warning, never handed back to the browser. */ private installSaveHook(): void { try { const holder = window as unknown as { openCadStudioSaveFile?: (name: string, bytes: Uint8Array) => boolean; }; holder.openCadStudioSaveFile = (name: unknown, bytes: unknown): boolean => { this.routeSaveHook(name, bytes); return true; // consumed — the app must never fall back to a download }; } catch (error) { console.warn("[onecaddraft-odoo] could not install openCadStudioSaveFile:", error); } } /** Normalize the hook arguments and queue the save; never throws. */ private routeSaveHook(rawName: unknown, rawBytes: unknown): void { const name = sanitizeName(typeof rawName === "string" ? rawName : ""); let bytes: Uint8Array | null = null; if (rawBytes instanceof Uint8Array) bytes = rawBytes; else if (rawBytes && typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(rawBytes)) { const view = rawBytes as ArrayBufferView; bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); } else bytes = normalizeBytes(rawBytes); if (!bytes || bytes.byteLength === 0) { console.warn("[onecaddraft-odoo] save hook: \"" + name + "\" carried no bytes — dropped (air-gapped)"); return; } // One dialog at a time, arrival order; a failed save never breaks the chain. this.saveHookChain = this.saveHookChain .then(() => this.saveHookRoute(name, bytes as Uint8Array)) .catch(() => undefined); } /** Hook routing: a/b/c per the header contract. Bytes are ALWAYS consumed. */ private async saveHookRoute(name: string, bytes: Uint8Array): Promise { const extension = extensionOf(name); try { guardCap(bytes); const dataB64 = bytesToBase64(bytes); if (OWNED_EXTENSIONS.has(extension)) { const bind = this.bindLookup(name); if (bind) { // (a) in-place write-back to the bound record. const result = await this.write({ recordId: bind.recordId, name, dataB64 }); this.bindSet(result.name || name, result.id); console.info( "[onecaddraft-odoo] wrote \"" + name + "\" back to Documents record " + result.id, ); } else { // (b) unbound drawing: explorer decides the folder (Projects by // default), then create + bind — future saves take path (a). const choice = await this.askExplorer({ mode: "save", defaultName: ensureExtension(name, OWNED_EXTENSION), }); if (!choice || !choice.name) { console.info( "[onecaddraft-odoo] save cancelled — \"" + name + "\" bytes discarded (no browser download; retry Save to store it)", ); return; } const clean = ensureExtension(choice.name, OWNED_EXTENSION); const result = await this.write({ name: clean, dataB64, folderId: choice.folderId }); this.bindSet(result.name || clean, result.id); console.info( "[onecaddraft-odoo] saved \"" + (result.name || clean) + "\" to Documents (record " + result.id + ") — future saves write back in place", ); } } else { // (c) every other format (obj/stl/pdf/csv/png/zip/…) -> Exports. const result = await this.saveExport({ name, dataB64 }); console.info( "[onecaddraft-odoo] exported \"" + (result.name || name) + "\" to the Documents Exports folder (record " + result.id + ")", ); } } catch (error) { console.warn( "[onecaddraft-odoo] save hook: \"" + name + "\" could not reach Odoo Documents (" + messageOf(error) + ") — bytes discarded, no browser download (air gap)", ); } } /* ---------------- Rust event queue ---------------- */ private enqueue(entry: QueueEntry): void { this.queue.push(entry); void this.drainQueue(); } /** Strictly FIFO, one request at a time; every id answered exactly once. */ private async drainQueue(): Promise { if (this.draining) return; this.draining = true; while (this.queue.length > 0) { const entry = this.queue.shift() as QueueEntry; try { const result = await this.runRequest(entry.method, entry.payload); this.respond(entry.id, true, result, null); } catch (error) { this.respond(entry.id, false, null, { message: messageOf(error) }); } } this.draining = false; } private respond(id: number, ok: boolean, result: unknown, error: { message: string } | null): void { const detail: HostResponseDetail = { id, ok, result, error }; window.dispatchEvent(new CustomEvent(EVT_HOST_RESPONSE, { detail })); } private async runRequest(method: string, payload: Record): Promise { if (method === "odoo.pickOpen" || method === "odoo.pickProgram") return this.pickForRust(method, payload); if (method === "odoo.pickSave") return this.pickSaveForRust(payload); if (method === "odoo.write") return this.rustWrite(payload); if (method === "odoo.saveExport") return this.rustSaveExport(payload); if (method === "odoo.import") return this.rustImport(payload); if (method === "saveAs") { return this.saveAs({ dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined, name: typeof payload.name === "string" ? payload.name : undefined, recordId: typeof payload.recordId === "number" ? payload.recordId : undefined, bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined, }); } if (method === "open") return this.openFromDocuments(); if (method === "export") { return this.exportTo({ dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined, name: typeof payload.name === "string" ? payload.name : undefined, bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined, }); } if (method === "importAsset") { return this.importAsset({ dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined, name: typeof payload.name === "string" ? payload.name : undefined, bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined, file: payload.file instanceof File ? payload.file : undefined, }); } if (HOST_METHODS.has(method)) { const timeoutMs = BULK_METHODS.has(method) ? WRITE_TIMEOUT_MS : undefined; return this.call(method, payload, { timeoutMs }); } throw new Error("OneCAD Draft odoo bridge: unknown host-request method \"" + method + "\""); } /* ------- Rust odoo bridge wire protocol (CustomEvent round trip) ------- */ /** odoo.pickOpen / odoo.pickProgram -> {recordId, name, bytes} | null. */ private async pickForRust(method: string, payload: Record): Promise { const requested = Array.isArray(payload.extensions) && payload.extensions.length ? (payload.extensions as string[]) : null; const extensions = requested || (method === "odoo.pickProgram" ? RUST_ASSET_EXTS : ["dxf", "dwg"]); const choice = await this.askExplorer({ mode: "open", extensions }); if (!choice || typeof choice.recordId !== "number") return null; const opened = await this.readDocument(choice.recordId); return { recordId: choice.recordId, name: opened.name || choice.recordName || "document", bytes: base64ToBytes(opened.dataB64), }; } /** odoo.pickSave -> {token, name}; the token binds the later write-back. */ private async pickSaveForRust(payload: Record): Promise { const design = payload.design !== false; const suggested = typeof payload.name === "string" && payload.name.trim() ? payload.name.trim() : "untitled"; const choice = await this.askExplorer({ mode: "save", defaultName: design ? ensureExtension(suggested, OWNED_EXTENSION) : suggested, }); if (!choice || !choice.name) return null; const token = newToken(); const name = sanitizeName(choice.name); this.pendingTokens.set(token, { recordId: null, name, folderId: choice.folderId || null }); return { token, name }; } /** odoo.write: /odoo/doc// in place, /odoo/pending// first save. */ private async rustWrite(payload: Record): Promise { const bytes = bytesOf(payload); if (!bytes) throw new Error("odoo.write: no bytes given"); guardCap(bytes); const dataB64 = bytesToBase64(bytes); const path = typeof payload.path === "string" ? payload.path : ""; if (path.startsWith("/odoo/pending/")) { const rest = path.slice("/odoo/pending/".length); const slash = rest.indexOf("/"); const token = slash > 0 ? rest.slice(0, slash) : ""; const pathName = slash > 0 ? sanitizeName(rest.slice(slash + 1)) : "document"; const bind = token ? this.pendingTokens.get(token) : undefined; const name = (bind ? bind.name : "") || pathName; let recordId = bind ? bind.recordId : null; if (!recordId) { const remembered = this.bindLookup(name); recordId = remembered ? remembered.recordId : null; } const result = await this.write({ recordId, name, dataB64, folderId: bind ? bind.folderId : undefined }); if (bind) bind.recordId = result.id; else if (token) this.pendingTokens.set(token, { recordId: result.id, name, folderId: null }); this.bindSet(result.name || name, result.id); return { id: result.id, name: result.name || name }; } if (path.startsWith("/odoo/doc/")) { const rest = path.slice("/odoo/doc/".length); const slash = rest.indexOf("/"); const recordId = slash > 0 ? Number(rest.slice(0, slash)) : 0; const name = slash > 0 ? sanitizeName(rest.slice(slash + 1)) : "document"; if (!Number.isInteger(recordId) || recordId <= 0) { throw new Error("odoo.write: bad record path \"" + path + "\""); } const result = await this.write({ recordId, name, dataB64 }); this.bindSet(result.name || name, recordId); return { id: result.id, name: result.name || name }; } throw new Error("odoo.write: unsupported path \"" + path + "\""); } /** odoo.saveExport: {name, bytes} -> Documents "Exports" folder. */ private async rustSaveExport(payload: Record): Promise { const bytes = bytesOf(payload); if (!bytes) throw new Error("odoo.saveExport: no bytes given"); guardCap(bytes); const result = await this.saveExport({ name: sanitizeName(payload.name) || "export", dataB64: bytesToBase64(bytes) }); return { id: result.id, name: result.name }; } /** odoo.import: mirror a computer import into the Documents "Assets" folder. */ private async rustImport(payload: Record): Promise { const bytes = bytesOf(payload); if (!bytes) throw new Error("odoo.import: no bytes given"); guardCap(bytes); const app = (await this.ping()).app; const name = typeof payload.name === "string" && payload.name ? payload.name : "asset"; const result = await this.write({ name, dataB64: bytesToBase64(bytes), folderId: app.assets || app.root }); return { id: result.id, name: result.name }; } } /* ------------------------------------------------------------------ */ /* Control-channel helpers (src/app/control/mod.rs via wasmBindings) */ /* ------------------------------------------------------------------ */ /** The Trunk loader publishes the wasm-bindgen module as window.wasmBindings * (docs/automation/web.md); the control channel is ready once its * ocs_control_submit/ocs_control_take pair is defined. */ interface WasmControlApi { ocs_control_submit: (request: string) => string; ocs_control_take: (ticket: string) => string | undefined; } function wasmControlApi(): WasmControlApi | null { const bindings = (window as unknown as { wasmBindings?: Partial }).wasmBindings; return bindings && typeof bindings.ocs_control_submit === "function" && typeof bindings.ocs_control_take === "function" ? (bindings as WasmControlApi) : null; } /** Submit one control request and follow it to the final reply: the app * answers async work with ok:true accepted/running + request_id (polled * through {"op":"operation"}) and stores the reply under the LAST ticket. */ async function controlExchange( api: WasmControlApi, request: Record, timeoutMs: number, ): Promise> { const deadline = Date.now() + timeoutMs; let ticket = api.ocs_control_submit(JSON.stringify(request)); 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: Record; try { reply = JSON.parse(raw) as Record; } catch { throw new Error("OneCAD Draft odoo bridge: control channel returned unparsable JSON"); } const status = typeof reply.status === "string" ? reply.status : ""; if (reply.ok === true && (status === "accepted" || status === "running")) { const requestId = typeof reply.request_id === "string" ? reply.request_id : ""; if (!requestId) { throw new Error("OneCAD Draft odoo bridge: control reply carries no request_id"); } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); ticket = api.ocs_control_submit(JSON.stringify({ op: "operation", request_id: requestId })); continue; } return reply; } throw new Error("OneCAD Draft odoo bridge: control request timed out after " + timeoutMs + " ms"); } /** The process-wide singleton (one bridge per app window). */ export const bridge = new OneCadBridge(); /** Family export: the app glue and dialogs always share this instance. */ export function exportForHost(): OneCadBridge { return bridge; }