Skip to main content

plushie_renderer_wasm/
lib.rs

1//! WASM entry point for the plushie renderer.
2//!
3//! Provides a `wasm-bindgen` API for running plushie in the browser.
4//! Uses `iced::daemon` with a canvas-based backend and communicates
5//! with the host via JavaScript callbacks.
6//!
7//! # Usage from JavaScript
8//!
9//! ```js
10//! import init, { PlushieApp } from './plushie_renderer_wasm.js';
11//!
12//! await init();
13//! const app = new PlushieApp(settingsJson, (event) => {
14//!     console.log('event:', event);
15//! });
16//! app.send_message(snapshotJson);
17//! ```
18//!
19//! # Usage from Rust (custom WASM builds with widgets)
20//!
21//! ```ignore
22//! let mut builder = plushie_widget_sdk::app::PlushieAppBuilder::new();
23//! builder.register(Box::new(MyWidget));
24//! let app = PlushieApp::with_widgets(settings, on_event, builder)?;
25//! app.send_message(snapshot_json)?;
26//! ```
27//!
28//! # Limitations
29//!
30//! - Platform effects (file dialogs, clipboard, notifications) are
31//!   stubbed as unsupported. Web API implementations can be added in
32//!   a future iteration.
33//! - The WASM entry point assumes standard single-threaded
34//!   `wasm32-unknown-unknown`. Shared-memory wasm modules are rejected
35//!   at runtime because the JavaScript callback output path is not
36//!   thread-safe.
37
38mod effects;
39mod output;
40
41use parking_lot::Mutex;
42
43use wasm_bindgen::prelude::*;
44
45use plushie_renderer_engine::Codec;
46use plushie_widget_sdk::protocol::IncomingMessage;
47use plushie_widget_sdk::runtime::{Message, StdinEvent};
48
49use plushie_renderer_lib::App;
50use plushie_renderer_lib::emitters::emit_hello;
51
52use effects::WebEffectHandler;
53use output::WebOutputWriter;
54
55/// Global message receiver slot. Initialized by the [`PlushieApp`]
56/// constructor, consumed once by the message subscription.
57static MSG_RX: Mutex<Option<futures_channel::mpsc::UnboundedReceiver<String>>> = Mutex::new(None);
58
59fn validate_protocol_version(settings: &serde_json::Value) -> Result<(), String> {
60    let expected = plushie_widget_sdk::protocol::PROTOCOL_VERSION;
61    match settings
62        .get("protocol_version")
63        .and_then(plushie_widget_sdk::protocol::json_protocol_version)
64    {
65        Some(version) if version == expected => Ok(()),
66        Some(version) => Err(format!(
67            "protocol version mismatch: expected {expected}, got {version}"
68        )),
69        None => Err(format!(
70            "missing or invalid protocol_version in Settings (expected {expected})"
71        )),
72    }
73}
74
75/// Parse a settings JSON string and validate the protocol version.
76///
77/// Centralizes the two-step "parse then validate" sequence the
78/// WASM constructor runs before wiring up its output sink. Pulled
79/// into a free function so it can be unit-tested without spinning
80/// up the iced daemon or touching the global sink: the constructor
81/// proper still calls into this and routes its errors to JsValue.
82///
83/// # Errors
84///
85/// Returns a human-readable error string when the JSON does not
86/// parse or when the protocol version is missing, malformed, or
87/// mismatched. The same message is what the WASM caller sees as
88/// a `JsValue` error.
89fn parse_and_validate_settings(settings_json: &str) -> Result<serde_json::Value, String> {
90    let settings: serde_json::Value =
91        serde_json::from_str(settings_json).map_err(|e| format!("invalid settings JSON: {e}"))?;
92    validate_protocol_version(&settings)?;
93    Ok(settings)
94}
95
96/// WASM plushie renderer handle.
97///
98/// Created via the constructor, which initializes the renderer and
99/// starts the iced daemon in the background. The host sends messages
100/// (Snapshots, Patches, etc.) via [`send_message`](PlushieApp::send_message)
101/// and receives events via the `on_event` callback.
102///
103/// This WASM entry point assumes the standard single-threaded
104/// `wasm32-unknown-unknown` target. The renderer stores the JavaScript
105/// `on_event` callback in its output sink, so construction fails when
106/// the module uses shared memory. Real wasm thread support needs a
107/// redesigned output path.
108#[wasm_bindgen]
109pub struct PlushieApp {
110    sender: futures_channel::mpsc::UnboundedSender<String>,
111}
112
113#[wasm_bindgen]
114impl PlushieApp {
115    /// Create a new plushie renderer with no custom widgets.
116    ///
117    /// Parses settings, validates the protocol version, initializes the
118    /// output writer, and starts the iced daemon in the background.
119    /// Returns a handle for sending messages.
120    ///
121    /// `on_event` is a JavaScript callback that receives serialized
122    /// event strings whenever the renderer emits an outgoing event.
123    #[wasm_bindgen(constructor)]
124    pub fn new(settings_json: &str, on_event: js_sys::Function) -> Result<PlushieApp, JsValue> {
125        Self::with_widgets(
126            settings_json,
127            on_event,
128            plushie_widget_sdk::app::PlushieAppBuilder::new(),
129        )
130    }
131
132    /// Send a JSON-encoded protocol message to the renderer.
133    ///
134    /// The message is parsed as an [`IncomingMessage`] and processed
135    /// by the iced daemon on the next event loop tick. This is the
136    /// WASM equivalent of writing to stdin on native.
137    ///
138    /// This method is intended for the same non-shared-memory
139    /// `wasm32-unknown-unknown` entry point as [`PlushieApp`].
140    ///
141    /// Accepts any valid protocol message: Snapshot, Patch, Settings,
142    /// Subscribe, Unsubscribe, WidgetOp, WindowOp, Effect,
143    /// WidgetCommand, etc.
144    pub fn send_message(&self, json: &str) -> Result<(), JsValue> {
145        self.sender
146            .unbounded_send(json.to_string())
147            .map_err(|e| JsValue::from_str(&format!("send failed: {e}")))
148    }
149}
150
151impl PlushieApp {
152    /// Create a renderer with pre-registered custom widgets.
153    ///
154    /// Rust callers building custom WASM modules use this to register
155    /// widgets at compile time. Widgets are Rust code compiled
156    /// into the WASM binary, they cannot be added at runtime from JS.
157    ///
158    /// ```ignore
159    /// let mut builder = PlushieAppBuilder::new();
160    /// builder.register(Box::new(MyWidget));
161    /// let app = PlushieApp::with_widgets(settings, on_event, builder)?;
162    /// ```
163    pub fn with_widgets(
164        settings_json: &str,
165        on_event: js_sys::Function,
166        builder: plushie_widget_sdk::app::PlushieAppBuilder,
167    ) -> Result<PlushieApp, JsValue> {
168        console_log::init_with_level(log::Level::Warn).ok();
169
170        // Order matters: parse settings and validate the protocol
171        // version before wiring up the event sink. Error paths here
172        // return Err(JsValue) directly to the caller; they must not
173        // route through a half-initialised sink.
174        let settings =
175            parse_and_validate_settings(settings_json).map_err(|e| JsValue::from_str(&e))?;
176
177        // Settings validated. Safe to initialise the output sink now.
178        let writer = WebOutputWriter::try_new(on_event)?;
179        let codec = Codec::Json;
180        let sink = plushie_renderer_lib::WriterSink::new(Box::new(writer), codec);
181        plushie_renderer_lib::emitters::init_sink(Box::new(sink));
182        plushie_renderer_lib::emitters::install_panic_hook();
183
184        let iced_settings = plushie_renderer_lib::settings::parse_iced_settings(&settings);
185        plushie_renderer_lib::settings::apply_validate_props(&settings);
186        let font_bytes = plushie_renderer_lib::settings::parse_inline_fonts(&settings);
187
188        // Load inline fonts directly into the global font system so they're
189        // available before the first render. On WASM there are no system fonts,
190        // so without this all text renders blank. Also set the sans-serif
191        // family mapping, since the default Family::SansSerif won't resolve to
192        // anything unless this mapping exists.
193        if !font_bytes.is_empty() {
194            let font_system = iced::advanced::graphics::text::font_system();
195            let mut fs = font_system.write().expect("font_system lock");
196            for bytes in &font_bytes {
197                fs.load_font(std::borrow::Cow::Owned(bytes.clone()));
198            }
199            // Find the first non-icon font and set it as sans-serif fallback.
200            let family_name = {
201                let raw = fs.raw();
202                let db = raw.db();
203                db.faces()
204                    .find(|f| !f.families.iter().any(|(n, _)| n == "Iced-Icons"))
205                    .and_then(|f| f.families.first().map(|(n, _)| n.clone()))
206            };
207            if let Some(name) = family_name {
208                log::info!("setting sans-serif family to: {}", name);
209                fs.raw().db_mut().set_sans_serif_family(name);
210            }
211        }
212
213        // Include custom type names in the hello message.
214        let ext_keys: Vec<String> = builder
215            .custom_type_names()
216            .iter()
217            .map(|s| s.to_string())
218            .collect();
219        let ext_key_refs: Vec<&str> = ext_keys.iter().map(|s| s.as_str()).collect();
220
221        // The WASM build only enables the tiny-skia iced feature;
222        // wgpu is not compiled in. Report what is actually shipped.
223        emit_hello("web", "tiny-skia", &ext_key_refs, &["iced"], "wasm")
224            .map_err(|e| JsValue::from_str(&format!("failed to emit hello: {e}")))?;
225
226        // Create the message channel for JS -> renderer communication.
227        let (sender, receiver) = futures_channel::mpsc::unbounded::<String>();
228        *MSG_RX.lock() = Some(receiver);
229
230        // Pack init data into a Mutex so the Fn closure can move it out once.
231        type InitData = (
232            serde_json::Value,
233            plushie_widget_sdk::app::PlushieAppBuilder,
234            Vec<Vec<u8>>,
235        );
236        let app_slot: Mutex<Option<InitData>> = Mutex::new(Some((settings, builder, font_bytes)));
237
238        // Spawn the iced daemon in the background. On WASM, spawn_local
239        // schedules the future on the browser's microtask queue, driven
240        // by requestAnimationFrame.
241        wasm_bindgen_futures::spawn_local(async move {
242            let result = iced::daemon(
243                move || {
244                    let (settings, builder, fonts) = app_slot
245                        .lock()
246                        .take()
247                        .expect("daemon init closure called more than once");
248
249                    let builder =
250                        builder.widget_set(&plushie_widget_sdk::runtime::iced_widget_set());
251                    let registry = builder.build();
252                    let effect_handler = Box::new(WebEffectHandler);
253                    let sink = plushie_renderer_lib::emitters::sink_arc();
254                    let mut app = App::new(registry, effect_handler, sink);
255
256                    app.scale_factor = plushie_renderer_lib::validate_scale_factor(
257                        settings
258                            .get("scale_factor")
259                            .and_then(|v| v.as_f64())
260                            .map(plushie_widget_sdk::prop_helpers::f64_to_f32)
261                            .unwrap_or(1.0),
262                    );
263
264                    let effects = app.core.apply(IncomingMessage::Settings { settings });
265                    for effect in effects {
266                        use plushie_renderer_engine::{CoreEffect, StateChange};
267                        if let CoreEffect::StateChange(StateChange::WidgetConfig(config)) = effect {
268                            let ctx = plushie_widget_sdk::registry::InitCtx {
269                                config: &config,
270                                theme: &app.theme,
271                                default_text_size: app.core.default_text_size,
272                                default_font: app.core.default_font,
273                            };
274                            app.registry.init_all(&ctx);
275                        }
276                    }
277
278                    let font_tasks: Vec<iced::Task<Message>> = fonts
279                        .into_iter()
280                        .map(|bytes| {
281                            iced::font::load(bytes).map(|result| {
282                                if let Err(e) = result {
283                                    log::error!("font load error: {e:?}");
284                                }
285                                Message::NoOp
286                            })
287                        })
288                        .collect();
289
290                    let task = if font_tasks.is_empty() {
291                        iced::Task::none()
292                    } else {
293                        iced::Task::batch(font_tasks)
294                    };
295
296                    (app, task)
297                },
298                App::update,
299                App::view_window,
300            )
301            .title(App::title_for_window)
302            .subscription(|app: &App| {
303                iced::Subscription::batch([
304                    app.renderer_subscriptions(),
305                    iced::Subscription::run(message_subscription).map(Message::Stdin),
306                ])
307            })
308            .theme(App::theme_for_window)
309            .scale_factor(App::scale_factor_for_window)
310            .settings(iced_settings)
311            .run();
312
313            if let Err(e) = result {
314                log::error!("iced daemon error: {e}");
315            }
316        });
317
318        Ok(PlushieApp { sender })
319    }
320}
321
322/// Subscription that reads JSON messages from the JS channel and feeds
323/// them to the iced event loop as [`StdinEvent`]s. Mirrors the native
324/// stdin subscription pattern.
325fn message_subscription() -> impl iced::futures::Stream<Item = StdinEvent> {
326    iced::stream::channel(32, async |mut sender| {
327        use iced::futures::{SinkExt, StreamExt};
328
329        let mut rx = MSG_RX
330            .lock()
331            .take()
332            .expect("message_subscription: no receiver (called more than once?)");
333
334        while let Some(json) = rx.next().await {
335            let event = match serde_json::from_str::<IncomingMessage>(&json) {
336                Ok(msg) => StdinEvent::Message(msg),
337                Err(e) => StdinEvent::Warning(format!("parse error: {e}")),
338            };
339            if sender.send(event).await.is_err() {
340                break;
341            }
342        }
343
344        // Channel closed (PlushieApp dropped); signal the daemon.
345        let _ = sender.send(StdinEvent::Closed).await;
346    })
347}
348
349#[cfg(test)]
350mod tests {
351    use serde_json::json;
352
353    use super::validate_protocol_version;
354
355    #[test]
356    fn validate_protocol_version_accepts_expected_value() {
357        let settings = json!({
358            "protocol_version": plushie_widget_sdk::protocol::PROTOCOL_VERSION,
359        });
360
361        assert!(validate_protocol_version(&settings).is_ok());
362    }
363
364    #[test]
365    fn validate_protocol_version_rejects_missing_value() {
366        let settings = json!({});
367
368        let err = validate_protocol_version(&settings).unwrap_err();
369        assert!(err.contains("missing or invalid protocol_version"));
370    }
371
372    #[test]
373    fn validate_protocol_version_rejects_non_integer_value() {
374        let settings = json!({
375            "protocol_version": 1.5,
376        });
377
378        let err = validate_protocol_version(&settings).unwrap_err();
379        assert!(err.contains("missing or invalid protocol_version"));
380    }
381
382    #[test]
383    fn validate_protocol_version_rejects_mismatch() {
384        let settings = json!({
385            "protocol_version": plushie_widget_sdk::protocol::PROTOCOL_VERSION + 1,
386        });
387
388        let err = validate_protocol_version(&settings).unwrap_err();
389        assert!(err.contains("protocol version mismatch"));
390    }
391
392    // -----------------------------------------------------------------------
393    // parse_and_validate_settings: the WASM entry point's gating step.
394    //
395    // These tests pin the error shapes a WASM host caller receives
396    // before anything is wired up, so a regression that changed the
397    // error wording (and broke a host's user-facing log) shows up
398    // immediately.
399    // -----------------------------------------------------------------------
400
401    use super::parse_and_validate_settings;
402
403    #[test]
404    fn parse_and_validate_settings_accepts_valid_json() {
405        let v = plushie_widget_sdk::protocol::PROTOCOL_VERSION;
406        let json = format!(r#"{{"protocol_version": {v}, "default_text_size": 14}}"#);
407        let settings = parse_and_validate_settings(&json).expect("valid settings");
408        assert_eq!(settings["default_text_size"], 14);
409    }
410
411    #[test]
412    fn parse_and_validate_settings_rejects_invalid_json_syntax() {
413        let err = parse_and_validate_settings("{not valid json}").unwrap_err();
414        assert!(
415            err.starts_with("invalid settings JSON"),
416            "unexpected error: {err}",
417        );
418    }
419
420    #[test]
421    fn parse_and_validate_settings_rejects_truncated_json() {
422        // Incomplete JSON: a frame cut mid-payload would land here.
423        let err = parse_and_validate_settings("{\"protocol_version\":").unwrap_err();
424        assert!(err.starts_with("invalid settings JSON"));
425    }
426
427    #[test]
428    fn parse_and_validate_settings_rejects_non_object_root() {
429        // Top-level array, string, or number is not a Settings object.
430        // The protocol_version field can't be read from a non-object,
431        // so validation surfaces a missing-version error.
432        let err = parse_and_validate_settings("[1, 2, 3]").unwrap_err();
433        assert!(
434            err.contains("missing or invalid protocol_version"),
435            "got: {err}",
436        );
437    }
438
439    #[test]
440    fn parse_and_validate_settings_rejects_missing_protocol_version() {
441        let err = parse_and_validate_settings("{}").unwrap_err();
442        assert!(err.contains("missing or invalid protocol_version"));
443    }
444
445    #[test]
446    fn parse_and_validate_settings_rejects_protocol_version_mismatch() {
447        let v = plushie_widget_sdk::protocol::PROTOCOL_VERSION + 1;
448        let json = format!(r#"{{"protocol_version": {v}}}"#);
449        let err = parse_and_validate_settings(&json).unwrap_err();
450        assert!(err.contains("protocol version mismatch"));
451    }
452}