[FIX] OneCAD web: Odoo Documents integration sources (services_odoo.rs, odoo glue, build script); wasm32 picker freeze fixed with web_time::Instant

This commit is contained in:
Z User committed 2026-10-18 17:43:03 +00:00
1 parent 4e0601611c
commit ac05e26934
9 files changed
+3028

No files matched your search

Generated
+1
View File
@@ -4278,6 +4278,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"web-time",
]
[[package]]
+4
View File
@@ -24,6 +24,10 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
# OneBSpace (OneCAD): std::time::Instant PANICS on wasm32-unknown-unknown
# ("time not implemented on this platform") — every picker deadline in
# services_odoo.rs must use web_time::Instant (performance.now() on wasm).
web-time = "1"
serde_json = { workspace = true }
web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
+6
View File
@@ -295,6 +295,12 @@ pub fn start(files: &Rc<Files>) {
/// Store what commands wrote: OPFS paths into OPFS, download paths as downloads.
pub fn flush(files: &Rc<Files>) {
for (path, bytes) in vfs::take_writes() {
// OneBSpace (OneCAD): in the Odoo shell, writes under /odoo/ go to
// the bound Documents record and /downloads/ exports go to the
// "Exports" folder (air-gapped: no device IO through the VFS).
if crate::services_odoo::handle_write(&path, &bytes) {
continue;
}
if let Some(name) = path.strip_prefix(OPFS) {
let name = name.to_string();
{
+947
View File
@@ -0,0 +1,947 @@
// OneBSpace (OneCAD) — odoo host bridge port
/**
* (C) OneBSpace — https://1bspace.com — Proprietary.
* OneCAD odoo/bridge.ts — the Odoo Documents bridge client + Rust facade.
* -----------------------------------------------------------------------
* Port of the family adapter (one-cut-pro apps/web/src/odoo/bridge.ts,
* vanilla twin: onepixel frontend/src/odoo-host/bridge.ts) onto the
* wasm_app_framework host protocol — authoritative implementation:
* one-os-dev/core-components/wasm_app_framework/static/src/wasm_app_action.js
*
* request : {source: "one-cad", kind: "odoo-documents", id, method, payload}
* posted on window.parent (same-origin Odoo shell iframe)
* reply : {source: "one-cad-host", kind: "odoo-documents", id, ok,
* result | error:{message}}
*
* Host methods and payload shapes (confirmed against the framework):
* ping {} -> {user, folder, app:{root, projects?, assets?, exports?}}
* list {} -> {documents:[{id,name,updatedAt,size}]}
* read {recordId} -> {name, dataB64}
* write {recordId?, name?, dataB64, folderId?} -> {id}
* delete {recordId} -> {deleted}
* usage {} -> {bytesUsed, documentCount}
* folders {} -> {tree:[{id,name,parentId,system}], app:{...app, appsFilesRoot}}
* createFolder {name, parentId?} -> {id}
* listFolder {folderId} -> {folders:[{id,name,system}], files:[{id,name,ext,mimetype,size,updatedAt}]}
* saveExport {name, dataB64, folderId?} -> {id} (folderId omitted -> the app's "exports" subfolder)
* stat {recordId} -> {id, name, mimetype}
*
* Family laws: Documents is the ONLY storage; 60 s calls / 300 s writes;
* one correlation id per call; air-gapped (no external fetch ever).
* The 128 MB per-record cap is enforced HERE, on the raw bytes, before any
* base64 conversion (the host mirrors it on base64 length * 3/4).
*
* RUST EVENT CONTRACT (window CustomEvents; the Rust side is built
* separately and consumes host responses through these):
*
* LISTEN "onecad:host-request" detail {id:number, method:string,
* (Rust -> JS) payload?:object}
* One door for everything. Known host
* methods (ping/list/read/write/delete/
* usage/folders/createFolder/listFolder/
* saveExport/stat) pass straight through
* to the host with their own payload.
* Orchestration methods run dialogs +
* writes in JS and never hit the host:
* "saveAs" {name?, dataB64?, recordId?, bytes?}
* "open" {}
* "export" {name?, dataB64?, bytes?}
* "importAsset" {name?, dataB64?, bytes?}
* Bytes may arrive as dataB64 (JSON) or a
* Uint8Array in payload.bytes; when absent
* the bridge asks the app to serialize
* itself via window.solidcraft (below).
* REQUEST QUEUE: entries are processed
* strictly FIFO, one at a time, and each
* id is answered exactly once.
*
* EMIT "onecad:host-response" detail {id, ok, result?, error?{message}}
* (JS -> Rust) correlate with the request id. Timeouts
* surface as ok:false errors (60 s calls,
* 300 s write/read/saveExport).
*
* EMIT "onecad:host-ready" detail {host:boolean, documentId, name}
* (JS -> Rust) fired once by the boot glue (odoo/
* index.ts) after waitForHost() and any
* ?document= deep-link read. Late-booting
* Rust can read the same state sync at
* window.onecadHost.{ready,host,documentId,name}.
*
* EMIT "onecad:document-opened" detail {documentId, name, dataB64}
* (JS -> Rust) fired after the deep-link record was
* read. The full payload stays available
* for late listeners via
* window.onecadHost.consumePendingOpen()
* (returns the detail once, then null).
*
* JS -> APP window.solidcraft.request("odoo.document.bytes")
* -> Promise<Uint8Array | ArrayBuffer | {dataB64}> — serialize
* the current workspace (used when a save/export carries no
* bytes). request("odoo.document.info") -> Promise<{name?}> —
* suggested document name; failures are ignored.
*
* STANDALONE DEV MODE — when the app is not inside the framework iframe,
* the facade goes inert: waitForHost() resolves immediately (host:false),
* every host call rejects with a clear "standalone" error and host-request
* events are answered ok:false — the app still boots, nothing hangs.
*
* This file is erasable-syntax-only TypeScript with ZERO dependencies: the
* addon build (scripts/build_onecad_addon.py) compiles it to bridge.js next
* to this file (node stripTypeScriptTypes; tsc when available).
*/
const DEFAULT_TIMEOUT_MS = 60_000;
const WRITE_TIMEOUT_MS = 300_000; // large .solidcraft containers over the bridge
const POLL_INTERVAL_MS = 100;
const HOST_WAIT_MS = 20_000; // ping every 100 ms for up to 20 s
/** Family cap (wasm_app_action.js MAX_DOCUMENT_BYTES), mirrored client-side. */
export const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
export const SOURCE_TAG = "one-cad";
export const HOST_SOURCE_TAG = "one-cad-host";
export const BRIDGE_KIND = "odoo-documents";
/** Window CustomEvent names of the Rust facade contract (see header). */
export const EVT_HOST_REQUEST = "onecad:host-request";
export const EVT_HOST_RESPONSE = "onecad:host-response";
export const EVT_HOST_READY = "onecad:host-ready";
export const EVT_DOCUMENT_OPENED = "onecad:document-opened";
/** window.solidcraft request names the bridge calls INTO the app. */
export const APP_SERIALIZE_METHOD = "odoo.document.bytes";
export const APP_INFO_METHOD = "odoo.document.info";
export interface OdooAppTree {
root: number;
projects?: number;
assets?: number;
exports?: number;
appsFilesRoot?: number;
}
export interface PingResult {
user: string;
folder: number;
app: OdooAppTree;
}
export interface FolderRow {
id: number;
name: string;
parentId: number | null;
system?: boolean;
}
export interface FileRow {
id: number;
name: string;
ext?: string;
mimetype?: string;
size: number;
updatedAt?: string;
}
export interface WriteResult {
id: number;
name?: string;
}
export interface ReadResult {
name: string;
dataB64: string;
}
export interface StatResult {
id: number;
name: string;
mimetype: string;
}
export interface UsageResult {
bytesUsed: number;
documentCount: number;
}
export interface FoldersResult {
tree: FolderRow[];
app: OdooAppTree;
}
export interface ListResult {
documents: FileRow[];
}
export interface CallOptions {
timeoutMs?: number;
}
export interface BindEntry {
recordId: number;
name: string;
updatedAt: string;
}
/** The document the app is currently working on (deep link / last save). */
export interface CurrentBinding {
documentId: number | null;
name: string | null;
}
export interface ExplorerChoice {
name?: string;
folderId?: number;
recordId?: number;
recordName?: string;
file?: File;
}
export type ExplorerOpener = (opts: {
mode: "save" | "open" | "asset" | "export";
defaultName?: string;
extensions?: string[];
}) => Promise<ExplorerChoice | null>;
export interface HostRequestDetail {
id: number;
method: string;
payload?: Record<string, unknown> | null;
/** services_odoo.rs names the request object `params`; payload is the glue
* spelling. runRequest accepts either. */
params?: Record<string, unknown> | null;
}
export interface HostResponseDetail {
id: number;
ok: boolean;
result?: unknown;
error?: { message: string } | null;
}
export interface HostReadyDetail {
host: boolean;
documentId: number | null;
name: string | null;
}
export interface DocumentOpenedDetail {
documentId: number;
name: string;
dataB64: string;
}
export interface SaveAsResult {
id: number;
name: string;
folderId: number | null;
}
export interface OpenResult {
id: number;
name: string;
dataB64: string;
}
export interface SavedResult {
id: number;
name: string;
}
/** Host method names accepted on "onecad:host-request" (pass-through). */
const HOST_METHODS = new Set([
"ping", "list", "read", "write", "delete", "usage",
"folders", "createFolder", "listFolder", "saveExport", "stat",
]);
/** Host methods that move bulk bytes — family law says 300 s. */
const BULK_METHODS = new Set(["read", "write", "saveExport"]);
/** Orchestration methods served by this bridge (dialogs + writes). */
const UI_METHODS = new Set(["saveAs", "open", "export", "importAsset"]);
const BIND_KEY_PREFIX = "one-cad:solidcraft:bind:";
const OWNED_EXTENSION = ".solidcraft";
/** Rust services_odoo.rs ASSET_EXTS — pick_program offers these from Documents. */
const RUST_ASSET_EXTS = ["step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf", "dxf", "gltf", "glb", "ply"];
/** Emulated pending paths must sanitize exactly like services_odoo.rs::sanitize. */
function sanitizeName(name: unknown): string {
const cleaned = String(name || "").replace(/[/\\]/g, "_").trim();
return cleaned || "file";
}
/** Pull the Uint8Array payload out of an odoo.write / odoo.import event. */
function bytesOf(payload: Record<string, unknown>): Uint8Array | null {
const raw = payload ? payload.bytes : undefined;
if (!raw) return null;
if (raw instanceof Uint8Array) return raw;
// Cross-realm payloads (tests, odd hosts): isView works across realms.
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(raw)) {
return new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
}
if (typeof ArrayBuffer !== "undefined" && raw instanceof ArrayBuffer) return new Uint8Array(raw);
return normalizeBytes(raw);
}
function newToken(): string {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return "t" + window.crypto.randomUUID().replace(/-/g, "");
}
return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2);
}
interface PendingTokenBinding {
recordId: number | null;
name: string;
folderId: number | null;
}
interface SolidcraftApi {
request?: (method: string, params?: unknown) => Promise<unknown>;
}
interface PendingEntry {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
interface QueueEntry {
id: number;
method: string;
payload: Record<string, unknown>;
}
/* ------------------------------------------------------------------ */
/* Codec + guard helpers (exported for the explorer and the app glue) */
/* ------------------------------------------------------------------ */
/** Chunked btoa so multi-megabyte CAD containers never blow the arg stack. */
export function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
const slice = bytes.subarray(i, i + chunk);
binary += String.fromCharCode.apply(null, Array.from(slice) as unknown as number[]);
}
return btoa(binary);
}
export function base64ToBytes(dataB64: string): Uint8Array {
const binary = atob(dataB64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
/** Family 128 MB cap, enforced on RAW bytes before base64 conversion. */
export function guardCap(bytes: Uint8Array): void {
if (bytes.byteLength > MAX_DOCUMENT_BYTES) {
throw new Error(
"OneCAD odoo bridge: document exceeds the 128 MB bridge limit (" +
bytes.byteLength + " raw bytes)",
);
}
}
function normalizeBytes(value: unknown): Uint8Array | null {
if (value instanceof Uint8Array) return value;
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (value && typeof value === "object") {
const holder = value as { dataB64?: unknown };
if (typeof holder.dataB64 === "string") return base64ToBytes(holder.dataB64);
}
return null;
}
function ensureExtension(name: string, extension: string): string {
const trimmed = (name || "").trim() || "Untitled";
const dot = trimmed.lastIndexOf(".");
if (dot > 0) return trimmed;
return trimmed + extension;
}
function stripOwnedExtension(name: string): string {
return (name || "").replace(/\.(solidcraft|solvecraft)$/i, "");
}
function detectInFrame(): boolean {
try {
return window.parent !== window;
} catch {
return false;
}
}
function solidcraftApi(): SolidcraftApi | null {
const holder = window as unknown as { solidcraft?: SolidcraftApi };
const api = holder.solidcraft;
return api && typeof api.request === "function" ? api : null;
}
function messageOf(error: unknown): string {
return error && typeof error === "object" && "message" in error
? String((error as { message?: unknown }).message)
: String(error);
}
/* ------------------------------------------------------------------ */
/* The bridge */
/* ------------------------------------------------------------------ */
class OneCadBridge {
inFrame: boolean;
standalone: boolean = false;
nextId: number = 1;
pending: Map<number, PendingEntry> = new Map();
queue: QueueEntry[] = [];
draining: boolean = false;
opener: ExplorerOpener | null = null;
binding: CurrentBinding = { documentId: null, name: null };
pendingOpen: DocumentOpenedDetail | null = null;
/** odoo.pickSave tokens -> binding (services_odoo.rs writes to
* /odoo/pending/<token>/<name>; the first write creates the record and
* every later Ctrl+S writes back in place). */
pendingTokens = new Map<string, PendingTokenBinding>();
constructor() {
this.inFrame = detectInFrame();
window.addEventListener("message", (event) => this.onMessage(event));
window.addEventListener(EVT_HOST_REQUEST, ((event: Event) => {
const detail = (event as CustomEvent).detail as HostRequestDetail | undefined;
if (!detail || typeof detail.id !== "number" || typeof detail.method !== "string") return;
// services_odoo.rs dispatches the request object as `params`; the
// orchestration glue historically used `payload`. Accept both.
const raw = detail.payload !== undefined ? detail.payload : detail.params;
this.enqueue({
id: detail.id,
method: detail.method,
payload: (raw && typeof raw === "object"
? raw
: {}) as Record<string, unknown>,
});
}) as EventListener);
}
/* ---------------- host plumbing ---------------- */
isHostAvailable(): boolean {
return this.inFrame && !this.standalone;
}
private onMessage(event: MessageEvent): void {
const message = event.data as
| { source?: string; kind?: string; id?: number; ok?: boolean; result?: unknown; error?: { message?: string } | null }
| null;
if (
!message ||
typeof message !== "object" ||
message.source !== HOST_SOURCE_TAG ||
message.kind !== BRIDGE_KIND ||
typeof message.id !== "number"
) {
return;
}
const pending = this.pending.get(message.id);
if (!pending) return;
this.pending.delete(message.id);
clearTimeout(pending.timer);
if (message.ok) pending.resolve(message.result);
else pending.reject(new Error(message.error?.message || "Odoo Documents bridge call failed"));
}
/** One correlation id per call; 60 s default / 300 s for writes. */
call<T>(method: string, payload?: Record<string, unknown>, options?: CallOptions): Promise<T> {
if (!this.inFrame) {
this.standalone = true;
return Promise.reject(
new Error(`OneCAD odoo bridge: standalone (no Odoo host) — "${method}" unavailable`),
);
}
const timeoutMs = (options && options.timeoutMs) || DEFAULT_TIMEOUT_MS;
const id = this.nextId++;
const envelope = {
source: SOURCE_TAG,
kind: BRIDGE_KIND,
id,
method,
payload: payload || {},
};
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`OneCAD odoo bridge: "${method}" timed out after ${timeoutMs} ms`));
}, timeoutMs);
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
// The framework shell is our parent window (same origin).
window.parent.postMessage(envelope, window.location.origin);
});
}
/** Ping every 100 ms until the host answers (mount-order independence). */
async waitForHost(timeoutMs: number = HOST_WAIT_MS): Promise<PingResult> {
if (!this.inFrame) {
this.standalone = true;
return { user: "", folder: 0, app: { root: 0 } };
}
const deadline = Date.now() + timeoutMs;
let lastError: Error | null = null;
while (Date.now() < deadline) {
try {
const pong = await this.ping();
this.standalone = false;
return pong;
} catch (error) {
lastError = error as Error;
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
}
throw new Error(
`OneCAD odoo bridge: host did not answer ping within ${timeoutMs} ms (${messageOf(lastError)})`,
);
}
/* ---------------- raw host calls ---------------- */
ping(): Promise<PingResult> {
return this.call<PingResult>("ping");
}
list(): Promise<ListResult> {
return this.call<ListResult>("list");
}
stat(recordId: number): Promise<StatResult> {
return this.call<StatResult>("stat", { recordId });
}
read(recordId: number): Promise<ReadResult> {
return this.call<ReadResult>("read", { recordId }, { timeoutMs: WRITE_TIMEOUT_MS });
}
write(payload: { recordId?: number; name?: string; dataB64: string; folderId?: number }, timeoutMs?: number): Promise<WriteResult> {
return this.call<WriteResult>("write", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS });
}
delete(recordId: number): Promise<{ deleted: boolean }> {
return this.call<{ deleted: boolean }>("delete", { recordId });
}
usage(): Promise<UsageResult> {
return this.call<UsageResult>("usage");
}
folders(): Promise<FoldersResult> {
return this.call<FoldersResult>("folders");
}
createFolder(payload: { name: string; parentId?: number }): Promise<{ id: number }> {
return this.call<{ id: number }>("createFolder", payload);
}
listFolder(folderId: number): Promise<{ folders: FolderRow[]; files: FileRow[] }> {
return this.call<{ folders: FolderRow[]; files: FileRow[] }>("listFolder", { folderId });
}
saveExport(payload: { name: string; dataB64: string; folderId?: number }, timeoutMs?: number): Promise<WriteResult> {
return this.call<WriteResult>("saveExport", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS });
}
/* ---------------- binding cache (localStorage) ---------------- */
getCurrentBinding(): CurrentBinding {
return this.binding;
}
/** document name -> Odoo record id, keyed by app + document name. */
bindLookup(name: string): BindEntry | null {
try {
const raw = window.localStorage.getItem(BIND_KEY_PREFIX + stripOwnedExtension(name));
if (!raw) return null;
const entry = JSON.parse(raw) as BindEntry;
return entry && typeof entry.recordId === "number" ? entry : null;
} catch {
return null;
}
}
bindSet(name: string, recordId: number): void {
this.binding = { documentId: recordId, name: stripOwnedExtension(name) };
try {
const entry: BindEntry = {
recordId,
name: stripOwnedExtension(name),
updatedAt: new Date().toISOString(),
};
window.localStorage.setItem(BIND_KEY_PREFIX + entry.name, JSON.stringify(entry));
} catch {
/* private mode — memory-only binding still works for this session */
}
}
/* ---------------- document flows ---------------- */
/** Ctrl+S style write-back / create. Guard runs BEFORE base64. */
async saveDocument(bytes: Uint8Array | ArrayBuffer, name: string, recordId?: number): Promise<WriteResult> {
const raw = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
guardCap(raw);
const dataB64 = bytesToBase64(raw);
const clean = ensureExtension(name, OWNED_EXTENSION);
const result = await this.write({ recordId, name: clean, dataB64 });
this.bindSet(result.name || clean, result.id);
return result;
}
/** Deep-link read; also refreshes the current binding. */
async readDocument(recordId: number): Promise<ReadResult> {
const result = await this.read(recordId);
this.bindSet(result.name, recordId);
return result;
}
/**
* ?document=<id> boot deep link. Never throws (boot must proceed);
* on success stores pendingOpen, fires "onecad:document-opened" and
* refreshes the binding so host-ready can carry the metadata.
*/
async openDeepLink(search?: string): Promise<boolean> {
let params: URLSearchParams;
try {
params = new URLSearchParams(search === undefined ? window.location.search : search);
} catch {
return false;
}
const rawId = params.get("document");
if (!rawId) return false;
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) {
console.warn("[onecad-odoo] ignoring malformed deep link ?document=" + rawId);
return false;
}
try {
const opened = await this.readDocument(id);
this.pendingOpen = { documentId: id, name: opened.name, dataB64: opened.dataB64 };
window.dispatchEvent(
new CustomEvent<DocumentOpenedDetail>(EVT_DOCUMENT_OPENED, { detail: this.pendingOpen }),
);
return true;
} catch (error) {
console.warn("[onecad-odoo] deep-link read failed for document " + id + ":", messageOf(error));
return false;
}
}
/** Late-boot consumers fetch the deep-link payload once, then null. */
consumePendingOpen(): DocumentOpenedDetail | null {
const pending = this.pendingOpen;
this.pendingOpen = null;
return pending;
}
setExplorerOpener(opener: ExplorerOpener): void {
this.opener = opener;
}
private async askExplorer(options: {
mode: "save" | "open" | "asset" | "export";
defaultName?: string;
extensions?: string[];
}): Promise<ExplorerChoice | null> {
if (!this.isHostAvailable()) {
throw new Error("OneCAD odoo bridge: standalone (no Odoo host) — explorer unavailable");
}
if (!this.opener) {
throw new Error("OneCAD odoo bridge: no explorer dialog wired (setExplorerOpener)");
}
return this.opener(options);
}
private async appSerialize(): Promise<Uint8Array> {
const api = solidcraftApi();
if (!api || !api.request) {
throw new Error(
"OneCAD odoo bridge: no document bytes given and window.solidcraft is unavailable",
);
}
const out = await api.request(APP_SERIALIZE_METHOD);
const bytes = normalizeBytes(out);
if (!bytes) {
throw new Error("OneCAD odoo bridge: window.solidcraft did not return document bytes");
}
return bytes;
}
private async appInfo(): Promise<{ name?: string }> {
const api = solidcraftApi();
if (!api || !api.request) return {};
try {
const info = (await api.request(APP_INFO_METHOD)) as { name?: unknown } | null;
return info && typeof info.name === "string" ? { name: info.name } : {};
} catch {
return {};
}
}
/** Save-as flow: bytes -> explorer (tree + name) -> write -> bind. */
async saveAs(options?: {
bytes?: Uint8Array | ArrayBuffer;
dataB64?: string;
name?: string;
recordId?: number;
}): Promise<SaveAsResult | null> {
const opts = options || {};
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
if (!dataB64) {
const raw = opts.bytes ? (opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes)) : await this.appSerialize();
guardCap(raw);
dataB64 = bytesToBase64(raw);
}
const info = await this.appInfo();
const suggested = opts.name || info.name || "untitled";
const choice = await this.askExplorer({
mode: "save",
defaultName: ensureExtension(suggested, OWNED_EXTENSION),
});
if (!choice || !choice.name) return null; // cancelled
const name = ensureExtension(choice.name, OWNED_EXTENSION);
const result = await this.write({
recordId: opts.recordId,
name,
dataB64,
folderId: choice.folderId,
});
this.bindSet(result.name || name, result.id);
return { id: result.id, name: result.name || name, folderId: choice.folderId || null };
}
/** Open flow: explorer (.solidcraft filter) -> read 300 s. */
async openFromDocuments(): Promise<OpenResult | null> {
const choice = await this.askExplorer({ mode: "open", extensions: ["solidcraft", "solvecraft"] });
if (!choice || typeof choice.recordId !== "number") return null; // cancelled
const opened = await this.readDocument(choice.recordId);
return { id: choice.recordId, name: opened.name, dataB64: opened.dataB64 };
}
/** Export flow: bytes -> (optional name dialog) -> saveExport -> Exports. */
async exportTo(options?: {
bytes?: Uint8Array | ArrayBuffer;
dataB64?: string;
name?: string;
}): Promise<SavedResult | null> {
const opts = options || {};
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
if (!dataB64) {
const raw = opts.bytes ? (opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes)) : await this.appSerialize();
guardCap(raw);
dataB64 = bytesToBase64(raw);
}
let name = (opts.name || "").trim();
let folderId: number | undefined;
if (!name) {
const info = await this.appInfo();
const choice = await this.askExplorer({
mode: "export",
defaultName: (info.name || "export") + ".step",
});
if (!choice || !choice.name) return null; // cancelled
name = choice.name;
folderId = choice.folderId;
}
const result = await this.saveExport({ name, dataB64, folderId });
return { id: result.id, name: result.name || name };
}
/** Import flow: file picker (or given bytes) -> write into Assets. */
async importAsset(options?: {
file?: File;
bytes?: Uint8Array | ArrayBuffer;
dataB64?: string;
name?: string;
}): Promise<SavedResult | null> {
const opts = options || {};
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
let name = (opts.name || "").trim();
let folderId: number | undefined;
if (!dataB64 && !opts.file && !opts.bytes) {
const choice = await this.askExplorer({ mode: "asset" });
if (!choice || !(choice.file instanceof File)) return null; // cancelled
folderId = choice.folderId;
const raw = new Uint8Array(await choice.file.arrayBuffer());
guardCap(raw);
dataB64 = bytesToBase64(raw);
name = name || choice.file.name;
} else if (!dataB64) {
const raw = opts.bytes
? opts.bytes instanceof Uint8Array
? opts.bytes
: new Uint8Array(opts.bytes)
: new Uint8Array(await (opts.file as File).arrayBuffer());
guardCap(raw);
dataB64 = bytesToBase64(raw);
name = name || (opts.file ? opts.file.name : "asset");
}
if (!folderId) {
const app = (await this.ping()).app;
folderId = app.assets || app.root;
}
const result = await this.write({ name: name || "asset", dataB64, folderId });
return { id: result.id, name: result.name || name || "asset" };
}
/* ---------------- Rust event queue ---------------- */
private enqueue(entry: QueueEntry): void {
this.queue.push(entry);
void this.drainQueue();
}
/** Strictly FIFO, one request at a time; every id answered exactly once. */
private async drainQueue(): Promise<void> {
if (this.draining) return;
this.draining = true;
while (this.queue.length > 0) {
const entry = this.queue.shift() as QueueEntry;
try {
const result = await this.runRequest(entry.method, entry.payload);
this.respond(entry.id, true, result, null);
} catch (error) {
this.respond(entry.id, false, null, { message: messageOf(error) });
}
}
this.draining = false;
}
private respond(id: number, ok: boolean, result: unknown, error: { message: string } | null): void {
const detail: HostResponseDetail = { id, ok, result, error };
window.dispatchEvent(new CustomEvent<HostResponseDetail>(EVT_HOST_RESPONSE, { detail }));
}
private async runRequest(method: string, payload: Record<string, unknown>): Promise<unknown> {
if (method === "odoo.pickOpen" || method === "odoo.pickProgram") return this.pickForRust(method, payload);
if (method === "odoo.pickSave") return this.pickSaveForRust(payload);
if (method === "odoo.write") return this.rustWrite(payload);
if (method === "odoo.saveExport") return this.rustSaveExport(payload);
if (method === "odoo.import") return this.rustImport(payload);
if (method === "saveAs") {
return this.saveAs({
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
name: typeof payload.name === "string" ? payload.name : undefined,
recordId: typeof payload.recordId === "number" ? payload.recordId : undefined,
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
});
}
if (method === "open") return this.openFromDocuments();
if (method === "export") {
return this.exportTo({
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
name: typeof payload.name === "string" ? payload.name : undefined,
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
});
}
if (method === "importAsset") {
return this.importAsset({
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
name: typeof payload.name === "string" ? payload.name : undefined,
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
file: payload.file instanceof File ? payload.file : undefined,
});
}
if (HOST_METHODS.has(method)) {
const timeoutMs = BULK_METHODS.has(method) ? WRITE_TIMEOUT_MS : undefined;
return this.call<unknown>(method, payload, { timeoutMs });
}
throw new Error("OneCAD odoo bridge: unknown host-request method \"" + method + "\"");
}
/* ------- services_odoo.rs wire protocol (CustomEvent round trip) ------- */
/** odoo.pickOpen / odoo.pickProgram -> {recordId, name, bytes} | null. */
private async pickForRust(method: string, payload: Record<string, unknown>): Promise<unknown> {
const requested = Array.isArray(payload.extensions) && payload.extensions.length
? (payload.extensions as string[])
: null;
const extensions = requested || (method === "odoo.pickProgram" ? RUST_ASSET_EXTS : ["solidcraft", "solvecraft"]);
const choice = await this.askExplorer({ mode: "open", extensions });
if (!choice || typeof choice.recordId !== "number") return null;
const opened = await this.readDocument(choice.recordId);
return {
recordId: choice.recordId,
name: opened.name || choice.recordName || "document",
bytes: base64ToBytes(opened.dataB64),
};
}
/** odoo.pickSave -> {token, name}; the token binds the later write-back. */
private async pickSaveForRust(payload: Record<string, unknown>): Promise<unknown> {
const design = payload.design !== false;
const suggested = typeof payload.name === "string" && payload.name.trim() ? payload.name.trim() : "untitled";
const choice = await this.askExplorer({
mode: "save",
defaultName: design ? ensureExtension(suggested, OWNED_EXTENSION) : suggested,
});
if (!choice || !choice.name) return null;
const token = newToken();
const name = sanitizeName(choice.name);
this.pendingTokens.set(token, { recordId: null, name, folderId: choice.folderId || null });
return { token, name };
}
/** odoo.write: /odoo/doc/<rid>/<name> in place, /odoo/pending/<token>/<name> first save. */
private async rustWrite(payload: Record<string, unknown>): Promise<unknown> {
const bytes = bytesOf(payload);
if (!bytes) throw new Error("odoo.write: no bytes given");
guardCap(bytes);
const dataB64 = bytesToBase64(bytes);
const path = typeof payload.path === "string" ? payload.path : "";
if (path.startsWith("/odoo/pending/")) {
const rest = path.slice("/odoo/pending/".length);
const slash = rest.indexOf("/");
const token = slash > 0 ? rest.slice(0, slash) : "";
const pathName = slash > 0 ? sanitizeName(rest.slice(slash + 1)) : "document";
const bind = token ? this.pendingTokens.get(token) : undefined;
const name = (bind ? bind.name : "") || pathName;
let recordId = bind ? bind.recordId : null;
if (!recordId) {
const remembered = this.bindLookup(name);
recordId = remembered ? remembered.recordId : null;
}
const result = await this.write({ recordId, name, dataB64, folderId: bind ? bind.folderId : undefined });
if (bind) bind.recordId = result.id;
else if (token) this.pendingTokens.set(token, { recordId: result.id, name, folderId: null });
this.bindSet(result.name || name, result.id);
return { id: result.id, name: result.name || name };
}
if (path.startsWith("/odoo/doc/")) {
const rest = path.slice("/odoo/doc/".length);
const slash = rest.indexOf("/");
const recordId = slash > 0 ? Number(rest.slice(0, slash)) : 0;
const name = slash > 0 ? sanitizeName(rest.slice(slash + 1)) : "document";
if (!Number.isInteger(recordId) || recordId <= 0) {
throw new Error("odoo.write: bad record path \"" + path + "\"");
}
const result = await this.write({ recordId, name, dataB64 });
this.bindSet(result.name || name, recordId);
return { id: result.id, name: result.name || name };
}
throw new Error("odoo.write: unsupported path \"" + path + "\"");
}
/** odoo.saveExport: {name, bytes} -> Documents "Exports" folder. */
private async rustSaveExport(payload: Record<string, unknown>): Promise<unknown> {
const bytes = bytesOf(payload);
if (!bytes) throw new Error("odoo.saveExport: no bytes given");
guardCap(bytes);
const result = await this.saveExport({ name: sanitizeName(payload.name) || "export", dataB64: bytesToBase64(bytes) });
return { id: result.id, name: result.name };
}
/** odoo.import: mirror a computer import into the Documents "Assets" folder. */
private async rustImport(payload: Record<string, unknown>): Promise<unknown> {
const bytes = bytesOf(payload);
if (!bytes) throw new Error("odoo.import: no bytes given");
guardCap(bytes);
const app = (await this.ping()).app;
const result = await this.write({ name: payload.name || "asset", dataB64: bytesToBase64(bytes), folderId: app.assets || app.root });
return { id: result.id, name: result.name };
}
}
/** The process-wide singleton (one bridge per app window). */
export const bridge = new OneCadBridge();
/** Family export: the app glue and dialogs always share this instance. */
export function exportForHost(): OneCadBridge {
return bridge;
}
+719
View File
@@ -0,0 +1,719 @@
// OneBSpace (OneCAD) — odoo host bridge port
/**
* (C) OneBSpace — https://1bspace.com — Proprietary.
* OneCAD 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 (.solidcraft chip)
* open — "Open from Documents": folder tree + files filtered to .solidcraft
* 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.6";
const STYLE_ID = "oca-explorer-style";
const MODES = {
save: {
heading: "Save to Documents",
description: "Choose a folder in your Odoo drive for this .solidcraft document.",
action: "Save",
prefer: "root",
lastKey: "one-cad:documents:last-folder:save",
},
open: {
heading: "Open from Documents",
description: "Browse your Odoo Documents drive and pick a .solidcraft document.",
action: "Open",
prefer: "root",
lastKey: "one-cad:documents:last-folder:open",
extensions: new Set(["solidcraft", "solvecraft"]),
},
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: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: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{--oca-bg:#1b1e24;--oca-panel:#23272f;--oca-panel2:#2a2f39;--oca-hairline:#333945;",
"--oca-text:#e8eaed;--oca-dim:#9aa3ad;--oca-accent:#4f8cff;--oca-accent-press:#3f76e0;",
"--oca-danger:#e5534b;--oca-ok:#3fb26f}",
".oca-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(--oca-text)}",
".oca-backdrop{position:absolute;inset:0;background:rgba(8,10,14,.62)}",
".oca-dialog{position:relative;width:min(860px,calc(100vw - 48px));height:min(560px,calc(100vh - 64px));",
"background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:10px;",
"box-shadow:0 24px 64px rgba(0,0,0,.55);display:flex;flex-direction:column;overflow:hidden}",
".oca-header{padding:14px 18px 10px;border-bottom:1px solid var(--oca-hairline);background:var(--oca-panel)}",
".oca-title{font-size:15px;font-weight:600}",
".oca-desc{margin-top:2px;color:var(--oca-dim);font-size:12px}",
".oca-desc-error{color:var(--oca-danger)}",
".oca-body{flex:1;display:flex;min-height:0}",
".oca-aside{width:250px;flex:none;display:flex;flex-direction:column;border-right:1px solid var(--oca-hairline);background:var(--oca-panel)}",
".oca-tree-box{flex:1;overflow:auto;padding:8px 6px}",
".oca-tree-label{margin:2px 8px 6px;color:var(--oca-dim);font-size:11px;text-transform:uppercase;letter-spacing:.06em}",
".oca-tree-error{padding:8px}",
".oca-tree-row{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:6px;cursor:pointer;white-space:nowrap}",
".oca-tree-row:hover{background:var(--oca-panel2)}",
".oca-tree-row.oca-active{background:var(--oca-panel2);color:#fff}",
".oca-tree-toggle{width:14px;height:14px;flex:none;color:var(--oca-dim);display:inline-flex;transition:transform .12s}",
".oca-tree-toggle.oca-open{transform:rotate(90deg)}",
".oca-tree-icon{display:inline-flex;color:var(--oca-accent)}",
".oca-tree-name{overflow:hidden;text-overflow:ellipsis}",
".oca-usage{padding:8px 14px;border-top:1px solid var(--oca-hairline);color:var(--oca-dim);font-size:11px}",
".oca-main{flex:1;display:flex;flex-direction:column;min-width:0}",
".oca-crumbs{display:flex;align-items:center;gap:2px;padding:8px 12px;border-bottom:1px solid var(--oca-hairline);overflow:hidden}",
".oca-crumb{display:inline-flex;align-items:center;gap:5px;background:none;border:0;color:var(--oca-dim);",
"font:inherit;padding:4px 7px;border-radius:6px;cursor:pointer}",
".oca-crumb:hover:not(:disabled){background:var(--oca-panel2);color:var(--oca-text)}",
".oca-crumb:disabled{opacity:.45;cursor:default}",
".oca-crumb-current{color:var(--oca-text)}",
".oca-crumb-sep{display:inline-flex;color:var(--oca-dim)}",
".oca-files{flex:1;overflow:auto;padding:6px 8px}",
".oca-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}",
".oca-file-row:hover{background:var(--oca-panel2)}",
".oca-file-row.oca-selected{background:rgba(79,140,255,.18);outline:1px solid var(--oca-accent)}",
".oca-file-icon{display:inline-flex;color:var(--oca-dim)}",
".oca-file-icon.oca-icon-owned{color:var(--oca-accent)}",
".oca-file-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".oca-file-size{color:var(--oca-dim);font-size:11px;flex:none}",
".oca-empty{color:var(--oca-dim);padding:18px 10px;text-align:center}",
".oca-savebar{border-top:1px solid var(--oca-hairline);padding:10px 14px;background:var(--oca-panel)}",
".oca-name-row{display:flex;align-items:flex-end;gap:10px}",
".oca-name-wrap{flex:1;min-width:0}",
".oca-name-label{display:block;color:var(--oca-dim);font-size:11px;margin-bottom:4px}",
".oca-name-field{display:flex;align-items:center;background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:6px;padding:0 8px}",
".oca-name-field:focus-within{border-color:var(--oca-accent)}",
".oca-name-input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--oca-text);font:inherit;padding:7px 0}",
".oca-suffix{color:var(--oca-dim);font-size:12px;user-select:none}",
".oca-create-row{display:flex;gap:8px;margin-top:8px}",
".oca-newfolder-input{flex:1;background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:6px;",
"color:var(--oca-text);font:inherit;padding:6px 9px;outline:0}",
".oca-newfolder-input:focus{border-color:var(--oca-accent)}",
".oca-picker{display:flex;flex-direction:column;gap:8px}",
".oca-pick-btn{display:inline-flex;align-items:center;gap:8px}",
".oca-picked{color:var(--oca-dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".oca-footer{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;",
"border-top:1px solid var(--oca-hairline);background:var(--oca-panel)}",
".oca-btn{display:inline-flex;align-items:center;gap:6px;font:inherit;padding:7px 14px;border-radius:6px;",
"border:1px solid var(--oca-hairline);background:var(--oca-panel2);color:var(--oca-text);cursor:pointer}",
".oca-btn:hover:not(:disabled){border-color:var(--oca-dim)}",
".oca-btn:disabled{opacity:.45;cursor:default}",
".oca-btn-accent{background:var(--oca-accent);border-color:var(--oca-accent);color:#fff}",
".oca-btn-accent:hover:not(:disabled){background:var(--oca-accent-press)}",
".oca-btn-ghost{background:none;border-color:transparent;color:var(--oca-dim)}",
".oca-btn-ghost:hover:not(:disabled){color:var(--oca-text)}",
".oca-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="oca-file-icon oca-icon-owned">' + ICONS.cube + "</span>";
}
return '<span class="oca-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.solidcraft")
* 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("[onecad-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" ? ".solidcraft" : "";
let defaultName = typeof opts.defaultName === "string" && opts.defaultName.trim() !== ""
? opts.defaultName.trim()
: mode === "save"
? "untitled.solidcraft"
: "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: "oca-backdrop" });
const dialog = h("div", { class: "oca-dialog", role: "dialog", "aria-modal": "true" });
const root = h("div", { class: "oca-explorer-root" }, backdrop, dialog);
const descEl = h("div", { class: "oca-desc" }, config.description);
const header = h("div", { class: "oca-header" },
h("div", { class: "oca-title" }, config.heading), descEl);
const treeLabel = h("p", { class: "oca-tree-label" }, "Loading drive…");
const treeBox = h("div", { class: "oca-tree-box" }, treeLabel);
const usageEl = h("div", { class: "oca-usage", style: "display:none" });
const aside = h("div", { class: "oca-aside" }, treeBox, usageEl);
const crumbs = h("div", { class: "oca-crumbs" });
const fileList = h("div", { class: "oca-files" });
const main = h("div", { class: "oca-main" }, crumbs, fileList);
const body = h("div", { class: "oca-body" }, aside, main);
/* name row (save + export) */
const nameInput = h("input", {
class: "oca-name-input",
id: "onecad-explorer-name",
type: "text",
value: stem,
placeholder: mode === "save" ? "Untitled" : "export",
spellcheck: "false",
});
const suffixChip = h("span", { class: "oca-suffix" }, suffix);
const nameField = h("div", { class: "oca-name-field" }, nameInput, suffixChip);
const nameRow = h("div", { class: "oca-name-row" },
h("div", { class: "oca-name-wrap" },
h("label", { class: "oca-name-label", for: "onecad-explorer-name" }, "File name"),
nameField));
/* new-folder row (save + export; asset keeps it too — retargeting) */
const newNameInput = h("input", {
class: "oca-newfolder-input",
type: "text",
placeholder: "Folder name…",
spellcheck: "false",
});
const createBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" }, "Create");
const cancelCreateBtn = h("button", { class: "oca-btn oca-btn-ghost", type: "button" }, "Cancel");
const createRow = h("div", { class: "oca-create-row", style: "display:none" }, newNameInput, createBtn, cancelCreateBtn);
const newFolderBtn = h("button", { class: "oca-btn", type: "button" },
h("span", { class: "oca-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: "oca-picked", text: "No file chosen yet." });
const pickBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" },
h("span", { class: "oca-btn-icon", html: ICONS.upload }), "Choose file…");
const picker = h("div", { class: "oca-picker" }, pickBtn, pickedLabel, fileInput);
const savebar = h("div", { class: "oca-savebar" });
if (wantsName) savebar.appendChild(nameRow);
if (mode === "asset") savebar.appendChild(picker);
savebar.appendChild(newFolderBtn);
savebar.appendChild(createRow);
/* footer */
const cancelBtn = h("button", { class: "oca-btn oca-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: "oca-btn oca-btn-accent", type: "button" },
h("span", { class: "oca-btn-icon", html: actionIcon }), config.action);
const footer = h("div", { class: "oca-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: "oca-tree-label" }, "Loading drive…"));
return;
}
if (treeError) {
const retry = h("button", { class: "oca-btn", type: "button" }, "Retry");
retry.addEventListener("click", () => boot());
treeBox.appendChild(h("div", { class: "oca-tree-error" },
h("p", { class: "oca-tree-label" }, treeError), retry));
return;
}
treeBox.appendChild(h("p", { class: "oca-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: "oca-tree-row" + (folder.id === currentFolderId ? " oca-active" : ""),
style: "padding-left:" + (8 + depth * 14) + "px",
});
const toggle = h("span", { class: "oca-tree-toggle" });
if (hasChildren) {
toggle.innerHTML = ICONS.chevronRight;
toggle.classList.add("oca-has-children");
if (isOpen) toggle.classList.add("oca-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: "oca-tree-icon", html: ICONS.folder }));
row.appendChild(h("span", { class: "oca-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: "oca-crumb oca-crumb-root", type: "button" });
driveBtn.appendChild(h("span", { class: "oca-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: "oca-crumb-sep", html: ICONS.chevronRight }));
const crumb = h("button", {
class: "oca-crumb" + (folder.id === currentFolderId ? " oca-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: "oca-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: "oca-file-row", type: "button" });
row.insertAdjacentHTML("beforeend", '<span class="oca-file-icon oca-icon-owned">' + ICONS.folder + "</span>");
row.appendChild(h("span", { class: "oca-file-name" }, folder.name));
if (folder.system) {
row.insertAdjacentHTML("beforeend",
'<span class="oca-file-size" title="Protected OneBSpace folder">' + ICONS.lock + "</span>");
}
row.appendChild(h("span", { class: "oca-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: "oca-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: "oca-empty" }, "No compatible files here yet."));
return;
}
for (const file of visibleFiles) {
const row = h("button", {
class: "oca-file-row" + (selectedFileId === file.id ? " oca-selected" : ""),
type: "button",
});
row.insertAdjacentHTML("beforeend", fileIconFor(file, extensions));
row.appendChild(h("span", { class: "oca-file-name" }, file.name));
row.appendChild(h("span", { class: "oca-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("oca-desc-error");
if (errorTimer) clearTimeout(errorTimer);
errorTimer = setTimeout(() => {
descEl.textContent = config.description;
descEl.classList.remove("oca-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();
});
}
+153
View File
@@ -0,0 +1,153 @@
// OneBSpace (OneCAD) — odoo host bridge port
/**
* (C) OneBSpace — https://1bspace.com — Proprietary.
* OneCAD 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, "onecad:document-opened");
* 3. signal "onecad:host-ready" {host, documentId, name} exactly once.
* The same state stays readable synchronously at window.onecadHost
* (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: scripts/build_onecad_addon.py compiles this file (erasable-
* syntax-only TS) 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.6";
// @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.6";
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 {
onecadHostReady?: boolean;
onecadHost?: {
ready: boolean;
host: boolean;
documentId: number | null;
name: string | null;
consumePendingOpen: () => DocumentOpenedDetail | null;
};
solidcraft?: { request?: (method: string, params?: unknown) => Promise<unknown> };
onecadOdoo?: {
bootFile?: (recordId: number, name: string, bytes: Uint8Array) => string | undefined;
};
}
const hostWindow = window as unknown as OneCadHostWindow;
hostWindow.onecadHost = {
ready: false,
host: false,
documentId: null,
name: null,
consumePendingOpen: () => bridge.consumePendingOpen(),
};
/* services_odoo.rs polls window.onecadHostReady (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.onecadHostReady = true;
} catch (error) {
console.warn("[onecad-odoo] could not set onecadHostReady:", error);
}
}
/* Deep-link opener: stage the record bytes into the engine VFS via the
* window.onecadOdoo.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.solidcraft;
const seam = hostWindow.onecadOdoo;
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(
"[onecad-odoo] deep-link open failed:",
error instanceof Error ? error.message : error,
);
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
console.warn("[onecad-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(
"[onecad-odoo] host did not answer in time — continuing without Odoo Documents:",
error,
);
}
} else {
console.info("[onecad-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("[onecad-odoo] deep-link read failed:", error);
}
}
const binding = bridge.getCurrentBinding();
const detail: HostReadyDetail = {
host,
documentId: binding.documentId,
name: binding.name,
};
const state = hostWindow.onecadHost;
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 {};
+389
View File
@@ -0,0 +1,389 @@
/**
* (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
* OneBSpace (OneCAD): odoo/onecad-odoo.js — the app-side host glue.
* ---------------------------------------------------------------------
* Entry module loaded by index.html ONLY inside a frame (standalone runs
* never import it). It is the middle layer between the Rust app
* (apps/solidcraft-web/src/services_odoo.rs, which installs
* `window.onecadOdoo` — the resolve/bootFile glue) and the wasm_app_
* framework Documents bridge (odoo/bridge.ts compiled to bridge.js):
*
* request(id, kind, payloadJson) Rust picker → explorer → resolve
* kind "open" → Documents explorer → bridge.read → Rust inserts
* bytes at /odoo/doc/<recordId>/<name>
* kind "save" → Documents explorer (Save) → token → Rust returns
* /odoo/pending/<token>/<name>
* kind "program" → asset explorer (Assets folder) → like "open"
* write(path, bytes) VFS drain → bridge write / saveExport
* /odoo/doc/<recordId>/<name> → bridge.write({recordId}) in place
* /odoo/pending/<token>/<name> → token meta (localStorage) → first
* write creates the record, later
* writes hit the same record (Ctrl+S)
* /downloads/<name> → bridge.saveExport into "Exports"
* import(name, bytes) computer-imported CAD assets mirrored
* into the "Assets" folder (sig-deduped)
*
* Path schemes on the emulated filesystem (the one-photo conventions):
* /odoo/doc/<recordId>/<name> an existing Documents record
* /odoo/pending/<token>/<name> a chosen destination not yet written
* /downloads/<name> an export → Documents "Exports"
*
* Deep-link boot gate: the shell's iframe URL carries ?document=<id> when
* the app was opened by clicking an owned file in Documents. The record is
* read, handed to Rust (bootFile inserts it into the engine's VFS at
* /odoo/doc/...) and opened like any opened file (doc.open / inserts).
*/
import {
bridge,
base64ToBytes,
bytesToBase64,
guardCap,
extensionOf,
sanitizeStem,
withExtension,
} from "./bridge.js";
import { openDocumentsExplorer } from "./explorer.js";
/* localStorage stores (the binding cache; Documents stays the truth) */
const PENDING_KEY = "one-cad:odoo:pending-tokens";
const IMPORT_SIGS_KEY = "one-cad:odoo:import-sigs";
const SAVE_FOLDER_KEY = "one-cad:documents:last-folder:save";
const EXPORT_FOLDER_KEY = "one-cad:documents:last-folder:export";
const OPEN_FOLDER_KEY = "one-cad:documents:last-folder:open";
/* Commands the app understands on an emulated path (io crate naming). */
const DESIGN_EXTENSIONS = new Set(["solidcraft", "solvecraft", "json"]);
const STEP_EXTENSIONS = new Set(["step", "stp", "iges", "igs", "brep"]);
const MESH_EXTENSIONS = new Set(["stl", "obj", "3mf", "gltf", "glb", "ply"]);
const ASSET_EXTENSIONS = new Set([
...STEP_EXTENSIONS,
...MESH_EXTENSIONS,
"dxf", "svg", "urdf",
]);
function embedded() {
try {
if (typeof window === "undefined" || !window.parent || window.parent === window) return false;
// Same-origin shell under the Odoo web client (family env.ts rule).
const path = window.parent.location.pathname;
return path.startsWith("/odoo") || path.startsWith("/web") || /[\?&]odoo-host=1/.test(window.location.search);
} catch (e) {
// Cross-origin parent cannot happen in the same-origin shell; treat any
// throw (parent probe blocked) as framed.
return true;
}
}
/* ------------------------------------------------------------------ */
/* localStorage stores */
/* ------------------------------------------------------------------ */
function loadMap(key) {
try {
const raw = window.localStorage.getItem(key);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (e) {
return {};
}
}
function saveMap(key, value) {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
/* private mode / quota — memory only for this session */
}
}
let tokens = loadMap(PENDING_KEY);
function saveTokens() {
saveMap(PENDING_KEY, tokens);
}
function newToken() {
return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
/** Pending-save tokens survive reloads so Ctrl+S keeps hitting the same
* record even after the token map left memory (one-photo convention). */
function tokenOf(path) {
const rest = path.slice("/odoo/pending/".length);
const slash = rest.indexOf("/");
return slash === -1 ? rest : rest.slice(0, slash);
}
function baseName(path) {
const withoutSlash = path.replace(/\/+$/, "");
const slash = withoutSlash.lastIndexOf("/");
return slash === -1 ? withoutSlash : withoutSlash.slice(slash + 1);
}
/* ------------------------------------------------------------------ */
/* Rust glue plumbing */
/* ------------------------------------------------------------------ */
/** The Rust side installs window.onecadOdoo (services_odoo.rs). Requests
* arriving before it are queued — boot-order races are impossible. */
function rustGlue() {
return window.onecadOdoo || null;
}
const requestQueue = [];
function dispatchRequest(id, kind, payloadJson) {
if (!rustGlue()) {
// The Rust glue is not installed yet (wasm still booting): queue it and
// retry — boot-order races are impossible.
requestQueue.push([id, kind, payloadJson]);
window.setTimeout(pumpQueue, 250);
return;
}
void handleRequest(id, kind, payloadJson);
}
function pumpQueue() {
if (!rustGlue()) return;
while (requestQueue.length) {
const [id, kind, payloadJson] = requestQueue.shift();
void handleRequest(id, kind, payloadJson);
}
}
async function handleRequest(id, kind, payloadJson) {
let payload = {};
try {
payload = payloadJson ? JSON.parse(payloadJson) : {};
} catch (e) {
payload = {};
}
try {
await bridge.waitForHost();
if (kind === "save") {
const design = !!payload.design;
const picked = await openDocumentsExplorer({
mode: "save",
defaultName: typeof payload.name === "string" && payload.name ? payload.name : "part.solidcraft",
preferAppKey: design ? "projects" : "exports",
lastFolderKey: design ? SAVE_FOLDER_KEY : EXPORT_FOLDER_KEY,
});
if (!picked || typeof picked.folderId !== "number") {
rustGlue().resolve(id, null); // cancelled — not an error
return;
}
const extension = extensionOf(payload.name || "");
const name = withExtension(picked.name, extension);
const token = newToken();
tokens[token] = { folderId: picked.folderId, name };
saveTokens();
rustGlue().resolve(id, { token, name });
return;
}
// "open" and "program": pick a record, read it, hand the bytes over.
const preferAppKey = kind === "program" ? "assets" : "projects";
const picked = await openDocumentsExplorer({
mode: "open",
extensions: Array.isArray(payload.extensions) ? payload.extensions : [],
preferAppKey,
lastFolderKey: OPEN_FOLDER_KEY,
});
if (!picked || typeof picked.recordId !== "number") {
rustGlue().resolve(id, null);
return;
}
const record = await bridge.read(picked.recordId);
const bytes = base64ToBytes(record.dataB64);
guardCap(bytes.length, record.name);
// Foreign assets (outside the app tree) are copied into "Assets" so
// imports always land there, like the family storage rules require.
void mirrorToAssets(picked.recordId, record.name, bytes);
rustGlue().resolve(id, { recordId: picked.recordId, name: record.name, bytes });
} catch (error) {
console.error("[one-cad odoo] request failed:", error);
try {
if (rustGlue()) rustGlue().resolve(id, null);
} catch (e) {
/* the Rust future times out on its own */
}
}
}
/* ------------------------------------------------------------------ */
/* VFS writes → Documents (saves + exports) */
/* ------------------------------------------------------------------ */
async function handleWrite(path, bytes) {
await hostReady;
const dataB64 = bytesToBase64(bytes);
if (path.indexOf("/odoo/doc/") === 0) {
// /odoo/doc/<recordId>/<name> — in-place write-back (Ctrl+S).
const rest = path.slice("/odoo/doc/".length);
const recordId = Number(rest.split("/")[0]);
if (!Number.isInteger(recordId) || recordId <= 0) throw new Error("bad document path: " + path);
await bridge.write({ recordId, dataB64 });
return;
}
if (path.indexOf("/odoo/pending/") === 0) {
// /odoo/pending/<token>/<name> — first write creates the record, then
// the token binds to it forever (the document_id → recordId session
// map, kept in localStorage so reloads keep writing in place).
const token = tokenOf(path);
const meta = tokens[token];
if (!meta) throw new Error("unknown save destination (stale token): " + token);
const bound = Number.isInteger(meta.recordId) && meta.recordId > 0;
const written = await bridge.write({
recordId: bound ? meta.recordId : undefined,
name: meta.name,
folderId: bound ? undefined : meta.folderId,
dataB64,
extension: bound ? undefined : extensionOf(meta.name),
});
if (!bound) {
meta.recordId = written.id;
saveTokens();
}
return;
}
if (path.indexOf("/downloads/") === 0) {
// Exports: no local device writes in the Odoo shell — every download
// path is rerouted into the app's "Exports" folder.
await bridge.saveExport({ name: baseName(path), dataB64 });
return;
}
console.warn("[one-cad odoo] ignoring VFS write outside the Odoo schemes:", path);
}
/* ------------------------------------------------------------------ */
/* Asset imports (computer → "Assets" folder) */
/* ------------------------------------------------------------------ */
let appTreeCache = null;
async function appAssetsFolder() {
if (!appTreeCache) {
const ping = await bridge.waitForHost();
appTreeCache = (ping && ping.app) || {};
}
return typeof appTreeCache.assets === "number" ? appTreeCache.assets : appTreeCache.root;
}
/** Copy a foreign record (picked from outside the app tree) into Assets. */
async function mirrorToAssets(recordId, name, bytes) {
try {
if (typeof recordId !== "number" || DESIGN_EXTENSIONS.has(extensionOf(name))) return;
const documents = await bridge.list();
if ((documents.documents || []).some((doc) => Number(doc.id) === Number(recordId))) return; // already ours
await writeAsset(name, bytes);
} catch (error) {
console.warn("[one-cad odoo] asset mirror skipped:", error);
}
}
async function writeAsset(name, bytes) {
guardCap(bytes.length, name);
const folderId = await appAssetsFolder();
const ext = extensionOf(name) || "bin";
await bridge.write({
name: sanitizeStem(name),
dataB64: bytesToBase64(bytes),
folderId,
extension: ext,
});
}
/** Files brought in from the computer (files.rs incoming) land in Assets. */
async function handleImport(name, bytes) {
await hostReady;
const sigs = loadMap(IMPORT_SIGS_KEY);
const sig = name + ":" + bytes.length;
if (sigs[sig]) return;
sigs[sig] = 1;
saveMap(IMPORT_SIGS_KEY, sigs);
try {
if (!ASSET_EXTENSIONS.has(extensionOf(name))) return;
await writeAsset(name, bytes);
} catch (error) {
console.warn("[one-cad odoo] import into Assets failed:", error);
}
}
/* ------------------------------------------------------------------ */
/* Boot: handshake, deep link (?document=<id>), host-ready promise */
/* ------------------------------------------------------------------ */
let hostReadyResolve;
const hostReady = new Promise((resolve) => {
hostReadyResolve = resolve;
});
/** Open command for an emulated path, mirroring the app's own Open logic:
* designs open, CAD formats are inserted, everything else just waits in
* the VFS (the bytes are at /odoo/doc/… and stay retrievable). */
function openCommandFor(name) {
const ext = extensionOf(name);
if (DESIGN_EXTENSIONS.has(ext)) return { command: "doc.open" };
if (STEP_EXTENSIONS.has(ext)) return { command: "file.insert_step" };
if (MESH_EXTENSIONS.has(ext)) return { command: "file.insert_mesh" };
if (ext === "dxf" || ext === "svg") return { command: "sketch.insert_" + ext };
return null;
}
async function boot() {
if (!embedded()) return;
try {
await bridge.waitForHost();
} catch (error) {
console.warn("[one-cad odoo] host handshake failed:", error);
hostReadyResolve(false); // fail writes loudly instead of hanging
return;
}
hostReadyResolve(true);
pumpQueue();
const documentId = bridge.bootDocumentId();
if (!documentId) return;
try {
const record = await bridge.read(documentId);
const bytes = base64ToBytes(record.dataB64);
guardCap(bytes.length, record.name);
// bootFile inserts the bytes into the engine VFS and returns the path.
const path = rustGlue().bootFile(documentId, record.name, bytes);
const open = openCommandFor(record.name);
if (open && window.solidcraft && typeof window.solidcraft.execute === "function") {
await window.solidcraft.execute(open.command, { path });
} else if (!open) {
console.info("[one-cad odoo] deep-linked file staged at", path);
}
} catch (error) {
console.error("[one-cad odoo] deep-link boot failed:", error);
}
}
/* ------------------------------------------------------------------ */
/* Public surface (called from services_odoo.rs) */
/* ------------------------------------------------------------------ */
window.onecadOdooJs = {
/** True once this module decided the app runs in the Odoo shell. */
embedded: embedded(),
/** Rust picker request: kind "open" | "save" | "program". */
request(id, kind, payloadJson) {
dispatchRequest(Number(id), String(kind || ""), payloadJson ? String(payloadJson) : "{}");
},
/** VFS write drained by services_odoo.rs (fire-and-forget save). */
write(path, bytes) {
handleWrite(String(path || ""), new Uint8Array(bytes)).catch((error) => {
console.error("[one-cad odoo] save to Documents failed:", path, error);
});
},
/** A CAD asset imported from the computer (mirrored into Assets). */
import(name, bytes) {
handleImport(String(name || ""), new Uint8Array(bytes)).catch((error) => {
console.warn("[one-cad odoo] import failed:", name, error);
});
},
};
if (window.onecadOdooJs.embedded) {
void boot();
}
+595
View File
@@ -0,0 +1,595 @@
// OneBSpace (OneCAD): Odoo Documents host integration.
//! OneBSpace (OneCAD): 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
//! [`solidcraft_ui_egui::Services`] is rerouted through the one_cad JS
//! bridge instead of the browser/device:
//!
//! - `pick_open` / `pick_save` / `pick_program` ask the JS host glue to show
//! the unified Documents explorer and resolve with an *emulated* VFS path:
//! `/odoo/doc/<recordId>/<name>` (an opened Documents record, bytes staged
//! in the engine VFS) or `/odoo/pending/<token>/<name>` (a Save-as
//! destination; the JS side keeps the token → record binding so a later
//! Ctrl+S writes back in place).
//! - writes drained from the engine VFS under `/odoo/…` go to the bound
//! Documents record and `/downloads/` exports go to the "Exports" folder —
//! the air gap keeps bytes off the local device ([`handle_write`]).
//! - `show_in_folder` is a no-op and `http_get` stays `None` (air gap: the
//! shell has no local filesystem to reveal and no outbound HTTP).
//!
//! 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 JS bridge: CustomEvents)
//!
//! Rust → JS: `window.dispatchEvent(new CustomEvent("onecad:host-request",
//! { detail }))` with `detail = { id: number, method: string, params: object }`.
//!
//! JS → Rust: `window.dispatchEvent(new CustomEvent("onecad:host-response",
//! { detail }))` with `detail = { id, ok: boolean, result?: any, error?: string }`.
//!
//! The glue must attach its `onecad:host-response` listener *before* the app
//! boots (index.html loads the bridge module ahead of the wasm) and set
//! `window.onecadHostReady = 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; the picker future resolves `None` (no error).
//!
//! For JS→Rust binary staging (deep links `?document=<id>`) the glue may
//! call `window.onecadOdoo.bootFile(recordId, name, uint8array) -> path`,
//! which inserts the bytes into the engine VFS at `/odoo/doc/<id>/<name>`.
//!
//! # HOW TO WIRE (exact locations in the current tree)
//!
//! 1. `apps/solidcraft-web/src/main.rs`, module block (tagged
//! `OneBSpace (OneCAD)`): `#[cfg(target_arch = "wasm32")] mod services_odoo;`
//! 2. `apps/solidcraft-web/src/main.rs`, inside `web::start()`'s
//! `WebRunner::start` app-install closure — immediately after the
//! `let services = Services { .. };` literal and **before**
//! `SolveApp::new(.., services)` (the tagged line
//! `// OneBSpace (OneCAD): install odoo services on wasm`,
//! currently main.rs line 537):
//! `let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());`
//! 3. **Exact VFS write drain point**: `apps/solidcraft-web/src/files.rs`
//! `flush()` — the `for (path, bytes) in vfs::take_writes()` loop
//! (files.rs line 297) hands every write to
//! `crate::services_odoo::handle_write(&path, &bytes)` *first*
//! (files.rs line 301); `true` consumes it (never OPFS, never a device
//! download). Writes are enqueued by `vfs::write()`
//! (crates/io/src/vfs.rs, wasm32 `imp::write`, line 84, which pushes the
//! path onto the `WRITES` queue) and drained by `imp::take_writes()`
//! (vfs.rs line 109).
//! 4. `apps/solidcraft-web/src/main.rs` line 130 (`Shell::files_tick`):
//! `crate::services_odoo::mirror_imports(&self.files)` mirrors CAD
//! imports from the computer into the Documents "Assets" folder.
// OneBSpace (OneCAD) — 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::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
use std::task::{Context as TaskContext, Poll, Wake, Waker};
use std::time::Duration;
// OneBSpace (OneCAD): std::time::Instant::now() PANICS on
// wasm32-unknown-unknown ("time not implemented on this platform") — the
// whole app loop died the moment a picker (Open/Save/Import/Export) ran.
// web_time::Instant maps to performance.now() on wasm, std on native.
use web_time::Instant;
use js_sys::{Array, Object, Reflect, Uint8Array};
use solidcraft_engine::io::vfs;
use solidcraft_ui_egui::{FilePickerFuture, Services};
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::JsFuture;
use crate::files::{self, Files};
/// 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/";
/// Rust → JS request event (see the WIRE PROTOCOL block above).
const REQUEST_EVENT: &str = "onecad:host-request";
/// JS → Rust response event (see the WIRE PROTOCOL block above).
const RESPONSE_EVENT: &str = "onecad:host-response";
const PICK_OPEN: &str = "odoo.pickOpen";
const PICK_SAVE: &str = "odoo.pickSave";
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 (the api.rs convention).
const MAX_PENDING: usize = 64;
/// A stuck shell (bridge JS missing, explorer broken) must not wedge the
/// app's file picker forever: the future cancels itself after this long.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
/// Retry cadence while `window.onecadHostReady` has not appeared yet.
const RETRY_MS: i32 = 250;
/// How long to wait for the JS bridge module to become ready per request.
const HOST_DEADLINE: Duration = Duration::from_secs(30);
/// CAD formats the app ingests (mirrors files.rs's input picker accept list
/// plus glTF/URDF). 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 (the api.rs pending
/// queue, adapted to Rust futures instead of JS promises).
static PENDING: RefCell<HashMap<u64, Arc<Slot>>> = RefCell::new(HashMap::new());
/// The request-id counter.
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
/// The `onecad:host-response` listener is installed exactly once.
static LISTENER: Cell<bool> = const { Cell::new(false) };
/// The `window.onecadOdoo` boot seam is installed exactly once.
static BOOT: 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 answer slot: the response listener fills it, the future drains it.
struct Slot {
done: Cell<bool>,
value: RefCell<Option<String>>,
waker: RefCell<Option<Waker>>,
}
impl Slot {
fn complete(&self, value: Option<String>) {
if self.done.get() {
return;
}
*self.value.borrow_mut() = value;
self.done.set(true);
if let Some(waker) = self.waker.borrow_mut().take() {
waker.wake();
}
}
}
/// The egui repaint waker (same pattern as ui-egui's own file picker).
struct Repaint(egui::Context);
impl Wake for Repaint {
fn wake(self: Arc<Self>) {
self.0.request_repaint();
}
}
/// The future a `Services` picker closure returns; resolves with the
/// emulated path once the shell answers, or `None` when cancelled/expired.
struct PickerFuture {
slot: Arc<Slot>,
deadline: Instant,
}
impl Future for PickerFuture {
type Output = Option<String>;
fn poll(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
if this.slot.done.get() {
return Poll::Ready(this.slot.value.borrow_mut().take());
}
if Instant::now() >= this.deadline {
this.slot.complete(None);
return Poll::Ready(None);
}
*this.slot.waker.borrow_mut() = Some(cx.waker().clone());
Poll::Pending
}
}
/// 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 boot seam and the response listener always
/// (harmless standalone), the Services overrides only inside the shell.
/// Call once at app start, right after the `Services` literal is built in
/// `main.rs` and before `SolveApp::new` (see HOW TO WIRE above).
pub fn install_odoo_services(services: Services, ctx: egui::Context) -> Services {
install_boot_seam();
install_response_listener(ctx.clone());
if !embedded() {
return services;
}
log::info!("OneCAD: Odoo shell detected — pickers and writes route through the Documents bridge");
let open_ctx = ctx.clone();
let save_ctx = ctx.clone();
let program_ctx = ctx;
Services {
// Open/Insert pickers browse Odoo Documents (explorer, then a read).
pick_open: Some(Box::new(move |extensions: &[&str]| {
let params = Object::new();
let list = Array::new();
for ext in extensions {
list.push(&JsValue::from_str(ext));
}
let _ = Reflect::set(&params, &"extensions".into(), &list.into());
pick(PICK_OPEN, params, open_ctx.clone())
})),
// Saves ask the explorer once; the returned pending token binds the
// design to its record (later Ctrl+S writes back in place).
pick_save: Some(Box::new(move |name: &str, extensions: &[&str]| {
let params = Object::new();
let _ = Reflect::set(&params, &"name".into(), &JsValue::from_str(name));
// Same design test as the upstream closure (main.rs).
let design = extensions.contains(&solidcraft_engine::io::DESIGN_EXT);
let _ = Reflect::set(&params, &"design".into(), &JsValue::from_bool(design));
pick(PICK_SAVE, params, save_ctx.clone())
})),
// The slicer program pick becomes an asset pick from the "Assets"
// folder: the picked file is staged in the VFS (no host program
// dialog exists in the shell).
pick_program: Some(Box::new(move || pick(PICK_PROGRAM, Object::new(), program_ctx.clone()))),
// Air gap: nothing local to reveal.
show_in_folder: Some(Box::new(|_: &str| {})),
// Air gap: no update checks over HTTP in the shell.
http_get: None,
..services
}
}
/// Park a picker slot and deliver the request to the shell; the returned
/// future resolves with the emulated path (or `None` on cancel/timeout).
fn pick(method: &'static str, params: Object, ctx: egui::Context) -> FilePickerFuture {
if PENDING.with(|p| p.borrow().len()) >= MAX_PENDING {
log::error!("OneCAD: too many pending bridge requests (maximum {MAX_PENDING})");
return Box::pin(std::future::ready(None));
}
let id = next_id();
let slot = Arc::new(Slot { done: Cell::new(false), value: RefCell::new(None), waker: RefCell::new(None) });
PENDING.with(|p| p.borrow_mut().insert(id, slot.clone()));
deliver(method, id, params, slot.clone());
// The api.rs pattern: nudge the UI so the future is polled promptly.
ctx.request_repaint();
Box::pin(PickerFuture { slot, deadline: Instant::now() + REQUEST_TIMEOUT })
}
fn next_id() -> u64 {
NEXT_ID.with(|n| {
let id = n.get();
n.set(id + 1);
id
})
}
/// Deliver a request once `window.onecadHostReady` appears (the glue sets it
/// after attaching its response listener), then cancel the slot if it never
/// does. Answers arrive out-of-band via `onecad:host-response`.
fn deliver(method: &'static str, id: u64, params: Object, slot: Arc<Slot>) {
wasm_bindgen_futures::spawn_local(async move {
let deadline = Instant::now() + HOST_DEADLINE;
loop {
if slot.done.get() {
return; // answered or cancelled while we were waiting
}
if host_ready() && dispatch_event(method, id, &params) {
return; // the shell owns the answer now
}
if Instant::now() >= deadline {
log::error!("OneCAD: the Odoo bridge never became ready; cancelling \"{method}\"");
slot.complete(None);
return;
}
sleep_ms(RETRY_MS).await;
}
});
}
fn host_ready() -> bool {
web_sys::window()
.map(|w| Reflect::get(&w, &"onecadHostReady".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false))
.unwrap_or(false)
}
async fn sleep_ms(ms: i32) {
let Some(window) = web_sys::window() else { return };
let promise = js_sys::Promise::new(&mut |resolve, _| {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
});
let _ = JsFuture::from(promise).await;
}
/// `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 `onecad:host-response` listener once: responses complete the
/// parked picker slots (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 {
slot.complete(path_from_result(&result));
} else {
log::warn!("OneCAD: the shell refused the request: {error}");
slot.complete(None);
}
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: 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));
}
}
/// Map a `onecad:host-response` result onto the emulated path the picker
/// future receives (see the WIRE PROTOCOL table above).
fn path_from_result(result: &JsValue) -> Option<String> {
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());
}
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);
}
// 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(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(path);
}
None
}
/// Stage a picked record's bytes in the engine VFS 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")));
vfs::insert(&path, bytes);
Some(path)
}
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 VFS: 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 `files.rs::flush`, the exact
/// `vfs::take_writes()` drain point): consume writes the shell must not turn
/// into device IO. `/odoo/…` writes go to the bound Documents record (or the
/// pending token's destination on first write), `/downloads/` exports go to
/// the "Exports" folder via saveExport. Returns true when the write was
/// consumed (standalone: always false, upstream behavior untouched).
pub fn handle_write(path: &str, bytes: &[u8]) -> bool {
if !embedded() {
return false;
}
let export = path.strip_prefix(files::DOWNLOADS).map(str::to_string);
let is_odoo = path.starts_with(ODOC) || path.starts_with(OPENDING);
if !is_odoo && export.is_none() {
return false;
}
let params = Object::new();
if let Some(name) = &export {
let _ = Reflect::set(&params, &"name".into(), &JsValue::from_str(name));
} else {
let _ = Reflect::set(&params, &"path".into(), &JsValue::from_str(path));
}
let _ = Reflect::set(&params, &"bytes".into(), &Uint8Array::from(bytes).into());
let method = if export.is_some() { SAVE_EXPORT } else { WRITE };
if !dispatch_event(method, next_id(), &params) {
log::error!("OneCAD: the Odoo bridge is unavailable; \"{path}\" stays in the engine VFS (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 engine VFS and the OPFS autosave until the save
// succeeds).
true
}
/// Mirror hook (tagged call site in `main.rs::files_tick`): CAD 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_imports(files: &Rc<Files>) {
if !embedded() {
return;
}
for (name, bytes, _) in files.incoming.borrow().iter() {
if !is_asset_name(name) {
continue;
}
if !MIRRORED.with(|m| m.borrow_mut().insert((name.clone(), bytes.len()))) {
continue;
}
let params = Object::new();
let _ = Reflect::set(&params, &"name".into(), &JsValue::from_str(name));
let _ = Reflect::set(&params, &"bytes".into(), &Uint8Array::from(bytes.as_slice()).into());
if !dispatch_event(IMPORT, next_id(), &params) {
log::warn!("OneCAD: could not mirror \"{name}\" into the Assets folder (bridge not ready)");
}
}
}
fn is_asset_name(name: &str) -> bool {
std::path::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)))
}
/// Install `window.onecadOdoo = { bootFile }` — the JS→Rust staging seam for
/// deep links (`?document=<id>`): the glue inserts the record's bytes into
/// the engine VFS, then opens the returned path via `window.solidcraft`.
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, &"onecadOdoo".into(), &glue).is_ok() {
BOOT.with(|b| b.set(true));
}
}
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""OneBSpace (OneCAD) — build the SolidCraft wasm bundle into the one_cad Odoo addon.
Stdlib-only build chain (python3 scripts/build_onecad_addon.py [--dry-run]):
1. optional clean: ODA_CLEAN=1 wipes the dist target before building
2. build: prefer trunk (apps/solidcraft-web/Trunk.toml); fallback is
onepixel's tools/cargo-run chain verbatim:
cargo build --release --target wasm32-unknown-unknown -p solidcraft-web
wasm-bindgen --target web --out-dir <dist> --out-name onecad <wasm>
wasm-opt -Oz (when on PATH)
plus index.html + static assets copied into <dist>
3. rebrand: <title>OneCAD</title> + data-app-name="OneCAD" in index.html;
drop sw.js / manifest.webmanifest / build.json / icon files
4. verify gate: FAIL on absolute /(assets|fonts|blobs)/ references in any text
file of the dist; require index.html, a *_bg.wasm and a
wasm-bindgen glue .js — exits non-zero on failure
5. mirror-copy: dist -> /home/z/my-project/one-os-dev/core-components/one_cad/static/onecad/
(rsync-like: stale files in the destination are removed)
6. reminder: bump bundleVersion (static/src/one_cad_app.js) + addon manifest version
Exit codes: 0 ok, 1 gate failure, 2 build environment problem.
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
APP = REPO / "apps" / "solidcraft-web"
DIST = REPO / "dist" / "web"
DEST = Path("/home/z/my-project/one-os-dev/core-components/one_cad/static/onecad")
WASM_TARGET = "wasm32-unknown-unknown"
WASM_ARTIFACT = REPO / "target" / WASM_TARGET / "release" / "solidcraft_web.wasm"
OUT_NAME = "onecad"
TEXT_SUFFIXES = {".html", ".js", ".mjs", ".css", ".json", ".svg", ".txt", ".webmanifest", ".map"}
ABS_REF = re.compile(r"""["'=(]/(?:assets|fonts|blobs)/""")
ICON_FILE = re.compile(r"^(icon|apple-touch-icon|favicon).*", re.I)
DROP_FILES = {"sw.js", "manifest.webmanifest", "site.webmanifest", "build.json"}
def log(message: str) -> None:
print(f"[onecad] {message}")
def sh(cmd: list[str], cwd: Path | None = None) -> None:
log("$ " + " ".join(cmd) + (f" (cwd={cwd})" if cwd else ""))
try:
subprocess.run([str(c) for c in cmd], cwd=str(cwd) if cwd else None, check=True)
except FileNotFoundError as exc:
sys.exit(f"[onecad] tool not found: {exc.filename}")
except subprocess.CalledProcessError as exc:
sys.exit(f"[onecad] command failed with exit code {exc.returncode}: {' '.join(str(c) for c in cmd)}")
def clean_dist() -> None:
if DIST.exists():
log(f"ODA_CLEAN=1: wiping {DIST}")
shutil.rmtree(DIST, ignore_errors=True)
def build() -> Path:
"""Build the wasm bundle; return the dist directory that holds index.html."""
if shutil.which("trunk"):
sh(["trunk", "build", "--release", "--public-url", "./"], cwd=APP)
else:
log("trunk not on PATH — using the cargo + wasm-bindgen chain (onepixel convention)")
sh(["cargo", "build", "--release", "--target", WASM_TARGET, "-p", "solidcraft-web"], cwd=REPO)
if not WASM_ARTIFACT.exists():
sys.exit(f"[onecad] expected wasm artifact missing: {WASM_ARTIFACT}")
DIST.mkdir(parents=True, exist_ok=True)
sh(["wasm-bindgen", "--target", "web", "--out-dir", DIST, "--out-name", OUT_NAME, WASM_ARTIFACT])
if shutil.which("wasm-opt"):
optimized = DIST / f"{OUT_NAME}_bg.wasm.opt"
sh(["wasm-opt", "-Oz", DIST / f"{OUT_NAME}_bg.wasm", "-o", optimized])
os.replace(optimized, DIST / f"{OUT_NAME}_bg.wasm")
else:
log("wasm-opt not on PATH — skipping -Oz (larger bundle, still correct)")
# Static assets trunk would have copied: index.html + the assets tree.
if (APP / "index.html").exists():
shutil.copy2(APP / "index.html", DIST / "index.html")
if (APP / "assets").is_dir():
shutil.copytree(APP / "assets", DIST / "assets", dirs_exist_ok=True)
for candidate in (DIST, APP / "dist"):
if (candidate / "index.html").is_file():
return candidate
sys.exit("[onecad] build produced no index.html in dist/web or apps/solidcraft-web/dist")
def rebrand(dist: Path) -> None:
index = dist / "index.html"
html = index.read_text(encoding="utf-8")
html, n_title = re.subn(r"<title\b[^>]*>.*?</title>", "<title>OneCAD</title>", html, flags=re.S | re.I)
if 'data-app-name="' in html:
html = re.sub(r'data-app-name="[^"]*"', 'data-app-name="OneCAD"', html)
else:
html = re.sub(r"<html\b", '<html data-app-name="OneCAD"', html, count=1)
# Service worker, PWA manifest and icon links have no meaning inside the
# Odoo iframe — drop the tags along with the files.
html = re.sub(r"<link[^>]+rel=[\"']manifest[\"'][^>]*>\s*", "", html, flags=re.I)
html = re.sub(r"<link[^>]+rel=[\"'][^\"']*(icon|apple-touch)[^\"']*[\"'][^>]*>\s*", "", html, flags=re.I)
index.write_text(html, encoding="utf-8")
log(f"rebranded {index} (title replacements: {n_title})")
dropped = 0
for path in sorted(dist.rglob("*")):
if not path.is_file():
continue
if path.name in DROP_FILES or ICON_FILE.match(path.name):
path.unlink()
dropped += 1
log(f"dropped {dropped} sw/manifest/icon files from the bundle")
def verify(dist: Path) -> list[str]:
"""The gate: absolute /(assets|fonts|blobs)/ refs fail; glue + wasm must exist."""
errors: list[str] = []
if not (dist / "index.html").is_file():
errors.append("missing index.html")
wasm_files = [p for p in dist.rglob("*.wasm")]
if not any(p.name.endswith("_bg.wasm") for p in wasm_files):
errors.append("no *_bg.wasm (wasm-bindgen output) in dist")
glue = [
p
for p in dist.rglob("*.js")
if "wasm_bindgen" in p.read_text(encoding="utf-8", errors="ignore")
or "wasm-bindgen" in p.read_text(encoding="utf-8", errors="ignore")
]
if not glue:
errors.append("no wasm-bindgen glue .js in dist")
offenders: list[str] = []
for path in sorted(dist.rglob("*")):
if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES:
continue
text = path.read_text(encoding="utf-8", errors="ignore")
if ABS_REF.search(text):
offenders.append(str(path.relative_to(dist)))
if offenders:
errors.append(
"absolute /(assets|fonts|blobs)/ references (must be relative — the bundle is served "
f"from /one_cad/static/onecad/): {', '.join(offenders[:10])}"
+ (f" … (+{len(offenders) - 10} more)" if len(offenders) > 10 else "")
)
if errors:
for error in errors:
log(f"GATE FAIL: {error}")
else:
log(f"GATE PASS: {len(wasm_files)} wasm, {len(glue)} glue js, all references relative")
return errors
def mirror(src: Path, dst: Path) -> None:
"""rsync-like: make dst exactly src (extra files in dst are removed)."""
src_files = {p.relative_to(src) for p in src.rglob("*") if p.is_file()}
src_dirs = {p.relative_to(src) for p in src.rglob("*") if p.is_dir()}
dst.mkdir(parents=True, exist_ok=True)
removed = 0
for path in sorted(dst.rglob("*"), key=lambda p: len(p.parts), reverse=True):
rel = path.relative_to(dst)
if path.is_dir() and rel not in src_dirs:
shutil.rmtree(path)
removed += 1
elif path.is_file() and rel not in src_files:
path.unlink()
removed += 1
for rel in sorted(src_dirs):
(dst / rel).mkdir(parents=True, exist_ok=True)
for rel in sorted(src_files):
shutil.copy2(src / rel, dst / rel)
log(f"mirrored {len(src_files)} files -> {dst} ({removed} stale entries removed)")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--dry-run", action="store_true", help="print the plan, no builds/writes; gates run read-only against an existing dist")
args = parser.parse_args()
tools = {name: bool(shutil.which(name)) for name in ("trunk", "cargo", "wasm-bindgen", "wasm-opt")}
tool_report = ", ".join(f"{name}={'yes' if present else 'no'}" for name, present in tools.items())
log(f"tools: {tool_report}")
if not args.dry_run and not (tools["trunk"] or (tools["cargo"] and tools["wasm-bindgen"])):
log("need trunk, or cargo + wasm-bindgen, on PATH")
return 2
if args.dry_run:
log(f"DRY RUN: clean={'yes' if os.environ.get('ODA_CLEAN') == '1' else 'no'} -> build ({'trunk' if tools['trunk'] else 'cargo chain'}) -> {DIST} -> rebrand -> gate -> mirror {DEST}")
if (DIST / "index.html").is_file():
log("an existing dist was found; running the verification gate read-only")
return 1 if verify(DIST) else 0
log("no dist yet — plan only")
return 0
if os.environ.get("ODA_CLEAN") == "1":
clean_dist()
dist = build()
rebrand(dist)
if verify(dist):
return 1
mirror(dist, DEST)
log("NEXT STEPS:")
log(" 1. bump bundleVersion in one-os-dev/core-components/one_cad/static/src/one_cad_app.js (cache-busts the iframe bundle)")
log(" 2. bump the version in one-os-dev/core-components/one_cad/__manifest__.py to match the addon release")
log(" 3. upgrade the module (odoo -u one_cad) so the new static bundle is served")
return 0
if __name__ == "__main__":
sys.exit(main())