Skip to main content

webui_ffi/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebUI FFI (Foreign Function Interface) for interoperability with other languages.
5//!
6//! This crate provides C-compatible APIs for the WebUI handler to be used from languages
7//! like Go, C#, Python, etc.
8//!
9//! ## Quick Start
10//!
11//! The simplest way to render an HTML template with data:
12//!
13//! ```c
14//! char *result = webui_render("<h1>{{title}}</h1>", "{\"title\":\"Hello\"}");
15//! if (result == NULL) {
16//!     const char *err = webui_last_error();
17//!     // handle error...
18//! } else {
19//!     // use result...
20//!     webui_free(result);
21//! }
22//! ```
23//!
24//! ## Error Handling
25//!
26//! All functions that can fail return `NULL` on error. Call [`webui_last_error`] to
27//! retrieve a human-readable error message. The error string is valid until the next
28//! FFI call on the same thread (follows the POSIX `dlerror()` pattern).
29
30use serde_json::Value;
31use std::cell::RefCell;
32use std::ffi::{CStr, CString};
33use std::os::raw::{c_char, c_void};
34use webui_handler::plugin::fast::FastHydrationPlugin;
35use webui_handler::plugin::webui::WebUIHydrationPlugin;
36use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
37use webui_parser::HtmlParser;
38use webui_protocol::WebUIProtocol;
39
40// ---------------------------------------------------------------------------
41// Thread-local error storage (POSIX dlerror() pattern)
42// ---------------------------------------------------------------------------
43
44thread_local! {
45    /// Stores the last error message for the current thread.
46    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
47}
48
49/// Record an error message so that `webui_last_error()` can return it.
50fn set_last_error(msg: impl Into<String>) {
51    let mut bytes = msg.into().into_bytes();
52    if let Some(nul_pos) = bytes.iter().position(|byte| *byte == 0) {
53        bytes.truncate(nul_pos);
54    }
55
56    // SAFETY: Any interior NUL byte was removed by truncating at its first position.
57    let c_string = unsafe { CString::from_vec_unchecked(bytes) };
58    LAST_ERROR.with(|cell| {
59        cell.replace(Some(c_string));
60    });
61}
62
63/// Clear any previously stored error.
64fn clear_last_error() {
65    LAST_ERROR.with(|cell| {
66        cell.replace(None);
67    });
68}
69
70// ---------------------------------------------------------------------------
71// Internal helpers
72// ---------------------------------------------------------------------------
73
74/// Opaque context wrapping a `WebUIHandler`.
75struct HandlerContext {
76    handler: WebUIHandler,
77}
78
79/// A simple string buffer for collecting rendered output.
80struct StringResponseWriter {
81    content: String,
82}
83
84impl StringResponseWriter {
85    fn new() -> Self {
86        Self {
87            content: String::new(),
88        }
89    }
90}
91
92impl ResponseWriter for StringResponseWriter {
93    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
94        self.content.push_str(content);
95        Ok(())
96    }
97
98    fn end(&mut self) -> webui_handler::Result<()> {
99        Ok(())
100    }
101}
102
103// ---------------------------------------------------------------------------
104// FFI: error reporting
105// ---------------------------------------------------------------------------
106
107/// Return the last error message, or `NULL` if no error has occurred.
108///
109/// The returned pointer is valid until the next FFI call **on the same thread**.
110/// Callers **must not** free the returned pointer.
111///
112/// # Thread Safety
113///
114/// Each thread has its own independent error state.
115#[no_mangle]
116pub extern "C" fn webui_last_error() -> *const c_char {
117    LAST_ERROR.with(|cell| {
118        let borrow = cell.borrow();
119        match borrow.as_ref() {
120            Some(c_str) => c_str.as_ptr(),
121            None => std::ptr::null(),
122        }
123    })
124}
125
126// ---------------------------------------------------------------------------
127// FFI: handler lifecycle
128// ---------------------------------------------------------------------------
129
130/// Create a new WebUI handler instance.
131///
132/// Returns an opaque pointer that must be passed to other `webui_handler_*`
133/// functions and eventually freed with [`webui_handler_destroy`].
134#[no_mangle]
135pub extern "C" fn webui_handler_create() -> *mut c_void {
136    let handler = WebUIHandler::new();
137    let context = Box::new(HandlerContext { handler });
138    Box::into_raw(context) as *mut c_void
139}
140
141/// Create a new WebUI handler instance with a named plugin.
142///
143/// # Arguments
144///
145/// * `plugin_id` - Null-terminated UTF-8 string identifying the plugin.
146///
147/// # Returns
148///
149/// An opaque pointer that must be freed with [`webui_handler_destroy`],
150/// or `NULL` on error (call [`webui_last_error`] for details).
151///
152/// # Safety
153///
154/// `plugin_id` must be a valid null-terminated UTF-8 string, or `NULL`.
155#[no_mangle]
156pub unsafe extern "C" fn webui_handler_create_with_plugin(plugin_id: *const c_char) -> *mut c_void {
157    clear_last_error();
158
159    let handler = if plugin_id.is_null() {
160        WebUIHandler::new()
161    } else {
162        match CStr::from_ptr(plugin_id).to_str() {
163            Ok("fast") => WebUIHandler::with_plugin(|| Box::new(FastHydrationPlugin::new())),
164            Ok("webui") => WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new())),
165            Ok(unknown) => {
166                set_last_error(format!("unknown plugin: {unknown}"));
167                return std::ptr::null_mut();
168            }
169            Err(e) => {
170                set_last_error(format!("invalid UTF-8 in plugin_id: {e}"));
171                return std::ptr::null_mut();
172            }
173        }
174    };
175
176    let context = Box::new(HandlerContext { handler });
177    Box::into_raw(context) as *mut c_void
178}
179
180/// Destroy a WebUI handler instance.
181///
182/// # Safety
183///
184/// `handler_ptr` must be a valid pointer returned by [`webui_handler_create`],
185/// or `NULL` (in which case this function is a no-op).
186#[no_mangle]
187pub unsafe extern "C" fn webui_handler_destroy(handler_ptr: *mut c_void) {
188    if !handler_ptr.is_null() {
189        let _ = Box::from_raw(handler_ptr as *mut HandlerContext);
190    }
191}
192
193// ---------------------------------------------------------------------------
194// FFI: protobuf-based render (existing API, now with error reporting)
195// ---------------------------------------------------------------------------
196
197/// Render a WebUI protocol (protobuf binary) with JSON data.
198///
199/// # Arguments
200///
201/// * `handler_ptr`   - Pointer returned by [`webui_handler_create`].
202/// * `protocol_data` - Pointer to protobuf binary data.
203/// * `protocol_len`  - Length of the protobuf data in bytes.
204/// * `data_json`     - Null-terminated JSON string with the render state.
205/// * `entry_id`      - Null-terminated UTF-8 string for the entry fragment.
206/// * `request_path`  - Null-terminated UTF-8 string for the URL path to match
207///   routes against (e.g., `"/contacts/42"`).
208///
209/// # Returns
210///
211/// A pointer to a null-terminated UTF-8 string with the rendered HTML, or
212/// `NULL` on error.  The caller **must** free the returned string with
213/// [`webui_free`].  On error, call [`webui_last_error`] for details.
214///
215/// # Safety
216///
217/// * `handler_ptr` must be a valid pointer returned by [`webui_handler_create`].
218/// * `protocol_data` must point to `protocol_len` bytes of valid memory.
219/// * `data_json`, `entry_id`, and `request_path` must be valid null-terminated UTF-8 strings.
220#[no_mangle]
221pub unsafe extern "C" fn webui_handler_render(
222    handler_ptr: *mut c_void,
223    protocol_data: *const u8,
224    protocol_len: usize,
225    data_json: *const c_char,
226    entry_id: *const c_char,
227    request_path: *const c_char,
228) -> *mut c_char {
229    clear_last_error();
230
231    if handler_ptr.is_null()
232        || protocol_data.is_null()
233        || data_json.is_null()
234        || entry_id.is_null()
235        || request_path.is_null()
236    {
237        set_last_error("one or more required arguments are null");
238        return std::ptr::null_mut();
239    }
240
241    // SAFETY: caller guarantees handler_ptr is valid and exclusively owned.
242    let context = &*(handler_ptr as *const HandlerContext);
243
244    // SAFETY: caller guarantees protocol_data points to protocol_len valid bytes.
245    let protocol_bytes = std::slice::from_raw_parts(protocol_data, protocol_len);
246
247    // SAFETY: caller guarantees data_json is a valid null-terminated string.
248    let data_str = match CStr::from_ptr(data_json).to_str() {
249        Ok(s) => s,
250        Err(e) => {
251            set_last_error(format!("invalid UTF-8 in data_json: {e}"));
252            return std::ptr::null_mut();
253        }
254    };
255
256    // SAFETY: caller guarantees entry_id is a valid null-terminated string.
257    let entry_str = match CStr::from_ptr(entry_id).to_str() {
258        Ok(s) => s,
259        Err(e) => {
260            set_last_error(format!("invalid UTF-8 in entry_id: {e}"));
261            return std::ptr::null_mut();
262        }
263    };
264
265    // SAFETY: caller guarantees request_path is a valid null-terminated string.
266    let path_str = match CStr::from_ptr(request_path).to_str() {
267        Ok(s) => s,
268        Err(e) => {
269            set_last_error(format!("invalid UTF-8 in request_path: {e}"));
270            return std::ptr::null_mut();
271        }
272    };
273
274    // Parse protocol from protobuf binary data
275    let protocol = match WebUIProtocol::from_protobuf(protocol_bytes) {
276        Ok(p) => p,
277        Err(e) => {
278            set_last_error(format!("failed to parse protobuf protocol: {e}"));
279            return std::ptr::null_mut();
280        }
281    };
282
283    // Parse data JSON
284    let data: Value = match serde_json::from_str(data_str) {
285        Ok(d) => d,
286        Err(e) => {
287            set_last_error(format!("failed to parse data JSON: {e}"));
288            return std::ptr::null_mut();
289        }
290    };
291
292    // Render
293    let mut writer = StringResponseWriter::new();
294    match context.handler.render(
295        &protocol,
296        &data,
297        &RenderOptions::new(entry_str, path_str),
298        &mut writer,
299    ) {
300        Ok(_) => match CString::new(writer.content) {
301            Ok(s) => s.into_raw(),
302            Err(e) => {
303                set_last_error(format!("rendered output contains interior NUL byte: {e}"));
304                std::ptr::null_mut()
305            }
306        },
307        Err(e) => {
308            set_last_error(format!("render failed: {e}"));
309            std::ptr::null_mut()
310        }
311    }
312}
313
314// ---------------------------------------------------------------------------
315// FFI: HTML-based render (new high-level API)
316// ---------------------------------------------------------------------------
317
318/// Parse an HTML template and render it with JSON data in a single call.
319///
320/// This is the **recommended entry point** for Go, C#, and Python consumers.
321/// It eliminates the need for callers to deal with protobuf serialisation.
322///
323/// # Arguments
324///
325/// * `html`      - Null-terminated UTF-8 string containing the HTML template.
326/// * `data_json` - Null-terminated UTF-8 JSON string with the render state.
327///
328/// # Returns
329///
330/// A pointer to a null-terminated UTF-8 string with the rendered HTML, or
331/// `NULL` on error.  The caller **must** free the returned string with
332/// [`webui_free`].  On error, call [`webui_last_error`] for details.
333///
334/// # Safety
335///
336/// Both `html` and `data_json` must be valid null-terminated UTF-8 strings.
337#[no_mangle]
338pub unsafe extern "C" fn webui_render(
339    html: *const c_char,
340    data_json: *const c_char,
341) -> *mut c_char {
342    clear_last_error();
343
344    if html.is_null() || data_json.is_null() {
345        set_last_error("html and data_json must not be null");
346        return std::ptr::null_mut();
347    }
348
349    // --- Extract C strings ---------------------------------------------------
350    let html_str = match CStr::from_ptr(html).to_str() {
351        Ok(s) => s,
352        Err(e) => {
353            set_last_error(format!("invalid UTF-8 in html: {e}"));
354            return std::ptr::null_mut();
355        }
356    };
357
358    let data_str = match CStr::from_ptr(data_json).to_str() {
359        Ok(s) => s,
360        Err(e) => {
361            set_last_error(format!("invalid UTF-8 in data_json: {e}"));
362            return std::ptr::null_mut();
363        }
364    };
365
366    // --- Parse HTML template into a WebUI protocol ---------------------------
367    let entry_key = "template";
368    let mut parser = HtmlParser::new();
369    if let Err(e) = parser.parse(entry_key, html_str) {
370        set_last_error(format!("HTML parse error: {e}"));
371        return std::ptr::null_mut();
372    }
373
374    let protocol = WebUIProtocol::new(parser.into_fragment_records());
375
376    // --- Parse JSON state ----------------------------------------------------
377    let data: Value = match serde_json::from_str(data_str) {
378        Ok(d) => d,
379        Err(e) => {
380            set_last_error(format!("failed to parse data JSON: {e}"));
381            return std::ptr::null_mut();
382        }
383    };
384
385    // --- Render --------------------------------------------------------------
386    let handler = WebUIHandler::new();
387    let mut writer = StringResponseWriter::new();
388
389    match handler.render(
390        &protocol,
391        &data,
392        &RenderOptions::new(entry_key, "/"),
393        &mut writer,
394    ) {
395        Ok(_) => match CString::new(writer.content) {
396            Ok(s) => s.into_raw(),
397            Err(e) => {
398                set_last_error(format!("rendered output contains interior NUL byte: {e}"));
399                std::ptr::null_mut()
400            }
401        },
402        Err(e) => {
403            set_last_error(format!("render failed: {e}"));
404            std::ptr::null_mut()
405        }
406    }
407}
408
409// ---------------------------------------------------------------------------
410// FFI: unified partial response
411// ---------------------------------------------------------------------------
412
413/// Produce a complete JSON partial response for client-side navigation.
414///
415/// Combines route templates, inventory, and matched route chain into a single
416/// JSON string: `{"templates":[...],"inventory":"...","chain":[...]}`.
417///
418/// # Arguments
419///
420/// * `protocol_data` - Pointer to protobuf binary data.
421/// * `protocol_len`  - Length of the protobuf data in bytes.
422/// * `entry_id`      - Null-terminated UTF-8 string for the persistent entry fragment.
423/// * `request_path`  - Null-terminated UTF-8 route path used to select the active route chain.
424/// * `inventory_hex` - Null-terminated hex string of the client's inventory bitmask
425///   (pass empty string `""` if no inventory).
426///
427/// # Returns
428///
429/// A heap-allocated JSON string, or `NULL` on error. Caller frees with [`webui_free`].
430///
431/// # Safety
432///
433/// * `protocol_data` must point to `protocol_len` bytes of valid memory.
434/// * `state_json`, `entry_id`, `request_path`, and `inventory_hex` must be valid
435///   null-terminated UTF-8 strings.
436#[no_mangle]
437pub unsafe extern "C" fn webui_render_partial(
438    protocol_data: *const u8,
439    protocol_len: usize,
440    state_json: *const c_char,
441    entry_id: *const c_char,
442    request_path: *const c_char,
443    inventory_hex: *const c_char,
444) -> *mut c_char {
445    clear_last_error();
446
447    match std::panic::catch_unwind(|| {
448        if protocol_data.is_null()
449            || state_json.is_null()
450            || entry_id.is_null()
451            || request_path.is_null()
452            || inventory_hex.is_null()
453        {
454            set_last_error("one or more required arguments are null");
455            return std::ptr::null_mut();
456        }
457
458        // SAFETY: The caller guarantees `protocol_data` points to `protocol_len` readable bytes.
459        let protocol_bytes = unsafe { std::slice::from_raw_parts(protocol_data, protocol_len) };
460
461        // SAFETY: The caller guarantees `state_json` is a valid null-terminated string.
462        let state_str = match unsafe { CStr::from_ptr(state_json) }.to_str() {
463            Ok(s) => s,
464            Err(e) => {
465                set_last_error(format!("invalid UTF-8 in state_json: {e}"));
466                return std::ptr::null_mut();
467            }
468        };
469
470        let state: Value = match serde_json::from_str(state_str) {
471            Ok(v) => v,
472            Err(e) => {
473                set_last_error(format!("invalid state JSON: {e}"));
474                return std::ptr::null_mut();
475            }
476        };
477
478        // SAFETY: The caller guarantees `entry_id` is a valid null-terminated string.
479        let entry_str = match unsafe { CStr::from_ptr(entry_id) }.to_str() {
480            Ok(s) => s,
481            Err(e) => {
482                set_last_error(format!("invalid UTF-8 in entry_id: {e}"));
483                return std::ptr::null_mut();
484            }
485        };
486
487        // SAFETY: The caller guarantees `request_path` is a valid null-terminated string.
488        let request_path_str = match unsafe { CStr::from_ptr(request_path) }.to_str() {
489            Ok(s) => s,
490            Err(e) => {
491                set_last_error(format!("invalid UTF-8 in request_path: {e}"));
492                return std::ptr::null_mut();
493            }
494        };
495
496        // SAFETY: The caller guarantees `inventory_hex` is a valid null-terminated string.
497        let inv_str = match unsafe { CStr::from_ptr(inventory_hex) }.to_str() {
498            Ok(s) => s,
499            Err(e) => {
500                set_last_error(format!("invalid UTF-8 in inventory_hex: {e}"));
501                return std::ptr::null_mut();
502            }
503        };
504
505        let protocol = match WebUIProtocol::from_protobuf(protocol_bytes) {
506            Ok(p) => p,
507            Err(e) => {
508                set_last_error(format!("failed to parse protobuf protocol: {e}"));
509                return std::ptr::null_mut();
510            }
511        };
512
513        let result = webui_handler::route_handler::render_partial(
514            &protocol,
515            state,
516            entry_str,
517            request_path_str,
518            inv_str,
519        );
520
521        match CString::new(result.to_string()) {
522            Ok(s) => s.into_raw(),
523            Err(e) => {
524                set_last_error(format!("JSON output contains interior NUL byte: {e}"));
525                std::ptr::null_mut()
526            }
527        }
528    }) {
529        Ok(ptr) => ptr,
530        Err(_) => {
531            set_last_error("panic in webui_render_partial");
532            std::ptr::null_mut()
533        }
534    }
535}
536
537/// Render templates and CSS for specific components by tag name.
538///
539/// Returns a JSON string with `{ templates, templateStyles, cssHrefs, inventory }`.
540/// The caller must free the returned string with [`webui_free`].
541///
542/// # Safety
543///
544/// All pointer arguments must be valid, non-null, null-terminated UTF-8 strings.
545/// `protocol_data` must point to `protocol_len` readable bytes.
546/// `component_tags_json` must be a JSON array of strings, e.g. `["settings-dialog"]`.
547#[no_mangle]
548pub unsafe extern "C" fn webui_render_component_templates(
549    protocol_data: *const u8,
550    protocol_len: usize,
551    component_tags_json: *const c_char,
552    inventory_hex: *const c_char,
553) -> *mut c_char {
554    clear_last_error();
555
556    match std::panic::catch_unwind(|| {
557        if protocol_data.is_null() || component_tags_json.is_null() || inventory_hex.is_null() {
558            set_last_error("one or more required arguments are null");
559            return std::ptr::null_mut();
560        }
561
562        let protocol_bytes = unsafe { std::slice::from_raw_parts(protocol_data, protocol_len) };
563
564        let tags_str = match unsafe { CStr::from_ptr(component_tags_json) }.to_str() {
565            Ok(s) => s,
566            Err(e) => {
567                set_last_error(format!("invalid UTF-8 in component_tags_json: {e}"));
568                return std::ptr::null_mut();
569            }
570        };
571
572        let tags: Vec<String> = match serde_json::from_str(tags_str) {
573            Ok(v) => v,
574            Err(e) => {
575                set_last_error(format!("invalid tags JSON: {e}"));
576                return std::ptr::null_mut();
577            }
578        };
579        let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
580
581        let inv_str = match unsafe { CStr::from_ptr(inventory_hex) }.to_str() {
582            Ok(s) => s,
583            Err(e) => {
584                set_last_error(format!("invalid UTF-8 in inventory_hex: {e}"));
585                return std::ptr::null_mut();
586            }
587        };
588
589        let protocol = match WebUIProtocol::from_protobuf(protocol_bytes) {
590            Ok(p) => p,
591            Err(e) => {
592                set_last_error(format!("failed to parse protobuf protocol: {e}"));
593                return std::ptr::null_mut();
594            }
595        };
596
597        let result =
598            webui_handler::route_handler::render_component_templates(&protocol, &tag_refs, inv_str);
599
600        match CString::new(result.to_string()) {
601            Ok(s) => s.into_raw(),
602            Err(e) => {
603                set_last_error(format!("JSON output contains interior NUL byte: {e}"));
604                std::ptr::null_mut()
605            }
606        }
607    }) {
608        Ok(ptr) => ptr,
609        Err(_) => {
610            set_last_error("panic in webui_render_component_templates");
611            std::ptr::null_mut()
612        }
613    }
614}
615
616/// Free a string returned by a WebUI FFI function.
617///
618/// # Safety
619///
620/// `string_ptr` must be a pointer returned by a WebUI FFI function (e.g.
621/// [`webui_handler_render`] or [`webui_render`]), or `NULL`
622/// (in which case this function is a no-op).
623#[no_mangle]
624pub unsafe extern "C" fn webui_free(string_ptr: *mut c_char) {
625    if !string_ptr.is_null() {
626        let _ = CString::from_raw(string_ptr);
627    }
628}