forked from 1b-agent/opencadstudio-upstream
[ADD] OneCAD Draft Odoo integration: Rust odoo_bridge (File>Open via Documents explorer, PollWebControl drain), web/odoo glue trio (save air-gap via openCadStudioSaveFile hook, deep-link via ocs_web_drop_file), CustomEvent web-sys features, BUILD-WASM.md build guide; sandbox-constrained opt-level=0 note in release profile
This commit is contained in:
1 parent
193f2ae9ab
commit
62babcb71e
11 files changed
+2823
-6
No files matched your search
@@ -70,14 +70,14 @@ impl OpenCADStudio {
|
||||
pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
// OneBSpace (OneCAD Draft): donations disabled — air-gapped build.
|
||||
"DONATE" => Some(Task::none()),
|
||||
"DONATE" => (),
|
||||
|
||||
// OneBSpace (OneCAD Draft): the upstream web-app link is gone.
|
||||
"WEBVERSION" => Some(Task::none()),
|
||||
"WEBVERSION" => (),
|
||||
|
||||
// OneBSpace (OneCAD Draft): external help/discussions link removed —
|
||||
// air-gapped build. Use the in-app help instead.
|
||||
"HELP" => Some(Task::none()),
|
||||
"HELP" => (),
|
||||
|
||||
// ── DWGPROPS — print round-trip-only HeaderVariables ─────────
|
||||
// No UI dialog for these yet; the command surfaces them so
|
||||
@@ -301,7 +301,7 @@ impl OpenCADStudio {
|
||||
|
||||
// OneBSpace (OneCAD Draft): the upstream GitHub issue tracker is
|
||||
// unreachable from an air-gapped build; feedback is a no-op.
|
||||
"REPORT" => Some(Task::none()),
|
||||
"REPORT" => (),
|
||||
|
||||
"ABOUT" => {
|
||||
return Some(Task::done(Message::AboutOpen));
|
||||
@@ -313,7 +313,7 @@ impl OpenCADStudio {
|
||||
|
||||
// OneBSpace (OneCAD Draft): upstream release notes are unreachable
|
||||
// from an air-gapped build.
|
||||
"CHANGELOG" => Some(Task::none()),
|
||||
"CHANGELOG" => (),
|
||||
|
||||
// ── ALIASEDIT — command-alias editor ───────────────────────────
|
||||
// Opens the command-alias table editor (ocad.pgp). Keyboard
|
||||
|
||||
@@ -1619,6 +1619,12 @@ impl OpenCADStudio {
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// OneBSpace (OneCAD Draft): embedded in the Odoo Documents shell,
|
||||
// File > Open is served by the host's Documents explorer
|
||||
// (`odoo.pickOpen`, web/odoo/ glue) instead of the browser picker.
|
||||
if crate::odoo_bridge::intercept_open() {
|
||||
return Task::none();
|
||||
}
|
||||
// The browser picker + parse happen inside `pick_and_load_web`;
|
||||
// the real name is unknown until then, so show a generic label
|
||||
// meanwhile.
|
||||
|
||||
@@ -954,7 +954,13 @@ impl OpenCADStudio {
|
||||
for note in crate::scene::model::image_model::take_decode_refusals() {
|
||||
self.command_line.push_error(¬e);
|
||||
}
|
||||
Task::batch([control, dropped])
|
||||
// OneBSpace (OneCAD Draft): drawings picked in the Odoo
|
||||
// Documents explorer land in the bridge queue; deliver them
|
||||
// through the same open path as a browser drop.
|
||||
let odoo_open = crate::odoo_bridge::take_completed_open()
|
||||
.map(|(name, bytes)| self.open_web_bytes(name, bytes))
|
||||
.unwrap_or_else(Task::none);
|
||||
Task::batch([control, dropped, odoo_open])
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Message::PollWebControl => Task::none(),
|
||||
|
||||
@@ -21,6 +21,9 @@ pub mod mcp_ops;
|
||||
pub mod modules;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) mod network;
|
||||
// OneBSpace (OneCAD Draft): Odoo Documents bridge (web/odoo/ glue, wasm only).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod odoo_bridge;
|
||||
pub mod par;
|
||||
pub mod patreon;
|
||||
pub mod perf;
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! OneBSpace (OneCAD Draft): Odoo Documents bridge — Rust side.
|
||||
//!
|
||||
//! Wires the app's `File > Open` flow to the OneBSpace Documents explorer
|
||||
//! while the studio runs embedded in the `one_cad_draft` Odoo addon iframe
|
||||
//! (wasm_app_framework). The JS half of this bridge lives in `web/odoo/`
|
||||
//! (see `web/odoo/README-odoo.md` for the full wire contract).
|
||||
//!
|
||||
//! Contract summary (README-odoo.md §3.2 / §8.5):
|
||||
//! - This module DISPATCHES `onecaddraft:host-request` CustomEvents
|
||||
//! `{id, method, payload}` on the window and LISTENS for
|
||||
//! `onecaddraft:host-response` `{id, ok, result?, error?}`.
|
||||
//! - The glue sets `window.onecaddraftHostReady = true` once its response
|
||||
//! listener is attached. This side polls/retires until the flag shows up;
|
||||
//! it NEVER sets or clears the flag itself.
|
||||
//! - Saves/exports are air-gapped WITHOUT this module: `sys.rs::download_bytes`
|
||||
//! already prefers the page hook `window.openCadStudioSaveFile(name, bytes)`
|
||||
//! registered by the glue, so bytes can never reach a browser download.
|
||||
//! Deep-link opens (`?document=<recordId>`) are staged by the glue through
|
||||
//! `wasmBindings.ocs_web_drop_file` and drained by `Message::PollWebControl`
|
||||
//! like any other drop. The ONLY thing Rust must provide is routing the
|
||||
//! in-app `File > Open` through the host explorer (`odoo.pickOpen`).
|
||||
//!
|
||||
//! Call sites (both `#[cfg(target_arch = "wasm32")]`):
|
||||
//! - `app::update::file::on_open_file` -> [`intercept_open`]
|
||||
//! - `app::update::Message::PollWebControl` -> [`take_completed_open`]
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
use base64::Engine as _;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// CustomEvent the glue listens on (Rust -> glue requests).
|
||||
const REQUEST_EVENT: &str = "onecaddraft:host-request";
|
||||
/// CustomEvent this module listens on (glue -> Rust responses).
|
||||
const RESPONSE_EVENT: &str = "onecaddraft:host-response";
|
||||
/// Host method used for `File > Open` (answered by the Documents explorer).
|
||||
const PICK_METHOD: &str = "odoo.pickOpen";
|
||||
/// Extensions offered to the host explorer for open (owned drawing types).
|
||||
const PICK_EXTENSIONS: &[&str] = &["dxf", "dwg"];
|
||||
/// The poll tick (`Message::PollWebControl`) fires every 50 ms; re-dispatch
|
||||
/// a still-pending pick request every 5th tick (250 ms) until the glue is up.
|
||||
const RETRY_EVERY_TICKS: u32 = 5;
|
||||
/// Give the glue ~30 s to appear (600 x 50 ms) before dropping the request.
|
||||
const HOST_DEADLINE_TICKS: u32 = 600;
|
||||
|
||||
/// A drawing the host explorer handed back, ready to be opened.
|
||||
struct OpenCompletion {
|
||||
name: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A `File > Open` request waiting for the glue (and the host) to answer.
|
||||
struct PendingPick {
|
||||
id: u64,
|
||||
ticks: u32,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static EMBEDDED: Cell<Option<bool>> = const { Cell::new(None) };
|
||||
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
|
||||
static PENDING: RefCell<Option<PendingPick>> = const { RefCell::new(None) };
|
||||
static COMPLETED: RefCell<Vec<OpenCompletion>> = const { RefCell::new(Vec::new()) };
|
||||
static LISTENER_INSTALLED: Cell<bool> = const { Cell::new(false) };
|
||||
static LISTENER_CLOSURE: RefCell<Option<Closure<dyn FnMut(JsValue)>>> =
|
||||
const { RefCell::new(None) };
|
||||
static ANNOUNCED: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// True while the studio runs inside the OneCAD Draft Odoo iframe.
|
||||
///
|
||||
/// Same-origin frame whose parent lives under the Odoo shell paths, or a
|
||||
/// `?odoo-host=1` escape hatch for testing. Cached after the first probe.
|
||||
pub fn embedded() -> bool {
|
||||
EMBEDDED.with(|cell| {
|
||||
if let Some(v) = cell.get() {
|
||||
return v;
|
||||
}
|
||||
let v = detect_embedded();
|
||||
cell.set(Some(v));
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_embedded() -> bool {
|
||||
let Some(window) = web_sys::window() else {
|
||||
return false;
|
||||
};
|
||||
// Forced embedding (testing / unusual host layouts).
|
||||
if let Ok(search) = window.location().search() {
|
||||
if search.contains("odoo-host=1") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Framed? (a top-level window is its own parent)
|
||||
let Ok(Some(parent)) = window.parent() else {
|
||||
return false;
|
||||
};
|
||||
let parent_val: &JsValue = parent.as_ref();
|
||||
let window_val: &JsValue = window.as_ref();
|
||||
let framed = parent_val != window_val;
|
||||
if !framed {
|
||||
return false;
|
||||
}
|
||||
// Same-origin parent under the Odoo shell? A cross-origin parent throws
|
||||
// on `location.pathname` access, which reads as "not our host".
|
||||
let path = match parent.location().pathname() {
|
||||
Ok(path) => path,
|
||||
Err(_) => return false,
|
||||
};
|
||||
path.starts_with("/odoo") || path.starts_with("/web")
|
||||
}
|
||||
|
||||
/// Route an in-app `File > Open` through the host's Documents explorer.
|
||||
///
|
||||
/// Called from `on_open_file`'s wasm32 arm instead of the browser picker.
|
||||
/// Returns `true` when the request is handled by the host (the caller must
|
||||
/// then not open the native browser picker), `false` when the app is not
|
||||
/// embedded and the stock picker should run.
|
||||
pub fn intercept_open() -> bool {
|
||||
if !embedded() {
|
||||
return false;
|
||||
}
|
||||
announce();
|
||||
install_listener();
|
||||
let already_pending = PENDING.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
if slot.is_some() {
|
||||
return true;
|
||||
}
|
||||
let id = NEXT_ID.with(|n| {
|
||||
let id = n.get();
|
||||
n.set(id + 1);
|
||||
id
|
||||
});
|
||||
*slot = Some(PendingPick { id, ticks: 0 });
|
||||
false
|
||||
});
|
||||
if already_pending {
|
||||
// A pick dialog is already open on the host; swallow the repeat.
|
||||
return true;
|
||||
}
|
||||
dispatch_pick_request(PENDING.with(|slot| slot.borrow().as_ref().map(|p| p.id)));
|
||||
true
|
||||
}
|
||||
|
||||
/// Poll-side hook: retries pending pick requests while the glue boots and
|
||||
/// pops the next completed open, if any. Runs once per `PollWebControl`
|
||||
/// (every 50 ms on web).
|
||||
pub fn take_completed_open() -> Option<(String, Vec<u8>)> {
|
||||
if !embedded() {
|
||||
return None;
|
||||
}
|
||||
// Retry / expire the pending pick request.
|
||||
let fire = PENDING.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
let Some(pending) = slot.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
pending.ticks += 1;
|
||||
if pending.ticks > HOST_DEADLINE_TICKS {
|
||||
warn_log("OneCAD Draft: Odoo shell never answered the open request; giving up.");
|
||||
*slot = None;
|
||||
return false;
|
||||
}
|
||||
pending.ticks % RETRY_EVERY_TICKS == 0
|
||||
});
|
||||
if fire {
|
||||
dispatch_pick_request(PENDING.with(|slot| slot.borrow().as_ref().map(|p| p.id)));
|
||||
}
|
||||
COMPLETED.with(|queue| queue.borrow_mut().pop()).map(|o| (o.name, o.bytes))
|
||||
}
|
||||
|
||||
/// Dispatch `odoo.pickOpen` to the glue (silently skipped until the glue
|
||||
/// signals `window.onecaddraftHostReady`).
|
||||
fn dispatch_pick_request(id: Option<u64>) {
|
||||
let Some(id) = id else { return };
|
||||
let Some(window) = web_sys::window() else {
|
||||
return;
|
||||
};
|
||||
if !host_ready(&window) {
|
||||
return;
|
||||
}
|
||||
let detail = js_sys::Object::new();
|
||||
let _ = js_sys::Reflect::set(&detail, &JsValue::from_str("id"), &JsValue::from(id));
|
||||
let _ = js_sys::Reflect::set(
|
||||
&detail,
|
||||
&JsValue::from_str("method"),
|
||||
&JsValue::from_str(PICK_METHOD),
|
||||
);
|
||||
let payload = js_sys::Object::new();
|
||||
let extensions = js_sys::Array::new();
|
||||
for ext in PICK_EXTENSIONS {
|
||||
extensions.push(&JsValue::from_str(ext));
|
||||
}
|
||||
let _ = js_sys::Reflect::set(&payload, &JsValue::from_str("extensions"), &extensions.into());
|
||||
let _ = js_sys::Reflect::set(&detail, &JsValue::from_str("payload"), &payload.into());
|
||||
|
||||
let init = web_sys::CustomEventInit::new();
|
||||
init.set_detail(&detail.into());
|
||||
if let Ok(event) = web_sys::CustomEvent::new_with_event_init_dict(REQUEST_EVENT, &init) {
|
||||
let _ = window.dispatch_event(&event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the `onecaddraft:host-response` listener exactly once.
|
||||
fn install_listener() {
|
||||
if LISTENER_INSTALLED.get() {
|
||||
return;
|
||||
}
|
||||
let Some(window) = web_sys::window() else {
|
||||
return;
|
||||
};
|
||||
let closure = Closure::<dyn FnMut(JsValue)>::new(|event: JsValue| handle_response(event));
|
||||
let target: &web_sys::EventTarget = window.unchecked_ref();
|
||||
let _ = target.add_event_listener_with_callback(
|
||||
RESPONSE_EVENT,
|
||||
closure.as_ref().unchecked_ref(),
|
||||
);
|
||||
LISTENER_CLOSURE.with(|cell| {
|
||||
cell.borrow_mut().replace(closure);
|
||||
});
|
||||
LISTENER_INSTALLED.set(true);
|
||||
}
|
||||
|
||||
/// Consume one glue response for an outstanding `odoo.pickOpen` request.
|
||||
fn handle_response(event: JsValue) {
|
||||
let Ok(event) = event.dyn_into::<web_sys::CustomEvent>() else {
|
||||
return;
|
||||
};
|
||||
let detail = event.detail();
|
||||
let key = JsValue::from_str("id");
|
||||
let Some(id) = js_sys::Reflect::get(&detail, &key)
|
||||
.ok()
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|f| f as u64)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// Only consume responses matching OUR pending request: the glue answers
|
||||
// every request exactly once, and nothing else shares this channel.
|
||||
let ours = PENDING.with(|slot| slot.borrow().as_ref().map(|p| p.id) == Some(id));
|
||||
if !ours {
|
||||
return;
|
||||
}
|
||||
PENDING.with(|slot| *slot.borrow_mut() = None);
|
||||
|
||||
let ok = js_sys::Reflect::get(&detail, &JsValue::from_str("ok"))
|
||||
.map(|v| v.is_truthy())
|
||||
.unwrap_or(false);
|
||||
let result = js_sys::Reflect::get(&detail, &JsValue::from_str("result"))
|
||||
.unwrap_or(JsValue::UNDEFINED);
|
||||
if !ok || !result.is_object() {
|
||||
// `null` result = user cancelled in the Documents explorer.
|
||||
info_log("OneCAD Draft: open cancelled in the Documents explorer.");
|
||||
return;
|
||||
}
|
||||
let name = js_sys::Reflect::get(&result, &JsValue::from_str("name"))
|
||||
.ok()
|
||||
.and_then(|v| v.as_string());
|
||||
let bytes_value =
|
||||
js_sys::Reflect::get(&result, &JsValue::from_str("bytes")).unwrap_or(JsValue::UNDEFINED);
|
||||
let bytes: Option<Vec<u8>> = if let Ok(array) = bytes_value.dyn_into::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else {
|
||||
// Fallback: base64 `content` (kept from the family wire contract).
|
||||
let content = js_sys::Reflect::get(&result, &JsValue::from_str("content"))
|
||||
.ok()
|
||||
.and_then(|v| v.as_string());
|
||||
content.and_then(|c| base64::engine::general_purpose::STANDARD.decode(c).ok())
|
||||
};
|
||||
match (name, bytes) {
|
||||
(Some(name), Some(bytes)) => {
|
||||
COMPLETED.with(|queue| {
|
||||
queue.borrow_mut().push(OpenCompletion { name, bytes });
|
||||
});
|
||||
}
|
||||
_ => warn_log("OneCAD Draft: malformed open response from the Odoo shell."),
|
||||
}
|
||||
}
|
||||
|
||||
fn host_ready(window: &web_sys::Window) -> bool {
|
||||
js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("onecaddraftHostReady"))
|
||||
.map(|v| v.is_truthy())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn announce() {
|
||||
if ANNOUNCED.get() {
|
||||
return;
|
||||
}
|
||||
ANNOUNCED.set(true);
|
||||
info_log("OneCAD Draft: Odoo shell detected — File > Open is served by Documents.");
|
||||
}
|
||||
|
||||
fn info_log(message: &str) {
|
||||
web_sys::console::log_1(&JsValue::from_str(message));
|
||||
}
|
||||
|
||||
fn warn_log(message: &str) {
|
||||
web_sys::console::warn_1(&JsValue::from_str(message));
|
||||
}
|
||||
Reference in new issue
Block a user