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