mod effects;
mod output;
use parking_lot::Mutex;
use wasm_bindgen::prelude::*;
use plushie_renderer_engine::Codec;
use plushie_widget_sdk::protocol::IncomingMessage;
use plushie_widget_sdk::runtime::{Message, StdinEvent};
use plushie_renderer_lib::App;
use plushie_renderer_lib::emitters::emit_hello;
use effects::WebEffectHandler;
use output::WebOutputWriter;
static MSG_RX: Mutex<Option<futures_channel::mpsc::UnboundedReceiver<String>>> = Mutex::new(None);
fn validate_protocol_version(settings: &serde_json::Value) -> Result<(), String> {
let expected = plushie_widget_sdk::protocol::PROTOCOL_VERSION;
match settings
.get("protocol_version")
.and_then(plushie_widget_sdk::protocol::json_protocol_version)
{
Some(version) if version == expected => Ok(()),
Some(version) => Err(format!(
"protocol version mismatch: expected {expected}, got {version}"
)),
None => Err(format!(
"missing or invalid protocol_version in Settings (expected {expected})"
)),
}
}
fn parse_and_validate_settings(settings_json: &str) -> Result<serde_json::Value, String> {
let settings: serde_json::Value =
serde_json::from_str(settings_json).map_err(|e| format!("invalid settings JSON: {e}"))?;
validate_protocol_version(&settings)?;
Ok(settings)
}
#[wasm_bindgen]
pub struct PlushieApp {
sender: futures_channel::mpsc::UnboundedSender<String>,
}
#[wasm_bindgen]
impl PlushieApp {
#[wasm_bindgen(constructor)]
pub fn new(settings_json: &str, on_event: js_sys::Function) -> Result<PlushieApp, JsValue> {
Self::with_widgets(
settings_json,
on_event,
plushie_widget_sdk::app::PlushieAppBuilder::new(),
)
}
pub fn send_message(&self, json: &str) -> Result<(), JsValue> {
self.sender
.unbounded_send(json.to_string())
.map_err(|e| JsValue::from_str(&format!("send failed: {e}")))
}
}
impl PlushieApp {
pub fn with_widgets(
settings_json: &str,
on_event: js_sys::Function,
builder: plushie_widget_sdk::app::PlushieAppBuilder,
) -> Result<PlushieApp, JsValue> {
console_log::init_with_level(log::Level::Warn).ok();
let settings =
parse_and_validate_settings(settings_json).map_err(|e| JsValue::from_str(&e))?;
let writer = WebOutputWriter::try_new(on_event)?;
let codec = Codec::Json;
let sink = plushie_renderer_lib::WriterSink::new(Box::new(writer), codec);
plushie_renderer_lib::emitters::init_sink(Box::new(sink));
plushie_renderer_lib::emitters::install_panic_hook();
let iced_settings = plushie_renderer_lib::settings::parse_iced_settings(&settings);
plushie_renderer_lib::settings::apply_validate_props(&settings);
let font_bytes = plushie_renderer_lib::settings::parse_inline_fonts(&settings);
if !font_bytes.is_empty() {
let font_system = iced::advanced::graphics::text::font_system();
let mut fs = font_system.write().expect("font_system lock");
for bytes in &font_bytes {
fs.load_font(std::borrow::Cow::Owned(bytes.clone()));
}
let family_name = {
let raw = fs.raw();
let db = raw.db();
db.faces()
.find(|f| !f.families.iter().any(|(n, _)| n == "Iced-Icons"))
.and_then(|f| f.families.first().map(|(n, _)| n.clone()))
};
if let Some(name) = family_name {
log::info!("setting sans-serif family to: {}", name);
fs.raw().db_mut().set_sans_serif_family(name);
}
}
let ext_keys: Vec<String> = builder
.custom_type_names()
.iter()
.map(|s| s.to_string())
.collect();
let ext_key_refs: Vec<&str> = ext_keys.iter().map(|s| s.as_str()).collect();
emit_hello("web", "tiny-skia", &ext_key_refs, &["iced"], "wasm")
.map_err(|e| JsValue::from_str(&format!("failed to emit hello: {e}")))?;
let (sender, receiver) = futures_channel::mpsc::unbounded::<String>();
*MSG_RX.lock() = Some(receiver);
type InitData = (
serde_json::Value,
plushie_widget_sdk::app::PlushieAppBuilder,
Vec<Vec<u8>>,
);
let app_slot: Mutex<Option<InitData>> = Mutex::new(Some((settings, builder, font_bytes)));
wasm_bindgen_futures::spawn_local(async move {
let result = iced::daemon(
move || {
let (settings, builder, fonts) = app_slot
.lock()
.take()
.expect("daemon init closure called more than once");
let builder =
builder.widget_set(&plushie_widget_sdk::runtime::iced_widget_set());
let registry = builder.build();
let effect_handler = Box::new(WebEffectHandler);
let sink = plushie_renderer_lib::emitters::sink_arc();
let mut app = App::new(registry, effect_handler, sink);
app.scale_factor = plushie_renderer_lib::validate_scale_factor(
settings
.get("scale_factor")
.and_then(|v| v.as_f64())
.map(plushie_widget_sdk::prop_helpers::f64_to_f32)
.unwrap_or(1.0),
);
let effects = app.core.apply(IncomingMessage::Settings { settings });
for effect in effects {
use plushie_renderer_engine::{CoreEffect, StateChange};
if let CoreEffect::StateChange(StateChange::WidgetConfig(config)) = effect {
let ctx = plushie_widget_sdk::registry::InitCtx {
config: &config,
theme: &app.theme,
default_text_size: app.core.default_text_size,
default_font: app.core.default_font,
};
app.registry.init_all(&ctx);
}
}
let font_tasks: Vec<iced::Task<Message>> = fonts
.into_iter()
.map(|bytes| {
iced::font::load(bytes).map(|result| {
if let Err(e) = result {
log::error!("font load error: {e:?}");
}
Message::NoOp
})
})
.collect();
let task = if font_tasks.is_empty() {
iced::Task::none()
} else {
iced::Task::batch(font_tasks)
};
(app, task)
},
App::update,
App::view_window,
)
.title(App::title_for_window)
.subscription(|app: &App| {
iced::Subscription::batch([
app.renderer_subscriptions(),
iced::Subscription::run(message_subscription).map(Message::Stdin),
])
})
.theme(App::theme_for_window)
.scale_factor(App::scale_factor_for_window)
.settings(iced_settings)
.run();
if let Err(e) = result {
log::error!("iced daemon error: {e}");
}
});
Ok(PlushieApp { sender })
}
}
fn message_subscription() -> impl iced::futures::Stream<Item = StdinEvent> {
iced::stream::channel(32, async |mut sender| {
use iced::futures::{SinkExt, StreamExt};
let mut rx = MSG_RX
.lock()
.take()
.expect("message_subscription: no receiver (called more than once?)");
while let Some(json) = rx.next().await {
let event = match serde_json::from_str::<IncomingMessage>(&json) {
Ok(msg) => StdinEvent::Message(msg),
Err(e) => StdinEvent::Warning(format!("parse error: {e}")),
};
if sender.send(event).await.is_err() {
break;
}
}
let _ = sender.send(StdinEvent::Closed).await;
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::validate_protocol_version;
#[test]
fn validate_protocol_version_accepts_expected_value() {
let settings = json!({
"protocol_version": plushie_widget_sdk::protocol::PROTOCOL_VERSION,
});
assert!(validate_protocol_version(&settings).is_ok());
}
#[test]
fn validate_protocol_version_rejects_missing_value() {
let settings = json!({});
let err = validate_protocol_version(&settings).unwrap_err();
assert!(err.contains("missing or invalid protocol_version"));
}
#[test]
fn validate_protocol_version_rejects_non_integer_value() {
let settings = json!({
"protocol_version": 1.5,
});
let err = validate_protocol_version(&settings).unwrap_err();
assert!(err.contains("missing or invalid protocol_version"));
}
#[test]
fn validate_protocol_version_rejects_mismatch() {
let settings = json!({
"protocol_version": plushie_widget_sdk::protocol::PROTOCOL_VERSION + 1,
});
let err = validate_protocol_version(&settings).unwrap_err();
assert!(err.contains("protocol version mismatch"));
}
use super::parse_and_validate_settings;
#[test]
fn parse_and_validate_settings_accepts_valid_json() {
let v = plushie_widget_sdk::protocol::PROTOCOL_VERSION;
let json = format!(r#"{{"protocol_version": {v}, "default_text_size": 14}}"#);
let settings = parse_and_validate_settings(&json).expect("valid settings");
assert_eq!(settings["default_text_size"], 14);
}
#[test]
fn parse_and_validate_settings_rejects_invalid_json_syntax() {
let err = parse_and_validate_settings("{not valid json}").unwrap_err();
assert!(
err.starts_with("invalid settings JSON"),
"unexpected error: {err}",
);
}
#[test]
fn parse_and_validate_settings_rejects_truncated_json() {
let err = parse_and_validate_settings("{\"protocol_version\":").unwrap_err();
assert!(err.starts_with("invalid settings JSON"));
}
#[test]
fn parse_and_validate_settings_rejects_non_object_root() {
let err = parse_and_validate_settings("[1, 2, 3]").unwrap_err();
assert!(
err.contains("missing or invalid protocol_version"),
"got: {err}",
);
}
#[test]
fn parse_and_validate_settings_rejects_missing_protocol_version() {
let err = parse_and_validate_settings("{}").unwrap_err();
assert!(err.contains("missing or invalid protocol_version"));
}
#[test]
fn parse_and_validate_settings_rejects_protocol_version_mismatch() {
let v = plushie_widget_sdk::protocol::PROTOCOL_VERSION + 1;
let json = format!(r#"{{"protocol_version": {v}}}"#);
let err = parse_and_validate_settings(&json).unwrap_err();
assert!(err.contains("protocol version mismatch"));
}
}