collective

Browse / github.com/storytold/soundcraft / patch

soundcraft-web: postMessage host bridge (`?host=parent`)

soundcraft-web: postMessage host bridge (`?host=parent`) Lets a same-origin page that frames SoundCraft in an `<iframe>` open files in it and receive what it saves, like the GridCraft and WordCraft web bridges. With `?host=parent` and a same-origin parent: - `soundcraft:ready {version}` once the app listens; - `soundcraft:open {name, bytes}` (ArrayBuffer or Uint8Array) is handled like a dropped file: a session (`.scraftz` bundle or bare `.scraft`) opens, audio and MIDI import; - every save, bounce and export the engine would deliver as a download posts `soundcraft:save {name, bytes}` (transferred ArrayBuffer) through the engine's delivery hook; Sav

Status
open
Score
0.00
Adopters
0 other operators
Author
halldave (imported from pull request #866, open)
Base
60087e0f46032118bf309ed9ea161a9b4a872713
Size
+376 −7 in 7 files
Review
pending
Shared
2026-10-28
Id
b2133ff4066c583f0e7f13a7

How it went

No other operator has reported using it yet.

Diff

raw · apps/soundcraft-web/Cargo.toml apps/soundcraft-web/src/bridge.rs apps/soundcraft-web/src/host.rs apps/soundcraft-web/src/main.rs apps/soundcraft-web/src/web.rs crates/engine/src/io.rs crates/engine/src/io/bundle.rs

diff --git a/apps/soundcraft-web/Cargo.toml b/apps/soundcraft-web/Cargo.toml--- a/apps/soundcraft-web/Cargo.toml+++ b/apps/soundcraft-web/Cargo.toml@@ -27,4 +27,4 @@ js-sys = "0.3" eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4"-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] }+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration", "MessageEvent", "UrlSearchParams"] }diff --git a/apps/soundcraft-web/src/bridge.rs b/apps/soundcraft-web/src/bridge.rsnew file mode 100644--- /dev/null+++ b/apps/soundcraft-web/src/bridge.rs@@ -0,0 +1,162 @@+//! The host bridge's decisions, kept free of `web_sys` so they are tested natively: which parent+//! origin the bridge may talk to, and whether an opened document fits.++/// 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,+    }+}++/// Refuse `soundcraft:open` bytes (`len`) when they, plus the `queued` bytes still waiting for the+/// next frame, exceed `cap`. Checked before the bytes are copied; draining the queue frees room.+pub fn check_open(name: &str, queued: f64, len: f64, cap: u64) -> Result<(), String> {+    if queued + len > cap as f64 {+        return Err(format!("{name}: file is too large to open ({len} bytes, {queued} already waiting; the limit is {cap} bytes)"));+    }+    Ok(())+}++#[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 open_cap_counts_documents_still_waiting() {+        assert!(check_open("a.scraftz", 0.0, 100.0, 100).is_ok());+        let e = check_open("a.scraftz", 0.0, 101.0, 100).unwrap_err();+        assert!(e.contains("too large") && e.contains("a.scraftz"));+        // Two documents that each fit are refused together until the first is drained.+        assert!(check_open("b.mid", 60.0, 60.0, 100).is_err());+        assert!(check_open("b.mid", 0.0, 60.0, 100).is_ok());+    }+}diff --git a/apps/soundcraft-web/src/host.rs b/apps/soundcraft-web/src/host.rsnew file mode 100644--- /dev/null+++ b/apps/soundcraft-web/src/host.rs@@ -0,0 +1,170 @@+//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed+//! SoundCraft in an `<iframe>` (such as a file manager). It follows GridCraft's and WordCraft's:+//!+//! - `soundcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.+//! - `soundcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or+//!   `Uint8Array`. It is handled like a file dropped on the window: a session (`.scraftz` bundle+//!   or bare `.scraft`) opens, MIDI and audio import.+//! - `soundcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every+//!   file SoundCraft would otherwise download: Save (the `<name>.scraftz` bundle, which marks the+//!   session saved), Save As, Save a Copy, bounces and the exports.+//! - `soundcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.+//! - `soundcraft:failed` (to the parent): `{ error }` when the app couldn't start, or when a+//!   `soundcraft:open` document (or the documents waiting to open) is over `MAX_OPEN_BYTES` (1 GiB,+//!   the session-bundle cap).+//!+//! 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:+//! saves download as usual. In host mode the page+//! starts on an empty session instead of the demo. SoundCraft has no user name, so there is no+//! `?author=`.++use std::cell::{Cell, RefCell};+use std::rc::Rc;++use wasm_bindgen::JsCast as _;+use wasm_bindgen::JsValue;+use wasm_bindgen::closure::Closure;++thread_local! {+    /// The bridge the engine's delivery hook posts to (a plain `fn` can't capture it).+    static BRIDGE: RefCell<Option<Bridge>> = const { RefCell::new(None) };+}++/// Files the parent sent, waiting for the next frame: `(name, bytes)`.+pub type Received = Rc<RefCell<Vec<(String, Vec<u8>)>>>;++#[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>>>,+    pub received: Received,+}++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(), received: Rc::default() })+    }++    /// Hear files from the parent and make [`deliver`] post to it.+    pub fn connect(&self, ctx: egui::Context) {+        self.listen(ctx);+        BRIDGE.with(|b| *b.borrow_mut() = Some(self.clone()));+    }++    /// Report unsaved changes (once a frame).+    pub fn report_dirty(&self, dirty: bool) {+        if self.reported.get() != Some(dirty) && self.send(&[("type", "soundcraft:dirty".into()), ("dirty", dirty.into())]) {+            self.reported.set(Some(dirty));+        }+    }++    pub fn post_ready(&self) {+        self.send(&[("type", "soundcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);+    }++    pub fn post_failed(&self, error: &str) {+        self.send(&[("type", "soundcraft:failed".into()), ("error", error.into())]);+    }++    fn post_save(&self, name: &str, bytes: &[u8]) -> Result<(), String> {+        let buffer = js_sys::Uint8Array::from(bytes).buffer();+        let message = message(&[("type", "soundcraft:save".into()), ("name", name.into()), ("bytes", buffer.clone().into())])+            .ok_or_else(|| "couldn't build the message".to_string())?;+        self.parent+            .post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))+            .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))+    }++    fn listen(&self, ctx: egui::Context) {+        let parent = self.parent.clone();+        let origin = self.origin.clone();+        let received = self.received.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();+            if string(&data, "type").as_deref() != Some("soundcraft:open") {+                return;+            }+            let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Session.scraftz".to_string());+            let Some(array) = byte_array(&data) else { return };+            // The cap covers this document plus any still waiting for the next frame.+            let queued: f64 = received.borrow().iter().map(|(_, b)| b.len() as f64).sum();+            if let Err(error) = crate::bridge::check_open(&name, queued, f64::from(array.byte_length()), MAX_OPEN_BYTES) {+                post_failed_to(&parent, &origin, &error);+                return;+            }+            let bytes = array.to_vec();+            received.borrow_mut().push((name, bytes));+            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();+        }+    }++    /// 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 engine's delivery hook: the parent when connected, else `fallback` (the download).+pub fn deliver(name: &str, bytes: &[u8], fallback: fn(&str, &[u8]) -> Result<(), String>) -> Result<(), String> {+    match BRIDGE.with(|b| b.borrow().clone()) {+        Some(bridge) => bridge.post_save(name, bytes),+        None => fallback(name, bytes),+    }+}++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+    }+}++fn post_failed_to(parent: &web_sys::Window, origin: &str, error: &str) {+    if let Some(m) = message(&[("type", "soundcraft:failed".into()), ("error", error.into())]) {+        let _ = parent.post_message(&m, origin);+    }+}++/// Most bytes `soundcraft:open` takes: the same cap a session bundle may unpack to+/// ([`soundcraft_engine::io::MAX_BUNDLE_BYTES`]), checked before the bytes are copied.+pub(crate) const MAX_OPEN_BYTES: u64 = soundcraft_engine::io::MAX_BUNDLE_BYTES;diff --git a/apps/soundcraft-web/src/main.rs b/apps/soundcraft-web/src/main.rs--- a/apps/soundcraft-web/src/main.rs+++ b/apps/soundcraft-web/src/main.rs@@ -15,9 +15,14 @@ //!   playback is heard. //! //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?empty` starts with an-//! empty session instead of the demo.+//! empty session instead of the demo; `?host=parent` hands open and save to a same-origin parent+//! page, or to the parent on the origin `?host_origin=` names (`host`). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]+mod bridge;+#[cfg(target_arch = "wasm32")]+mod host; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs--- a/apps/soundcraft-web/src/web.rs+++ b/apps/soundcraft-web/src/web.rs@@ -14,7 +14,7 @@ const LOADING_ID: &str = "soundcraft_loading";  pub fn start() {     eframe::WebLogger::init(log::LevelFilter::Info).ok();-    io::set_deliver(download);+    io::set_deliver(|name, bytes| crate::host::deliver(name, bytes, download));     wasm_bindgen_futures::spawn_local(async {         let Some(document) = web_sys::window().and_then(|w| w.document()) else {             log::error!("no document");@@ -24,6 +24,11 @@ pub fn start() {             log::error!("missing <canvas id=\"{CANVAS_ID}\">");             return;         };+        // `?host=parent`: a same-origin page framing SoundCraft opens files in it and stores what+        // it saves (`host`).+        let bridge =+            if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new(query_param("host_origin").as_deref()) } else { None };+        let app_bridge = bridge.clone();         let mut options = eframe::WebOptions::default();         if query().contains("webgl")             && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup@@ -38,13 +43,24 @@ pub fn start() {                     if let Some(rs) = &cc.wgpu_render_state {                         log::info!("soundcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);                     }-                    let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() };+                    let engine =+                        if app_bridge.is_some() || query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() };                     let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone()));                     let app = SoundApp::new(engine, Some(player), Services::default());-                    Ok(Box::new(WebShell { app, inbox: Rc::default(), samples: Rc::default() }))+                    if let Some(bridge) = &app_bridge {+                        bridge.connect(cc.egui_ctx.clone());+                    }+                    Ok(Box::new(WebShell { app, inbox: Rc::default(), samples: Rc::default(), bridge: app_bridge }))                 }),             )             .await;+        // Ready only once the runner started; otherwise the parent hears why it didn't.+        if let Some(bridge) = &bridge {+            match &result {+                Ok(()) => bridge.post_ready(),+                Err(e) => bridge.post_failed(&format!("{e:?}")),+            }+        }         if let Some(el) = document.get_element_by_id(LOADING_ID) {             match result {                 Ok(()) => el.remove(),@@ -58,6 +74,10 @@ fn query() -> String {     web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } +fn query_param(key: &str) -> Option<String> {+    web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)+}+ /// What a file read from the browser is for. #[derive(Debug, Clone, Copy)] enum Purpose {@@ -115,6 +135,7 @@ struct WebShell {     app: SoundApp,     inbox: Inbox,     samples: SampleInbox,+    bridge: Option<crate::host::Bridge>, }  impl WebShell {@@ -196,6 +217,11 @@ impl eframe::App for WebShell {     fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {         self.take_drops(ctx);         self.take_path_prompt(ctx);+        if let Some(bridge) = &self.bridge {+            // Files from the host page are handled like dropped ones.+            let received = std::mem::take(&mut *bridge.received.borrow_mut());+            self.inbox.borrow_mut().extend(received.into_iter().map(|(name, bytes)| (Purpose::of(&name), name, bytes)));+        }         let pending = std::mem::take(&mut *self.inbox.borrow_mut());         for (purpose, name, bytes) in pending {             self.handle(purpose, name, bytes);@@ -217,6 +243,9 @@ impl eframe::App for WebShell {      fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {         self.app.ui(ui);+        if let Some(bridge) = &self.bridge {+            bridge.report_dirty(self.app.engine.is_dirty());+        }     } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs--- a/crates/engine/src/io.rs+++ b/crates/engine/src/io.rs@@ -19,7 +19,7 @@ mod session; mod strip;  use bundle::BundleLimits;-pub use bundle::{BUNDLE_EXTENSION, open_session_bytes, session_bundle};+pub use bundle::{BUNDLE_EXTENSION, MAX_BUNDLE_BYTES, open_session_bytes, session_bundle}; pub use import::{DecodedImport, add_source, decode_import, import_audio_bytes, load_sampler_bytes, relink_videos}; pub(crate) use import::{MAX_IMPORT_SAMPLES, at_rate, place_decoded}; pub use midi::{export_midi, import_midi_bytes};diff --git a/crates/engine/src/io/bundle.rs b/crates/engine/src/io/bundle.rs--- a/crates/engine/src/io/bundle.rs+++ b/crates/engine/src/io/bundle.rs@@ -17,7 +17,10 @@ pub(super) struct BundleLimits {     pub(super) bytes: u64, } -const BUNDLE_LIMITS: BundleLimits = BundleLimits { entries: 10_000, bytes: 1 << 30 };+/// Most bytes a bundle may unpack to; hosts check an upload against it before copying it.+pub const MAX_BUNDLE_BYTES: u64 = 1 << 30;++const BUNDLE_LIMITS: BundleLimits = BundleLimits { entries: 10_000, bytes: MAX_BUNDLE_BYTES };  /// The session as a bundle: `<name>.scraft` plus every source's audio as 32-bit float WAV under /// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the