// Bivar adaptation of CADCraft's MIT OR Apache-2.0 host integration. // Same-window messages stay on this origin. No source file is uploaded or persisted here. use std::cell::RefCell; use std::rc::Rc; use cadcraft_engine::{DocState, Session}; use cadcraft_ui_egui::CadApp; use wasm_bindgen::{JsCast as _, JsValue, closure::Closure}; use web_sys::js_sys; const MAX_DOCS: usize = 4; const MAX_BYTES: usize = 16 * 1024 * 1024; const MAX_TOTAL: usize = 48 * 1024 * 1024; const MAX_ENTITIES: usize = 100_000; thread_local! { static CURRENT: RefCell> = const { RefCell::new(None) }; } #[derive(Clone)] pub struct Bridge { window: web_sys::Window, origin: String, pending: Rc>>, } impl Bridge { pub fn new(ctx: egui::Context) -> Self { let window = web_sys::window().unwrap_or_else(|| unreachable_window()); let origin = window.location().origin().unwrap_or_default(); let pending: Rc>> = Rc::default(); let queue = pending.clone(); let source = window.clone(); let expected = origin.clone(); let handler = Closure::::new(move |event: web_sys::MessageEvent| { if event.origin() != expected || !event.source().is_some_and(|s| js_sys::Object::is(&s, &source)) { return; } let data = event.data(); if text(&data, "type").as_deref() != Some("bivar-cad:request") { return; } if text(&data, "id").is_none_or(|id| id.len() > 64) || queue.borrow().len() >= 8 { return; } queue.borrow_mut().push(data); ctx.request_repaint(); }); if window.add_event_listener_with_callback("message", handler.as_ref().unchecked_ref()).is_ok() { handler.forget(); } Self { window, origin, pending } } pub fn install(&self) { CURRENT.with(|b| *b.borrow_mut() = Some(self.clone())); } pub fn ready(&self) { self.post("bivar-cad:ready", &[("version", "0.12.0-bivar.1".into())]); } pub fn pick(&self) { self.post("bivar-cad:pick", &[]); } pub fn export_drawing(&self, name: &str) { self.post("bivar-cad:export", &[("name", name.into())]); } fn post(&self, kind: &str, fields: &[(&str, JsValue)]) { let value = object(fields); set(&value, "type", kind.into()); let _ = self.window.post_message(&value, &self.origin); } pub fn report(&self, app: &CadApp, reported: &mut Option) { let token = format!("{}:{:?}", app.session.active, app.session.docs.iter().map(|s| (s.uid, s.revision, s.is_dirty(), s.title.as_str())).collect::>()); if reported.as_ref() == Some(&token) { return; } let docs = metadata(app); self.post("bivar-cad:changed", &[("token", token.clone().into()), ("documents", docs.into()), ("active", (app.session.active as f64).into())]); *reported = Some(token); } pub fn apply(&self, app: &mut CadApp) { let requests = std::mem::take(&mut *self.pending.borrow_mut()); for request in requests { let id = text(&request, "id").unwrap_or_default(); match self.dispatch(app, &request) { Ok((data, transfers)) => { let reply = object(&[("type", "bivar-cad:response".into()), ("id", id.into()), ("ok", true.into()), ("data", data)]); let _ = self.window.post_message_with_transfer(&reply, &self.origin, &transfers); } Err(error) => self.post("bivar-cad:response", &[("id", id.into()), ("ok", false.into()), ("error", error.into())]), } } } fn dispatch(&self, app: &mut CadApp, request: &JsValue) -> Result<(JsValue, js_sys::Array), String> { let transfers = js_sys::Array::new(); match text(request, "op").as_deref() { Some("open") => { if app.session.docs.len() >= MAX_DOCS { return Err("Close a drawing first. The desk holds up to four drawings.".into()); } let (name, doc) = prepare(request)?; app.session.docs.push(DocState::new(doc, &name, Some(name.clone()))); app.session.active = app.session.docs.len() - 1; app.session.running = None; app.session.suspended = None; app.ui.start_tab = false; app.canvas.zoom_pending = true; Ok((object(&[("documents", metadata(app).into()), ("active", (app.session.active as f64).into())]).into(), transfers)) } Some("new") => { if app.session.docs.len() >= MAX_DOCS { return Err("Close a drawing before creating another.".into()); } let mut fresh = Session::new(); let state = fresh.docs.pop().ok_or("The empty drawing could not be created")?; app.session.docs.push(state); app.session.active = app.session.docs.len() - 1; app.ui.start_tab = false; app.canvas.zoom_pending = true; Ok((object(&[("documents", metadata(app).into()), ("active", (app.session.active as f64).into())]).into(), transfers)) } Some("restore") => { let entries = js_sys::Reflect::get(request, &"documents".into()).map_err(js_error)?; if !js_sys::Array::is_array(&entries) { return Err("Project drawings are missing.".into()); } let entries = js_sys::Array::from(&entries); if entries.length() == 0 || entries.length() as usize > MAX_DOCS { return Err("A CAD project must contain one to four drawings.".into()); } let active = number(request, "active").filter(|n| n.fract() == 0.0 && *n >= 0.0 && *n < f64::from(entries.length())).ok_or("Invalid active drawing")? as usize; let mut prepared = Vec::new(); let mut total = 0usize; for item in entries.iter() { total += byte_array(&item).ok_or("Missing drawing bytes")?.length() as usize; if total > MAX_TOTAL { return Err("The CAD working drawings exceed 48 MiB.".into()); } prepared.push(prepare(&item)?); } // Every drawing has parsed successfully before the old session is replaced. let mut session = Session::empty(); for (name, doc) in prepared { session.docs.push(DocState::new(doc, &name, Some(name.clone()))); } session.active = active; app.session = session; app.ui.start_tab = false; app.canvas.zoom_pending = true; Ok((object(&[("documents", metadata(app).into()), ("active", (active as f64).into())]).into(), transfers)) } Some("capture") => { if app.session.docs.len() > MAX_DOCS { return Err("Close extra drawings before saving this project.".into()); } let docs = js_sys::Array::new(); let mut total = 0usize; for state in &app.session.docs { if state.block_edit.is_some() { return Err("Finish or discard block editing before saving the project.".into()); } let name = drawing_name(&state.title); let bytes = cadcraft_io::write(state.drawing(), &name).map_err(|e| e.to_string())?; total += bytes.len(); if bytes.len() > MAX_BYTES || total > MAX_TOTAL { return Err("The CAD working drawings exceed the project size limit.".into()); } let buffer = js_sys::Uint8Array::from(bytes.as_slice()).buffer(); transfers.push(&buffer); docs.push(&object(&[("uid", state.uid.to_string().into()), ("name", name.into()), ("bytes", buffer.into())])); } Ok((object(&[("documents", docs.into()), ("active", (app.session.active as f64).into())]).into(), transfers)) } Some("export") => { let format = text(request, "format").filter(|s| matches!(s.as_str(), "dxf" | "pdf" | "svg")).ok_or("Unsupported drawing export")?; let state = app.session.state().map_err(|e| e.to_string())?; if state.block_edit.is_some() { return Err("Finish or discard block editing before exporting.".into()); } let stem = drawing_name(&state.title).trim_end_matches(".dxf").to_owned(); let name = format!("{stem}.{format}"); let bytes = if format == "pdf" { // CAD's bright screen colours need a paper plot style for legible output. cadcraft_io::pdf(state.drawing(), &cadcraft_doc::Space::Model, &cadcraft_io::PdfOptions { plot_style_table: Some("monochrome.ctb".into()), paper: Some("A3".into()), landscape: Some(true), fit: Some(true), margins: Some([10.0; 4]), compress: true, hyperlinks: false, title: stem, ..cadcraft_io::PdfOptions::default() }) } else { cadcraft_io::write(state.drawing(), &name) }.map_err(|e| e.to_string())?; if bytes.len() > MAX_TOTAL { return Err("The export exceeds 48 MiB.".into()); } let buffer = js_sys::Uint8Array::from(bytes.as_slice()).buffer(); transfers.push(&buffer); Ok((object(&[("name", name.into()), ("bytes", buffer.into())]).into(), transfers)) } Some("fit") => { app.run("zoom", serde_json::json!({"mode":"extents"}))?; Ok((JsValue::NULL, transfers)) } _ => Err("Unknown CAD operation.".into()), } } } fn prepare(value: &JsValue) -> Result<(String, cadcraft_doc::Drawing), String> { let name = text(value, "name").filter(|s| !s.is_empty() && s.len() <= 255 && s.to_ascii_lowercase().ends_with(".dxf")).ok_or("A DXF working drawing is required")?; let bytes = byte_array(value).ok_or("Drawing data is missing")?; if bytes.length() == 0 || bytes.length() as usize > MAX_BYTES { return Err("CAD drawings must be between 1 byte and 16 MiB.".into()); } let doc = cadcraft_io::read(&bytes.to_vec(), &name).map_err(|e| e.to_string())?; if doc.entity_count() > MAX_ENTITIES { return Err("The drawing exceeds 100,000 entities.".into()); } Ok((name, doc)) } fn drawing_name(name: &str) -> String { let clean = name.rsplit(['/', '\\']).next().unwrap_or("Drawing"); let stem = clean.rsplit_once('.').map_or(clean, |(stem, _)| stem); format!("{}.dxf", stem.chars().take(180).collect::()) } fn metadata(app: &CadApp) -> js_sys::Array { let docs = js_sys::Array::new(); for state in &app.session.docs { docs.push(&object(&[("uid", state.uid.to_string().into()), ("name", drawing_name(&state.title).into()), ("entities", (state.doc.entity_count() as f64).into())])); } docs } pub fn deliver(name: &str, mime: &str, bytes: &[u8]) -> Result<(), String> { if bytes.len() > MAX_TOTAL { return Err("The drawing export exceeds 48 MiB.".into()); } CURRENT.with(|b| { let b = b.borrow(); let bridge = b.as_ref().ok_or("The CAD desk is not available")?; let buffer = js_sys::Uint8Array::from(bytes).buffer(); let reply = object(&[("type", "bivar-cad:download".into()), ("name", name.into()), ("mime", mime.into()), ("bytes", buffer.clone().into())]); bridge.window.post_message_with_transfer(&reply, &bridge.origin, &js_sys::Array::of1(&buffer)).map_err(js_error) }) } pub fn startup_failed(window: &web_sys::Window, origin: &str, error: &str) { let _ = window.post_message(&object(&[("type", "bivar-cad:failed".into()), ("error", error.into())]), origin); } fn object(fields: &[(&str, JsValue)]) -> js_sys::Object { let value = js_sys::Object::new(); for (key, item) in fields { set(&value, key, item.clone()); } value } fn set(value: &js_sys::Object, key: &str, item: JsValue) { let _ = js_sys::Reflect::set(value, &key.into(), &item); } fn text(value: &JsValue, key: &str) -> Option { js_sys::Reflect::get(value, &key.into()).ok()?.as_string() } fn number(value: &JsValue, key: &str) -> Option { js_sys::Reflect::get(value, &key.into()).ok()?.as_f64().filter(|v| v.is_finite()) } fn byte_array(value: &JsValue) -> Option { let b = js_sys::Reflect::get(value, &"bytes".into()).ok()?; if b.is_instance_of::() { Some(js_sys::Uint8Array::new(&b)) } else { b.dyn_into::().ok() } } fn js_error(value: JsValue) -> String { value.as_string().unwrap_or_else(|| format!("{value:?}")) } // The web runner creates the bridge only after obtaining a Window. fn unreachable_window() -> web_sys::Window { wasm_bindgen::throw_str("A browser window is required") }