948 lines
36 KiB
TypeScript
948 lines
36 KiB
TypeScript
// OneBSpace (OneCAD) — odoo host bridge port
|
|
/**
|
|
* (C) OneBSpace — https://1bspace.com — Proprietary.
|
|
* OneCAD 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", kind: "odoo-documents", id, method, payload}
|
|
* posted on window.parent (same-origin Odoo shell iframe)
|
|
* reply : {source: "one-cad-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 side is built
|
|
* separately and consumes host responses through these):
|
|
*
|
|
* LISTEN "onecad: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.solidcraft (below).
|
|
* REQUEST QUEUE: entries are processed
|
|
* strictly FIFO, one at a time, and each
|
|
* id is answered exactly once.
|
|
*
|
|
* EMIT "onecad: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 "onecad: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 can read the same state sync at
|
|
* window.onecadHost.{ready,host,documentId,name}.
|
|
*
|
|
* EMIT "onecad: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.onecadHost.consumePendingOpen()
|
|
* (returns the detail once, then null).
|
|
*
|
|
* JS -> APP window.solidcraft.request("odoo.document.bytes")
|
|
* -> Promise<Uint8Array | ArrayBuffer | {dataB64}> — serialize
|
|
* the current workspace (used when a save/export carries no
|
|
* bytes). request("odoo.document.info") -> Promise<{name?}> —
|
|
* suggested document name; failures are ignored.
|
|
*
|
|
* 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 and host-request
|
|
* events are answered ok:false — the app still boots, nothing hangs.
|
|
*
|
|
* This file is erasable-syntax-only TypeScript with ZERO dependencies: the
|
|
* addon build (scripts/build_onecad_addon.py) compiles it to bridge.js next
|
|
* to this file (node stripTypeScriptTypes; tsc when available).
|
|
*/
|
|
|
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
const WRITE_TIMEOUT_MS = 300_000; // large .solidcraft containers 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";
|
|
export const HOST_SOURCE_TAG = "one-cad-host";
|
|
export const BRIDGE_KIND = "odoo-documents";
|
|
|
|
/** Window CustomEvent names of the Rust facade contract (see header). */
|
|
export const EVT_HOST_REQUEST = "onecad:host-request";
|
|
export const EVT_HOST_RESPONSE = "onecad:host-response";
|
|
export const EVT_HOST_READY = "onecad:host-ready";
|
|
export const EVT_DOCUMENT_OPENED = "onecad:document-opened";
|
|
|
|
/** window.solidcraft 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<ExplorerChoice | null>;
|
|
|
|
export interface HostRequestDetail {
|
|
id: number;
|
|
method: string;
|
|
payload?: Record<string, unknown> | null;
|
|
/** services_odoo.rs names the request object `params`; payload is the glue
|
|
* spelling. runRequest accepts either. */
|
|
params?: Record<string, unknown> | 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 "onecad: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:solidcraft:bind:";
|
|
const OWNED_EXTENSION = ".solidcraft";
|
|
|
|
/** Rust services_odoo.rs ASSET_EXTS — pick_program offers these from Documents. */
|
|
const RUST_ASSET_EXTS = ["step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf", "dxf", "gltf", "glb", "ply"];
|
|
|
|
/** Emulated pending paths must sanitize exactly like services_odoo.rs::sanitize. */
|
|
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<string, unknown>): 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 PendingTokenBinding {
|
|
recordId: number | null;
|
|
name: string;
|
|
folderId: number | null;
|
|
}
|
|
|
|
interface SolidcraftApi {
|
|
request?: (method: string, params?: unknown) => Promise<unknown>;
|
|
}
|
|
|
|
interface PendingEntry {
|
|
resolve: (value: unknown) => void;
|
|
reject: (error: Error) => void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
interface QueueEntry {
|
|
id: number;
|
|
method: string;
|
|
payload: Record<string, unknown>;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Codec + guard helpers (exported for the explorer and the app glue) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/** Chunked btoa so multi-megabyte CAD containers 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 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;
|
|
}
|
|
|
|
function stripOwnedExtension(name: string): string {
|
|
return (name || "").replace(/\.(solidcraft|solvecraft)$/i, "");
|
|
}
|
|
|
|
function detectInFrame(): boolean {
|
|
try {
|
|
return window.parent !== window;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function solidcraftApi(): SolidcraftApi | null {
|
|
const holder = window as unknown as { solidcraft?: SolidcraftApi };
|
|
const api = holder.solidcraft;
|
|
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<number, PendingEntry> = 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 (services_odoo.rs writes to
|
|
* /odoo/pending/<token>/<name>; the first write creates the record and
|
|
* every later Ctrl+S writes back in place). */
|
|
pendingTokens = new Map<string, PendingTokenBinding>();
|
|
|
|
constructor() {
|
|
this.inFrame = detectInFrame();
|
|
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;
|
|
// services_odoo.rs dispatches 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<string, unknown>,
|
|
});
|
|
}) 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<T>(method: string, payload?: Record<string, unknown>, options?: CallOptions): Promise<T> {
|
|
if (!this.inFrame) {
|
|
this.standalone = true;
|
|
return Promise.reject(
|
|
new Error(`OneCAD 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<T>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
this.pending.delete(id);
|
|
reject(new Error(`OneCAD 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<PingResult> {
|
|
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 odoo bridge: host did not answer ping within ${timeoutMs} ms (${messageOf(lastError)})`,
|
|
);
|
|
}
|
|
|
|
/* ---------------- raw host calls ---------------- */
|
|
|
|
ping(): Promise<PingResult> {
|
|
return this.call<PingResult>("ping");
|
|
}
|
|
|
|
list(): Promise<ListResult> {
|
|
return this.call<ListResult>("list");
|
|
}
|
|
|
|
stat(recordId: number): Promise<StatResult> {
|
|
return this.call<StatResult>("stat", { recordId });
|
|
}
|
|
|
|
read(recordId: number): Promise<ReadResult> {
|
|
return this.call<ReadResult>("read", { recordId }, { timeoutMs: WRITE_TIMEOUT_MS });
|
|
}
|
|
|
|
write(payload: { recordId?: number; name?: string; dataB64: string; folderId?: number }, timeoutMs?: number): Promise<WriteResult> {
|
|
return this.call<WriteResult>("write", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS });
|
|
}
|
|
|
|
delete(recordId: number): Promise<{ deleted: boolean }> {
|
|
return this.call<{ deleted: boolean }>("delete", { recordId });
|
|
}
|
|
|
|
usage(): Promise<UsageResult> {
|
|
return this.call<UsageResult>("usage");
|
|
}
|
|
|
|
folders(): Promise<FoldersResult> {
|
|
return this.call<FoldersResult>("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<WriteResult> {
|
|
return this.call<WriteResult>("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<WriteResult> {
|
|
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<ReadResult> {
|
|
const result = await this.read(recordId);
|
|
this.bindSet(result.name, recordId);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* ?document=<id> boot deep link. Never throws (boot must proceed);
|
|
* on success stores pendingOpen, fires "onecad:document-opened" and
|
|
* refreshes the binding so host-ready can carry the metadata.
|
|
*/
|
|
async openDeepLink(search?: string): Promise<boolean> {
|
|
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("[onecad-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<DocumentOpenedDetail>(EVT_DOCUMENT_OPENED, { detail: this.pendingOpen }),
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
console.warn("[onecad-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<ExplorerChoice | null> {
|
|
if (!this.isHostAvailable()) {
|
|
throw new Error("OneCAD odoo bridge: standalone (no Odoo host) — explorer unavailable");
|
|
}
|
|
if (!this.opener) {
|
|
throw new Error("OneCAD odoo bridge: no explorer dialog wired (setExplorerOpener)");
|
|
}
|
|
return this.opener(options);
|
|
}
|
|
|
|
private async appSerialize(): Promise<Uint8Array> {
|
|
const api = solidcraftApi();
|
|
if (!api || !api.request) {
|
|
throw new Error(
|
|
"OneCAD odoo bridge: no document bytes given and window.solidcraft is unavailable",
|
|
);
|
|
}
|
|
const out = await api.request(APP_SERIALIZE_METHOD);
|
|
const bytes = normalizeBytes(out);
|
|
if (!bytes) {
|
|
throw new Error("OneCAD odoo bridge: window.solidcraft did not return document bytes");
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
private async appInfo(): Promise<{ name?: string }> {
|
|
const api = solidcraftApi();
|
|
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<SaveAsResult | null> {
|
|
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 (.solidcraft filter) -> read 300 s. */
|
|
async openFromDocuments(): Promise<OpenResult | null> {
|
|
const choice = await this.askExplorer({ mode: "open", extensions: ["solidcraft", "solvecraft"] });
|
|
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<SavedResult | null> {
|
|
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") + ".step",
|
|
});
|
|
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<SavedResult | null> {
|
|
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" };
|
|
}
|
|
|
|
/* ---------------- 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<void> {
|
|
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<HostResponseDetail>(EVT_HOST_RESPONSE, { detail }));
|
|
}
|
|
|
|
private async runRequest(method: string, payload: Record<string, unknown>): Promise<unknown> {
|
|
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<unknown>(method, payload, { timeoutMs });
|
|
}
|
|
throw new Error("OneCAD odoo bridge: unknown host-request method \"" + method + "\"");
|
|
}
|
|
/* ------- services_odoo.rs wire protocol (CustomEvent round trip) ------- */
|
|
/** odoo.pickOpen / odoo.pickProgram -> {recordId, name, bytes} | null. */
|
|
private async pickForRust(method: string, payload: Record<string, unknown>): Promise<unknown> {
|
|
const requested = Array.isArray(payload.extensions) && payload.extensions.length
|
|
? (payload.extensions as string[])
|
|
: null;
|
|
const extensions = requested || (method === "odoo.pickProgram" ? RUST_ASSET_EXTS : ["solidcraft", "solvecraft"]);
|
|
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<string, unknown>): Promise<unknown> {
|
|
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/<rid>/<name> in place, /odoo/pending/<token>/<name> first save. */
|
|
private async rustWrite(payload: Record<string, unknown>): Promise<unknown> {
|
|
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<string, unknown>): Promise<unknown> {
|
|
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<string, unknown>): Promise<unknown> {
|
|
const bytes = bytesOf(payload);
|
|
if (!bytes) throw new Error("odoo.import: no bytes given");
|
|
guardCap(bytes);
|
|
const app = (await this.ping()).app;
|
|
const result = await this.write({ name: payload.name || "asset", dataB64: bytesToBase64(bytes), folderId: app.assets || app.root });
|
|
return { id: result.id, name: result.name };
|
|
}
|
|
}
|
|
|
|
/** 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;
|
|
}
|