plushie_renderer_wasm/
lib.rs1mod 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
55static 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
75fn 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_bindgen]
109pub struct PlushieApp {
110 sender: futures_channel::mpsc::UnboundedSender<String>,
111}
112
113#[wasm_bindgen]
114impl PlushieApp {
115 #[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 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 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 let settings =
175 parse_and_validate_settings(settings_json).map_err(|e| JsValue::from_str(&e))?;
176
177 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 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 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 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 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 let (sender, receiver) = futures_channel::mpsc::unbounded::<String>();
228 *MSG_RX.lock() = Some(receiver);
229
230 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 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
322fn 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 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 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 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 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}