notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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 }