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/// Refer to the CLI/crate documentation for the current list of supported
157/// identifiers.
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 mut 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, &mut 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}