kui_ffi/ext.rs
1//! Loading a C **extension**: a shared library that draws into a frame the
2//! host owns, keeps its own state, and gets its own events back.
3//!
4//! The mirror of the rest of this crate. There, C is the host and kui is the
5//! library it links; here a Rust host runs the window and the C library is
6//! the guest, sharing one frame with the host's own view - the same deal
7//! `kui_lua::LuaExtension` gets, over the same [`Extension`] trait, with a
8//! `.so` in place of a script.
9//!
10//! # The plugin's side
11//!
12//! Seven symbols, two of them required - `kui_ext_abi` and `kui_ext_view` -
13//! and five optional (see `include/kui.h`):
14//!
15//! ```c
16//! uint32_t kui_ext_abi(void); /* required: KUI_ABI_VERSION */
17//! const char *kui_ext_name(void); /* else: file stem */
18//! void *kui_ext_init(void); /* else: NULL */
19//! const KuiStr *kui_ext_slots(size_t *count); /* else: "root" */
20//! void kui_ext_view(void *user, KuiCtx *ctx); /* required */
21//! void kui_ext_on_event(void *user, const KuiEvent *ev);
22//! void kui_ext_free(void *user);
23//! ```
24//!
25//! `kui_ext_abi` is required rather than merely checked when present: the
26//! plugin most likely to lack it is one built against a header from before
27//! the symbol existed, which is exactly the mismatched plugin (older
28//! plugin, newer host) the check exists to refuse. Absence is refused the
29//! way a mismatch is, before anything else is looked up.
30//!
31//! `kui_ext_view` receives a context borrowing the host's frame and calls the
32//! ordinary `kui_open`/`kui_text`/`kui_close` builders on it. Everything it
33//! opens is tagged with the origin the runner assigned this extension, which
34//! is what routes its clicks back to `kui_ext_on_event` and keeps the host
35//! from ever seeing them. The context is alive for that one call: nothing
36//! inside it may be stored across frames, and the input and draw entry points
37//! do not apply to it - the runner drives those.
38//!
39//! # Where the `kui_*` symbols come from
40//!
41//! On ELF and Mach-O the plugin links against nothing. It leaves the whole
42//! API undefined and resolves it from the host executable at `dlopen` time,
43//! exactly as a Lua C module resolves `lua_*` from the interpreter that
44//! loaded it. That costs the host one linker flag - `--export-dynamic`,
45//! without which its symbols are in the binary but not in the dynamic
46//! symbol table the loader reads - and this crate's `build.rs` passes it
47//! for the examples here. A host outside this crate passes its own;
48//! `cargo run -p kui-devtools --bin cbuild` builds the plugin side.
49//!
50//! Windows cannot work that way: a DLL may not leave an import unresolved,
51//! so the plugin names the module each `kui_*` comes from and takes that
52//! name from an import library. Two shapes, and both are fine:
53//!
54//! * against the **host's** import library, which is what `build.rs` makes
55//! for the examples here by exporting the host's `kui_*`. One copy of the
56//! library in the process, as on ELF, and the plugin loads into that host
57//! and no other.
58//! * against **`kui_ffi.dll`**, which is the ordinary Windows plugin shape
59//! (a Python extension imports from `python313.dll`, not from
60//! `python.exe`) and gives a plugin binary that loads into any host
61//! shipping that DLL.
62//!
63//! The second used to be broken, and the fix is what ABI 10 is: two copies
64//! of this library in one process is fine for everything that travels
65//! through a pointer the host hands over - a `KuiCtx` is a `KuiCtx` - and
66//! was fine for nothing that lived in a `static`. `kui_reply` was the only
67//! such thing, and its sink now rides on the event as a function pointer
68//! into whichever copy opened it (see [`crate::KuiReplySink`]). The loader
69//! below is the same either way.
70
71use std::ffi::{CStr, CString, c_char, c_void};
72use std::path::Path;
73
74use kui_core::{Extension, Slot, Ui, UiEvent, Value};
75
76use crate::convert::kstr;
77use crate::{KUI_ABI_VERSION, KuiCtx, KuiEvent, KuiStr, KuiValue};
78
79type ViewFn = extern "C" fn(*mut c_void, *mut KuiCtx);
80type EventFn = extern "C" fn(*mut c_void, *const KuiEvent);
81
82/// A C shared library loaded as a guest extension of a Rust host.
83///
84/// The plugin exports `kui_ext_abi` and `kui_ext_view` (and optionally
85/// `kui_ext_name`, `kui_ext_init`, `kui_ext_slots`, `kui_ext_on_event` and
86/// `kui_ext_free`; `include/kui.h` has the prototypes). Each frame the
87/// host's `Ui` reaches a slot the plugin fills, `kui_ext_view` is called
88/// with a context borrowing that frame and builds into it with the
89/// ordinary `kui_open` / `kui_text` / `kui_close` calls; events from the
90/// nodes it built go to `kui_ext_on_event`, never to the host, and what
91/// it answers with `kui_reply` reaches the host with the plugin's origin.
92///
93/// It implements [`Extension`], so it plugs into `kui_native::app(..)
94/// .extension_as(namespace, ext)` or a `kui_core::Extensions` list like
95/// any other extension. On ELF and Mach-O the plugin links against nothing
96/// and resolves `kui_*` from the host executable at load, which needs the
97/// host linked with `--export-dynamic` (this crate's `build.rs` does it
98/// for the examples); on Windows the plugin links against the host's
99/// import library or against `kui_ffi.dll`.
100///
101/// ```rust,no_run
102/// use kui_ffi::CExtension;
103///
104/// // SAFETY: the plugin's code runs in this process; loading it is
105/// // trusting it as much as linking it would be.
106/// let ext = unsafe { CExtension::open("target/debug/panel.so") }?;
107/// let launcher = kui_native::app("host").extension_as("todos", ext);
108/// # let _ = launcher;
109/// # Ok::<(), String>(())
110/// ```
111pub struct CExtension {
112 name: String,
113 handle: *mut c_void,
114 /// Whatever `kui_ext_init` returned, handed back to every entry point.
115 user: *mut c_void,
116 view: ViewFn,
117 on_event: Option<EventFn>,
118 free: Option<extern "C" fn(*mut c_void)>,
119 /// What `kui_ext_slots` returned at load, copied out: the slot names
120 /// this plugin fills. Empty means `"root"`.
121 slots: Vec<String>,
122}
123
124impl CExtension {
125 /// Loads the shared library at `path` and runs its `kui_ext_init`.
126 ///
127 /// Fails, with the reason as the error, if the library will not load,
128 /// if it declares no `kui_ext_abi` or one other than this build's
129 /// [`KUI_ABI_VERSION`], or if it has no `kui_ext_view`. The plugin's
130 /// `kui_ext_free` runs and the library is unloaded when the
131 /// `CExtension` is dropped.
132 ///
133 /// # Safety
134 /// The library's entry points are called on the host's frame and its
135 /// code runs in the host's process: loading one is trusting it exactly
136 /// as much as linking it would be.
137 pub unsafe fn open(path: impl AsRef<Path>) -> Result<Self, String> {
138 let path = path.as_ref();
139 let cpath = sys::path_arg(path)
140 .ok_or_else(|| format!("{}: path contains a NUL", path.display()))?;
141
142 let handle = unsafe { sys::load(&cpath) };
143 if handle.is_null() {
144 return Err(format!("{}: {}", path.display(), sys::last_error()));
145 }
146
147 // A plugin built against a header this build has outgrown reads the
148 // structs it writes at the wrong offsets. Same check `include/kui.h`
149 // asks a C host to make, made for it - and a plugin with no
150 // `kui_ext_abi` at all is that case, not a lenient one: the header
151 // that predates the symbol is a header this build has outgrown.
152 let mut ext = Self {
153 name: String::new(),
154 handle,
155 user: std::ptr::null_mut(),
156 // Placeholder: replaced below, before anything can call it.
157 view: placeholder_view,
158 on_event: None,
159 free: None,
160 slots: Vec::new(),
161 };
162 let Some(abi) = (unsafe { ext.sym::<extern "C" fn() -> u32>("kui_ext_abi") }) else {
163 return Err(format!(
164 "{}: plugin declares no ABI; this build is {KUI_ABI_VERSION}",
165 path.display()
166 ));
167 };
168 let claimed = abi();
169 if claimed != KUI_ABI_VERSION {
170 return Err(format!(
171 "{}: plugin is ABI {claimed}, this build is {KUI_ABI_VERSION}",
172 path.display()
173 ));
174 }
175
176 let Some(view) = (unsafe { ext.sym::<ViewFn>("kui_ext_view") }) else {
177 return Err(format!("{}: no kui_ext_view", path.display()));
178 };
179 ext.view = view;
180 ext.on_event = unsafe { ext.sym::<EventFn>("kui_ext_on_event") };
181 ext.free = unsafe { ext.sym::<extern "C" fn(*mut c_void)>("kui_ext_free") };
182
183 // The slots it fills, read once: the plugin keeps the array alive
184 // for its own lifetime, and the names are copied out so nothing
185 // here reads it again.
186 if let Some(slots) =
187 unsafe { ext.sym::<extern "C" fn(*mut usize) -> *const KuiStr>("kui_ext_slots") }
188 {
189 let mut count = 0usize;
190 let p = slots(&mut count);
191 if !p.is_null() {
192 let names = unsafe { std::slice::from_raw_parts(p, count) };
193 ext.slots = names.iter().map(|s| kstr(*s).into_owned()).collect();
194 }
195 }
196
197 ext.name = match unsafe { ext.sym::<extern "C" fn() -> *const c_char>("kui_ext_name") } {
198 Some(f) => {
199 let p = f();
200 if p.is_null() {
201 String::new()
202 } else {
203 unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
204 }
205 }
206 None => String::new(),
207 };
208 if ext.name.is_empty() {
209 ext.name = path.file_stem().map_or_else(
210 || path.display().to_string(),
211 |s| s.to_string_lossy().into_owned(),
212 );
213 }
214
215 // Last, so a plugin that allocates in init only does so once every
216 // other check has passed and `free` is already wired up to undo it.
217 if let Some(init) = unsafe { ext.sym::<extern "C" fn() -> *mut c_void>("kui_ext_init") } {
218 ext.user = init();
219 }
220 Ok(ext)
221 }
222
223 /// # Safety
224 /// `T` must be the signature the plugin defines the symbol with.
225 unsafe fn sym<T>(&self, name: &str) -> Option<T> {
226 debug_assert_eq!(size_of::<T>(), size_of::<*mut c_void>());
227 let cname = CString::new(name).ok()?;
228 let p = unsafe { sys::symbol(self.handle, &cname) };
229 // Transmuting a data pointer to a function pointer is not something
230 // Rust sanctions, but it is what dlsym is for.
231 (!p.is_null()).then(|| unsafe { std::mem::transmute_copy::<*mut c_void, T>(&p) })
232 }
233}
234
235extern "C" fn placeholder_view(_user: *mut c_void, _ctx: *mut KuiCtx) {}
236
237/// The three calls that differ per platform. Everything above is the same
238/// on both.
239#[cfg(unix)]
240mod sys {
241 use super::{CStr, CString, c_char, c_void};
242
243 unsafe extern "C" {
244 fn dlopen(path: *const c_char, flags: i32) -> *mut c_void;
245 fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
246 fn dlclose(handle: *mut c_void) -> i32;
247 fn dlerror() -> *const c_char;
248 }
249
250 /// Resolve everything now, so a plugin missing a `kui_*` symbol fails at
251 /// load with a name in the message instead of at the first frame that
252 /// reaches it.
253 const RTLD_NOW: i32 = 2;
254 /// Keep the plugin's own symbols out of the global namespace: two plugins
255 /// both defining `kui_ext_view` are the normal case, not a collision.
256 ///
257 /// Not a shared number, and the mistake is quiet: 4 is `RTLD_LOCAL` on
258 /// Apple's dyld, where it has to be passed because the default there is
259 /// `RTLD_GLOBAL`, and `RTLD_NOLOAD` on glibc, where local is already the
260 /// default. Passing Apple's value on Linux asks for a handle only if the
261 /// library is *already* loaded, which it is not, so every load fails.
262 #[cfg(target_vendor = "apple")]
263 const RTLD_LOCAL: i32 = 4;
264 #[cfg(not(target_vendor = "apple"))]
265 const RTLD_LOCAL: i32 = 0;
266
267 /// What [`path_arg`] produces and [`load`] takes: the platform's own
268 /// spelling of a path, since the two do not agree on one.
269 pub type PathArg = CString;
270
271 /// # Safety
272 /// Runs the library's initializers.
273 pub unsafe fn load(path: &PathArg) -> *mut c_void {
274 // Clear any stale error first: dlerror() is only meaningful right
275 // after a failed dl* call, and a previous one's message lingers.
276 unsafe { dlerror() };
277 unsafe { dlopen(path.as_ptr(), RTLD_NOW | RTLD_LOCAL) }
278 }
279
280 /// # Safety
281 /// `handle` must come from [`load`] and still be open.
282 pub unsafe fn symbol(handle: *mut c_void, name: &CStr) -> *mut c_void {
283 unsafe { dlsym(handle, name.as_ptr()) }
284 }
285
286 /// # Safety
287 /// Nothing may call into the library afterwards.
288 pub unsafe fn unload(handle: *mut c_void) {
289 unsafe { dlclose(handle) };
290 }
291
292 pub fn last_error() -> String {
293 let e = unsafe { dlerror() };
294 if e.is_null() {
295 "unknown error".into()
296 } else {
297 unsafe { CStr::from_ptr(e) }.to_string_lossy().into_owned()
298 }
299 }
300
301 /// The path as `dlopen` wants it. A path with an interior NUL cannot be
302 /// spelled and is refused here rather than truncated.
303 pub fn path_arg(path: &std::path::Path) -> Option<PathArg> {
304 CString::new(path.as_os_str().as_encoded_bytes()).ok()
305 }
306}
307
308#[cfg(windows)]
309mod sys {
310 use super::{CStr, c_char, c_void};
311
312 #[link(name = "kernel32")]
313 unsafe extern "system" {
314 fn LoadLibraryExW(path: *const u16, file: *mut c_void, flags: u32) -> *mut c_void;
315 fn GetProcAddress(handle: *mut c_void, name: *const c_char) -> *mut c_void;
316 fn FreeLibrary(handle: *mut c_void) -> i32;
317 fn GetLastError() -> u32;
318 }
319
320 /// What [`path_arg`] produces and [`load`] takes: `LoadLibraryW`'s own
321 /// UTF-16, NUL-terminated.
322 ///
323 /// It used to be a `CString` with the code units packed into its bytes,
324 /// which could never have worked: every ASCII character puts a zero
325 /// byte in its pair, so `CString::new` refused every path there has
326 /// ever been and no plugin could load on Windows at all. Found by
327 /// running the workspace tests on Windows for the first time on
328 /// 2026-09-08 — `ext.rs`'s own test says so now on the platform, and
329 /// said "path contains a NUL" for `kernel32.dll`.
330 pub type PathArg = Vec<u16>;
331
332 /// `LOAD_WITH_ALTERED_SEARCH_PATH`: search the *plugin's* own directory
333 /// for the DLLs it imports, before the process directory and PATH.
334 ///
335 /// Without it a plugin is looked up from `node.exe`'s directory or the
336 /// host exe's, which is not where a plugin's siblings live — and on
337 /// Windows a plugin does import something, since a DLL may not leave a
338 /// symbol undefined. A panel next to the `kui_ffi.dll` it was linked
339 /// against failed to load with error 126 until this flag, which is how
340 /// it was found. It only applies to an absolute path, which is why
341 /// [`CExtension::open`] makes one.
342 const LOAD_WITH_ALTERED_SEARCH_PATH: u32 = 0x8;
343
344 /// # Safety
345 /// Runs the library's initializers.
346 pub unsafe fn load(path: &PathArg) -> *mut c_void {
347 unsafe {
348 LoadLibraryExW(
349 path.as_ptr(),
350 std::ptr::null_mut(),
351 LOAD_WITH_ALTERED_SEARCH_PATH,
352 )
353 }
354 }
355
356 /// # Safety
357 /// `handle` must come from [`load`] and still be open.
358 pub unsafe fn symbol(handle: *mut c_void, name: &CStr) -> *mut c_void {
359 unsafe { GetProcAddress(handle, name.as_ptr()) }
360 }
361
362 /// # Safety
363 /// Nothing may call into the library afterwards.
364 pub unsafe fn unload(handle: *mut c_void) {
365 unsafe { FreeLibrary(handle) };
366 }
367
368 pub fn last_error() -> String {
369 format!("LoadLibraryW failed (GetLastError {})", unsafe {
370 GetLastError()
371 })
372 }
373
374 /// The path as `LoadLibraryW` wants it: UTF-16 with a terminator, and
375 /// absolute, because [`LOAD_WITH_ALTERED_SEARCH_PATH`] is ignored for a
376 /// relative one and that flag is what lets a plugin find the DLLs it
377 /// was linked beside. A path that does not resolve keeps its spelling —
378 /// a bare `kernel32.dll` is a name for the loader to search, not a file
379 /// in the working directory, and canonicalizing it would be wrong.
380 /// Only here: on unix a bare `libc.so.6` is a name for `dlopen` to
381 /// search too, and resolving it against the working directory first
382 /// could load a different library than the one meant.
383 ///
384 /// A path with an interior NUL is refused, since `LoadLibraryW` would
385 /// stop there and open something the caller did not name.
386 pub fn path_arg(path: &std::path::Path) -> Option<PathArg> {
387 use std::os::windows::ffi::OsStrExt;
388 let abs = std::fs::canonicalize(path);
389 let path = abs.as_deref().unwrap_or(path);
390 let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
391 if wide.contains(&0) {
392 return None;
393 }
394 wide.push(0);
395 Some(wide)
396 }
397}
398
399impl Extension for CExtension {
400 fn name(&self) -> &str {
401 &self.name
402 }
403
404 fn slots(&self) -> &[String] {
405 &self.slots
406 }
407
408 fn view(&mut self, slot: &Slot<'_>, ui: &mut Ui<'_>) -> Result<(), String> {
409 // Borrows the host's frame for this call only - the plugin builds
410 // into the same tree the host just built into, under its own origin.
411 // Which slot, and with what, rides on the context: `kui_slot_name`
412 // and `kui_slot_params` read it back, borrowed for the call like an
413 // event's payload (one clone of the params per fill, the C side's
414 // cost, which is what `on_event`'s payload already pays).
415 // `borrowing_in` rather than `borrowing`, for the same reason the
416 // runner's host callback uses it: a plugin's `kui_slot` reaches
417 // the frame's filler through this pointer, so a plugin may declare
418 // the slot of an extension the host loaded, exactly as a Lua
419 // script's `fill` does (`kui_core::slot`). It cannot load one
420 // itself — a plugin's context has no list of its own — so what it
421 // declares is a name somebody above it already knows.
422 let mut ctx = KuiCtx::borrowing_in(ui);
423 ctx.slot_name = Some(slot.name.to_owned());
424 ctx.slot_namespace = Some(slot.namespace.to_owned());
425 ctx.slot_params =
426 (!matches!(slot.params, Value::Null)).then(|| KuiValue(slot.params.clone()));
427 (self.view)(self.user, &mut ctx);
428 Ok(())
429 }
430
431 fn on_event(&mut self, ev: &UiEvent) -> Vec<Value> {
432 let Some(cb) = self.on_event else {
433 return Vec::new();
434 };
435 // Borrowed for the duration of the callback, like every other
436 // payload C sees.
437 let payload = KuiValue(ev.payload.clone());
438 let mut out = KuiEvent {
439 origin: ev.origin.0,
440 key: ev.key.0,
441 payload: &payload,
442 window: ev.window.0,
443 slot: ev.slot.map_or(0, |k| k.0),
444 ..Default::default()
445 };
446 // Replies: the plugin calls `kui_reply(ev, value)` during the
447 // callback, as often as it likes, and the sink open around the
448 // call collects them for the host.
449 crate::slots::collect_replies(&mut out, |ev| cb(self.user, ev))
450 }
451}
452
453impl Drop for CExtension {
454 fn drop(&mut self) {
455 if let Some(free) = self.free {
456 free(self.user);
457 }
458 // After `free`, so the plugin's own code is still mapped when it runs.
459 unsafe { sys::unload(self.handle) };
460 }
461}
462
463#[cfg(test)]
464pub(crate) mod tests {
465 use super::*;
466
467 /// A shared library that loads on every target this crate builds for
468 /// and defines no `kui_ext_*` symbol at all: the platform's own C
469 /// runtime. That is the shape of a plugin built against a header from
470 /// before `kui_ext_abi` existed - the case the check exists to
471 /// refuse - reached without a C compiler in the test. The real mutant,
472 /// `examples/c/features/slots/panel.c` with its `kui_ext_abi` line deleted, is built by
473 /// `cbuild` and driven through `c_panel --headless` in CI.
474 pub(crate) fn a_library_with_no_kui_symbols() -> &'static str {
475 if cfg!(target_vendor = "apple") {
476 // Not a file on disk since the dyld shared cache, but dlopen by
477 // this path resolves it from the cache.
478 "/usr/lib/libSystem.B.dylib"
479 } else if cfg!(windows) {
480 "kernel32.dll"
481 } else if cfg!(target_env = "musl") {
482 "libc.so"
483 } else {
484 "libc.so.6"
485 }
486 }
487
488 #[test]
489 fn a_plugin_without_kui_ext_abi_is_refused_before_anything_else() {
490 let path = a_library_with_no_kui_symbols();
491 // SAFETY: the library is the process's own C runtime, already loaded.
492 let err = match unsafe { CExtension::open(path) } {
493 Ok(ext) => panic!("{path} loaded as a plugin named {:?}", ext.name()),
494 Err(err) => err,
495 };
496 // The whole message, not a substring: a library with no kui symbols
497 // also has no `kui_ext_view`, and the ABI refusal has to be the one
498 // that wins - it is the check the header's own text promises.
499 assert_eq!(
500 err,
501 format!("{path}: plugin declares no ABI; this build is {KUI_ABI_VERSION}")
502 );
503 }
504}