forked from 1b-agent/fieldcraft-upstream
[ADD] Odoo Documents integration: services_odoo.rs (sync-Services completion queue drained per frame), odoo glue trio (bridge/explorer/index), web.rs/main.rs wiring, ?v=20.0.1.0.2 cache-bust chain
This commit is contained in:
1 parent
f8c0e8500a
commit
669c13cd55
7 files changed
+2572
-25
No files matched your search
@@ -3,47 +3,43 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>FieldCraft</title>
|
||||
<title>OneCAD Simulation</title>
|
||||
<meta name="description" content="Open-source CAE modeling in Rust: mesh a part, set up the analysis and solve it, in your browser.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="FieldCraft">
|
||||
<meta property="og:title" content="FieldCraft">
|
||||
<meta property="og:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
|
||||
<meta property="og:url" content="https://tangency-labs.github.io/fieldcraft/">
|
||||
<meta property="og:image" content="https://tangency-labs.github.io/fieldcraft/og.png">
|
||||
<meta property="og:image:width" content="1280">
|
||||
<meta property="og:image:height" content="640">
|
||||
<meta property="og:image:alt" content="FieldCraft: Open-source CAE built in Rust">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="FieldCraft">
|
||||
<meta name="twitter:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
|
||||
<meta name="twitter:image" content="https://tangency-labs.github.io/fieldcraft/og.png">
|
||||
<!-- OneBSpace: upstream's social-card meta (og: / twitter:) and its og.png copy are
|
||||
stripped — no social junk in this app's bundle. -->
|
||||
<link data-trunk rel="rust" data-bin="fieldcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="icon" type="image/svg+xml" href="../../assets/app-icon/icon-small.svg" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/app-icon/icon.iconset/icon_128x128@2x.png" data-target-path="icons" />
|
||||
<link data-trunk rel="copy-file" href="../../docs/media/og.png" />
|
||||
<link rel="apple-touch-icon" href="icons/icon_128x128@2x.png">
|
||||
<!-- OneBSpace: the Odoo Documents host bridge glue (src/odoo). Copied verbatim
|
||||
(not hashed): the framework's bundleVersion ?v= on the iframe URL is the
|
||||
cache buster, like every other OneBSpace app bundle. -->
|
||||
<link data-trunk rel="copy-dir" href="src/odoo" data-target-path="odoo" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262c37; }
|
||||
#fieldcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#fieldcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 16px; padding: 0 16px; text-align: center; color: #c3cad6; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
#fieldcraft_loading img { width: 96px; height: 96px; }
|
||||
/* OneBSpace: the upstream logo image is gone — a plain wordmark instead. */
|
||||
#fieldcraft_loading .wordmark { font: 600 clamp(28px, 6vmin, 52px) system-ui, sans-serif;
|
||||
letter-spacing: 0.18em; text-indent: 0.18em; color: #c3cad6; user-select: none; }
|
||||
#fieldcraft_loading p { margin: 0; max-width: 32em; }
|
||||
#fieldcraft_loading.failed { pointer-events: auto; }
|
||||
#fieldcraft_loading.failed img, #fieldcraft_loading.failed .spinner { display: none; }
|
||||
#fieldcraft_loading.failed .spinner { display: none; }
|
||||
.spinner { width: 24px; height: 24px; border-radius: 50%; border: 3px solid #4a5468; border-top-color: #c3cad6; animation: spin 0.9s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="fieldcraft_canvas"></canvas>
|
||||
<div id="fieldcraft_loading">
|
||||
<img src="icons/icon_128x128@2x.png" alt="">
|
||||
<div id="fieldcraft_loading" role="status">
|
||||
<span class="wordmark">OneCAD Simulation</span>
|
||||
<div class="spinner"></div>
|
||||
<p>Loading FieldCraft…</p>
|
||||
<noscript><p>FieldCraft needs JavaScript and WebAssembly.</p></noscript>
|
||||
<span class="sr-only">Loading OneCAD Simulation…</span>
|
||||
<!-- // OneBSpace (OneCAD Simulation): the no-JavaScript note names the fork -->
|
||||
<noscript><p>OneCAD Simulation needs JavaScript and WebAssembly.</p></noscript>
|
||||
</div>
|
||||
<script>
|
||||
// The page could not load or start the app (no WebAssembly, a failed download): say so
|
||||
@@ -54,7 +50,8 @@
|
||||
if (!el || el.classList.contains("failed")) return;
|
||||
el.classList.add("failed");
|
||||
el.textContent = "";
|
||||
["FieldCraft could not start: " + why, "It needs a current browser with WebAssembly and WebGPU or WebGL2."].forEach(function (t) {
|
||||
// // OneBSpace (OneCAD Simulation): the failure message names the fork
|
||||
["OneCAD Simulation could not start: " + why, "It needs a current browser with WebAssembly and WebGPU or WebGL2."].forEach(function (t) {
|
||||
var p = document.createElement("p");
|
||||
p.textContent = t;
|
||||
el.appendChild(p);
|
||||
@@ -64,5 +61,15 @@
|
||||
addEventListener("unhandledrejection", function (e) { fail(String(e.reason && e.reason.message || e.reason)); });
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// OneBSpace: boot the Odoo Documents bridge glue only inside a frame (the
|
||||
// wasm_app_framework shell). Standalone runs never import it.
|
||||
// odoo/index.js is src/odoo/index.ts compiled by the addon build script.
|
||||
try {
|
||||
if (window.parent !== window) {
|
||||
import("./odoo/index.js?v=20.0.1.0.0").catch((e) => console.warn("OneCAD Simulation odoo bridge:", e));
|
||||
}
|
||||
} catch (e) { /* cross-origin parent (standalone) — skip */ }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -26,6 +26,9 @@ mod storage;
|
||||
mod usage;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
// OneBSpace (OneCAD Simulation): Odoo Documents host integration
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod services_odoo;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
|
||||
@@ -0,0 +1,947 @@
|
||||
// (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
|
||||
// OneCAD Simulation Odoo Documents glue (ported from OneCAD).
|
||||
/**
|
||||
* OneCAD Simulation 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-simulation", kind: "odoo-documents", id, method, payload}
|
||||
* posted on window.parent (same-origin Odoo shell iframe)
|
||||
* reply : {source: "one-cad-simulation-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 "onecadsim: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.onecadsim (below).
|
||||
* REQUEST QUEUE: entries are processed
|
||||
* strictly FIFO, one at a time, and each
|
||||
* id is answered exactly once.
|
||||
*
|
||||
* EMIT "onecadsim: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 "onecadsim: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.onecadsimHost.{ready,host,documentId,name}.
|
||||
*
|
||||
* EMIT "onecadsim: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.onecadsimHost.consumePendingOpen()
|
||||
* (returns the detail once, then null).
|
||||
*
|
||||
* JS -> APP window.onecadsim.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 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 .fieldcraft 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-simulation";
|
||||
export const HOST_SOURCE_TAG = "one-cad-simulation-host";
|
||||
export const BRIDGE_KIND = "odoo-documents";
|
||||
|
||||
/** Window CustomEvent names of the Rust facade contract (see header). */
|
||||
export const EVT_HOST_REQUEST = "onecadsim:host-request";
|
||||
export const EVT_HOST_RESPONSE = "onecadsim:host-response";
|
||||
export const EVT_HOST_READY = "onecadsim:host-ready";
|
||||
export const EVT_DOCUMENT_OPENED = "onecadsim:document-opened";
|
||||
|
||||
/** window.onecadsim 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 "onecadsim: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-sim:fieldcraft:bind:";
|
||||
const OWNED_EXTENSION = ".fieldcraft";
|
||||
|
||||
/** 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 OnecadsimApi {
|
||||
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 Simulation 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(/\.(fieldcraft|solidcraft)$/i, "");
|
||||
}
|
||||
|
||||
function detectInFrame(): boolean {
|
||||
try {
|
||||
return window.parent !== window;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function onecadsimApi(): OnecadsimApi | null {
|
||||
const holder = window as unknown as { onecadsim?: OnecadsimApi };
|
||||
const api = holder.onecadsim;
|
||||
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 Simulation 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 Simulation 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 Simulation 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 "onecadsim: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("[onecadsim-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("[onecadsim-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 Simulation odoo bridge: standalone (no Odoo host) — explorer unavailable");
|
||||
}
|
||||
if (!this.opener) {
|
||||
throw new Error("OneCAD Simulation odoo bridge: no explorer dialog wired (setExplorerOpener)");
|
||||
}
|
||||
return this.opener(options);
|
||||
}
|
||||
|
||||
private async appSerialize(): Promise<Uint8Array> {
|
||||
const api = onecadsimApi();
|
||||
if (!api || !api.request) {
|
||||
throw new Error(
|
||||
"OneCAD Simulation odoo bridge: no document bytes given and window.onecadsim is unavailable",
|
||||
);
|
||||
}
|
||||
const out = await api.request(APP_SERIALIZE_METHOD);
|
||||
const bytes = normalizeBytes(out);
|
||||
if (!bytes) {
|
||||
throw new Error("OneCAD Simulation odoo bridge: window.onecadsim did not return document bytes");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async appInfo(): Promise<{ name?: string }> {
|
||||
const api = onecadsimApi();
|
||||
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 (.fieldcraft filter) -> read 300 s. */
|
||||
async openFromDocuments(): Promise<OpenResult | null> {
|
||||
const choice = await this.askExplorer({ mode: "open", extensions: ["fieldcraft", "solidcraft"] });
|
||||
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 Simulation 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 : ["fieldcraft", "solidcraft"]);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
// (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
|
||||
// OneCAD Simulation Odoo Documents glue (ported from OneCAD).
|
||||
/**
|
||||
* OneCAD Simulation odoo/explorer.js — the unified Documents explorer dialogs.
|
||||
* ----------------------------------------------------------------------
|
||||
* Vanilla-JS (ES module, zero deps) port of the family gold-standard
|
||||
* explorer (one-photo src/odoo-host/explorer.js — same layout, same
|
||||
* interactions, same last-folder memory), restyled with an injected dark
|
||||
* CAD palette (no CSS imports — one <style> tag) so it reads native over
|
||||
* the egui canvas. Four dialogs, one explorer:
|
||||
*
|
||||
* save — "Save to Documents": folder tree + name input (.fieldcraft chip)
|
||||
* open — "Open from Documents": folder tree + files filtered to .fieldcraft
|
||||
* asset — "Import asset": file picker -> Assets folder (+ retarget)
|
||||
* export — "Export": name input -> Exports folder
|
||||
*
|
||||
* left — My Drive folder tree (recursive, expandable, usage footer)
|
||||
* right — breadcrumb + folder/file rows (icon, name, size)
|
||||
* footer — Cancel left, primary action right, Esc / backdrop cancels
|
||||
*
|
||||
* Everything runs through the bridge (odoo/bridge.js — compiled from
|
||||
* bridge.ts): listFolder / createFolder / folders / usage. No framework.
|
||||
*
|
||||
* @returns per mode (resolve value, null = cancelled):
|
||||
* save -> {name, folderId}
|
||||
* open -> {recordId, recordName}
|
||||
* asset -> {file, folderId, name}
|
||||
* export -> {name, folderId}
|
||||
*/
|
||||
import { bridge } from "./bridge.js?v=20.0.1.0.0";
|
||||
|
||||
const STYLE_ID = "ocs-explorer-style";
|
||||
|
||||
const MODES = {
|
||||
save: {
|
||||
heading: "Save to Documents",
|
||||
description: "Choose a folder in your Odoo drive for this .fieldcraft document.",
|
||||
action: "Save",
|
||||
prefer: "root",
|
||||
lastKey: "one-cad-sim:documents:last-folder:save",
|
||||
},
|
||||
open: {
|
||||
heading: "Open from Documents",
|
||||
description: "Browse your Odoo Documents drive and pick a .fieldcraft document.",
|
||||
action: "Open",
|
||||
prefer: "root",
|
||||
lastKey: "one-cad-sim:documents:last-folder:open",
|
||||
extensions: new Set(["fieldcraft", "solidcraft"]),
|
||||
},
|
||||
asset: {
|
||||
heading: "Import asset",
|
||||
description: "Pick a CAD asset from your computer to copy into the Assets folder.",
|
||||
action: "Import",
|
||||
prefer: "assets",
|
||||
lastKey: "one-cad-sim:documents:last-folder:asset",
|
||||
extensions: new Set([
|
||||
"step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf",
|
||||
"dxf", "svg", "gltf", "glb", "ply", "urdf",
|
||||
]),
|
||||
},
|
||||
export: {
|
||||
heading: "Export",
|
||||
description: "Name the export — it lands in your Documents Exports folder.",
|
||||
action: "Export",
|
||||
prefer: "exports",
|
||||
lastKey: "one-cad-sim:documents:last-folder:export",
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Small SVG icon set (lucide-style, stroke = currentColor) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function svg(paths, size) {
|
||||
return (
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
||||
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" ' +
|
||||
'style="width:' + (size || 16) + 'px;height:' + (size || 16) + 'px;flex:none">' +
|
||||
paths + "</svg>"
|
||||
);
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
folder: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'),
|
||||
lock: svg('<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', 11),
|
||||
chevronRight: svg('<path d="m9 18 6-6-6-6"/>', 12),
|
||||
hardDrive: svg('<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>'),
|
||||
file: svg('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>'),
|
||||
cube: svg('<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'),
|
||||
save: svg('<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 1-1 1H7"/>'),
|
||||
folderPlus: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="M12 10v6"/><path d="M9 13h6"/>'),
|
||||
upload: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>'),
|
||||
open: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="m2 21 5.1-5.1a2 2 0 0 1 2.8 0l1.1 1.1a2 2 0 0 0 2.8 0l4.2-4.2a2 2 0 0 1 2.8 0L22 14"/>'),
|
||||
download: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>'),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Injected dark CAD styles (the only CSS this module ever adds) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const STYLE_CSS = [
|
||||
":root{--ocs-bg:#1b1e24;--ocs-panel:#23272f;--ocs-panel2:#2a2f39;--ocs-hairline:#333945;",
|
||||
"--ocs-text:#e8eaed;--ocs-dim:#9aa3ad;--ocs-accent:#4f8cff;--ocs-accent-press:#3f76e0;",
|
||||
"--ocs-danger:#e5534b;--ocs-ok:#3fb26f}",
|
||||
".ocs-explorer-root{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;",
|
||||
"font:13px/1.45 'Segoe UI',system-ui,-apple-system,sans-serif;color:var(--ocs-text)}",
|
||||
".ocs-backdrop{position:absolute;inset:0;background:rgba(8,10,14,.62)}",
|
||||
".ocs-dialog{position:relative;width:min(860px,calc(100vw - 48px));height:min(560px,calc(100vh - 64px));",
|
||||
"background:var(--ocs-bg);border:1px solid var(--ocs-hairline);border-radius:10px;",
|
||||
"box-shadow:0 24px 64px rgba(0,0,0,.55);display:flex;flex-direction:column;overflow:hidden}",
|
||||
".ocs-header{padding:14px 18px 10px;border-bottom:1px solid var(--ocs-hairline);background:var(--ocs-panel)}",
|
||||
".ocs-title{font-size:15px;font-weight:600}",
|
||||
".ocs-desc{margin-top:2px;color:var(--ocs-dim);font-size:12px}",
|
||||
".ocs-desc-error{color:var(--ocs-danger)}",
|
||||
".ocs-body{flex:1;display:flex;min-height:0}",
|
||||
".ocs-aside{width:250px;flex:none;display:flex;flex-direction:column;border-right:1px solid var(--ocs-hairline);background:var(--ocs-panel)}",
|
||||
".ocs-tree-box{flex:1;overflow:auto;padding:8px 6px}",
|
||||
".ocs-tree-label{margin:2px 8px 6px;color:var(--ocs-dim);font-size:11px;text-transform:uppercase;letter-spacing:.06em}",
|
||||
".ocs-tree-error{padding:8px}",
|
||||
".ocs-tree-row{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:6px;cursor:pointer;white-space:nowrap}",
|
||||
".ocs-tree-row:hover{background:var(--ocs-panel2)}",
|
||||
".ocs-tree-row.ocs-active{background:var(--ocs-panel2);color:#fff}",
|
||||
".ocs-tree-toggle{width:14px;height:14px;flex:none;color:var(--ocs-dim);display:inline-flex;transition:transform .12s}",
|
||||
".ocs-tree-toggle.ocs-open{transform:rotate(90deg)}",
|
||||
".ocs-tree-icon{display:inline-flex;color:var(--ocs-accent)}",
|
||||
".ocs-tree-name{overflow:hidden;text-overflow:ellipsis}",
|
||||
".ocs-usage{padding:8px 14px;border-top:1px solid var(--ocs-hairline);color:var(--ocs-dim);font-size:11px}",
|
||||
".ocs-main{flex:1;display:flex;flex-direction:column;min-width:0}",
|
||||
".ocs-crumbs{display:flex;align-items:center;gap:2px;padding:8px 12px;border-bottom:1px solid var(--ocs-hairline);overflow:hidden}",
|
||||
".ocs-crumb{display:inline-flex;align-items:center;gap:5px;background:none;border:0;color:var(--ocs-dim);",
|
||||
"font:inherit;padding:4px 7px;border-radius:6px;cursor:pointer}",
|
||||
".ocs-crumb:hover:not(:disabled){background:var(--ocs-panel2);color:var(--ocs-text)}",
|
||||
".ocs-crumb:disabled{opacity:.45;cursor:default}",
|
||||
".ocs-crumb-current{color:var(--ocs-text)}",
|
||||
".ocs-crumb-sep{display:inline-flex;color:var(--ocs-dim)}",
|
||||
".ocs-files{flex:1;overflow:auto;padding:6px 8px}",
|
||||
".ocs-file-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;background:none;border:0;",
|
||||
"color:inherit;font:inherit;padding:7px 9px;border-radius:6px;cursor:pointer}",
|
||||
".ocs-file-row:hover{background:var(--ocs-panel2)}",
|
||||
".ocs-file-row.ocs-selected{background:rgba(79,140,255,.18);outline:1px solid var(--ocs-accent)}",
|
||||
".ocs-file-icon{display:inline-flex;color:var(--ocs-dim)}",
|
||||
".ocs-file-icon.ocs-icon-owned{color:var(--ocs-accent)}",
|
||||
".ocs-file-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
||||
".ocs-file-size{color:var(--ocs-dim);font-size:11px;flex:none}",
|
||||
".ocs-empty{color:var(--ocs-dim);padding:18px 10px;text-align:center}",
|
||||
".ocs-savebar{border-top:1px solid var(--ocs-hairline);padding:10px 14px;background:var(--ocs-panel)}",
|
||||
".ocs-name-row{display:flex;align-items:flex-end;gap:10px}",
|
||||
".ocs-name-wrap{flex:1;min-width:0}",
|
||||
".ocs-name-label{display:block;color:var(--ocs-dim);font-size:11px;margin-bottom:4px}",
|
||||
".ocs-name-field{display:flex;align-items:center;background:var(--ocs-bg);border:1px solid var(--ocs-hairline);border-radius:6px;padding:0 8px}",
|
||||
".ocs-name-field:focus-within{border-color:var(--ocs-accent)}",
|
||||
".ocs-name-input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--ocs-text);font:inherit;padding:7px 0}",
|
||||
".ocs-suffix{color:var(--ocs-dim);font-size:12px;user-select:none}",
|
||||
".ocs-create-row{display:flex;gap:8px;margin-top:8px}",
|
||||
".ocs-newfolder-input{flex:1;background:var(--ocs-bg);border:1px solid var(--ocs-hairline);border-radius:6px;",
|
||||
"color:var(--ocs-text);font:inherit;padding:6px 9px;outline:0}",
|
||||
".ocs-newfolder-input:focus{border-color:var(--ocs-accent)}",
|
||||
".ocs-picker{display:flex;flex-direction:column;gap:8px}",
|
||||
".ocs-pick-btn{display:inline-flex;align-items:center;gap:8px}",
|
||||
".ocs-picked{color:var(--ocs-dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
||||
".ocs-footer{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;",
|
||||
"border-top:1px solid var(--ocs-hairline);background:var(--ocs-panel)}",
|
||||
".ocs-btn{display:inline-flex;align-items:center;gap:6px;font:inherit;padding:7px 14px;border-radius:6px;",
|
||||
"border:1px solid var(--ocs-hairline);background:var(--ocs-panel2);color:var(--ocs-text);cursor:pointer}",
|
||||
".ocs-btn:hover:not(:disabled){border-color:var(--ocs-dim)}",
|
||||
".ocs-btn:disabled{opacity:.45;cursor:default}",
|
||||
".ocs-btn-accent{background:var(--ocs-accent);border-color:var(--ocs-accent);color:#fff}",
|
||||
".ocs-btn-accent:hover:not(:disabled){background:var(--ocs-accent-press)}",
|
||||
".ocs-btn-ghost{background:none;border-color:transparent;color:var(--ocs-dim)}",
|
||||
".ocs-btn-ghost:hover:not(:disabled){color:var(--ocs-text)}",
|
||||
".ocs-btn-icon{display:inline-flex}",
|
||||
].join("\n");
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = STYLE_CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function h(tag, attrs) {
|
||||
const el = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const key in attrs) {
|
||||
if (!Object.prototype.hasOwnProperty.call(attrs, key)) continue;
|
||||
const value = attrs[key];
|
||||
if (key === "class") el.className = value;
|
||||
else if (key === "html") el.innerHTML = value;
|
||||
else if (key === "text") el.textContent = value;
|
||||
else if (key.slice(0, 2) === "on" && typeof value === "function")
|
||||
el.addEventListener(key.slice(2), value);
|
||||
else if (value !== null && value !== undefined) el.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
for (let i = 2; i < arguments.length; i++) {
|
||||
const child = arguments[i];
|
||||
if (child === null || child === undefined) continue;
|
||||
el.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function formatSize(size) {
|
||||
size = Number(size) || 0;
|
||||
if (size < 1024) return size + " B";
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + " KB";
|
||||
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + " MB";
|
||||
return (size / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||
}
|
||||
|
||||
function extensionOf(name) {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function fileIconFor(file, extensions) {
|
||||
const ext = extensionOf(file.name || "");
|
||||
if (extensions && extensions.has(ext)) {
|
||||
return '<span class="ocs-file-icon ocs-icon-owned">' + ICONS.cube + "</span>";
|
||||
}
|
||||
return '<span class="ocs-file-icon">' + ICONS.file + "</span>";
|
||||
}
|
||||
|
||||
function rememberLastFolder(folderId, key) {
|
||||
try {
|
||||
window.localStorage.setItem(key, String(folderId));
|
||||
} catch (e) {
|
||||
/* private mode — memory only */
|
||||
}
|
||||
}
|
||||
|
||||
function restoreLastFolder(key) {
|
||||
try {
|
||||
const id = Number(window.localStorage.getItem(key));
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The explorer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Open one Documents explorer dialog.
|
||||
*
|
||||
* @param {object} opts
|
||||
* mode "save" | "open" | "asset" | "export"
|
||||
* defaultName save/export mode: suggested file name ("part.fieldcraft")
|
||||
* extensions open/asset mode: restrict file rows to these extensions
|
||||
* @returns {Promise<{name?, folderId?, recordId?, recordName?, file?} | null>}
|
||||
* null = cancelled (Esc, backdrop, Cancel button).
|
||||
*/
|
||||
export function openDocumentsExplorer(opts) {
|
||||
opts = opts || {};
|
||||
const mode = MODES[opts.mode] ? opts.mode : "save";
|
||||
const config = MODES[mode];
|
||||
const extensions = opts.extensions && opts.extensions.length
|
||||
? new Set(opts.extensions.map((ext) => String(ext).toLowerCase().replace(/^\./, "")))
|
||||
: config.extensions || null;
|
||||
|
||||
/* Standalone dev mode: no host, no dialog — the caller treats null as
|
||||
* "nothing chosen", so the app keeps running untouched. */
|
||||
if (typeof bridge.isHostAvailable === "function" && !bridge.isHostAvailable()) {
|
||||
console.info("[onecadsim-odoo] explorer: no Odoo host (standalone) — dialog unavailable");
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
ensureStyle();
|
||||
|
||||
/* Save/export name split: stem in the field, extension as the chip. */
|
||||
const wantsName = mode === "save" || mode === "export";
|
||||
const defaultExtension = mode === "save" ? ".fieldcraft" : "";
|
||||
let defaultName = typeof opts.defaultName === "string" && opts.defaultName.trim() !== ""
|
||||
? opts.defaultName.trim()
|
||||
: mode === "save"
|
||||
? "untitled.fieldcraft"
|
||||
: "export";
|
||||
let stem = defaultName;
|
||||
let suffix = "";
|
||||
const dotIdx = defaultName.lastIndexOf(".");
|
||||
if (dotIdx > 0) {
|
||||
stem = defaultName.slice(0, dotIdx);
|
||||
suffix = defaultName.slice(dotIdx);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
document.removeEventListener("keydown", onDocKeyDown, true);
|
||||
if (root && root.parentNode) root.parentNode.removeChild(root);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
/* ---------------- state ---------------- */
|
||||
let tree = null;
|
||||
let treeError = null;
|
||||
let appTree = {};
|
||||
let appRootId = null;
|
||||
let currentFolderId = null;
|
||||
let folderFolders = [];
|
||||
let folderFiles = [];
|
||||
let loadingFiles = false;
|
||||
let selectedFileId = null;
|
||||
let pickedFile = null;
|
||||
let createBusy = false;
|
||||
let usage = null;
|
||||
const expandedSet = new Set();
|
||||
|
||||
/* ---------------- chrome ---------------- */
|
||||
const backdrop = h("div", { class: "ocs-backdrop" });
|
||||
const dialog = h("div", { class: "ocs-dialog", role: "dialog", "aria-modal": "true" });
|
||||
const root = h("div", { class: "ocs-explorer-root" }, backdrop, dialog);
|
||||
|
||||
const descEl = h("div", { class: "ocs-desc" }, config.description);
|
||||
const header = h("div", { class: "ocs-header" },
|
||||
h("div", { class: "ocs-title" }, config.heading), descEl);
|
||||
|
||||
const treeLabel = h("p", { class: "ocs-tree-label" }, "Loading drive…");
|
||||
const treeBox = h("div", { class: "ocs-tree-box" }, treeLabel);
|
||||
const usageEl = h("div", { class: "ocs-usage", style: "display:none" });
|
||||
const aside = h("div", { class: "ocs-aside" }, treeBox, usageEl);
|
||||
|
||||
const crumbs = h("div", { class: "ocs-crumbs" });
|
||||
const fileList = h("div", { class: "ocs-files" });
|
||||
const main = h("div", { class: "ocs-main" }, crumbs, fileList);
|
||||
const body = h("div", { class: "ocs-body" }, aside, main);
|
||||
|
||||
/* name row (save + export) */
|
||||
const nameInput = h("input", {
|
||||
class: "ocs-name-input",
|
||||
id: "onecadsim-explorer-name",
|
||||
type: "text",
|
||||
value: stem,
|
||||
placeholder: mode === "save" ? "Untitled" : "export",
|
||||
spellcheck: "false",
|
||||
});
|
||||
const suffixChip = h("span", { class: "ocs-suffix" }, suffix);
|
||||
const nameField = h("div", { class: "ocs-name-field" }, nameInput, suffixChip);
|
||||
const nameRow = h("div", { class: "ocs-name-row" },
|
||||
h("div", { class: "ocs-name-wrap" },
|
||||
h("label", { class: "ocs-name-label", for: "onecadsim-explorer-name" }, "File name"),
|
||||
nameField));
|
||||
|
||||
/* new-folder row (save + export; asset keeps it too — retargeting) */
|
||||
const newNameInput = h("input", {
|
||||
class: "ocs-newfolder-input",
|
||||
type: "text",
|
||||
placeholder: "Folder name…",
|
||||
spellcheck: "false",
|
||||
});
|
||||
const createBtn = h("button", { class: "ocs-btn ocs-btn-accent", type: "button" }, "Create");
|
||||
const cancelCreateBtn = h("button", { class: "ocs-btn ocs-btn-ghost", type: "button" }, "Cancel");
|
||||
const createRow = h("div", { class: "ocs-create-row", style: "display:none" }, newNameInput, createBtn, cancelCreateBtn);
|
||||
const newFolderBtn = h("button", { class: "ocs-btn", type: "button" },
|
||||
h("span", { class: "ocs-btn-icon", html: ICONS.folderPlus }), "New folder");
|
||||
|
||||
/* asset file picker */
|
||||
const fileInput = h("input", {
|
||||
type: "file",
|
||||
style: "display:none",
|
||||
...(extensions ? { accept: Array.from(extensions).map((ext) => "." + ext).join(",") } : {}),
|
||||
});
|
||||
const pickedLabel = h("div", { class: "ocs-picked", text: "No file chosen yet." });
|
||||
const pickBtn = h("button", { class: "ocs-btn ocs-btn-accent", type: "button" },
|
||||
h("span", { class: "ocs-btn-icon", html: ICONS.upload }), "Choose file…");
|
||||
const picker = h("div", { class: "ocs-picker" }, pickBtn, pickedLabel, fileInput);
|
||||
|
||||
const savebar = h("div", { class: "ocs-savebar" });
|
||||
if (wantsName) savebar.appendChild(nameRow);
|
||||
if (mode === "asset") savebar.appendChild(picker);
|
||||
savebar.appendChild(newFolderBtn);
|
||||
savebar.appendChild(createRow);
|
||||
|
||||
/* footer */
|
||||
const cancelBtn = h("button", { class: "ocs-btn ocs-btn-ghost", type: "button" }, "Cancel");
|
||||
const actionIcon = mode === "save" ? ICONS.save : mode === "open" ? ICONS.open : mode === "asset" ? ICONS.upload : ICONS.download;
|
||||
const actionBtn = h("button", { class: "ocs-btn ocs-btn-accent", type: "button" },
|
||||
h("span", { class: "ocs-btn-icon", html: actionIcon }), config.action);
|
||||
const footer = h("div", { class: "ocs-footer" }, cancelBtn, actionBtn);
|
||||
|
||||
dialog.appendChild(header);
|
||||
dialog.appendChild(body);
|
||||
if (mode !== "open") dialog.appendChild(savebar);
|
||||
dialog.appendChild(footer);
|
||||
|
||||
const redraw = () => {
|
||||
if (mode === "save") {
|
||||
actionBtn.disabled = !nameInput.value.trim() || currentFolderId === null;
|
||||
} else if (mode === "open") {
|
||||
actionBtn.disabled = selectedFileId === null;
|
||||
} else if (mode === "asset") {
|
||||
actionBtn.disabled = !pickedFile || currentFolderId === null;
|
||||
} else {
|
||||
actionBtn.disabled = !nameInput.value.trim() || currentFolderId === null;
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------- tree rendering ---------------- */
|
||||
function renderTree() {
|
||||
treeBox.innerHTML = "";
|
||||
if (tree === null && !treeError) {
|
||||
treeBox.appendChild(h("p", { class: "ocs-tree-label" }, "Loading drive…"));
|
||||
return;
|
||||
}
|
||||
if (treeError) {
|
||||
const retry = h("button", { class: "ocs-btn", type: "button" }, "Retry");
|
||||
retry.addEventListener("click", () => boot());
|
||||
treeBox.appendChild(h("div", { class: "ocs-tree-error" },
|
||||
h("p", { class: "ocs-tree-label" }, treeError), retry));
|
||||
return;
|
||||
}
|
||||
treeBox.appendChild(h("p", { class: "ocs-tree-label" }, "My Drive"));
|
||||
const childrenOf = new Map();
|
||||
for (const folder of tree) {
|
||||
const key = folder.parentId === null ? "root" : String(folder.parentId);
|
||||
if (!childrenOf.has(key)) childrenOf.set(key, []);
|
||||
childrenOf.get(key).push(folder);
|
||||
}
|
||||
const renderNodes = (parentId, depth) => {
|
||||
const key = parentId === null ? "root" : String(parentId);
|
||||
const children = childrenOf.get(key) || [];
|
||||
for (const folder of children) {
|
||||
const hasChildren = (childrenOf.get(String(folder.id)) || []).length > 0;
|
||||
const isOpen = expandedSet.has(folder.id);
|
||||
const row = h("div", {
|
||||
class: "ocs-tree-row" + (folder.id === currentFolderId ? " ocs-active" : ""),
|
||||
style: "padding-left:" + (8 + depth * 14) + "px",
|
||||
});
|
||||
const toggle = h("span", { class: "ocs-tree-toggle" });
|
||||
if (hasChildren) {
|
||||
toggle.innerHTML = ICONS.chevronRight;
|
||||
toggle.classList.add("ocs-has-children");
|
||||
if (isOpen) toggle.classList.add("ocs-open");
|
||||
}
|
||||
toggle.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (expandedSet.has(folder.id)) expandedSet.delete(folder.id);
|
||||
else expandedSet.add(folder.id);
|
||||
renderTree();
|
||||
});
|
||||
row.appendChild(toggle);
|
||||
row.appendChild(h("span", { class: "ocs-tree-icon", html: ICONS.folder }));
|
||||
row.appendChild(h("span", { class: "ocs-tree-name" }, folder.name));
|
||||
row.addEventListener("click", () => enterFolder(folder.id));
|
||||
treeBox.appendChild(row);
|
||||
if (hasChildren && isOpen) renderNodes(folder.id, depth + 1);
|
||||
}
|
||||
};
|
||||
renderNodes(null, 0);
|
||||
usageEl.style.display = usage ? "" : "none";
|
||||
if (usage) {
|
||||
usageEl.textContent = usage.documentCount + " file" + (usage.documentCount === 1 ? "" : "s") +
|
||||
" · " + formatSize(usage.bytesUsed) + " used";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- breadcrumb ---------------- */
|
||||
function renderCrumbs() {
|
||||
crumbs.innerHTML = "";
|
||||
const driveBtn = h("button", { class: "ocs-crumb ocs-crumb-root", type: "button" });
|
||||
driveBtn.appendChild(h("span", { class: "ocs-btn-icon", html: ICONS.hardDrive }));
|
||||
driveBtn.appendChild(document.createTextNode("My Drive"));
|
||||
driveBtn.disabled = appRootId === null;
|
||||
driveBtn.addEventListener("click", () => appRootId !== null && enterFolder(appRootId));
|
||||
crumbs.appendChild(driveBtn);
|
||||
const path = [];
|
||||
let cursor = currentFolderId;
|
||||
const byId = new Map(tree ? tree.map((row) => [row.id, row]) : []);
|
||||
while (cursor !== null && byId.has(cursor)) {
|
||||
path.unshift(byId.get(cursor));
|
||||
cursor = byId.get(cursor).parentId;
|
||||
}
|
||||
for (const folder of path) {
|
||||
crumbs.appendChild(h("span", { class: "ocs-crumb-sep", html: ICONS.chevronRight }));
|
||||
const crumb = h("button", {
|
||||
class: "ocs-crumb" + (folder.id === currentFolderId ? " ocs-crumb-current" : ""),
|
||||
type: "button",
|
||||
}, folder.name);
|
||||
crumb.addEventListener("click", () => enterFolder(folder.id));
|
||||
crumbs.appendChild(crumb);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- files ---------------- */
|
||||
function renderFiles() {
|
||||
fileList.innerHTML = "";
|
||||
if (loadingFiles) {
|
||||
fileList.appendChild(h("p", { class: "ocs-empty" }, "Loading…"));
|
||||
return;
|
||||
}
|
||||
/* Desktop file-manager behavior: subfolders first (click = enter),
|
||||
* then files — open mode filtered to the requested extensions. */
|
||||
for (const folder of folderFolders) {
|
||||
const row = h("button", { class: "ocs-file-row", type: "button" });
|
||||
row.insertAdjacentHTML("beforeend", '<span class="ocs-file-icon ocs-icon-owned">' + ICONS.folder + "</span>");
|
||||
row.appendChild(h("span", { class: "ocs-file-name" }, folder.name));
|
||||
if (folder.system) {
|
||||
row.insertAdjacentHTML("beforeend",
|
||||
'<span class="ocs-file-size" title="Protected OneBSpace folder">' + ICONS.lock + "</span>");
|
||||
}
|
||||
row.appendChild(h("span", { class: "ocs-file-size" }, "Folder"));
|
||||
row.addEventListener("click", () => enterFolder(folder.id));
|
||||
fileList.appendChild(row);
|
||||
}
|
||||
const visibleFiles = extensions
|
||||
? folderFiles.filter((file) => extensions.has(extensionOf(file.name || "")))
|
||||
: folderFiles;
|
||||
if (folderFolders.length === 0 && visibleFiles.length === 0) {
|
||||
fileList.appendChild(h("p", { class: "ocs-empty" },
|
||||
mode === "save" || mode === "export" ? "This folder is empty." : "No compatible files in this folder."));
|
||||
return;
|
||||
}
|
||||
if (visibleFiles.length === 0) {
|
||||
fileList.appendChild(h("p", { class: "ocs-empty" }, "No compatible files here yet."));
|
||||
return;
|
||||
}
|
||||
for (const file of visibleFiles) {
|
||||
const row = h("button", {
|
||||
class: "ocs-file-row" + (selectedFileId === file.id ? " ocs-selected" : ""),
|
||||
type: "button",
|
||||
});
|
||||
row.insertAdjacentHTML("beforeend", fileIconFor(file, extensions));
|
||||
row.appendChild(h("span", { class: "ocs-file-name" }, file.name));
|
||||
row.appendChild(h("span", { class: "ocs-file-size" }, formatSize(file.size || 0)));
|
||||
row.addEventListener("click", () => {
|
||||
selectedFileId = file.id;
|
||||
renderFiles();
|
||||
redraw();
|
||||
});
|
||||
row.addEventListener("dblclick", () => {
|
||||
if (mode === "open") settle({ recordId: file.id, recordName: file.name });
|
||||
});
|
||||
fileList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- actions ---------------- */
|
||||
function enterFolder(folderId) {
|
||||
currentFolderId = folderId;
|
||||
selectedFileId = null;
|
||||
rememberLastFolder(folderId, config.lastKey);
|
||||
loadFiles(folderId);
|
||||
renderTree();
|
||||
renderCrumbs();
|
||||
redraw();
|
||||
}
|
||||
|
||||
function loadFiles(folderId) {
|
||||
loadingFiles = true;
|
||||
renderFiles();
|
||||
bridge
|
||||
.listFolder(folderId)
|
||||
.then((listing) => {
|
||||
folderFiles = listing.files || [];
|
||||
folderFolders = listing.folders || [];
|
||||
loadingFiles = false;
|
||||
renderFiles();
|
||||
})
|
||||
.catch((error) => {
|
||||
folderFiles = [];
|
||||
folderFolders = [];
|
||||
loadingFiles = false;
|
||||
renderFiles();
|
||||
flashError("Could not list this folder — " + (error && error.message ? error.message : "unknown error"));
|
||||
});
|
||||
}
|
||||
|
||||
function onCreateFolder() {
|
||||
const trimmed = newNameInput.value.trim();
|
||||
if (!trimmed || currentFolderId === null || createBusy) return;
|
||||
createBusy = true;
|
||||
createBtn.disabled = true;
|
||||
bridge
|
||||
.createFolder({ name: trimmed, parentId: currentFolderId })
|
||||
.then((created) => {
|
||||
tree = (tree || []).concat([{ id: created.id, name: trimmed, parentId: currentFolderId }]);
|
||||
expandedSet.add(currentFolderId);
|
||||
expandedSet.add(created.id);
|
||||
newNameInput.value = "";
|
||||
createRow.style.display = "none";
|
||||
newFolderBtn.style.display = "";
|
||||
enterFolder(created.id);
|
||||
})
|
||||
.catch((error) => {
|
||||
flashError("Could not create folder — " + (error && error.message ? error.message : "unknown error"));
|
||||
})
|
||||
.finally(() => {
|
||||
createBusy = false;
|
||||
createBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAction() {
|
||||
if (mode === "save") {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name || currentFolderId === null) return;
|
||||
settle({ name, folderId: currentFolderId });
|
||||
} else if (mode === "open") {
|
||||
const file = folderFiles.find((candidate) => candidate.id === selectedFileId);
|
||||
if (file) settle({ recordId: file.id, recordName: file.name });
|
||||
} else if (mode === "asset") {
|
||||
if (pickedFile && currentFolderId !== null) {
|
||||
settle({ file: pickedFile, folderId: currentFolderId, name: pickedFile.name });
|
||||
}
|
||||
} else {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name || currentFolderId === null) return;
|
||||
settle({ name, folderId: currentFolderId });
|
||||
}
|
||||
}
|
||||
|
||||
pickBtn.addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", () => {
|
||||
pickedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
|
||||
pickedLabel.textContent = pickedFile
|
||||
? pickedFile.name + " · " + formatSize(pickedFile.size)
|
||||
: "No file chosen yet.";
|
||||
redraw();
|
||||
});
|
||||
|
||||
newFolderBtn.addEventListener("click", () => {
|
||||
newFolderBtn.style.display = "none";
|
||||
createRow.style.display = "";
|
||||
newNameInput.focus();
|
||||
});
|
||||
cancelCreateBtn.addEventListener("click", () => {
|
||||
newNameInput.value = "";
|
||||
createRow.style.display = "none";
|
||||
newFolderBtn.style.display = "";
|
||||
});
|
||||
createBtn.addEventListener("click", onCreateFolder);
|
||||
newNameInput.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") onCreateFolder();
|
||||
});
|
||||
nameInput.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") confirmAction();
|
||||
});
|
||||
nameInput.addEventListener("input", redraw);
|
||||
cancelBtn.addEventListener("click", () => settle(null));
|
||||
actionBtn.addEventListener("click", confirmAction);
|
||||
backdrop.addEventListener("mousedown", () => settle(null));
|
||||
dialog.addEventListener("mousedown", (ev) => ev.stopPropagation());
|
||||
|
||||
function onDocKeyDown(ev) {
|
||||
if (ev.key !== "Escape") return;
|
||||
ev.stopPropagation();
|
||||
settle(null);
|
||||
}
|
||||
document.addEventListener("keydown", onDocKeyDown, true);
|
||||
|
||||
let errorTimer = null;
|
||||
function flashError(message) {
|
||||
descEl.textContent = message;
|
||||
descEl.classList.add("ocs-desc-error");
|
||||
if (errorTimer) clearTimeout(errorTimer);
|
||||
errorTimer = setTimeout(() => {
|
||||
descEl.textContent = config.description;
|
||||
descEl.classList.remove("ocs-desc-error");
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/* ---------------- boot ---------------- */
|
||||
function boot() {
|
||||
tree = null;
|
||||
treeError = null;
|
||||
renderTree();
|
||||
void (async () => {
|
||||
try {
|
||||
const folders = await bridge.folders();
|
||||
tree = folders.tree || [];
|
||||
appTree = folders.app || {};
|
||||
appRootId = typeof appTree.root === "number" ? appTree.root : null;
|
||||
const preferred = typeof appTree[config.prefer] === "number"
|
||||
? appTree[config.prefer]
|
||||
: appTree.root;
|
||||
const restored = restoreLastFolder(config.lastKey);
|
||||
const known = new Set(tree.map((row) => row.id));
|
||||
const target = restored && known.has(restored) ? restored : preferred;
|
||||
currentFolderId = target;
|
||||
const byId = new Map(tree.map((row) => [row.id, row]));
|
||||
let cursor = target;
|
||||
while (cursor !== null) {
|
||||
expandedSet.add(cursor);
|
||||
cursor = byId.has(cursor) ? byId.get(cursor).parentId : null;
|
||||
}
|
||||
renderTree();
|
||||
renderCrumbs();
|
||||
loadFiles(target);
|
||||
bridge
|
||||
.usage()
|
||||
.then((info) => {
|
||||
usage = info;
|
||||
renderTree();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
} catch (error) {
|
||||
treeError = error && error.message ? error.message : "Could not load the drive";
|
||||
renderTree();
|
||||
}
|
||||
redraw();
|
||||
if (wantsName) {
|
||||
window.setTimeout(() => {
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}, 50);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
document.body.appendChild(root);
|
||||
boot();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
|
||||
// OneCAD Simulation Odoo Documents glue (ported from OneCAD).
|
||||
/**
|
||||
* OneCAD Simulation odoo/index.ts — the boot glue (bridge <-> explorer <-> Rust app).
|
||||
* ----------------------------------------------------------------------
|
||||
* Entry module loaded by index.html ONLY inside the wasm_app_framework
|
||||
* iframe (standalone runs never import it — see the import guard there).
|
||||
* 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, "onecadsim:document-opened");
|
||||
* 3. signal "onecadsim:host-ready" {host, documentId, name} exactly once.
|
||||
* The same state stays readable synchronously at window.onecadsimHost
|
||||
* (late-booting Rust) together with consumePendingOpen(), which hands
|
||||
* the deep-link payload {documentId, name, dataB64} over once.
|
||||
*
|
||||
* 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 addon build
|
||||
* to odoo/index.js next to bridge.js; explorer.js is a plain-JS module
|
||||
* copied verbatim. Exports nothing.
|
||||
*/
|
||||
import { 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 registers listeners after this module
|
||||
* has run, so it polls this synchronously-readable state on init). */
|
||||
interface OneCadHostWindow {
|
||||
onecadsimHostReady?: boolean;
|
||||
onecadsimHost?: {
|
||||
ready: boolean;
|
||||
host: boolean;
|
||||
documentId: number | null;
|
||||
name: string | null;
|
||||
consumePendingOpen: () => DocumentOpenedDetail | null;
|
||||
};
|
||||
onecadsim?: { request?: (method: string, params?: unknown) => Promise<unknown> };
|
||||
onecadsimOdoo?: {
|
||||
bootFile?: (recordId: number, name: string, bytes: Uint8Array) => string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
const hostWindow = window as unknown as OneCadHostWindow;
|
||||
hostWindow.onecadsimHost = {
|
||||
ready: false,
|
||||
host: false,
|
||||
documentId: null,
|
||||
name: null,
|
||||
consumePendingOpen: () => bridge.consumePendingOpen(),
|
||||
};
|
||||
|
||||
/* services_odoo.rs polls window.onecadsimHostReady (a plain boolean) before
|
||||
* dispatching odoo.pickOpen / odoo.pickSave / odoo.write to this glue.
|
||||
* 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 wedges the
|
||||
* app's file dialogs for REQUEST_TIMEOUT (120 s) with no dialog on screen. */
|
||||
function markHostReady(): void {
|
||||
try {
|
||||
hostWindow.onecadsimHostReady = true;
|
||||
} catch (error) {
|
||||
console.warn("[onecadsim-odoo] could not set onecadsimHostReady:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/* Deep-link opener: stage the record bytes into the engine VFS via the
|
||||
* window.onecadsimOdoo.bootFile seam (installed by services_odoo.rs), then ask
|
||||
* the app to open the staged path through its control API. The wasm app API
|
||||
* appears asynchronously after boot, so poll briefly for it. */
|
||||
async function openStagedDeepLink(entry: DocumentOpenedDetail | null): Promise<void> {
|
||||
if (!entry || typeof entry.dataB64 !== "string") return;
|
||||
const deadline = Date.now() + 60000;
|
||||
while (Date.now() < deadline) {
|
||||
const api = hostWindow.onecadsim;
|
||||
const seam = hostWindow.onecadsimOdoo;
|
||||
if (api && typeof api.request === "function" && seam && typeof seam.bootFile === "function") {
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(entry.dataB64), (char) => char.charCodeAt(0));
|
||||
const path = seam.bootFile(entry.documentId, entry.name, bytes);
|
||||
if (typeof path === "string" && path) {
|
||||
await api.request("ui.file", { path });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[onecadsim-odoo] deep-link open failed:",
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
console.warn("[onecadsim-odoo] deep-link open skipped: the app API never appeared");
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
let host = false;
|
||||
if (bridge.inFrame) {
|
||||
try {
|
||||
await bridge.waitForHost();
|
||||
host = true;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[onecadsim-odoo] host did not answer in time — continuing without Odoo Documents:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.info("[onecadsim-odoo] standalone (no host frame) — Odoo Documents disabled");
|
||||
}
|
||||
|
||||
/* Unblock the Rust pickers first; the deep-link read continues in the
|
||||
* background and must never keep the file dialogs hostage. */
|
||||
markHostReady();
|
||||
let deepLink = false;
|
||||
if (host) {
|
||||
try {
|
||||
deepLink = await bridge.openDeepLink();
|
||||
} catch (error) {
|
||||
console.warn("[onecadsim-odoo] deep-link read failed:", error);
|
||||
}
|
||||
}
|
||||
const binding = bridge.getCurrentBinding();
|
||||
|
||||
const detail: HostReadyDetail = {
|
||||
host,
|
||||
documentId: binding.documentId,
|
||||
name: binding.name,
|
||||
};
|
||||
const state = hostWindow.onecadsimHost;
|
||||
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 {};
|
||||
@@ -0,0 +1,708 @@
|
||||
// OneBSpace (OneCAD Simulation): Odoo Documents host integration.
|
||||
//! OneBSpace (OneCAD Simulation): Odoo Documents host integration for the wasm32 build
|
||||
//! (the `mod services_odoo;` declaration in `main.rs` carries the
|
||||
//! `#[cfg(target_arch = "wasm32")]` gate).
|
||||
//!
|
||||
//! Inside the wasm_app_framework shell every host seam of
|
||||
//! [`fieldcraft_ui_egui::Services`] is rerouted through the one-cad-simulation JS
|
||||
//! bridge instead of the browser/device. FieldCraft's pickers are *synchronous*
|
||||
//! (`Fn(&[Filter]) -> Option<String>`), so unlike the OneCAD port this module does
|
||||
//! not return futures: the closure fires the bridge request and answers `None`
|
||||
//! (the UI sees "cancelled"), and the real completion is queued here and drained
|
||||
//! once per frame by the web shell:
|
||||
//!
|
||||
//! - an *open* pick stages the record's bytes in the page filesystem at
|
||||
//! `/odoo/doc/<recordId>/<name>` and queues `ModelApp::open_path` (the same
|
||||
//! entry `web.rs::open_arrived` uses, so statuses and worker opens behave);
|
||||
//! - a *save* pick queues `file.save_as {"path": "/odoo/pending/<token>/<name>"}`;
|
||||
//! the engine write is air-gapped by [`handle_write`] and the bridge binds the
|
||||
//! pending token to the Documents record, so the model's file becomes the
|
||||
//! record and later Ctrl+S writes back in place.
|
||||
//! - writes drained from the page filesystem under `/odoo/…` (or model files
|
||||
//! anywhere else) go to the bound Documents record; every other write becomes
|
||||
//! an export into the "Exports" folder — the air gap keeps bytes off the local
|
||||
//! device ([`handle_write`]); the web shell checks the autosave/recovery folder
|
||||
//! *first*, so the localStorage recovery mirror keeps working as upstream.
|
||||
//!
|
||||
//! Everything is inert standalone: when the page is not framed by the Odoo shell
|
||||
//! ([`embedded`]) nothing is overridden and the upstream behavior is untouched.
|
||||
//!
|
||||
//! # WIRE PROTOCOL (matches the one-cad-simulation JS bridge: CustomEvents)
|
||||
//!
|
||||
//! Rust → JS: `window.dispatchEvent(new CustomEvent("onecadsim:host-request",
|
||||
//! { detail }))` with `detail = { id: number, method: string, params: object }`.
|
||||
//!
|
||||
//! JS → Rust: `window.dispatchEvent(new CustomEvent("onecadsim:host-response",
|
||||
//! { detail }))` with `detail = { id, ok: boolean, result?: any, error?: string }`.
|
||||
//!
|
||||
//! The glue must attach its `onecadsim:host-response` listener *before* the app
|
||||
//! boots (index.html loads the bridge module ahead of the wasm) and set
|
||||
//! `window.onecadsimHostReady = true` when listening; until the flag appears
|
||||
//! requests are retried every [`RETRY_MS`] for [`HOST_DEADLINE`].
|
||||
//!
|
||||
//! | method | params | result (on `ok`) |
|
||||
//! |--------------------|------------------------------------------|------------------|
|
||||
//! | `odoo.pickOpen` | `{ extensions: string[] }` | `{ recordId, name, bytes: Uint8Array }` or `{ content: <base64> }` → bytes staged at `/odoo/doc/<recordId>/<name>` |
|
||||
//! | `odoo.pickSave` | `{ name: string, design: boolean }` | `{ token, name }` → `/odoo/pending/<token>/<name>`, or `{ path: "/odoo/…" }` |
|
||||
//! | `odoo.pickProgram` | `{}` (asset pick from the "Assets" folder) | same shape as `odoo.pickOpen` |
|
||||
//! | `odoo.write` | `{ path: string, bytes: Uint8Array }` | ack only (`ok`) — Documents record write-back |
|
||||
//! | `odoo.saveExport` | `{ name: string, bytes: Uint8Array }` | ack only — lands in the "Exports" folder |
|
||||
//! | `odoo.import` | `{ name: string, bytes: Uint8Array }` | ack only — best-effort mirror into "Assets" |
|
||||
//!
|
||||
//! A `null`/absent `result` with `ok: true` means the user cancelled the
|
||||
//! explorer; nothing is queued and the UI stays as it was.
|
||||
//!
|
||||
//! For JS→Rust staging (deep links `?document=<id>`) the glue calls
|
||||
//! `window.onecadsimOdoo.bootFile(recordId, name, uint8array) -> path`, which
|
||||
//! inserts the bytes into the page filesystem at `/odoo/doc/<id>/<name>`, then
|
||||
//! asks the app to open the returned path via `window.onecadsim.request`.
|
||||
//!
|
||||
//! `window.onecadsim` is a small promise-based API the glue drives (the OneCAD
|
||||
//! fork's `api.rs` equivalent, kept minimal):
|
||||
//!
|
||||
//! - `request("ui.file", { path })` → queues the open; resolves immediately.
|
||||
//! - `request("odoo.document.bytes")` → resolves with the current model's
|
||||
//! `.fieldcraft` bytes ([`Session::file_bytes`], via the shell's frame drain).
|
||||
//! - `request("odoo.document.info")` → resolves with `{ name }` (the current
|
||||
//! model's file name, if any).
|
||||
//!
|
||||
//! # HOW TO WIRE (exact locations in this tree)
|
||||
//!
|
||||
//! 1. `apps/fieldcraft-web/src/main.rs`, module block (tagged
|
||||
//! `OneBSpace (OneCAD Simulation)`): `#[cfg(target_arch = "wasm32")] mod services_odoo;`
|
||||
//! 2. `apps/fieldcraft-web/src/web.rs`, the startup closure — immediately after
|
||||
//! the `services(inbox, ctx)` literal and before `with_services`:
|
||||
//! `let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());`
|
||||
//! 3. `apps/fieldcraft-web/src/web.rs` `Shell::logic` (tagged line):
|
||||
//! `crate::services_odoo::drain(&mut self.app);` — completes queued picks.
|
||||
//! 4. `apps/fieldcraft-web/src/web.rs` `Shell::hand_out_writes` (tagged): the
|
||||
//! `else if crate::services_odoo::handle_write(&path, &bytes) { continue; }`
|
||||
//! branch between the recovery-folder branch and the download branch.
|
||||
//! 5. `apps/fieldcraft-web/src/web.rs` `Shell::open_arrived` (tagged):
|
||||
//! `crate::services_odoo::mirror_import(&name, …)` mirrors computer imports
|
||||
//! into the Documents "Assets" folder.
|
||||
|
||||
// OneBSpace (OneCAD Simulation) — this entire file is downstream; keep it out of
|
||||
// upstream syncs (greppable tag above).
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
|
||||
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
|
||||
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
/// An existing Documents record inside the emulated filesystem.
|
||||
const ODOC: &str = "/odoo/doc/";
|
||||
/// A Save-as destination chosen in the explorer, not yet written.
|
||||
const OPENDING: &str = "/odoo/pending/";
|
||||
/// Scratch area whose writes the shell swallows (never a record, never a download).
|
||||
const OTMP: &str = "/odoo/tmp/";
|
||||
/// Rust → JS request event (see the WIRE PROTOCOL block above).
|
||||
const REQUEST_EVENT: &str = "onecadsim:host-request";
|
||||
/// JS → Rust response event (see the WIRE PROTOCOL block above).
|
||||
const RESPONSE_EVENT: &str = "onecadsim:host-response";
|
||||
const PICK_OPEN: &str = "odoo.pickOpen";
|
||||
const PICK_SAVE: &str = "odoo.pickSave";
|
||||
/// Supported by the JS bridge; FieldCraft's `Services` has no program picker,
|
||||
/// so the Rust side never emits it today.
|
||||
#[allow(dead_code)]
|
||||
const PICK_PROGRAM: &str = "odoo.pickProgram";
|
||||
const WRITE: &str = "odoo.write";
|
||||
const SAVE_EXPORT: &str = "odoo.saveExport";
|
||||
const IMPORT: &str = "odoo.import";
|
||||
/// At most this many bridge requests parked at once.
|
||||
const MAX_PENDING: usize = 64;
|
||||
/// Retry cadence while `window.onecadsimHostReady` has not appeared yet.
|
||||
const RETRY_MS: i32 = 250;
|
||||
/// How long to wait for the JS bridge module to become ready per request
|
||||
/// (in retry ticks of [`RETRY_MS`]: 30 s).
|
||||
const HOST_DEADLINE_TICKS: u32 = 120;
|
||||
|
||||
/// CAE/model formats the app ingests beyond its own (mirrors the JS bridge's
|
||||
/// asset list). Imported copies land in the Documents "Assets" folder.
|
||||
const ASSET_EXTS: [&str; 12] = ["step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf", "dxf", "gltf", "glb", "ply"];
|
||||
|
||||
thread_local! {
|
||||
/// Decided once: does this page run inside the Odoo shell?
|
||||
static EMBEDDED: Cell<Option<bool>> = const { Cell::new(None) };
|
||||
/// Pending picker requests, keyed by request id.
|
||||
static PENDING: RefCell<HashMap<u64, Arc<Slot>>> = RefCell::new(HashMap::new());
|
||||
/// Completions waiting for the shell's next frame ([`drain`]).
|
||||
static COMPLETED: RefCell<Vec<Completion>> = RefCell::new(Vec::new());
|
||||
/// Promise resolvers parked for `odoo.document.bytes`, keyed by request id.
|
||||
static BYTES_WAITING: RefCell<HashMap<u64, Function>> = RefCell::new(HashMap::new());
|
||||
/// Promise resolvers parked for `odoo.document.info`, keyed by request id.
|
||||
static INFO_WAITING: RefCell<HashMap<u64, Function>> = RefCell::new(HashMap::new());
|
||||
/// The request-id counter.
|
||||
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
|
||||
/// The `onecadsim:host-response` listener is installed exactly once.
|
||||
static LISTENER: Cell<bool> = const { Cell::new(false) };
|
||||
/// The `window.onecadsimOdoo` boot seam is installed exactly once.
|
||||
static BOOT: Cell<bool> = const { Cell::new(false) };
|
||||
/// The `window.onecadsim` API is installed exactly once.
|
||||
static API: Cell<bool> = const { Cell::new(false) };
|
||||
/// Asset imports already mirrored this session (name, size).
|
||||
static MIRRORED: RefCell<HashSet<(String, usize)>> = RefCell::new(HashSet::new());
|
||||
}
|
||||
|
||||
/// A picker request parked in flight: the response listener removes it from
|
||||
/// [`PENDING`] and pushes the queued completion for [`drain`] (the sync
|
||||
/// `Services` design has no future to poll a slot, so the queue is the handoff).
|
||||
struct Slot {
|
||||
done: Cell<bool>,
|
||||
}
|
||||
|
||||
/// Work the shell performs on its next frame.
|
||||
enum Completion {
|
||||
/// Open the emulated path (`ModelApp::open_path`).
|
||||
Open(String),
|
||||
/// Save the model to the emulated path (`file.save_as`).
|
||||
SaveAs(String),
|
||||
/// Resolve `odoo.document.bytes` with the model's bytes.
|
||||
Bytes(u64),
|
||||
/// Resolve `odoo.document.info` with the model's file name.
|
||||
Info(u64),
|
||||
}
|
||||
|
||||
/// Is this page running inside the wasm_app_framework shell? Decided once,
|
||||
/// the family rule (one-cut-pro env.ts): framed and the same-origin parent
|
||||
/// is the Odoo web client (`/odoo` or `/web`). `?odoo-host=1` forces it on
|
||||
/// for local testing against a stub shell.
|
||||
pub fn embedded() -> bool {
|
||||
if let Some(decided) = EMBEDDED.with(Cell::get) {
|
||||
return decided;
|
||||
}
|
||||
let decided = embedded_uncached();
|
||||
EMBEDDED.with(|e| e.set(Some(decided)));
|
||||
decided
|
||||
}
|
||||
|
||||
fn embedded_uncached() -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let Ok(search) = window.location().search() else { return false };
|
||||
if search.contains("odoo-host=1") {
|
||||
return true;
|
||||
}
|
||||
let Ok(parent) = window.parent() else { return false };
|
||||
// Top window: standalone run, upstream behavior everywhere.
|
||||
let parent_top = parent
|
||||
.as_ref()
|
||||
.map(|p| {
|
||||
AsRef::<JsValue>::as_ref(p) == AsRef::<JsValue>::as_ref(&window)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if parent_top {
|
||||
return false;
|
||||
}
|
||||
// Same-origin shell: the parent path is readable; anything else that is
|
||||
// framed counts as embedded (the shell never cross-origins the iframe).
|
||||
let pathname = parent
|
||||
.as_ref()
|
||||
.and_then(|p| p.location().pathname().ok());
|
||||
match pathname {
|
||||
Some(path) => path.starts_with("/odoo") || path.starts_with("/web"),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the integration: the seams and the response listener always
|
||||
/// (harmless standalone), the Services overrides only inside the shell.
|
||||
/// Call once at app start, right after the `services(inbox, ctx)` literal in
|
||||
/// `web.rs` and before `with_services` (see HOW TO WIRE above).
|
||||
pub fn install_odoo_services(services: Services, ctx: egui::Context) -> Services {
|
||||
install_response_listener(ctx.clone());
|
||||
install_seams(ctx.clone());
|
||||
if !embedded() {
|
||||
return services;
|
||||
}
|
||||
log::info!("OneCAD Simulation: Odoo shell detected — pickers and writes route through the Documents bridge");
|
||||
let open_ctx = ctx.clone();
|
||||
let save_ctx = ctx;
|
||||
Services {
|
||||
// Open/Insert pickers browse Odoo Documents: fire the explorer request
|
||||
// and answer `None` (the shell completes the open on a later frame).
|
||||
pick_open: Some(Box::new(move |filters: &[Filter]| {
|
||||
let params = Object::new();
|
||||
let list = Array::new();
|
||||
for ext in filter_extensions(filters) {
|
||||
list.push(&JsValue::from_str(&ext));
|
||||
}
|
||||
let _ = Reflect::set(¶ms, &"extensions".into(), &list.into());
|
||||
pick(PICK_OPEN, params, open_ctx.clone());
|
||||
None
|
||||
})),
|
||||
// Saves ask the explorer once; the returned pending token binds the
|
||||
// model to its record (the queued `file.save_as` lands it, later
|
||||
// Ctrl+S writes back in place).
|
||||
pick_save: Some(Box::new(move |name: &str, filters: &[Filter]| {
|
||||
let params = Object::new();
|
||||
let _ = Reflect::set(¶ms, &"name".into(), &JsValue::from_str(name));
|
||||
// A model save (the owned `.fieldcraft` type) drives the owned save flow.
|
||||
let design = filters.iter().any(|f| f.extensions.iter().any(|e| e.eq_ignore_ascii_case("fieldcraft")));
|
||||
let _ = Reflect::set(¶ms, &"design".into(), &JsValue::from_bool(design));
|
||||
pick(PICK_SAVE, params, save_ctx.clone());
|
||||
None
|
||||
})),
|
||||
// Air gap: no external links from the shell.
|
||||
open_url: Some(Box::new(|_: &str| Ok(()))),
|
||||
..services
|
||||
}
|
||||
}
|
||||
|
||||
/// The union of a picker filter set's extensions, lowercase, deduped, in order.
|
||||
fn filter_extensions(filters: &[Filter]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for filter in filters {
|
||||
for ext in filter.extensions {
|
||||
let ext = ext.to_ascii_lowercase();
|
||||
if !out.iter().any(|seen| *seen == ext) {
|
||||
out.push(ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Fire a bridge request (retried until the glue is ready); the answer arrives
|
||||
/// out-of-band via `onecadsim:host-response` and completes a queued pick.
|
||||
fn pick(method: &'static str, params: Object, ctx: egui::Context) {
|
||||
if PENDING.with(|p| p.borrow().len()) >= MAX_PENDING {
|
||||
log::error!("OneCAD Simulation: too many pending bridge requests (maximum {MAX_PENDING})");
|
||||
return;
|
||||
}
|
||||
let id = next_id();
|
||||
let slot = Arc::new(Slot { done: Cell::new(false) });
|
||||
PENDING.with(|p| p.borrow_mut().insert(id, slot));
|
||||
deliver(method, id, params, 0);
|
||||
// Nudge the UI so the shell's frame drain runs promptly.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
fn next_id() -> u64 {
|
||||
NEXT_ID.with(|n| {
|
||||
let id = n.get();
|
||||
n.set(id + 1);
|
||||
id
|
||||
})
|
||||
}
|
||||
|
||||
/// Deliver a request once `window.onecadsimHostReady` appears (the glue sets it
|
||||
/// after attaching its response listener), then cancel the slot if it never
|
||||
/// does. Answers arrive out-of-band via `onecadsim:host-response`.
|
||||
fn deliver(method: &'static str, id: u64, params: Object, tick: u32) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let slot_done = PENDING.with(|p| p.borrow().get(&id).map(|s| s.done.get()).unwrap_or(true));
|
||||
if slot_done {
|
||||
return; // answered or cancelled while we were waiting
|
||||
}
|
||||
if host_ready() && dispatch_event(method, id, ¶ms) {
|
||||
return; // the shell owns the answer now
|
||||
}
|
||||
if tick >= HOST_DEADLINE_TICKS {
|
||||
log::error!("OneCAD Simulation: the Odoo bridge never became ready; cancelling \"{method}\"");
|
||||
PENDING.with(|p| p.borrow_mut().remove(&id));
|
||||
return;
|
||||
}
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, RETRY_MS);
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
deliver(method, id, params, tick + 1);
|
||||
});
|
||||
}
|
||||
|
||||
fn host_ready() -> bool {
|
||||
web_sys::window()
|
||||
.map(|w| Reflect::get(&w, &"onecadsimHostReady".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `window.dispatchEvent(new CustomEvent(method, { detail: {id, method, params} }))`.
|
||||
/// Built through `js_sys` (no extra web-sys feature gates). `false` when the
|
||||
/// event could not be created or dispatched (no shell listening).
|
||||
fn dispatch_event(method: &str, id: u64, params: &Object) -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let Ok(ctor) = Reflect::get(&window, &"CustomEvent".into()) else { return false };
|
||||
let Ok(ctor) = ctor.dyn_into::<js_sys::Function>() else { return false };
|
||||
let detail = Object::new();
|
||||
let _ = Reflect::set(&detail, &"id".into(), &JsValue::from(id as f64));
|
||||
let _ = Reflect::set(&detail, &"method".into(), &JsValue::from_str(method));
|
||||
let _ = Reflect::set(&detail, &"params".into(), params);
|
||||
let init = Object::new();
|
||||
let _ = Reflect::set(&init, &"detail".into(), &detail.into());
|
||||
let args = Array::of2(&JsValue::from_str(REQUEST_EVENT), &init.into());
|
||||
let Ok(event) = Reflect::construct(&ctor, &args) else { return false };
|
||||
let Ok(dispatch) = Reflect::get(&window, &"dispatchEvent".into()) else { return false };
|
||||
let Ok(dispatch) = dispatch.dyn_into::<js_sys::Function>() else { return false };
|
||||
dispatch.call1(&window, &event).is_ok()
|
||||
}
|
||||
|
||||
/// Install the `onecadsim:host-response` listener once: responses complete the
|
||||
/// parked picker slots (queued work for [`drain`]) or log write/import failures.
|
||||
fn install_response_listener(ctx: egui::Context) {
|
||||
if LISTENER.with(Cell::get) {
|
||||
return;
|
||||
}
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let listener = Closure::<dyn Fn(JsValue)>::new(move |event: JsValue| {
|
||||
let detail = Reflect::get(&event, &"detail".into()).unwrap_or(JsValue::UNDEFINED);
|
||||
let id = field_u64(&detail, "id");
|
||||
let ok = Reflect::get(&detail, &"ok".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let error = field_string(&detail, "error").unwrap_or_default();
|
||||
let result = Reflect::get(&detail, &"result".into()).unwrap_or(JsValue::UNDEFINED);
|
||||
let slot = PENDING.with(|p| p.borrow_mut().remove(&id));
|
||||
match slot {
|
||||
Some(_slot) => {
|
||||
if ok {
|
||||
// OneBSpace (OneCAD Simulation): the completion goes on the
|
||||
// queue the shell's frame drain consumes ([`drain`]).
|
||||
if let Some(completion) = completion_from_result(&result) {
|
||||
COMPLETED.with(|c| c.borrow_mut().push(completion));
|
||||
}
|
||||
} else {
|
||||
log::warn!("OneCAD Simulation: the shell refused the request: {error}");
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// Writes, exports and imports are acknowledged without a parked
|
||||
// slot; only their failures are interesting here.
|
||||
None if !ok => log::warn!("OneCAD Simulation: the shell refused a write/export/import: {error}"),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
let Ok(add) = Reflect::get(&window, &"addEventListener".into()) else { return };
|
||||
let Ok(add) = add.dyn_into::<js_sys::Function>() else { return };
|
||||
if add.call2(&window, &JsValue::from_str(RESPONSE_EVENT), &listener.into_js_value()).is_ok() {
|
||||
LISTENER.with(|l| l.set(true));
|
||||
log::info!("OneCAD Simulation: response listener installed");
|
||||
} else {
|
||||
log::warn!("OneCAD Simulation: response listener install FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `onecadsim:host-response` result onto the queued completion the shell
|
||||
/// drains (see the WIRE PROTOCOL table above).
|
||||
fn completion_from_result(result: &JsValue) -> Option<Completion> {
|
||||
if !result.is_object() {
|
||||
return None; // the user cancelled the explorer
|
||||
}
|
||||
let name = field_string(result, "name");
|
||||
// An opened record: bytes come as a Uint8Array (preferred) or base64.
|
||||
if let Ok(bytes) = Reflect::get(result, &"bytes".into())
|
||||
&& bytes.is_instance_of::<Uint8Array>()
|
||||
{
|
||||
let Ok(bytes) = bytes.dyn_into::<Uint8Array>() else { return None };
|
||||
return stage_record(field_u64(result, "recordId"), name.as_deref(), bytes.to_vec()).map(Completion::Open);
|
||||
}
|
||||
if let Some(content) = field_string(result, "content")
|
||||
&& let Some(bytes) = base64_decode(&content)
|
||||
{
|
||||
return stage_record(field_u64(result, "recordId"), name.as_deref(), bytes).map(Completion::Open);
|
||||
}
|
||||
// A Save-as destination: the pending token names the path the engine
|
||||
// will write to (the glue binds the token to the record).
|
||||
let token = field_string(result, "token");
|
||||
if let (Some(token), Some(name)) = (token, name) {
|
||||
return Some(Completion::SaveAs(format!("{OPENDING}{token}/{}", sanitize(&name))));
|
||||
}
|
||||
// Or the glue resolved an explicit emulated path itself.
|
||||
if let Some(path) = field_string(result, "path")
|
||||
&& path.starts_with("/odoo/")
|
||||
{
|
||||
return Some(Completion::Open(path));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Stage a picked record's bytes in the page filesystem at `/odoo/doc/<id>/<name>`.
|
||||
fn stage_record(record_id: u64, name: Option<&str>, bytes: Vec<u8>) -> Option<String> {
|
||||
if record_id == 0 {
|
||||
return None;
|
||||
}
|
||||
let path = format!("{ODOC}{record_id}/{}", sanitize(name.unwrap_or("document")));
|
||||
match fieldcraft_io::fs::insert(Path::new(&path), bytes) {
|
||||
Ok(()) => Some(path),
|
||||
Err(e) => {
|
||||
log::error!("OneCAD Simulation: could not stage the document ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn field_u64(value: &JsValue, key: &str) -> u64 {
|
||||
Reflect::get(value, &key.into()).ok().and_then(|v| v.as_f64()).map(|f| f as u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn field_string(value: &JsValue, key: &str) -> Option<String> {
|
||||
Reflect::get(value, &key.into()).ok().and_then(|v| v.as_string())
|
||||
}
|
||||
|
||||
/// File names only key the filesystem: keep them single-segment.
|
||||
fn sanitize(name: &str) -> String {
|
||||
let cleaned: String = name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect();
|
||||
let trimmed = cleaned.trim();
|
||||
if trimmed.is_empty() {
|
||||
"file".into()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny base64 (standard alphabet, padding optional) decoder for bridge
|
||||
/// payloads that arrive as `content` strings instead of `Uint8Array`s.
|
||||
fn base64_decode(input: &str) -> Option<Vec<u8>> {
|
||||
fn value(byte: u8) -> Option<u32> {
|
||||
match byte {
|
||||
b'A'..=b'Z' => Some((byte - b'A') as u32),
|
||||
b'a'..=b'z' => Some((byte - b'a') as u32 + 26),
|
||||
b'0'..=b'9' => Some((byte - b'0') as u32 + 52),
|
||||
b'+' => Some(62),
|
||||
b'/' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let clean: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace() && *b != b'=').collect();
|
||||
let mut out = Vec::with_capacity(clean.len() * 3 / 4);
|
||||
for chunk in clean.chunks(4) {
|
||||
if chunk.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for (i, byte) in chunk.iter().enumerate() {
|
||||
acc |= value(*byte)? << (18 - 6 * i);
|
||||
}
|
||||
out.push((acc >> 16) as u8);
|
||||
if chunk.len() > 2 {
|
||||
out.push((acc >> 8) as u8);
|
||||
}
|
||||
if chunk.len() > 3 {
|
||||
out.push(acc as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Drain hook (tagged call site in `web.rs::hand_out_writes`, right after the
|
||||
/// recovery-folder branch): consume writes the shell must not turn into device
|
||||
/// downloads. `/odoo/…` writes go to the bound Documents record (or the pending
|
||||
/// token's destination on first write); model files elsewhere are write-backs
|
||||
/// by name; everything else is an export into the "Exports" folder. Returns
|
||||
/// true when the write was consumed (standalone: always false, upstream
|
||||
/// behavior untouched).
|
||||
pub fn handle_write(path: &Path, bytes: &[u8]) -> bool {
|
||||
if !embedded() {
|
||||
return false;
|
||||
}
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
// Scratch writes (never a record): swallowed, nothing leaves the page.
|
||||
if path_str.starts_with(OTMP) {
|
||||
return true;
|
||||
}
|
||||
let params = Object::new();
|
||||
let method = if path_str.starts_with(ODOC) || path_str.starts_with(OPENDING) {
|
||||
let _ = Reflect::set(¶ms, &"path".into(), &JsValue::from_str(&path_str));
|
||||
WRITE
|
||||
} else if is_model_path(&path_str) {
|
||||
// Model files written outside the emulated tree (New model saved to the
|
||||
// page's folder): the bridge resolves the record by name or creates one.
|
||||
let _ = Reflect::set(¶ms, &"path".into(), &JsValue::from_str(&path_str));
|
||||
WRITE
|
||||
} else {
|
||||
// Decks, reports, results: an export into the "Exports" folder.
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "export".into());
|
||||
let _ = Reflect::set(¶ms, &"name".into(), &JsValue::from_str(&name));
|
||||
SAVE_EXPORT
|
||||
};
|
||||
let _ = Reflect::set(¶ms, &"bytes".into(), &Uint8Array::from(bytes).into());
|
||||
if !dispatch_event(method, next_id(), ¶ms) {
|
||||
log::error!("OneCAD Simulation: the Odoo bridge is unavailable; \"{path_str}\" stays in the page (the Documents copy was not updated)");
|
||||
}
|
||||
// Consumed either way: the air gap wins even if the bridge module is
|
||||
// gone — bytes never reach the local device from the shell (a copy
|
||||
// survives in the page filesystem and the localStorage autosave until
|
||||
// the save succeeds).
|
||||
true
|
||||
}
|
||||
|
||||
/// `.fieldcraft`/`.solidcraft`/`.3mf` are the model-ish types the shell writes
|
||||
/// back to Documents; everything else counts as an export.
|
||||
fn is_model_path(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ["fieldcraft", "solidcraft", "3mf"].iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
|
||||
}
|
||||
|
||||
/// Mirror hook (tagged call site in `web.rs::open_arrived`): CAE assets brought
|
||||
/// in from the computer get a copy in the Documents "Assets" folder (family
|
||||
/// storage rule). Deduplicated per (name, size) this session; the JS side
|
||||
/// additionally dedupes by signature across reloads.
|
||||
pub fn mirror_import(name: &str, bytes: Option<&[u8]>) {
|
||||
if !embedded() {
|
||||
return;
|
||||
}
|
||||
let Some(bytes) = bytes else { return };
|
||||
if !is_asset_name(name) {
|
||||
return;
|
||||
}
|
||||
if !MIRRORED.with(|m| m.borrow_mut().insert((name.to_string(), bytes.len()))) {
|
||||
return;
|
||||
}
|
||||
let params = Object::new();
|
||||
let _ = Reflect::set(¶ms, &"name".into(), &JsValue::from_str(name));
|
||||
let _ = Reflect::set(¶ms, &"bytes".into(), &Uint8Array::from(bytes).into());
|
||||
if !dispatch_event(IMPORT, next_id(), ¶ms) {
|
||||
log::warn!("OneCAD Simulation: could not mirror \"{name}\" into the Assets folder (bridge not ready)");
|
||||
}
|
||||
}
|
||||
|
||||
fn is_asset_name(name: &str) -> bool {
|
||||
Path::new(name)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ASSET_EXTS.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
|
||||
}
|
||||
|
||||
/// The shell's per-frame drain (tagged call site in `web.rs::Shell::logic`):
|
||||
/// completes queued picks and answers the glue's API requests.
|
||||
pub fn drain(app: &mut ModelApp) {
|
||||
let work: Vec<Completion> = COMPLETED.with(|c| std::mem::take(&mut *c.borrow_mut()));
|
||||
if !work.is_empty() {
|
||||
log::info!("OneCAD Simulation: drain {} queued item(s)", work.len());
|
||||
}
|
||||
for completion in work {
|
||||
match completion {
|
||||
Completion::Open(path) => app.open_path(&path),
|
||||
Completion::SaveAs(path) => {
|
||||
if let Err(e) = app.session.execute("file.save_as", &serde_json::json!({"path": path})) {
|
||||
log::warn!("OneCAD Simulation: the save-as to \"{path}\" failed: {e}");
|
||||
}
|
||||
}
|
||||
Completion::Bytes(id) => match app.session.file_bytes(None) {
|
||||
Ok(bytes) => complete_bytes(id, &bytes),
|
||||
Err(e) => BYTES_WAITING.with(|b| {
|
||||
if let Some(reject) = b.borrow_mut().remove(&id) {
|
||||
let _ = reject.call1(&JsValue::UNDEFINED, &JsValue::from_str(&format!("{e}")));
|
||||
}
|
||||
}),
|
||||
},
|
||||
Completion::Info(id) => {
|
||||
let name = app
|
||||
.session
|
||||
.path
|
||||
.as_deref()
|
||||
.map(|p| sanitize(p.rsplit('/').next().unwrap_or(p)))
|
||||
.filter(|n| !n.is_empty());
|
||||
complete_info(id, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_bytes(id: u64, bytes: &[u8]) {
|
||||
if let Some(resolve) = BYTES_WAITING.with(|b| b.borrow_mut().remove(&id)) {
|
||||
let _ = resolve.call1(&JsValue::UNDEFINED, &Uint8Array::from(bytes).into());
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_info(id: u64, name: Option<String>) {
|
||||
if let Some(resolve) = INFO_WAITING.with(|b| b.borrow_mut().remove(&id)) {
|
||||
let info = Object::new();
|
||||
let _ = Reflect::set(&info, &"name".into(), &name.as_deref().map(JsValue::from_str).unwrap_or(JsValue::NULL));
|
||||
let _ = resolve.call1(&JsValue::UNDEFINED, &info.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the JS→Rust seams once:
|
||||
///
|
||||
/// - `window.onecadsimOdoo = { bootFile }` — the staging seam for deep links
|
||||
/// (`?document=<id>`): the glue inserts the record's bytes into the page
|
||||
/// filesystem, then opens the returned path via `window.onecadsim.request`.
|
||||
/// - `window.onecadsim = { request }` — the promise-based API the glue drives
|
||||
/// (the OneCAD fork's `api.rs` equivalent, kept minimal).
|
||||
fn install_seams(ctx: egui::Context) {
|
||||
install_boot_seam();
|
||||
install_api(ctx);
|
||||
}
|
||||
|
||||
fn install_boot_seam() {
|
||||
if BOOT.with(Cell::get) {
|
||||
return;
|
||||
}
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let boot_file = Closure::<dyn Fn(JsValue, JsValue, JsValue) -> JsValue>::new(
|
||||
|record_id: JsValue, name: JsValue, bytes: JsValue| {
|
||||
let id = record_id.as_f64().map(|f| f as u64).unwrap_or(0);
|
||||
if id == 0 {
|
||||
return JsValue::UNDEFINED;
|
||||
}
|
||||
let name = name.as_string().unwrap_or_else(|| "document".into());
|
||||
let Ok(bytes) = bytes.dyn_into::<Uint8Array>() else { return JsValue::UNDEFINED };
|
||||
match stage_record(id, Some(&name), bytes.to_vec()) {
|
||||
Some(path) => JsValue::from_str(&path),
|
||||
None => JsValue::UNDEFINED,
|
||||
}
|
||||
},
|
||||
);
|
||||
let glue = Object::new();
|
||||
let _ = Reflect::set(&glue, &"bootFile".into(), &boot_file.into_js_value());
|
||||
if Reflect::set(&window, &"onecadsimOdoo".into(), &glue).is_ok() {
|
||||
BOOT.with(|b| b.set(true));
|
||||
}
|
||||
}
|
||||
|
||||
fn install_api(ctx: egui::Context) {
|
||||
if API.with(Cell::get) {
|
||||
return;
|
||||
}
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let request = Closure::<dyn Fn(JsValue, JsValue) -> JsValue>::new(move |method: JsValue, params: JsValue| {
|
||||
let method = method.as_string().unwrap_or_default();
|
||||
let ctx = ctx.clone();
|
||||
js_sys::Promise::new(&mut move |resolve, reject| match method.as_str() {
|
||||
// Open the staged deep-link path on the shell's next frame.
|
||||
"ui.file" => {
|
||||
let path = Reflect::get(¶ms, &"path".into()).ok().and_then(|v| v.as_string());
|
||||
match path {
|
||||
Some(path) if path.starts_with("/odoo/") => {
|
||||
COMPLETED.with(|c| c.borrow_mut().push(Completion::Open(path)));
|
||||
ctx.request_repaint();
|
||||
let _ = resolve.call1(&JsValue::UNDEFINED, &JsValue::NULL);
|
||||
}
|
||||
_ => {
|
||||
let _ = reject.call1(&JsValue::UNDEFINED, &JsValue::from_str("ui.file: an /odoo/ path is required"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The current model's bytes, resolved from the shell's frame drain.
|
||||
"odoo.document.bytes" => {
|
||||
let id = next_id();
|
||||
BYTES_WAITING.with(|b| b.borrow_mut().insert(id, resolve));
|
||||
COMPLETED.with(|c| c.borrow_mut().push(Completion::Bytes(id)));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// The current model's file name (best effort), same drain.
|
||||
"odoo.document.info" => {
|
||||
let id = next_id();
|
||||
INFO_WAITING.with(|b| b.borrow_mut().insert(id, resolve));
|
||||
COMPLETED.with(|c| c.borrow_mut().push(Completion::Info(id)));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
other => {
|
||||
let _ = reject.call1(&JsValue::UNDEFINED, &JsValue::from_str(format!("onecadsim: unknown method \"{other}\"").as_str()));
|
||||
}
|
||||
})
|
||||
.into()
|
||||
});
|
||||
let api = Object::new();
|
||||
let _ = Reflect::set(&api, &"request".into(), &request.into_js_value());
|
||||
if Reflect::set(&window, &"onecadsim".into(), &api).is_ok() {
|
||||
API.with(|a| a.set(true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,10 @@ pub fn start() {
|
||||
web_options(),
|
||||
Box::new(|cc| {
|
||||
let inbox = Inbox::default();
|
||||
let mut app = ModelApp::new(Session::default()).with_services(services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let services = services(inbox.clone(), cc.egui_ctx.clone());
|
||||
// OneBSpace (OneCAD Simulation): install odoo services on wasm
|
||||
let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());
|
||||
let mut app = ModelApp::new(Session::default()).with_services(services);
|
||||
// Anonymous usage: asked once, recorded only when the user agreed (docs/privacy.md).
|
||||
if let Some(host) = crate::usage::host() {
|
||||
app = app.with_usage(host);
|
||||
@@ -118,7 +121,7 @@ pub fn start() {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
Err(e) => failed(&format!("FieldCraft could not start: {}", js_err(e))),
|
||||
Err(e) => failed(&format!("OneCAD Simulation could not start: {}", js_err(e))),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -143,7 +146,7 @@ fn failed(why: &str) {
|
||||
// Best effort: the console has the message too.
|
||||
let _ = el.class_list().add_1("failed");
|
||||
for line in
|
||||
[why, "FieldCraft needs a browser with WebGPU or WebGL2 (a current Chrome, Edge, Firefox or Safari) and graphics acceleration turned on."]
|
||||
[why, "OneCAD Simulation needs a browser with WebGPU or WebGL2 (a current Chrome, Edge, Firefox or Safari) and graphics acceleration turned on."]
|
||||
{
|
||||
if let Ok(p) = document.create_element("p") {
|
||||
p.set_text_content(Some(line));
|
||||
@@ -227,6 +230,8 @@ impl Shell {
|
||||
let mut paths = Vec::with_capacity(batch.len());
|
||||
for (name, read) in batch {
|
||||
let path = page_path(&name);
|
||||
// OneBSpace (OneCAD Simulation): mirror computer imports into the Documents "Assets" folder
|
||||
crate::services_odoo::mirror_import(&name, read.as_deref().ok());
|
||||
match read.and_then(|bytes| fieldcraft_io::fs::insert(Path::new(&path), bytes).map_err(|e| e.to_string())) {
|
||||
Ok(()) => paths.push(path),
|
||||
Err(e) => self.app.set_status(format!("{name}: {e}"), true),
|
||||
@@ -257,6 +262,9 @@ impl Shell {
|
||||
}
|
||||
Err(e) => log::warn!("autosave: {e}"),
|
||||
}
|
||||
} // OneBSpace (OneCAD Simulation): the shell air-gaps every other write through the Documents bridge
|
||||
else if crate::services_odoo::handle_write(&path, &bytes) {
|
||||
continue;
|
||||
} else if let Err(e) = download(&path, &bytes) {
|
||||
self.app.set_status(format!("{}: the download did not start ({e})", path.display()), true);
|
||||
}
|
||||
@@ -279,6 +287,8 @@ impl Shell {
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.open_arrived();
|
||||
// OneBSpace (OneCAD Simulation): complete Documents picks (open/save-as/serialize)
|
||||
crate::services_odoo::drain(&mut self.app);
|
||||
self.app.logic(ctx);
|
||||
self.autosave(ctx);
|
||||
if self.app.now - self.sent_at >= crate::usage::INTERVAL {
|
||||
|
||||
Reference in new issue
Block a user