lib.rs (15795B)
1 mod commands; 2 mod host_fns; 3 4 use host_fns::HostEnv; 5 use notedeck::{AppContext, AppResponse}; 6 use wasmer::{FunctionEnv, Instance, Module, Store}; 7 8 pub struct WasmApp { 9 store: Store, 10 instance: Instance, 11 env: FunctionEnv<HostEnv>, 12 name: String, 13 } 14 15 impl WasmApp { 16 /// Load a WASM app from raw bytes. 17 pub fn from_bytes(wasm_bytes: &[u8]) -> Result<Self, Box<dyn std::error::Error>> { 18 let mut store = Store::default(); 19 let module = Module::new(&store, wasm_bytes)?; 20 21 let host_env = HostEnv::new(); 22 let env = FunctionEnv::new(&mut store, host_env); 23 24 let imports = host_fns::create_imports(&mut store, &env); 25 let instance = Instance::new(&mut store, &module, &imports)?; 26 27 // Give host functions access to WASM linear memory. 28 let memory = instance.exports.get_memory("memory")?.clone(); 29 env.as_mut(&mut store).memory = Some(memory.clone()); 30 31 // Read app name from exported globals, if present. 32 let name = 33 read_app_name(&instance, &mut store, &memory).unwrap_or_else(|| "WASM App".to_string()); 34 35 Ok(Self { 36 store, 37 instance, 38 env, 39 name, 40 }) 41 } 42 43 /// Load a WASM app from a file path. 44 pub fn from_file(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> { 45 let bytes = std::fs::read(path)?; 46 Self::from_bytes(&bytes) 47 } 48 49 /// The display name of this WASM app. 50 pub fn name(&self) -> &str { 51 &self.name 52 } 53 } 54 55 /// Read app name from WASM exports: nd_app_name_ptr (i32) and nd_app_name_len (i32). 56 fn read_app_name( 57 instance: &Instance, 58 store: &mut Store, 59 memory: &wasmer::Memory, 60 ) -> Option<String> { 61 let ptr_global = instance.exports.get_global("nd_app_name_ptr").ok()?; 62 let len_global = instance.exports.get_global("nd_app_name_len").ok()?; 63 64 let ptr = ptr_global.get(store).i32()? as u64; 65 let len = len_global.get(store).i32()? as usize; 66 67 if len == 0 { 68 return None; 69 } 70 71 let view = memory.view(store); 72 let mut buf = vec![0u8; len]; 73 view.read(ptr, &mut buf).ok()?; 74 String::from_utf8(buf).ok() 75 } 76 77 impl WasmApp { 78 /// Run one WASM frame: clear state, call nd_update, return collected commands. 79 fn run_wasm_frame(&mut self, available: egui::Vec2) -> Vec<commands::UiCommand> { 80 let data = self.env.as_mut(&mut self.store); 81 data.commands.clear(); 82 data.button_occ.clear(); 83 data.available_width = available.x; 84 data.available_height = available.y; 85 86 let nd_update = self 87 .instance 88 .exports 89 .get_function("nd_update") 90 .expect("WASM module must export nd_update"); 91 if let Err(e) = nd_update.call(&mut self.store, &[]) { 92 tracing::error!("WASM nd_update error: {e}"); 93 } 94 95 let data = self.env.as_mut(&mut self.store); 96 std::mem::take(&mut data.commands) 97 } 98 } 99 100 impl notedeck::App for WasmApp { 101 fn render(&mut self, _ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { 102 let cmds = self.run_wasm_frame(ui.available_size()); 103 let new_events = commands::render_commands(&cmds, ui); 104 self.env.as_mut(&mut self.store).button_events = new_events; 105 AppResponse::none() 106 } 107 } 108 109 #[cfg(test)] 110 mod tests { 111 use super::*; 112 use crate::commands::UiCommand; 113 114 /// Helper: compile WAT to WASM bytes and load as WasmApp. 115 fn app_from_wat(wat: &str) -> WasmApp { 116 let wasm = wat::parse_str(wat).expect("valid WAT"); 117 WasmApp::from_bytes(&wasm).expect("load WASM module") 118 } 119 120 /// Helper: run one frame of the WASM app inside a headless egui context. 121 /// Returns the commands that were generated. 122 fn run_update(app: &mut WasmApp) -> Vec<UiCommand> { 123 let mut result_cmds = Vec::new(); 124 let ctx = egui::Context::default(); 125 let _ = ctx.run(egui::RawInput::default(), |ctx| { 126 egui::CentralPanel::default().show(ctx, |ui| { 127 let cmds = app.run_wasm_frame(ui.available_size()); 128 result_cmds = cmds.clone(); 129 let new_events = commands::render_commands(&cmds, ui); 130 app.env.as_mut(&mut app.store).button_events = new_events; 131 }); 132 }); 133 result_cmds 134 } 135 136 #[test] 137 fn load_empty_module() { 138 let app = app_from_wat( 139 r#"(module 140 (memory (export "memory") 1) 141 (func (export "nd_update")) 142 )"#, 143 ); 144 assert!(app.instance.exports.get_function("nd_update").is_ok()); 145 } 146 147 #[test] 148 fn run_noop_update() { 149 let mut app = app_from_wat( 150 r#"(module 151 (memory (export "memory") 1) 152 (func (export "nd_update")) 153 )"#, 154 ); 155 let cmds = run_update(&mut app); 156 assert!(cmds.is_empty()); 157 } 158 159 #[test] 160 fn call_nd_label() { 161 let mut app = app_from_wat( 162 r#"(module 163 (import "env" "nd_label" (func $nd_label (param i32 i32))) 164 (memory (export "memory") 1) 165 (data (i32.const 0) "hi") 166 (func (export "nd_update") 167 (call $nd_label (i32.const 0) (i32.const 2)) 168 ) 169 )"#, 170 ); 171 let cmds = run_update(&mut app); 172 assert_eq!(cmds.len(), 1); 173 assert!(matches!(&cmds[0], UiCommand::Label(t) if t == "hi")); 174 } 175 176 #[test] 177 fn call_nd_heading() { 178 let mut app = app_from_wat( 179 r#"(module 180 (import "env" "nd_heading" (func $nd_heading (param i32 i32))) 181 (memory (export "memory") 1) 182 (data (i32.const 0) "Title") 183 (func (export "nd_update") 184 (call $nd_heading (i32.const 0) (i32.const 5)) 185 ) 186 )"#, 187 ); 188 let cmds = run_update(&mut app); 189 assert_eq!(cmds.len(), 1); 190 assert!(matches!(&cmds[0], UiCommand::Heading(t) if t == "Title")); 191 } 192 193 #[test] 194 fn call_nd_button() { 195 let mut app = app_from_wat( 196 r#"(module 197 (import "env" "nd_button" (func $nd_button (param i32 i32) (result i32))) 198 (memory (export "memory") 1) 199 (data (i32.const 0) "Click") 200 (func (export "nd_update") 201 (drop (call $nd_button (i32.const 0) (i32.const 5))) 202 ) 203 )"#, 204 ); 205 let cmds = run_update(&mut app); 206 assert_eq!(cmds.len(), 1); 207 assert!(matches!(&cmds[0], UiCommand::Button(t) if t == "Click")); 208 } 209 210 #[test] 211 fn call_nd_add_space() { 212 let mut app = app_from_wat( 213 r#"(module 214 (import "env" "nd_add_space" (func $nd_add_space (param f32))) 215 (memory (export "memory") 1) 216 (func (export "nd_update") 217 (call $nd_add_space (f32.const 10.0)) 218 ) 219 )"#, 220 ); 221 let cmds = run_update(&mut app); 222 assert_eq!(cmds.len(), 1); 223 assert!(matches!(&cmds[0], UiCommand::AddSpace(px) if (*px - 10.0).abs() < f32::EPSILON)); 224 } 225 226 #[test] 227 fn call_multiple_host_fns() { 228 let mut app = app_from_wat( 229 r#"(module 230 (import "env" "nd_heading" (func $nd_heading (param i32 i32))) 231 (import "env" "nd_label" (func $nd_label (param i32 i32))) 232 (import "env" "nd_button" (func $nd_button (param i32 i32) (result i32))) 233 (import "env" "nd_add_space" (func $nd_add_space (param f32))) 234 (memory (export "memory") 1) 235 (data (i32.const 0) "Hello") 236 (data (i32.const 5) "World") 237 (data (i32.const 10) "Btn") 238 (func (export "nd_update") 239 (call $nd_heading (i32.const 0) (i32.const 5)) 240 (call $nd_add_space (f32.const 8.0)) 241 (call $nd_label (i32.const 5) (i32.const 5)) 242 (drop (call $nd_button (i32.const 10) (i32.const 3))) 243 ) 244 )"#, 245 ); 246 let cmds = run_update(&mut app); 247 assert_eq!(cmds.len(), 4); 248 assert!(matches!(&cmds[0], UiCommand::Heading(t) if t == "Hello")); 249 assert!(matches!(&cmds[1], UiCommand::AddSpace(_))); 250 assert!(matches!(&cmds[2], UiCommand::Label(t) if t == "World")); 251 assert!(matches!(&cmds[3], UiCommand::Button(t) if t == "Btn")); 252 } 253 254 #[test] 255 fn button_returns_prev_frame_event() { 256 // Module that stores nd_button's return value in a global. 257 let mut app = app_from_wat( 258 r#"(module 259 (import "env" "nd_button" (func $nd_button (param i32 i32) (result i32))) 260 (memory (export "memory") 1) 261 (data (i32.const 0) "Click") 262 (global $result (mut i32) (i32.const -1)) 263 (global (export "btn_result") (mut i32) (i32.const -1)) 264 (func (export "nd_update") 265 (global.set $result (call $nd_button (i32.const 0) (i32.const 5))) 266 (global.set 1 (global.get $result)) 267 ) 268 )"#, 269 ); 270 271 // Frame 1: no previous events, button returns 0 272 run_update(&mut app); 273 let result = app 274 .instance 275 .exports 276 .get_global("btn_result") 277 .unwrap() 278 .get(&mut app.store) 279 .i32() 280 .unwrap(); 281 assert_eq!(result, 0, "first frame: button should return 0"); 282 283 // Simulate a click by injecting an event 284 app.env 285 .as_mut(&mut app.store) 286 .button_events 287 .insert("Click".to_string(), true); 288 289 // Frame 2: button should now return 1 290 run_update(&mut app); 291 let result = app 292 .instance 293 .exports 294 .get_global("btn_result") 295 .unwrap() 296 .get(&mut app.store) 297 .i32() 298 .unwrap(); 299 assert_eq!( 300 result, 1, 301 "second frame: button should return 1 after click" 302 ); 303 } 304 305 #[test] 306 fn app_name_from_exports() { 307 let app = app_from_wat( 308 r#"(module 309 (memory (export "memory") 1) 310 (data (i32.const 500) "Test App") 311 (global (export "nd_app_name_ptr") i32 (i32.const 500)) 312 (global (export "nd_app_name_len") i32 (i32.const 8)) 313 (func (export "nd_update")) 314 )"#, 315 ); 316 assert_eq!(app.name(), "Test App"); 317 } 318 319 #[test] 320 fn app_name_defaults_when_missing() { 321 let app = app_from_wat( 322 r#"(module 323 (memory (export "memory") 1) 324 (func (export "nd_update")) 325 )"#, 326 ); 327 assert_eq!(app.name(), "WASM App"); 328 } 329 330 #[test] 331 fn available_width_returns_value() { 332 // Module that reads nd_available_width and stores it in a global. 333 let mut app = app_from_wat( 334 r#"(module 335 (import "env" "nd_available_width" (func $nd_available_width (result f32))) 336 (memory (export "memory") 1) 337 (global (export "width_result") (mut f32) (f32.const 0.0)) 338 (func (export "nd_update") 339 (global.set 0 (call $nd_available_width)) 340 ) 341 )"#, 342 ); 343 344 run_update(&mut app); 345 let val = app 346 .instance 347 .exports 348 .get_global("width_result") 349 .unwrap() 350 .get(&mut app.store); 351 let result = match val { 352 wasmer::Value::F32(f) => f, 353 wasmer::Value::F64(f) => f as f32, 354 other => panic!("unexpected value type: {:?}", other), 355 }; 356 // In headless egui, available width is positive 357 assert!(result > 0.0, "available_width should be positive"); 358 } 359 360 #[test] 361 fn draw_rect_produces_command() { 362 let mut app = app_from_wat( 363 r#"(module 364 (import "env" "nd_draw_rect" (func $nd_draw_rect (param f32 f32 f32 f32 i32))) 365 (memory (export "memory") 1) 366 (func (export "nd_update") 367 (call $nd_draw_rect 368 (f32.const 10.0) (f32.const 20.0) 369 (f32.const 100.0) (f32.const 50.0) 370 (i32.const 0xFF0000FF)) 371 ) 372 )"#, 373 ); 374 let cmds = run_update(&mut app); 375 assert_eq!(cmds.len(), 1); 376 assert!(matches!( 377 &cmds[0], 378 UiCommand::DrawRect { x, y, w, h, color } 379 if (*x - 10.0).abs() < f32::EPSILON 380 && (*y - 20.0).abs() < f32::EPSILON 381 && (*w - 100.0).abs() < f32::EPSILON 382 && (*h - 50.0).abs() < f32::EPSILON 383 && *color == 0xFF0000FF 384 )); 385 } 386 387 #[test] 388 fn draw_circle_produces_command() { 389 let mut app = app_from_wat( 390 r#"(module 391 (import "env" "nd_draw_circle" (func $nd_draw_circle (param f32 f32 f32 i32))) 392 (memory (export "memory") 1) 393 (func (export "nd_update") 394 (call $nd_draw_circle 395 (f32.const 50.0) (f32.const 50.0) 396 (f32.const 25.0) 397 (i32.const 0x00FF00FF)) 398 ) 399 )"#, 400 ); 401 let cmds = run_update(&mut app); 402 assert_eq!(cmds.len(), 1); 403 assert!(matches!( 404 &cmds[0], 405 UiCommand::DrawCircle { cx, cy, r, color } 406 if (*cx - 50.0).abs() < f32::EPSILON 407 && (*cy - 50.0).abs() < f32::EPSILON 408 && (*r - 25.0).abs() < f32::EPSILON 409 && *color == 0x00FF00FF 410 )); 411 } 412 413 #[test] 414 fn draw_line_produces_command() { 415 let mut app = app_from_wat( 416 r#"(module 417 (import "env" "nd_draw_line" (func $nd_draw_line (param f32 f32 f32 f32 f32 i32))) 418 (memory (export "memory") 1) 419 (func (export "nd_update") 420 (call $nd_draw_line 421 (f32.const 0.0) (f32.const 0.0) 422 (f32.const 100.0) (f32.const 100.0) 423 (f32.const 2.0) 424 (i32.const 0xFFFFFFFF)) 425 ) 426 )"#, 427 ); 428 let cmds = run_update(&mut app); 429 assert_eq!(cmds.len(), 1); 430 assert!( 431 matches!(&cmds[0], UiCommand::DrawLine { width, .. } if (*width - 2.0).abs() < f32::EPSILON) 432 ); 433 } 434 435 #[test] 436 fn draw_text_produces_command() { 437 let mut app = app_from_wat( 438 r#"(module 439 (import "env" "nd_draw_text" (func $nd_draw_text (param f32 f32 i32 i32 f32 i32))) 440 (memory (export "memory") 1) 441 (data (i32.const 0) "hello") 442 (func (export "nd_update") 443 (call $nd_draw_text 444 (f32.const 10.0) (f32.const 20.0) 445 (i32.const 0) (i32.const 5) 446 (f32.const 16.0) 447 (i32.const 0xFFFFFFFF)) 448 ) 449 )"#, 450 ); 451 let cmds = run_update(&mut app); 452 assert_eq!(cmds.len(), 1); 453 assert!(matches!( 454 &cmds[0], 455 UiCommand::DrawText { text, size, .. } 456 if text == "hello" && (*size - 16.0).abs() < f32::EPSILON 457 )); 458 } 459 460 #[test] 461 fn from_bytes_rejects_invalid_wasm() { 462 let result = WasmApp::from_bytes(b"not wasm"); 463 assert!(result.is_err()); 464 } 465 }