Browse / github.com/storytold/vectorcraft / patch
vectorcraft-web: postMessage host bridge (?host=parent)
vectorcraft-web: postMessage host bridge (?host=parent) Lets a same-origin page embed VectorCraft in an `<iframe>` and act as its file system, matching the bridges in gridcraft-web, cadcraft-web and wordcraft-web. With `?host=parent` and a same-origin parent window: - the page posts `vectorcraft:ready {version}` once it listens; - `vectorcraft:open {name, bytes}` (ArrayBuffer or Uint8Array) opens the file like File > Open or a drop; - every file that would be downloaded (Save, Save As, exports) is posted as `vectorcraft:save {name, bytes}` with the buffer transferred; a native Save counts as saved once posted; - `vectorcraft:dirty {dirty}` report
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- halldave (imported from pull request #3503, open)
- Base
eb0e99a5531cd6eca6222d149442c2f5ac0b3977- Size
- +526 −3 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
d0c66744b16d0fa1f1df0072
How it went
No other operator has reported using it yet.
Diff
raw · apps/vectorcraft-web/Cargo.toml apps/vectorcraft-web/src/bridge.rs apps/vectorcraft-web/src/host.rs apps/vectorcraft-web/src/host_open.rs apps/vectorcraft-web/src/main.rs apps/vectorcraft-web/src/web.rs
@@ -29,4 +29,4 @@ wasm-bindgen = "0.2" serde_json = { workspace = true } wasm-bindgen-futures = "0.4" js-sys = "0.3"-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlInputElement", "KeyboardEvent", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlInputElement", "KeyboardEvent", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "MessageEvent", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }new file mode 100644@@ -0,0 +1,245 @@+//! The host bridge's decisions, kept free of `web_sys` so they are tested natively: which parent+//! origin the bridge may talk to, and which saves the parent has confirmed.++use std::collections::BTreeMap;++/// How long the parent has to answer `vectorcraft:save` with `vectorcraft:saved` (milliseconds).+pub const SAVE_ACK_TIMEOUT_MS: f64 = 30_000.0;++/// The origin the bridge posts to and hears from, or `None` for no bridge (saves download).+///+/// - `own`: this page's origin.+/// - `parent`: the parent window's origin, `None` when reading it threw (a cross-origin parent).+/// - `allowed`: the `?host_origin=` query value, an origin the embedding page vouches for.+///+/// A same-origin parent is accepted. A cross-origin parent is accepted only when `allowed` is a+/// valid URL origin (`scheme://host[:port]`, nothing else); messages then go to that exact origin,+/// so the browser drops them if the parent is anything else. Opaque origins (`"null"`) never match.+pub fn target_origin(own: &str, parent: Option<&str>, allowed: Option<&str>) -> Option<String> {+ if let Some(parent) = parent+ && parent == own+ && is_origin(own)+ {+ return Some(own.to_string());+ }+ canonical_origin(allowed?.trim())+}++/// `s` as a browser serialises an origin (lowercase scheme and host, no default port), or `None`+/// when it isn't one [`is_origin`] accepts. `MessageEvent.origin` is compared with this string+/// exactly, so `https://Files.Example:443` must become `https://files.example` to ever match.+pub fn canonical_origin(s: &str) -> Option<String> {+ if !is_origin(s) {+ return None;+ }+ let (scheme, rest) = s.split_once("://")?;+ let scheme = scheme.to_ascii_lowercase();+ let (host, port) = match rest.split_once(':') {+ Some((h, p)) => (h, Some(p.parse::<u16>().ok()?)),+ None => (rest, None),+ };+ let host = host.to_ascii_lowercase();+ match (scheme.as_str(), port) {+ ("https", Some(443)) | ("http", Some(80)) | (_, None) => Some(format!("{scheme}://{host}")),+ (_, Some(port)) => Some(format!("{scheme}://{host}:{port}")),+ }+}++/// True for a URL origin: `http(s)://host[:port]` with no path, query, fragment or credentials,+/// and only in forms whose browser serialisation [`canonical_origin`] can produce: an ASCII host+/// (an IDN must be given in punycode, `xn--…`), and a numeric host only as a dotted-decimal IPv4.+pub fn is_origin(s: &str) -> bool {+ let Some((scheme, rest)) = s.split_once("://") else { return false };+ if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) || rest.is_empty() {+ return false;+ }+ // IPv6 literals aren't accepted; name the host instead.+ if rest.contains(['/', '?', '#', '@', ' ', '\\', '[', ']']) {+ return false;+ }+ let (host, port) = match rest.split_once(':') {+ Some((h, p)) => (h, Some(p)),+ None => (rest, None),+ };+ if host.is_empty() || !host.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_')) {+ return false;+ }+ // Browsers rewrite numeric hosts (`0x7f.1`, `127.1`, `010.0.0.1`) to dotted decimal; accept+ // only the form they would produce.+ let last = host.trim_end_matches('.').rsplit('.').next().unwrap_or("");+ if last.bytes().all(|b| b.is_ascii_digit()) || last.to_ascii_lowercase().starts_with("0x") {+ let parts: Vec<&str> = host.split('.').collect();+ let decimal = |p: &&str| !p.is_empty() && (*p == "0" || !p.starts_with('0')) && p.parse::<u8>().is_ok();+ if parts.len() != 4 || !parts.iter().all(decimal) {+ return false;+ }+ }+ match port {+ Some(p) => p.bytes().all(|b| b.is_ascii_digit()) && p.parse::<u16>().is_ok(),+ None => true,+ }+}++/// Saves posted to the parent and not yet confirmed.+#[derive(Debug, Default)]+pub struct SaveAcks {+ next_id: u64,+ /// Request id → (file name, deadline in ms, whether it is the session itself).+ pending: BTreeMap<u64, (String, f64, bool)>,+ /// A session save failed or went unanswered: the session stays dirty until one is confirmed.+ unconfirmed: bool,+}++impl SaveAcks {+ /// Record a save posted at `now`; returns its request id.+ pub fn posted(&mut self, name: &str, session: bool, now: f64) -> u64 {+ self.next_id = self.next_id.wrapping_add(1);+ self.pending.insert(self.next_id, (name.to_string(), now + SAVE_ACK_TIMEOUT_MS, session));+ self.next_id+ }++ /// The parent confirmed request `id`. False when no such save is waiting.+ pub fn saved(&mut self, id: u64) -> bool {+ match self.pending.remove(&id) {+ Some((_, _, session)) => {+ if session {+ self.unconfirmed = false;+ }+ true+ }+ None => false,+ }+ }++ /// The parent reported request `id` failed; returns its name.+ pub fn failed(&mut self, id: u64) -> Option<String> {+ let (name, _, session) = self.pending.remove(&id)?;+ self.unconfirmed |= session;+ Some(name)+ }++ /// Saves past their deadline at `now`, removed and returned as `(id, name)`.+ pub fn expire(&mut self, now: f64) -> Vec<(u64, String)> {+ let late: Vec<u64> = self.pending.iter().filter(|(_, (_, deadline, _))| now >= *deadline).map(|(id, _)| *id).collect();+ late.into_iter()+ .filter_map(|id| {+ let (name, _, session) = self.pending.remove(&id)?;+ self.unconfirmed |= session;+ Some((id, name))+ })+ .collect()+ }++ /// Whether any save is still waiting for the parent.+ pub fn waiting(&self) -> bool {+ !self.pending.is_empty()+ }++ /// The unsaved-changes state to report: the engine's, held dirty while a session save is+ /// unconfirmed.+ pub fn dirty(&self, engine_dirty: bool) -> bool {+ engine_dirty || self.unconfirmed || self.pending.values().any(|(_, _, session)| *session)+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ const OWN: &str = "https://app.example";++ #[test]+ fn same_origin_parent_enables_the_bridge() {+ assert_eq!(target_origin(OWN, Some(OWN), None).as_deref(), Some(OWN));+ }++ #[test]+ fn cross_origin_parent_disables_the_bridge() {+ // Reading a cross-origin parent's origin throws (None) or names another origin.+ assert_eq!(target_origin(OWN, None, None), None);+ assert_eq!(target_origin(OWN, Some("https://evil.example"), None), None);+ assert_eq!(target_origin(OWN, Some("null"), None), None);+ assert_eq!(target_origin("null", Some("null"), None), None);+ }++ #[test]+ fn host_origin_parameter_must_be_an_exact_origin() {+ let files = "https://files.example:8443";+ assert_eq!(target_origin(OWN, None, Some(files)).as_deref(), Some(files));+ for bad in+ ["*", "null", "files.example", "https://files.example/", "https://a@files.example", "javascript://x", "https://", "https://x:99999"]+ {+ assert_eq!(target_origin(OWN, None, Some(bad)), None, "{bad}");+ }+ }++ #[test]+ fn host_origin_is_stored_as_the_browser_serialises_it() {+ // `MessageEvent.origin` is lowercase with no default port; the stored origin must match it.+ for (given, stored) in [+ ("https://Files.Example", "https://files.example"),+ ("HTTPS://FILES.EXAMPLE", "https://files.example"),+ ("https://files.example:443", "https://files.example"),+ ("http://files.example:80", "http://files.example"),+ ("http://files.example:443", "http://files.example:443"),+ ("https://files.example:80", "https://files.example:80"),+ ("https://files.example:08443", "https://files.example:8443"),+ ("https://xn--bcher-kva.example", "https://xn--bcher-kva.example"),+ ("http://127.0.0.1:8080", "http://127.0.0.1:8080"),+ (" https://Files.Example:443 ", "https://files.example"),+ ] {+ assert_eq!(target_origin(OWN, None, Some(given)).as_deref(), Some(stored), "{given}");+ }+ }++ #[test]+ fn host_origin_a_browser_would_serialise_differently_is_refused() {+ for bad in [+ "https://b\u{fc}cher.example",+ "https://files.example:",+ "https://0x7f.0.0.1",+ "https://127.1",+ "https://010.0.0.1",+ "https://256.0.0.1",+ "https://files%2eexample",+ "https://[::1]",+ ] {+ assert_eq!(target_origin(OWN, None, Some(bad)), None, "{bad}");+ }+ }++ #[test]+ fn save_stays_dirty_until_the_parent_confirms() {+ let mut acks = SaveAcks::default();+ let id = acks.posted("a.vectorcraft", true, 0.0);+ // The engine marked the session saved, but the parent hasn't confirmed.+ assert!(acks.dirty(false));+ assert!(!acks.saved(id + 1), "an unknown id confirms nothing");+ assert!(acks.dirty(false));+ assert!(acks.saved(id));+ assert!(!acks.dirty(false));+ assert!(!acks.waiting());+ }++ #[test]+ fn unanswered_save_times_out_as_failed_and_stays_dirty() {+ let mut acks = SaveAcks::default();+ let id = acks.posted("a.vectorcraft", true, 1_000.0);+ assert!(acks.expire(1_000.0 + SAVE_ACK_TIMEOUT_MS - 1.0).is_empty());+ assert_eq!(acks.expire(1_000.0 + SAVE_ACK_TIMEOUT_MS), vec![(id, "a.vectorcraft".to_string())]);+ assert!(acks.dirty(false));+ // A later confirmed save clears it.+ let next = acks.posted("a.vectorcraft", true, 50_000.0);+ assert!(acks.saved(next));+ assert!(!acks.dirty(false));+ }++ #[test]+ fn export_acks_do_not_hold_the_session_dirty() {+ let mut acks = SaveAcks::default();+ let id = acks.posted("art.svg", false, 0.0);+ assert!(!acks.dirty(false));+ assert_eq!(acks.failed(id).as_deref(), Some("art.svg"));+ assert!(!acks.dirty(false));+ }+}new file mode 100644@@ -0,0 +1,209 @@+//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed+//! VectorCraft in an `<iframe>`. Messages are plain objects with a `type`:+//!+//! - `vectorcraft:ready` (to the parent): `{ version }`, the app is listening; send a document now.+//! - `vectorcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or+//! `Uint8Array`. It opens like a file picked with File › Open or dropped on the window.+//! - `vectorcraft:save` (to the parent): `{ id, name, bytes }` (a transferred `ArrayBuffer`) for+//! every file VectorCraft would otherwise download (Save, Save As, Export…).+//! - `vectorcraft:saved` (from the parent): `{ id }`, that save is stored. Until it arrives a+//! document save keeps the app reported dirty; after 30 s without it the app posts+//! `vectorcraft:failed { id, error }` and stays dirty.+//! - `vectorcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.+//! - `vectorcraft:failed` (to the parent): `{ error }` when the app couldn't start, or when a+//! `vectorcraft:open` document is over [`crate::host_open::MAX_OPEN_BYTES`] (it isn't opened), when the page+//! has no canvas, or when a save went unconfirmed.+//!+//! The bridge is installed only for a same-origin parent, or for a parent on the origin named by+//! `?host_origin=` (a bare `scheme://host[:port]` origin); messages go to that exact origin and only+//! the parent window on it is heard. A cross-origin parent without `host_origin` gets no bridge, so+//! saves download as usual.++use std::cell::{Cell, RefCell};+use std::rc::Rc;++use vectorcraft_ui_egui::VectorcraftApp;+use wasm_bindgen::JsCast as _;+use wasm_bindgen::JsValue;+use wasm_bindgen::closure::Closure;++use crate::bridge::SaveAcks;++/// Longest file name taken from the parent page.+const MAX_NAME_CHARS: usize = 255;++thread_local! {+ /// The bridge [`deliver`] posts through, once installed.+ static BRIDGE: RefCell<Option<Bridge>> = const { RefCell::new(None) };+}++#[derive(Clone)]+pub struct Bridge {+ window: web_sys::Window,+ parent: web_sys::Window,+ origin: String,+ /// The unsaved-changes state last reported to the parent.+ reported: Rc<Cell<Option<bool>>>,+ /// Saves waiting for `vectorcraft:saved`.+ acks: Rc<RefCell<SaveAcks>>,+}++impl Bridge {+ /// `None` when the page isn't framed, or the parent is cross-origin and not the origin+ /// `host_origin` allows (see [`crate::bridge::target_origin`]).+ pub fn new(host_origin: Option<&str>) -> Option<Self> {+ let window = web_sys::window()?;+ let parent = window.parent().ok().flatten()?;+ if js_sys::Object::is(&parent, &window) {+ return None;+ }+ let own = window.location().origin().ok()?;+ // Reading a cross-origin parent's location throws: treat that as cross-origin.+ let parent_origin = parent.location().origin().ok();+ let origin = crate::bridge::target_origin(&own, parent_origin.as_deref(), host_origin)?;+ Some(Self { window, parent, origin, reported: Rc::default(), acks: Rc::default() })+ }++ /// Make this the bridge [`deliver`] and [`report_dirty`] use.+ pub fn install(&self) {+ BRIDGE.with(|b| *b.borrow_mut() = Some(self.clone()));+ }++ pub fn post_ready(&self) {+ self.send(&[("type", "vectorcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);+ }++ pub fn post_failed(&self, error: &str) {+ self.send(&[("type", "vectorcraft:failed".into()), ("error", error.into())]);+ }++ /// Hear documents from the parent; they arrive through the open inbox like a picked file.+ pub fn listen(&self, inbox: crate::web::Inbox, ctx: egui::Context) {+ let bridge = self.clone();+ let parent = self.parent.clone();+ let origin = self.origin.clone();+ let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {+ if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {+ return;+ }+ let data = event.data();+ match string(&data, "type").as_deref() {+ Some("vectorcraft:open") => {}+ Some("vectorcraft:saved") => {+ let id = js_sys::Reflect::get(&data, &JsValue::from_str("id")).ok().and_then(|v| v.as_f64());+ if let Some(id) = id.filter(|id| id.is_finite() && *id >= 0.0 && id.fract() == 0.0) {+ bridge.acks.borrow_mut().saved(id as u64);+ ctx.request_repaint();+ }+ return;+ }+ _ => return,+ }+ let name = string(&data, "name")+ .filter(|n| !n.trim().is_empty())+ .map(|n| n.chars().take(MAX_NAME_CHARS).collect())+ .unwrap_or_else(|| "Untitled.vectorcraft".to_string());+ let Some(array) = byte_array(&data) else { return };+ // Checked before the bytes are copied out of the parent's buffer.+ if let Err(e) = crate::host_open::check_open_size(&name, u64::from(array.byte_length())) {+ log::warn!("vectorcraft-web: {e}");+ bridge.post_failed(&e);+ return;+ }+ inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));+ ctx.request_repaint();+ });+ if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {+ // The listener lives as long as the page.+ on_message.forget();+ }+ }++ fn post_save(&self, name: &str, bytes: &[u8]) -> Result<(), String> {+ let buffer = js_sys::Uint8Array::from(bytes).buffer();+ let document = [vectorcraft_format::EXTENSION, vectorcraft_format::LEGACY_EXTENSION].iter().any(|ext| name.ends_with(&format!(".{ext}")));+ let id = self.acks.borrow_mut().posted(name, document, js_sys::Date::now());+ let sent = message(&[+ ("type", "vectorcraft:save".into()),+ ("id", JsValue::from_f64(id as f64)),+ ("name", name.into()),+ ("bytes", buffer.clone().into()),+ ])+ .ok_or_else(|| "couldn't build the message".to_string())+ .and_then(|m| {+ self.parent+ .post_message_with_transfer(&m, &self.origin, &js_sys::Array::of1(&buffer))+ .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))+ });+ if sent.is_err() {+ self.acks.borrow_mut().failed(id);+ }+ sent+ }++ /// Post a message to the parent; false if it couldn't be sent.+ fn send(&self, fields: &[(&str, JsValue)]) -> bool {+ message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())+ }+}++/// The installed bridge, in host mode.+pub fn installed() -> Option<Bridge> {+ BRIDGE.with(|b| b.borrow().clone())+}++/// In host mode, hand a file that would be downloaded to the parent page: `None` outside host+/// mode, else whether it was posted.+pub fn deliver(name: &str, bytes: &[u8]) -> Option<Result<(), String>> {+ let bridge = BRIDGE.with(|b| b.borrow().clone())?;+ Some(bridge.post_save(name, bytes))+}++/// After each frame: tell the parent when unsaved changes appear or go away (held dirty while a+/// document save is unconfirmed), and fail saves the parent didn't confirm within 30 s.+pub fn report_dirty(app: &VectorcraftApp, ctx: &egui::Context) {+ let Some(bridge) = BRIDGE.with(|b| b.borrow().clone()) else { return };+ let expired = bridge.acks.borrow_mut().expire(js_sys::Date::now());+ for (id, name) in expired {+ bridge.send(&[+ ("type", "vectorcraft:failed".into()),+ ("id", JsValue::from_f64(id as f64)),+ ("error", format!("{name}: the host page didn't confirm the save").into()),+ ]);+ }+ let engine_dirty = app.session.documents().iter().any(|d| d.is_dirty());+ let (dirty, waiting) = {+ let acks = bridge.acks.borrow();+ (acks.dirty(engine_dirty), acks.waiting())+ };+ if waiting {+ ctx.request_repaint_after(std::time::Duration::from_secs(1));+ }+ if bridge.reported.get() != Some(dirty) && bridge.send(&[("type", "vectorcraft:dirty".into()), ("dirty", dirty.into())]) {+ bridge.reported.set(Some(dirty));+ }+}++fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {+ let m = js_sys::Object::new();+ for (key, value) in fields {+ js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;+ }+ Some(m)+}++fn string(data: &JsValue, key: &str) -> Option<String> {+ js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()+}++/// `bytes` (an `ArrayBuffer` or `Uint8Array`) as a view, not yet copied.+fn byte_array(data: &JsValue) -> Option<js_sys::Uint8Array> {+ let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;+ if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {+ Some(array.clone())+ } else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {+ Some(js_sys::Uint8Array::new(&bytes))+ } else {+ None+ }+}new file mode 100644@@ -0,0 +1,31 @@+//! The size cap on documents a parent page sends through the host bridge (`host.rs`).++/// Bytes a document sent by the parent page (`vectorcraft:open`) may have. It matches the cap+/// on a library file read from disk (`MAX_FILE` in the engine's `cmd/library.rs`) and the+/// largest raster VectorCraft allocates in the browser (`MAX_RASTER_ALLOC` on wasm32), so the+/// bridge never copies more into the open inbox than the app's other import paths read.+pub const MAX_OPEN_BYTES: u64 = 512 << 20;++/// `Err` with the reason posted back as `vectorcraft:failed` when `len` bytes are over the cap.+pub fn check_open_size(name: &str, len: u64) -> Result<(), String> {+ if len > MAX_OPEN_BYTES {+ return Err(format!(+ "{name} wasn't opened: it is {len} bytes, larger than the {} MB VectorCraft opens from the host page",+ MAX_OPEN_BYTES >> 20+ ));+ }+ Ok(())+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn caps_open_bytes() {+ assert!(check_open_size("a.svg", 0).is_ok());+ assert!(check_open_size("a.svg", MAX_OPEN_BYTES).is_ok());+ let e = check_open_size("big.svg", MAX_OPEN_BYTES + 1).unwrap_err();+ assert!(e.contains("big.svg") && e.contains("512 MB"), "{e}");+ }+}@@ -16,13 +16,22 @@ //! - a Cmd or Ctrl chord the keymap binds runs its command, not the browser's own action as well //! (`Cmd+=` zooms the canvas, not the page; `Cmd+Z` undoes in the document) (`keys`). //!+//! With `?host=parent` (inside an `<iframe>`), a same-origin parent page (or the parent on the+//! origin `?host_origin=` names) opens documents and receives saves instead of downloads (`host.rs`).+//! //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any //! language code) stands in for the browser's languages when the interface language is Automatic. #[cfg(target_arch = "wasm32")] mod api;+#[cfg(any(target_arch = "wasm32", test))]+mod bridge; #[cfg(target_arch = "wasm32")] mod fonts;+#[cfg(target_arch = "wasm32")]+mod host;+#[cfg(any(target_arch = "wasm32", test))]+mod host_open; #[cfg(any(target_arch = "wasm32", test))] mod keys; #[cfg(target_arch = "wasm32")]@@ -60,8 +60,19 @@ struct Host { pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok();+ // `?host=parent`: a same-origin page framing VectorCraft (or the parent on the origin+ // `?host_origin=` names) opens documents in it and stores what it saves (`crate::host`).+ // Installed first, so a page without a canvas still reports the failure.+ if query_param("host").as_deref() == Some("parent")+ && let Some(bridge) = crate::host::Bridge::new(query_param("host_origin").as_deref())+ {+ bridge.install();+ } let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else { log::error!("missing <canvas id=\"{CANVAS_ID}\">");+ if let Some(bridge) = crate::host::installed() {+ bridge.post_failed(&format!("missing <canvas id=\"{CANVAS_ID}\">"));+ } return; }; detect_language();@@ -136,6 +147,9 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi if let Err(e) = crate::api::install() { log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e)); }+ if let Some(bridge) = crate::host::installed() {+ bridge.listen(host.inbox.clone(), ctx.clone());+ } VectorcraftApp::new(Session::new(), services).with_control(control_rx) } };@@ -144,7 +158,13 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi ) .await; match (result, kept) {- (Ok(()), _) => {+ (Ok(()), restart) => {+ // Ready only once the runner started (and only on the first start, not a restart).+ if restart.is_none()+ && let Some(bridge) = crate::host::installed()+ {+ bridge.post_ready();+ } if let Err(e) = crate::pencil::install(&runner, &pen_canvas, &pointers) { log::error!("vectorcraft-web: no pen input: {}", js_err(e)); }@@ -152,7 +172,12 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi el.remove(); } }- (Err(e), None) => notice(&[&format!("VectorCraft failed to start: {}", js_err(e)), "A browser with WebGPU or WebGL2 is required."]),+ (Err(e), None) => {+ if let Some(bridge) = crate::host::installed() {+ bridge.post_failed(&js_err(e.clone()));+ }+ notice(&[&format!("VectorCraft failed to start: {}", js_err(e)), "A browser with WebGPU or WebGL2 is required."]);+ } (Err(e), Some(kept)) => { log::error!("vectorcraft-web: couldn't restart the graphics: {}", js_err(e)); give_up(slot.borrow_mut().take(), kept);@@ -370,6 +395,7 @@ impl eframe::App for WebShell { } let Some(app) = &mut self.app else { return }; app.tick(ctx);+ crate::host::report_dirty(app, ctx); crate::api::poll_replies(); app.finish_ime_logic(ctx); }@@ -572,6 +598,9 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { if crate::api::capture(path, bytes) { return Ok(()); }+ if let Some(posted) = crate::host::deliver(&fileio::file_name(path), bytes) {+ return posted;+ } let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into()); let window = web_sys::window().ok_or("no window")?;