Skip to main content

webui_ffi/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4// FFI crate requires unsafe for C-compatible ABI boundary.
5#![allow(unsafe_code)]
6
7//! WebUI FFI (Foreign Function Interface) for interoperability with other languages.
8//!
9//! This crate provides C-compatible APIs for the WebUI handler to be used from languages
10//! like Go, C#, Python, etc.
11//!
12//! ## Quick Start
13//!
14//! The simplest way to render an HTML template with data:
15//!
16//! ```c
17//! char *result = webui_render("<h1>{{title}}</h1>", "{\"title\":\"Hello\"}");
18//! if (result == NULL) {
19//!     const char *err = webui_last_error();
20//!     // handle error...
21//! } else {
22//!     // use result...
23//!     webui_free(result);
24//! }
25//! ```
26//!
27//! ## Error Handling
28//!
29//! All functions that can fail return `NULL` on error. Call [`webui_last_error`] to
30//! retrieve a human-readable error message. The error string is valid until the next
31//! FFI call on the same thread (follows the POSIX `dlerror()` pattern).
32
33use serde_json::Value;
34use std::cell::RefCell;
35use std::ffi::{CStr, CString};
36use std::os::raw::{c_char, c_void};
37use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
38use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
39use webui_handler::plugin::webui::WebUIHydrationPlugin;
40use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
41#[cfg(feature = "parser")]
42use webui_parser::HtmlParser;
43use webui_protocol::WebUIProtocol;
44
45// ---------------------------------------------------------------------------
46// Thread-local error storage (POSIX dlerror() pattern)
47// ---------------------------------------------------------------------------
48
49thread_local! {
50    /// Stores the last error message for the current thread.
51    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
52}
53
54/// Record an error message so that `webui_last_error()` can return it.
55fn set_last_error(msg: impl Into<String>) {
56    let mut bytes = msg.into().into_bytes();
57    if let Some(nul_pos) = bytes.iter().position(|byte| *byte == 0) {
58        bytes.truncate(nul_pos);
59    }
60
61    // SAFETY: Any interior NUL byte was removed by truncating at its first position.
62    let c_string = unsafe { CString::from_vec_unchecked(bytes) };
63    LAST_ERROR.with(|cell| {
64        cell.replace(Some(c_string));
65    });
66}
67
68/// Clear any previously stored error.
69fn clear_last_error() {
70    LAST_ERROR.with(|cell| {
71        cell.replace(None);
72    });
73}
74
75// ---------------------------------------------------------------------------
76// Internal helpers
77// ---------------------------------------------------------------------------
78
79/// Opaque context wrapping a `WebUIHandler`.
80struct HandlerContext {
81    handler: WebUIHandler,
82    /// CSP nonce for inline `<script>` tags (set via `webui_handler_set_nonce`).
83    nonce: Option<String>,
84}
85
86/// A simple string buffer for collecting rendered output.
87struct StringResponseWriter {
88    content: String,
89}
90
91impl StringResponseWriter {
92    fn new() -> Self {
93        Self {
94            content: String::new(),
95        }
96    }
97}
98
99impl ResponseWriter for StringResponseWriter {
100    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
101        self.content.push_str(content);
102        Ok(())
103    }
104
105    fn end(&mut self) -> webui_handler::Result<()> {
106        Ok(())
107    }
108}
109
110// ---------------------------------------------------------------------------
111// FFI: error reporting
112// ---------------------------------------------------------------------------
113
114/// Return the last error message, or `NULL` if no error has occurred.
115///
116/// The returned pointer is valid until the next FFI call **on the same thread**.
117/// Callers **must not** free the returned pointer.
118///
119/// # Thread Safety
120///
121/// Each thread has its own independent error state.
122#[no_mangle]
123pub extern "C" fn webui_last_error() -> *const c_char {
124    LAST_ERROR.with(|cell| {
125        let borrow = cell.borrow();
126        match borrow.as_ref() {
127            Some(c_str) => c_str.as_ptr(),
128            None => std::ptr::null(),
129        }
130    })
131}
132
133// ---------------------------------------------------------------------------
134// FFI: handler lifecycle
135// ---------------------------------------------------------------------------
136
137/// Create a new WebUI handler instance.
138///
139/// Returns an opaque pointer that must be passed to other `webui_handler_*`
140/// functions and eventually freed with [`webui_handler_destroy`].
141#[no_mangle]
142pub extern "C" fn webui_handler_create() -> *mut c_void {
143    let handler = WebUIHandler::new();
144    let context = Box::new(HandlerContext {
145        handler,
146        nonce: None,
147    });
148    Box::into_raw(context) as *mut c_void
149}
150
151/// Create a new WebUI handler instance with a named plugin.
152///
153/// # Arguments
154///
155/// * `plugin_id` - Null-terminated UTF-8 string identifying the plugin.
156///   `"fast"` and `"fast-v2"` select deprecated FAST 2 compatibility markers;
157///   `"fast-v3"` selects FAST 3 markers.
158///
159/// # Returns
160///
161/// An opaque pointer that must be freed with [`webui_handler_destroy`],
162/// or `NULL` on error (call [`webui_last_error`] for details).
163///
164/// # Safety
165///
166/// `plugin_id` must be a valid null-terminated UTF-8 string, or `NULL`.
167#[no_mangle]
168pub unsafe extern "C" fn webui_handler_create_with_plugin(plugin_id: *const c_char) -> *mut c_void {
169    clear_last_error();
170
171    let handler = if plugin_id.is_null() {
172        WebUIHandler::new()
173    } else {
174        match CStr::from_ptr(plugin_id).to_str() {
175            Ok("fast" | "fast-v2") => {
176                WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
177            }
178            Ok("fast-v3") => WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new())),
179            Ok("webui") => WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new())),
180            Ok(unknown) => {
181                set_last_error(format!(
182                    "unknown plugin: {unknown}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
183                ));
184                return std::ptr::null_mut();
185            }
186            Err(e) => {
187                set_last_error(format!("invalid UTF-8 in plugin_id: {e}"));
188                return std::ptr::null_mut();
189            }
190        }
191    };
192
193    let context = Box::new(HandlerContext {
194        handler,
195        nonce: None,
196    });
197    Box::into_raw(context) as *mut c_void
198}
199
200/// Destroy a WebUI handler instance.
201///
202/// # Safety
203///
204/// `handler_ptr` must be a valid pointer returned by [`webui_handler_create`],
205/// or `NULL` (in which case this function is a no-op).
206#[no_mangle]
207pub unsafe extern "C" fn webui_handler_destroy(handler_ptr: *mut c_void) {
208    if !handler_ptr.is_null() {
209        let _ = Box::from_raw(handler_ptr as *mut HandlerContext);
210    }
211}
212
213/// Set the CSP nonce for inline `<script>` tags on a handler instance.
214///
215/// When set, all subsequent renders via [`webui_handler_render`] will include
216/// `nonce="VALUE"` on inline script tags and emit a
217/// `<meta name="webui-nonce" content="VALUE">` tag in the `<head>`.
218///
219/// Pass `NULL` to clear a previously set nonce.
220///
221/// # Thread Safety
222///
223/// Handler instances are **not** thread-safe. Callers must serialize access
224/// to a single `handler_ptr` — do not call `set_nonce` concurrently with
225/// `render` or other operations on the same handler.
226///
227/// # Safety
228///
229/// * `handler_ptr` must be a valid pointer returned by [`webui_handler_create`].
230/// * `nonce` must be a valid null-terminated UTF-8 string, or `NULL`.
231/// * Caller must ensure exclusive access to `handler_ptr` (no concurrent calls).
232#[no_mangle]
233pub unsafe extern "C" fn webui_handler_set_nonce(handler_ptr: *mut c_void, nonce: *const c_char) {
234    clear_last_error();
235
236    if handler_ptr.is_null() {
237        set_last_error("handler_ptr is null");
238        return;
239    }
240
241    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
242        // SAFETY: caller guarantees handler_ptr is valid and exclusively owned.
243        let context = &mut *(handler_ptr as *mut HandlerContext);
244
245        if nonce.is_null() {
246            context.nonce = None;
247        } else {
248            // SAFETY: caller guarantees nonce is a valid null-terminated string.
249            match CStr::from_ptr(nonce).to_str() {
250                Ok(s) => context.nonce = Some(s.to_string()),
251                Err(e) => {
252                    set_last_error(format!("invalid UTF-8 in nonce: {e}"));
253                }
254            }
255        }
256    })) {
257        Ok(()) => {}
258        Err(_) => {
259            set_last_error("panic in webui_handler_set_nonce");
260        }
261    }
262}
263
264// ---------------------------------------------------------------------------
265// FFI: protobuf-based render (existing API, now with error reporting)
266// ---------------------------------------------------------------------------
267
268/// Render a WebUI protocol (protobuf binary) with JSON data.
269///
270/// # Arguments
271///
272/// * `handler_ptr`   - Pointer returned by [`webui_handler_create`].
273/// * `protocol_data` - Pointer to protobuf binary data.
274/// * `protocol_len`  - Length of the protobuf data in bytes.
275/// * `data_json`     - Null-terminated JSON string with the render state.
276/// * `entry_id`      - Null-terminated UTF-8 string for the entry fragment.
277/// * `request_path`  - Null-terminated UTF-8 string for the URL path to match
278///   routes against (e.g., `"/contacts/42"`).
279///
280/// # Returns
281///
282/// A pointer to a null-terminated UTF-8 string with the rendered HTML, or
283/// `NULL` on error.  The caller **must** free the returned string with
284/// [`webui_free`].  On error, call [`webui_last_error`] for details.
285///
286/// # Safety
287///
288/// * `handler_ptr` must be a valid pointer returned by [`webui_handler_create`].
289/// * `protocol_data` must point to `protocol_len` bytes of valid memory.
290/// * `data_json`, `entry_id`, and `request_path` must be valid null-terminated UTF-8 strings.
291#[no_mangle]
292pub unsafe extern "C" fn webui_handler_render(
293    handler_ptr: *mut c_void,
294    protocol_data: *const u8,
295    protocol_len: usize,
296    data_json: *const c_char,
297    entry_id: *const c_char,
298    request_path: *const c_char,
299) -> *mut c_char {
300    clear_last_error();
301
302    if handler_ptr.is_null()
303        || protocol_data.is_null()
304        || data_json.is_null()
305        || entry_id.is_null()
306        || request_path.is_null()
307    {
308        set_last_error("one or more required arguments are null");
309        return std::ptr::null_mut();
310    }
311
312    // SAFETY: caller guarantees handler_ptr is valid and exclusively owned.
313    let context = &*(handler_ptr as *const HandlerContext);
314
315    // SAFETY: caller guarantees protocol_data points to protocol_len valid bytes.
316    let protocol_bytes = std::slice::from_raw_parts(protocol_data, protocol_len);
317
318    // SAFETY: caller guarantees data_json is a valid null-terminated string.
319    let data_str = match CStr::from_ptr(data_json).to_str() {
320        Ok(s) => s,
321        Err(e) => {
322            set_last_error(format!("invalid UTF-8 in data_json: {e}"));
323            return std::ptr::null_mut();
324        }
325    };
326
327    // SAFETY: caller guarantees entry_id is a valid null-terminated string.
328    let entry_str = match CStr::from_ptr(entry_id).to_str() {
329        Ok(s) => s,
330        Err(e) => {
331            set_last_error(format!("invalid UTF-8 in entry_id: {e}"));
332            return std::ptr::null_mut();
333        }
334    };
335
336    // SAFETY: caller guarantees request_path is a valid null-terminated string.
337    let path_str = match CStr::from_ptr(request_path).to_str() {
338        Ok(s) => s,
339        Err(e) => {
340            set_last_error(format!("invalid UTF-8 in request_path: {e}"));
341            return std::ptr::null_mut();
342        }
343    };
344
345    // Parse protocol from protobuf binary data
346    let protocol = match WebUIProtocol::from_protobuf(protocol_bytes) {
347        Ok(p) => p,
348        Err(e) => {
349            set_last_error(format!("failed to parse protobuf protocol: {e}"));
350            return std::ptr::null_mut();
351        }
352    };
353
354    // Parse data JSON
355    let data: Value = match serde_json::from_str(data_str) {
356        Ok(d) => d,
357        Err(e) => {
358            set_last_error(format!("failed to parse data JSON: {e}"));
359            return std::ptr::null_mut();
360        }
361    };
362
363    // Render
364    let mut options = RenderOptions::new(entry_str, path_str);
365    if let Some(ref nonce) = context.nonce {
366        options = options.with_nonce(nonce);
367    }
368
369    let mut writer = StringResponseWriter::new();
370    match context
371        .handler
372        .render(&protocol, &data, &options, &mut writer)
373    {
374        Ok(_) => match CString::new(writer.content) {
375            Ok(s) => s.into_raw(),
376            Err(e) => {
377                set_last_error(format!("rendered output contains interior NUL byte: {e}"));
378                std::ptr::null_mut()
379            }
380        },
381        Err(e) => {
382            set_last_error(format!("render failed: {e}"));
383            std::ptr::null_mut()
384        }
385    }
386}
387
388// ---------------------------------------------------------------------------
389// FFI: HTML-based render (new high-level API)
390// ---------------------------------------------------------------------------
391
392/// Parse an HTML template and render it with JSON data in a single call.
393///
394/// This is the **recommended entry point** for Go, C#, and Python consumers.
395/// It eliminates the need for callers to deal with protobuf serialisation.
396///
397/// Requires the `parser` feature (enabled by default). When built without
398/// the `parser` feature, this function always returns `NULL` and sets an
399/// error via [`webui_last_error`].
400///
401/// # Arguments
402///
403/// * `html`      - Null-terminated UTF-8 string containing the HTML template.
404/// * `data_json` - Null-terminated UTF-8 JSON string with the render state.
405///
406/// # Returns
407///
408/// A pointer to a null-terminated UTF-8 string with the rendered HTML, or
409/// `NULL` on error.  The caller **must** free the returned string with
410/// [`webui_free`].  On error, call [`webui_last_error`] for details.
411///
412/// # Safety
413///
414/// Both `html` and `data_json` must be valid null-terminated UTF-8 strings.
415#[no_mangle]
416pub unsafe extern "C" fn webui_render(
417    html: *const c_char,
418    data_json: *const c_char,
419) -> *mut c_char {
420    clear_last_error();
421
422    if html.is_null() || data_json.is_null() {
423        set_last_error("html and data_json must not be null");
424        return std::ptr::null_mut();
425    }
426
427    #[cfg(not(feature = "parser"))]
428    {
429        let _ = (html, data_json);
430        set_last_error(
431            "webui_render requires the \"parser\" feature, which was not enabled at build time",
432        );
433        return std::ptr::null_mut();
434    }
435
436    #[cfg(feature = "parser")]
437    match std::panic::catch_unwind(|| webui_render_impl(html, data_json)) {
438        Ok(ptr) => ptr,
439        Err(_) => {
440            set_last_error("panic in webui_render");
441            std::ptr::null_mut()
442        }
443    }
444}
445
446/// Inner implementation of [`webui_render`], compiled only with the `parser` feature.
447#[cfg(feature = "parser")]
448unsafe fn webui_render_impl(html: *const c_char, data_json: *const c_char) -> *mut c_char {
449    // --- Extract C strings ---------------------------------------------------
450    // SAFETY: caller (webui_render) already verified non-null.
451    let html_str = match CStr::from_ptr(html).to_str() {
452        Ok(s) => s,
453        Err(e) => {
454            set_last_error(format!("invalid UTF-8 in html: {e}"));
455            return std::ptr::null_mut();
456        }
457    };
458
459    // SAFETY: caller (webui_render) already verified non-null.
460    let data_str = match CStr::from_ptr(data_json).to_str() {
461        Ok(s) => s,
462        Err(e) => {
463            set_last_error(format!("invalid UTF-8 in data_json: {e}"));
464            return std::ptr::null_mut();
465        }
466    };
467
468    // --- Parse HTML template into a WebUI protocol ---------------------------
469    let entry_key = "template";
470    let mut parser = HtmlParser::new();
471    if let Err(e) = parser.parse(entry_key, html_str) {
472        set_last_error(format!("HTML parse error: {e}"));
473        return std::ptr::null_mut();
474    }
475
476    let protocol = WebUIProtocol::new(parser.into_fragment_records());
477
478    // --- Parse JSON state ----------------------------------------------------
479    let data: Value = match serde_json::from_str(data_str) {
480        Ok(d) => d,
481        Err(e) => {
482            set_last_error(format!("failed to parse data JSON: {e}"));
483            return std::ptr::null_mut();
484        }
485    };
486
487    // --- Render --------------------------------------------------------------
488    let handler = WebUIHandler::new();
489    let mut writer = StringResponseWriter::new();
490
491    match handler.render(
492        &protocol,
493        &data,
494        &RenderOptions::new(entry_key, "/"),
495        &mut writer,
496    ) {
497        Ok(_) => match CString::new(writer.content) {
498            Ok(s) => s.into_raw(),
499            Err(e) => {
500                set_last_error(format!("rendered output contains interior NUL byte: {e}"));
501                std::ptr::null_mut()
502            }
503        },
504        Err(e) => {
505            set_last_error(format!("render failed: {e}"));
506            std::ptr::null_mut()
507        }
508    }
509}
510
511// ---------------------------------------------------------------------------
512// FFI: unified partial response
513// ---------------------------------------------------------------------------
514
515/// Produce a complete JSON partial response for client-side navigation.
516///
517/// Combines route templates, inventory, and matched route chain into a single
518/// JSON string: `{"templates":[...],"inventory":"...","chain":[...]}`.
519///
520/// # Arguments
521///
522/// * `protocol_data` - Pointer to protobuf binary data.
523/// * `protocol_len`  - Length of the protobuf data in bytes.
524/// * `entry_id`      - Null-terminated UTF-8 string for the persistent entry fragment.
525/// * `request_path`  - Null-terminated UTF-8 route path used to select the active route chain.
526/// * `inventory_hex` - Null-terminated hex string of the client's inventory bitmask
527///   (pass empty string `""` if no inventory).
528///
529/// # Returns
530///
531/// A heap-allocated JSON string, or `NULL` on error. Caller frees with [`webui_free`].
532///
533/// # Safety
534///
535/// * `protocol_data` must point to `protocol_len` bytes of valid memory.
536/// * `state_json`, `entry_id`, `request_path`, and `inventory_hex` must be valid
537///   null-terminated UTF-8 strings.
538#[no_mangle]
539pub unsafe extern "C" fn webui_render_partial(
540    protocol_data: *const u8,
541    protocol_len: usize,
542    state_json: *const c_char,
543    entry_id: *const c_char,
544    request_path: *const c_char,
545    inventory_hex: *const c_char,
546) -> *mut c_char {
547    clear_last_error();
548
549    match std::panic::catch_unwind(|| {
550        if protocol_data.is_null()
551            || state_json.is_null()
552            || entry_id.is_null()
553            || request_path.is_null()
554            || inventory_hex.is_null()
555        {
556            set_last_error("one or more required arguments are null");
557            return std::ptr::null_mut();
558        }
559
560        // SAFETY: The caller guarantees `protocol_data` points to `protocol_len` readable bytes.
561        let protocol_bytes = unsafe { std::slice::from_raw_parts(protocol_data, protocol_len) };
562
563        // SAFETY: The caller guarantees `state_json` is a valid null-terminated string.
564        let state_str = match unsafe { CStr::from_ptr(state_json) }.to_str() {
565            Ok(s) => s,
566            Err(e) => {
567                set_last_error(format!("invalid UTF-8 in state_json: {e}"));
568                return std::ptr::null_mut();
569            }
570        };
571
572        let state: Value = match serde_json::from_str(state_str) {
573            Ok(v) => v,
574            Err(e) => {
575                set_last_error(format!("invalid state JSON: {e}"));
576                return std::ptr::null_mut();
577            }
578        };
579
580        // SAFETY: The caller guarantees `entry_id` is a valid null-terminated string.
581        let entry_str = match unsafe { CStr::from_ptr(entry_id) }.to_str() {
582            Ok(s) => s,
583            Err(e) => {
584                set_last_error(format!("invalid UTF-8 in entry_id: {e}"));
585                return std::ptr::null_mut();
586            }
587        };
588
589        // SAFETY: The caller guarantees `request_path` is a valid null-terminated string.
590        let request_path_str = match unsafe { CStr::from_ptr(request_path) }.to_str() {
591            Ok(s) => s,
592            Err(e) => {
593                set_last_error(format!("invalid UTF-8 in request_path: {e}"));
594                return std::ptr::null_mut();
595            }
596        };
597
598        // SAFETY: The caller guarantees `inventory_hex` is a valid null-terminated string.
599        let inv_str = match unsafe { CStr::from_ptr(inventory_hex) }.to_str() {
600            Ok(s) => s,
601            Err(e) => {
602                set_last_error(format!("invalid UTF-8 in inventory_hex: {e}"));
603                return std::ptr::null_mut();
604            }
605        };
606
607        let protocol = match WebUIProtocol::from_protobuf(protocol_bytes) {
608            Ok(p) => p,
609            Err(e) => {
610                set_last_error(format!("failed to parse protobuf protocol: {e}"));
611                return std::ptr::null_mut();
612            }
613        };
614
615        // Per-request index — see ProtocolIndex doc for caching guidance.
616        let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
617
618        let mut result = match webui_handler::route_handler::render_partial(
619            &protocol,
620            entry_str,
621            request_path_str,
622            inv_str,
623            &mut index,
624        ) {
625            Ok(v) => v,
626            Err(e) => {
627                set_last_error(format!("render_partial failed: {e}"));
628                return std::ptr::null_mut();
629            }
630        };
631        if let Some(obj) = result.as_object_mut() {
632            obj.insert("state".into(), state);
633        }
634
635        match CString::new(result.to_string()) {
636            Ok(s) => s.into_raw(),
637            Err(e) => {
638                set_last_error(format!("JSON output contains interior NUL byte: {e}"));
639                std::ptr::null_mut()
640            }
641        }
642    }) {
643        Ok(ptr) => ptr,
644        Err(_) => {
645            set_last_error("panic in webui_render_partial");
646            std::ptr::null_mut()
647        }
648    }
649}
650
651/// Render templates and CSS for specific components by tag name.
652///
653/// Returns a JSON string with `{ templates, templateStyles, cssHrefs, inventory }`.
654/// The caller must free the returned string with [`webui_free`].
655///
656/// # Safety
657///
658/// All pointer arguments must be valid, non-null, null-terminated UTF-8 strings.
659/// `protocol_data` must point to `protocol_len` readable bytes.
660/// `component_tags_json` must be a JSON array of strings, e.g. `["settings-dialog"]`.
661#[no_mangle]
662pub unsafe extern "C" fn webui_render_component_templates(
663    protocol_data: *const u8,
664    protocol_len: usize,
665    component_tags_json: *const c_char,
666    inventory_hex: *const c_char,
667) -> *mut c_char {
668    clear_last_error();
669
670    match std::panic::catch_unwind(|| {
671        if protocol_data.is_null() || component_tags_json.is_null() || inventory_hex.is_null() {
672            set_last_error("one or more required arguments are null");
673            return std::ptr::null_mut();
674        }
675
676        let protocol_bytes = unsafe { std::slice::from_raw_parts(protocol_data, protocol_len) };
677
678        let tags_str = match unsafe { CStr::from_ptr(component_tags_json) }.to_str() {
679            Ok(s) => s,
680            Err(e) => {
681                set_last_error(format!("invalid UTF-8 in component_tags_json: {e}"));
682                return std::ptr::null_mut();
683            }
684        };
685
686        let tags: Vec<String> = match serde_json::from_str(tags_str) {
687            Ok(v) => v,
688            Err(e) => {
689                set_last_error(format!("invalid tags JSON: {e}"));
690                return std::ptr::null_mut();
691            }
692        };
693        let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
694
695        let inv_str = match unsafe { CStr::from_ptr(inventory_hex) }.to_str() {
696            Ok(s) => s,
697            Err(e) => {
698                set_last_error(format!("invalid UTF-8 in inventory_hex: {e}"));
699                return std::ptr::null_mut();
700            }
701        };
702
703        let protocol = match WebUIProtocol::from_protobuf(protocol_bytes) {
704            Ok(p) => p,
705            Err(e) => {
706                set_last_error(format!("failed to parse protobuf protocol: {e}"));
707                return std::ptr::null_mut();
708            }
709        };
710
711        // Per-request index — see ProtocolIndex doc for caching guidance.
712        let index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
713
714        let result = match webui_handler::route_handler::render_component_templates(
715            &protocol, &tag_refs, inv_str, &index,
716        ) {
717            Ok(v) => v,
718            Err(e) => {
719                set_last_error(format!("render_component_templates failed: {e}"));
720                return std::ptr::null_mut();
721            }
722        };
723
724        match CString::new(result.to_string()) {
725            Ok(s) => s.into_raw(),
726            Err(e) => {
727                set_last_error(format!("JSON output contains interior NUL byte: {e}"));
728                std::ptr::null_mut()
729            }
730        }
731    }) {
732        Ok(ptr) => ptr,
733        Err(_) => {
734            set_last_error("panic in webui_render_component_templates");
735            std::ptr::null_mut()
736        }
737    }
738}
739
740/// Free a string returned by a WebUI FFI function.
741///
742/// # Safety
743///
744/// `string_ptr` must be a pointer returned by a WebUI FFI function (e.g.
745/// [`webui_handler_render`] or [`webui_render`]), or `NULL`
746/// (in which case this function is a no-op).
747#[no_mangle]
748pub unsafe extern "C" fn webui_free(string_ptr: *mut c_char) {
749    if !string_ptr.is_null() {
750        let _ = CString::from_raw(string_ptr);
751    }
752}
753
754/// Extract the CSS token name list from a serialized WebUI protocol.
755///
756/// Returns a heap-allocated newline-delimited string of token names,
757/// e.g. `"colorBrandBackground\nfontSizeBase300"`.
758///
759/// Returns an empty string `""` when the protocol has no tokens.
760/// Returns `NULL` only on error (call [`webui_last_error`] for details).
761///
762/// The caller must free the returned string with [`webui_free`].
763///
764/// # Safety
765///
766/// * `protocol_data` must point to `protocol_len` valid bytes.
767/// * The returned pointer must be freed with [`webui_free`].
768#[no_mangle]
769pub unsafe extern "C" fn webui_protocol_tokens(
770    protocol_data: *const u8,
771    protocol_len: usize,
772) -> *mut c_char {
773    clear_last_error();
774
775    match std::panic::catch_unwind(|| {
776        if protocol_data.is_null() {
777            set_last_error("protocol_data is null");
778            return std::ptr::null_mut();
779        }
780
781        // SAFETY: caller guarantees protocol_data points to protocol_len readable bytes.
782        let proto_bytes = unsafe { std::slice::from_raw_parts(protocol_data, protocol_len) };
783
784        let protocol = match WebUIProtocol::from_protobuf(proto_bytes) {
785            Ok(p) => p,
786            Err(e) => {
787                set_last_error(format!("failed to parse protobuf: {e}"));
788                return std::ptr::null_mut();
789            }
790        };
791
792        // join() on an empty vec produces "", which is a valid success result.
793        let joined = protocol.tokens.join("\n");
794
795        match CString::new(joined) {
796            Ok(cs) => cs.into_raw(),
797            Err(e) => {
798                set_last_error(format!("token string contains null byte: {e}"));
799                std::ptr::null_mut()
800            }
801        }
802    }) {
803        Ok(ptr) => ptr,
804        Err(_) => {
805            set_last_error("panic in webui_protocol_tokens");
806            std::ptr::null_mut()
807        }
808    }
809}