rpi_extensions/lib.rs
1//! Rust-native (cdylib) plugin loader + `AgentTool` adapter for rpi.
2//!
3//! This is Part B1 of the extension-alignment plan. `rpi` loads extensions as
4//! **compiled Rust cdylibs** (`.dll`/`.so`/`.dylib`) via `libloading` — **not**
5//! TS/jiti — because we control both sides and the plugin is Rust
6//! (`动态加载可以加载rs 的代码 没必要是 ts`). The ABI contract lives in
7//! [`rpi_plugin_sdk`]; this crate is the **host side** that loads plugins and
8//! bridges their tools/events into rpi's native async types.
9//!
10//! ## Crate DAG position
11//!
12//! Depends on **`rpi-plugin-sdk` + `rpi-ai` + `rpi-agent` only** (NOT
13//! `rpi-harness`). The harness consumes this crate's adapters as trait objects
14//! via `AgentHarnessOptions` injection, so `rpi-harness` never imports
15//! `rpi-extensions` — cycle-free (verified by adversarial review of the first
16//! draft, which wrongly added a `rpi-harness` dep).
17//!
18//! ## The async-across-ABI bridge (load-bearing — corrected vs first draft)
19//!
20//! A plugin tool drives an execution through **four** plugin-exported fns
21//! (`execute`→handle, `poll`, `cancel`, `destroy`) — see
22//! [`rpi_plugin_sdk::ToolExecuteFn`] etc. The host's [`PluginToolAdapter`]
23//! impls `AgentTool::execute` by:
24//!
25//! 1. **NOT owning a runtime.** Acquire the ambient runtime
26//! (`tokio::runtime::Handle::try_current()`) — the adapter only ever runs
27//! inside the agent loop's runtime.
28//! 2. Set up an **unbounded mpsc** for `ToolResultPartial` (async side drains +
29//! forwards to `on_update`) and a **oneshot** for the terminal
30//! `AgentToolResult`.
31//! 3. `handle.spawn_blocking(move || drive(plugin, cancel_flag, partial_tx,
32//! done_tx))` — the blocking driver loops `poll` until `Done`/`Err`,
33//! forwarding `Pending` partials through the mpsc (via a `catch_unwind`
34//! trampoline so a panicking partial callback can't unwind across FFI),
35//! then sends the terminal result + calls `destroy` **exactly once** on exit.
36//! 4. The async future `select!`s between the oneshot (terminal) and
37//! `signal.cancelled()` (the child token). On cancel: **set an `AtomicBool`
38//! cancel flag (SeqCst)** so the blocking driver observes it; **keep
39//! awaiting the oneshot** (never drop the driver — `spawn_blocking` tasks
40//! run to completion regardless of outer-future drop, so dropping is a
41//! thread leak).
42//! 5. `Drop`/drop-guard sets **only the cancel flag** — never calls `destroy`
43//! synchronously (the driver may be mid-`poll`); `destroy` is called exactly
44//! once by the blocking driver.
45//!
46//! `cancel` ≠ `destroy`: the first draft conflated them → UAF/double-free. Here
47//! `cancel` is an idempotent thread-safe flag-set; `destroy` is the single
48//! free, owned by the driver.
49//!
50//! ## Events
51//!
52//! [`ExtensionEmitter`] impls `AgentEmitter` by subscribing to the host's
53//! `broadcast::Sender<AgentEvent>`, translating each `AgentEvent` → a
54//! [`rpi_plugin_sdk::StablePluginEvent`], and dispatching to every registered
55//! handler for the event's tag — all dispatch wrapped in `catch_unwind`. The
56//! 33-category `on()` surface (B3) is driven through this emitter; the
57//! 10 already-emitted `AgentEvent` variants fold into the matching tags now,
58//! and the remaining tags light up as B3/B4/B5 add the emission points.
59
60use std::ffi::c_void;
61use std::sync::{Arc, Mutex};
62
63use rpi_plugin_sdk::{
64 EventHandlerFn, EventTag, FreeStringFn, LegacyPluginApiV1, LegacyRuntimeActionFn, PluginApiVt,
65 ProviderRequestFn, RenderFn, ResourcesDiscoverFn, RuntimeActionFn, StablePluginEvent,
66 StableToolSchema, StbString, StbStringRef, ToolCancelFn, ToolDestroyFn, ToolExecuteFn,
67 ToolPollFn,
68};
69use thiserror::Error;
70
71pub use actions::{
72 reload_callback_from_mailbox, trampoline_runtime_action, trampoline_runtime_action_v1,
73 ActionBridge, ReloadMailbox, RuntimeActionHost,
74};
75pub use loader::{
76 load_dir, load_one, load_session, load_session_mixed, merge_registries, ExtensionSession,
77 LoadedPlugin, PluginKeepalive, PluginLoadError,
78};
79pub use provider::PluggableProvider;
80pub use provider_hooks::ExtensionProviderHooks;
81pub use registry::{
82 assert_active, ExtensionRegistry, ExtensionTool, RegisteredFlag, RegisteredHandler,
83 RegisteredProvider, RegisteredRenderer, RegisteredRendererKind, RegistryEntry,
84 RegistrySnapshot, ResourcesDiscoverHandler,
85};
86pub use resources::{emit_resources_discover, DiscoveredResources};
87pub use tool::{PluginToolAdapter, PluginToolHandle};
88pub use translate::{ExtensionEmitter, TeeEmitter};
89
90mod actions;
91mod loader;
92mod provider;
93mod provider_hooks;
94mod registry;
95mod resources;
96mod tool;
97mod translate;
98
99/// A diagnostics/event sink the host wires so the loader + adapter can report
100/// plugin-load skips, ABI mismatches, panics caught at the FFI boundary, etc.
101/// Mirrors the `diagnostic` channel pi surfaces for extensions.
102pub trait PluginDiagnostics: Send + Sync {
103 /// A non-fatal warning (e.g. "skipped plugin foo: ABI version mismatch").
104 fn warn(&self, message: &str);
105 /// A plugin that registered something but a registration slot is not yet
106 /// wired on the host (e.g. an event tag the host does not emit yet).
107 fn unsupported(&self, message: &str);
108}
109
110/// A no-op diagnostics sink (the default when the host does not supply one).
111#[derive(Default)]
112pub struct NullDiagnostics;
113
114impl PluginDiagnostics for NullDiagnostics {
115 fn warn(&self, _message: &str) {}
116 fn unsupported(&self, _message: &str) {}
117}
118
119/// Error returned by [`PluginToolAdapter::execute`] when the plugin side failed
120/// (terminal `Err` from `poll`, or the drive handle was never produced).
121#[derive(Debug, Error)]
122pub enum PluginToolError {
123 #[error("plugin execute returned a null handle (allocation failure)")]
124 NullHandle,
125 #[error("plugin error: {0}")]
126 Plugin(String),
127 #[error("runtime unavailable: {0}")]
128 NoRuntime(String),
129}
130
131/// The host's `free_string` for [`StbString`]s the host *produces* and hands to
132/// the plugin (event payloads, action outputs, execute params when the host
133/// owns them). The plugin frees what it *receives* via this; the host frees
134/// what it *receives* via the plugin's `free_string` (stored per-tool).
135///
136/// Reconstructs the `Box<[u8]>` from ptr+len and drops it. Idempotent on
137/// empty/null.
138pub extern "C" fn host_free_string(s: StbString) {
139 if s.is_empty() || s.ptr.is_null() {
140 return;
141 }
142 // SAFETY: the host produced this StbString via `StbString::from_owned`/
143 // `from_string` (a `Box<[u8]>`), so ptr+len reconstruct the same allocation.
144 unsafe {
145 let slice = std::slice::from_raw_parts(s.ptr as *const u8, s.len);
146 let _ = Box::from_raw(slice as *const [u8] as *mut [u8]);
147 }
148}
149
150// ===========================================================================
151// HostApi — the PluginApiVt the host builds and hands to each plugin's register
152// ===========================================================================
153
154/// The host state a [`PluginApiVt`] closes over. Held behind `Arc` so the fn
155/// pointers (which are `extern "C"`,不好做闭包) can recover the host state via
156/// the `user_data` slot — but since `extern "C" fn` cannot capture, the host
157/// stores per-registration receiver state in the [`HostApi`] itself keyed by
158/// nothing (single registry per host), and the fns are thin trampolines that
159/// read a process-global-attached registry. In v1 we keep it simple: the host
160/// builds one `HostApi` per load session; the `register_*` trampolines forward
161/// into it.
162///
163/// This struct is `Send + Sync` (registry is `Mutex`-guarded).
164pub struct HostApi {
165 registry: Mutex<Option<ExtensionRegistry>>,
166 diagnostics: Arc<dyn PluginDiagnostics>,
167 /// B5a: the plugin→host action bridge, carried in
168 /// [`PluginApiVt::user_data`] so [`trampoline_runtime_action`] can recover
169 /// the harness state from ANY thread a plugin calls from (post-register, no
170 /// thread-local). `None` keeps the no-bridge stub `runtime_action` and the
171 /// register `user_data` (the `HostApi` pointer) — so older call sites that
172 /// don't pass a bridge behave exactly as before.
173 action_bridge: Option<Arc<ActionBridge>>,
174}
175
176impl HostApi {
177 /// Build a fresh host API bound to the given registry + diagnostics. With
178 /// no action bridge, `runtime_action` stays the stub returning `-1`.
179 pub fn new(registry: ExtensionRegistry, diagnostics: Arc<dyn PluginDiagnostics>) -> Arc<Self> {
180 Arc::new(Self {
181 registry: Mutex::new(Some(registry)),
182 diagnostics,
183 action_bridge: None,
184 })
185 }
186
187 /// B5a: build a host API that wires the real [`trampoline_runtime_action`]
188 /// via the bridge. The bridge is cloned into every plugin's vtable
189 /// `user_data` so post-register `runtime_action` calls recover it on any
190 /// thread. `rpi-cli` keeps one master `Arc<ActionBridge>` per session; the
191 /// `HostApi` here holds a clone only for the duration of `load_one` (it is
192 /// dropped after `take_registry`, but the master arc in `rpi-cli` keeps the
193 /// pointer valid).
194 pub fn with_action_bridge(
195 registry: ExtensionRegistry,
196 diagnostics: Arc<dyn PluginDiagnostics>,
197 action_bridge: Arc<ActionBridge>,
198 ) -> Arc<Self> {
199 Arc::new(Self {
200 registry: Mutex::new(Some(registry)),
201 diagnostics,
202 action_bridge: Some(action_bridge),
203 })
204 }
205
206 fn with_registry<R>(&self, f: impl FnOnce(&mut ExtensionRegistry) -> R) -> Option<R> {
207 let mut guard = self.registry.lock().expect("host api registry lock");
208 guard.as_mut().map(f)
209 }
210
211 /// Detach the registry (call after registration completes so the host can
212 /// move it out for use). The `Mutex<Option<...>>` is left `None`.
213 pub fn take_registry(&self) -> Option<ExtensionRegistry> {
214 self.registry.lock().expect("host api registry lock").take()
215 }
216
217 /// Build the ABI v2 C vtable passed to `rpi_plugin_register_v2`.
218 ///
219 /// Every optional registrar slot currently resolves to a real `extern "C"`
220 /// trampoline that forwards into `self`'s registry (so a plugin that calls
221 /// `register_tool` / `register_command` / renderer registration now sees
222 /// its registration land). `runtime_action` resolves
223 /// to the real [`trampoline_runtime_action`] when a bridge is present (B5a),
224 /// else the stub returning `-1`.
225 ///
226 /// **`user_data`**: the register trampolines recover host state via the
227 /// thread-local `CURRENT_HOST_API` (set for the duration of register in
228 /// `load_one`) — they ignore `user_data`. Post-register `runtime_action`
229 /// cannot use the thread-local (plugin calls from foreign threads), so when
230 /// a bridge is present `user_data` is repurposed to point at the
231 /// `ActionBridge` (the SDK-designated "host's opaque context"). The bridge's
232 /// master `Arc` is kept by `rpi-cli` for the harness lifetime, so the
233 /// pointer a plugin stores during register stays valid.
234 pub fn build_vtable(self: &Arc<Self>) -> PluginApiVt {
235 // When a bridge is present, user_data carries it (post-register action
236 // recovery). Otherwise keep the register-path HostApi pointer (harmless
237 // — register trampolines use the thread-local and ignore user_data).
238 // Cast both arms to the `RuntimeActionFn` pointer type — distinct fn
239 // items have unique types even with identical signatures, so the match
240 // needs a common fn-pointer type to unify on (the vtable field is
241 // `RuntimeActionFn`, a bare `extern "C" fn` alias, not an `Option`).
242 let (runtime_action_fn, ud) = match &self.action_bridge {
243 Some(bridge) => (
244 trampoline_runtime_action as RuntimeActionFn,
245 Arc::as_ptr(bridge) as *mut c_void,
246 ),
247 None => (
248 stub_runtime_action as RuntimeActionFn,
249 Arc::as_ptr(self) as *mut c_void,
250 ),
251 };
252 PluginApiVt {
253 free_string: host_free_string,
254 register_tool: Some(trampoline_register_tool),
255 register_command: Some(trampoline_register_command),
256 register_shortcut: Some(trampoline_register_shortcut),
257 register_flag: Some(trampoline_register_flag),
258 register_provider: Some(trampoline_register_provider), // B5c
259 register_message_renderer: Some(trampoline_register_message_renderer), // B5c
260 register_markdown_transformer: Some(trampoline_register_markdown_transformer), // B5c
261 register_entry_renderer: Some(trampoline_register_entry_renderer), // B5c
262 register_event_handler: Some(trampoline_register_event_handler),
263 register_resources_discover: Some(trampoline_register_resources_discover),
264 runtime_action: runtime_action_fn,
265 dispatch_event: Some(trampoline_dispatch_event),
266 user_data: ud,
267 }
268 }
269
270 /// Build the frozen ABI v1 view used only for a plugin exporting the legacy
271 /// `rpi_plugin_register` symbol. Its runtime-action slot rejects ids above
272 /// the v1 range before dispatch.
273 pub fn build_legacy_vtable(self: &Arc<Self>) -> LegacyPluginApiV1 {
274 let v2 = self.build_vtable();
275 let legacy_runtime_action = if self.action_bridge.is_some() {
276 trampoline_runtime_action_v1 as LegacyRuntimeActionFn
277 } else {
278 stub_runtime_action as LegacyRuntimeActionFn
279 };
280 LegacyPluginApiV1 {
281 free_string: v2.free_string,
282 register_tool: v2.register_tool,
283 register_command: v2.register_command,
284 register_shortcut: v2.register_shortcut,
285 register_flag: v2.register_flag,
286 register_provider: v2.register_provider,
287 register_message_renderer: v2.register_message_renderer,
288 register_markdown_transformer: v2.register_markdown_transformer,
289 register_entry_renderer: v2.register_entry_renderer,
290 register_event_handler: v2.register_event_handler,
291 register_resources_discover: v2.register_resources_discover,
292 runtime_action: legacy_runtime_action,
293 dispatch_event: v2.dispatch_event,
294 user_data: v2.user_data,
295 }
296 }
297}
298
299// Thread-local "current host api" for the duration of a plugin register call.
300// Set by [`load_one`] before calling register, cleared after. This is the
301// sound way to let `extern "C" fn` trampolines (which cannot capture) reach the
302// host's registry: the register call is synchronous and single-threaded per
303// plugin, so a thread-local is unambiguous.
304thread_local! {
305 static CURRENT_HOST_API: std::cell::Cell<*const HostApi> = std::cell::Cell::new(std::ptr::null());
306}
307
308/// SAFETY: must be called only while the `Arc<HostApi>` pointed to by `api` is
309/// kept alive (i.e. inside `with_current_api`). Sets the thread-local current
310/// api pointer.
311unsafe fn set_current_api(api: &Arc<HostApi>) {
312 CURRENT_HOST_API.with(|c| c.set(Arc::as_ptr(api) as *const HostApi));
313}
314
315fn clear_current_api() {
316 CURRENT_HOST_API.with(|c| c.set(std::ptr::null()));
317}
318
319/// Run `f` with the current thread-local host api borrowed. No-op (returns
320/// `false`) if no api is current (e.g. a trampoline called outside register).
321fn with_current_api<R>(f: impl FnOnce(&HostApi) -> R) -> Option<R> {
322 let ptr = CURRENT_HOST_API.with(|c| c.get());
323 if ptr.is_null() {
324 return None;
325 }
326 // SAFETY: the `Arc<HostApi>` is alive for the duration of register (kept by
327 // `load_one`'s stack), and this thread set it; borrowing here is sound.
328 let api = unsafe { &*ptr };
329 Some(f(api))
330}
331
332/// True if a current host api is set (cheap check used by trampolines that only
333/// need presence, not the api itself).
334fn current_api_present() -> bool {
335 let ptr = CURRENT_HOST_API.with(|c| c.get());
336 !ptr.is_null()
337}
338
339// --- the registrar trampolines (extern "C", forward into the current api) ---
340
341extern "C" fn trampoline_register_tool(
342 schema: *const StableToolSchema,
343 execute_fn: ToolExecuteFn,
344 poll_fn: ToolPollFn,
345 cancel_fn: ToolCancelFn,
346 destroy_fn: ToolDestroyFn,
347 plugin_free_string: FreeStringFn,
348) -> i32 {
349 if !current_api_present() {
350 return -1;
351 }
352 if schema.is_null() {
353 return 1;
354 }
355 // SAFETY: the plugin guarantees `schema` is valid for the call; we copy the
356 // strings out immediately (under the borrow) and free them via the plugin's
357 // `plugin_free_string` right after, so no retention past the call.
358 let (name, description, parameters_value, schema_owned) = unsafe {
359 let s = &*schema;
360 (
361 s.name.to_string_lossy(),
362 s.description.to_string_lossy(),
363 serde_json::from_str::<serde_json::Value>(&s.parameters.to_string_lossy()).ok(),
364 *s,
365 )
366 };
367 // Free the schema strings via the plugin's free fn (plugin produced them).
368 // `FreeStringFn` is `extern "C" fn(StbString)` — calling a bare fn pointer
369 // is safe (no `unsafe` needed); the idempotent free contract is on the plugin.
370 plugin_free_string(schema_owned.name);
371 plugin_free_string(schema_owned.description);
372 plugin_free_string(schema_owned.parameters);
373 let Some(parameters_value) = parameters_value else {
374 return 2;
375 };
376 if name.trim().is_empty() || !parameters_value.is_object() {
377 return 2;
378 }
379 let tool = rpi_ai::types::Tool {
380 name,
381 description,
382 parameters: rpi_ai::types::Schema::new(parameters_value),
383 constrained_sampling: None,
384 };
385 let handle = PluginToolHandle {
386 execute_fn,
387 poll_fn,
388 cancel_fn,
389 destroy_fn,
390 plugin_free_string,
391 };
392 // `register_tool` returns `bool` (true on overwrite of a prior same-named
393 // tool). For the plugin a name collision is not a hard failure — always
394 // return 0 (success) when the registry accepted it; -1 only if the registry
395 // was already detached.
396 let ok =
397 with_current_api(
398 |api| match api.with_registry(|reg| reg.register_tool(tool, handle)) {
399 Some(_) => true,
400 None => false,
401 },
402 );
403 if ok == Some(true) {
404 0
405 } else {
406 -1
407 }
408}
409
410extern "C" fn trampoline_register_command(
411 name: StbStringRef,
412 description: StbStringRef,
413 handler: rpi_plugin_sdk::CommandHandlerFn,
414) -> i32 {
415 if !current_api_present() {
416 return -1;
417 }
418 // SAFETY: the plugin guarantees the refs are valid for this call.
419 let (name, description) =
420 unsafe { (name.as_str().to_string(), description.as_str().to_string()) };
421 let ok = with_current_api(|api| {
422 // The v1 command ABI predates an explicit context parameter. Keep a
423 // null context for compatibility; plugins that need host state can use
424 // `runtime_action` from their command callback.
425 match api.with_registry(|reg| {
426 reg.register_command(name, description, handler, std::ptr::null_mut())
427 }) {
428 Some(_) => true,
429 None => false,
430 }
431 });
432 if ok == Some(true) {
433 0
434 } else {
435 -1
436 }
437}
438
439extern "C" fn trampoline_register_shortcut(_key: StbStringRef, _description: StbStringRef) -> i32 {
440 // v1: shortcuts are a TUI concern (B5). Record nothing; return ok so the
441 // plugin doesn't error, but note via diagnostics it's unsupported.
442 with_current_api(|api| api.diagnostics.unsupported("register_shortcut (TUI — B5)"));
443 0
444}
445
446extern "C" fn trampoline_register_flag(name: StbStringRef, description: StbStringRef) -> i32 {
447 if !current_api_present() {
448 return -1;
449 }
450 // SAFETY: the plugin guarantees these borrowed refs are valid for the
451 // duration of the registration call; copy them before returning.
452 let (name, description) =
453 unsafe { (name.as_str().to_string(), description.as_str().to_string()) };
454 if name.trim().is_empty() || name.starts_with('-') || name.contains('=') || name.contains(' ') {
455 return 2;
456 }
457 let ok = with_current_api(|api| {
458 match api.with_registry(|reg| reg.register_flag(name, description)) {
459 Some(_) => true,
460 None => false,
461 }
462 });
463 if ok == Some(true) {
464 0
465 } else {
466 -1
467 }
468}
469
470extern "C" fn trampoline_register_event_handler(
471 tag: EventTag,
472 handler: EventHandlerFn,
473 user_data: *mut c_void,
474) -> i32 {
475 if !current_api_present() {
476 return -1;
477 }
478 let ok = with_current_api(|api| {
479 match api.with_registry(|reg| reg.register_event_handler(tag, handler, user_data)) {
480 Some(_) => true,
481 None => false,
482 }
483 });
484 if ok == Some(true) {
485 0
486 } else {
487 -1
488 }
489}
490
491/// B5c: `register_provider` trampoline. Runs synchronously inside a plugin's
492/// `register` call (thread-local `CURRENT_HOST_API` is set). The plugin hands its
493/// `provider_id`/`base_url`/`api_style` (borrowed `StbStringRef`s), its
494/// `request_fn`, its own `plugin_free_string` (the `out` StbString `request_fn`
495/// later produces is plugin-owned — the host reclaims it via this fn), and its
496/// opaque `user_data`. We copy the id/base_url/api_style to owned `String`s
497/// (they're the provider's identity, read when building the
498/// [`PluggableProvider`](crate::PluggableProvider) — we can't keep the borrowed
499/// refs past register), then store the full record in the registry.
500extern "C" fn trampoline_register_provider(
501 provider_id: StbStringRef,
502 base_url: StbStringRef,
503 api_style: StbStringRef,
504 request_fn: ProviderRequestFn,
505 plugin_free_string: FreeStringFn,
506 user_data: *mut c_void,
507) -> i32 {
508 if !current_api_present() {
509 return -1;
510 }
511 // SAFETY: the plugin guarantees the refs are valid for this call.
512 let record = crate::registry::RegisteredProvider {
513 provider_id: unsafe { provider_id.as_str().to_string() },
514 base_url: unsafe { base_url.as_str().to_string() },
515 api_style: unsafe { api_style.as_str().to_string() },
516 request_fn,
517 plugin_free_string,
518 user_data,
519 };
520 let ok = with_current_api(
521 |api| match api.with_registry(|reg| reg.register_provider(record)) {
522 Some(_) => true,
523 None => false,
524 },
525 );
526 if ok == Some(true) {
527 0
528 } else {
529 -1
530 }
531}
532
533/// B5c: the three renderer registrars share a single recording helper; each
534/// trampoline below fixes its [`RegisteredRendererKind`] and forwards here.
535fn register_renderer_common(
536 name: StbStringRef,
537 kind: crate::registry::RegisteredRendererKind,
538 render_fn: RenderFn,
539 plugin_free_string: FreeStringFn,
540 user_data: *mut c_void,
541) -> i32 {
542 if !current_api_present() {
543 return -1;
544 }
545 // SAFETY: the plugin guarantees the ref is valid for this call.
546 let record = crate::registry::RegisteredRenderer {
547 name: unsafe { name.as_str().to_string() },
548 kind,
549 render_fn,
550 plugin_free_string,
551 user_data,
552 };
553 let ok = with_current_api(
554 |api| match api.with_registry(|reg| reg.register_renderer(record)) {
555 Some(_) => true,
556 None => false,
557 },
558 );
559 if ok == Some(true) {
560 0
561 } else {
562 -1
563 }
564}
565
566extern "C" fn trampoline_register_message_renderer(
567 name: StbStringRef,
568 render_fn: RenderFn,
569 plugin_free_string: FreeStringFn,
570 user_data: *mut c_void,
571) -> i32 {
572 register_renderer_common(
573 name,
574 crate::registry::RegisteredRendererKind::Message,
575 render_fn,
576 plugin_free_string,
577 user_data,
578 )
579}
580
581extern "C" fn trampoline_register_markdown_transformer(
582 name: StbStringRef,
583 render_fn: RenderFn,
584 plugin_free_string: FreeStringFn,
585 user_data: *mut c_void,
586) -> i32 {
587 register_renderer_common(
588 name,
589 crate::registry::RegisteredRendererKind::Markdown,
590 render_fn,
591 plugin_free_string,
592 user_data,
593 )
594}
595
596extern "C" fn trampoline_register_entry_renderer(
597 name: StbStringRef,
598 render_fn: RenderFn,
599 plugin_free_string: FreeStringFn,
600 user_data: *mut c_void,
601) -> i32 {
602 register_renderer_common(
603 name,
604 crate::registry::RegisteredRendererKind::Entry,
605 render_fn,
606 plugin_free_string,
607 user_data,
608 )
609}
610
611extern "C" fn trampoline_dispatch_event(_event: StablePluginEvent, _user_data: *mut c_void) -> i32 {
612 // v1: a plugin emitting an event upstream — we accept it (return 0) but do
613 // not yet forward to host subscribers (no upstream channel wired). B5 wires
614 // the reverse-direction event bus.
615 0
616}
617
618/// B5b: `register_resources_discover` trampoline. Runs synchronously inside a
619/// plugin's `register` call (so the thread-local `CURRENT_HOST_API` is set → the
620/// register-path recovery works, same as the other `trampoline_register_*` fns).
621/// The plugin hands its `handler`, its own `plugin_free_string` (the `out`
622/// StbString the handler later produces is plugin-owned — the host must reclaim
623/// it via this fn), and its opaque `user_data`. The host stores all three in the
624/// registry and later fans the discovery event out via `emit_resources_discover`.
625extern "C" fn trampoline_register_resources_discover(
626 handler: ResourcesDiscoverFn,
627 plugin_free_string: FreeStringFn,
628 user_data: *mut c_void,
629) -> i32 {
630 if !current_api_present() {
631 return -1;
632 }
633 let ok = with_current_api(|api| {
634 match api.with_registry(|reg| {
635 reg.register_resources_discover(handler, plugin_free_string, user_data)
636 }) {
637 Some(_) => true,
638 None => false,
639 }
640 });
641 if ok == Some(true) {
642 0
643 } else {
644 -1
645 }
646}
647
648/// Fallback for [`HostApi`]s built without an [`ActionBridge`] (the
649/// `HostApi::new` path). Return -1 (`EPERM`-ish) so a plugin can detect
650/// "unsupported" without crashing. When a bridge is present, `build_vtable`
651/// installs [`trampoline_runtime_action`] instead.
652extern "C" fn stub_runtime_action(
653 _action_id: u32,
654 _args: StbStringRef,
655 _out: *mut StbString,
656 _user_data: *mut c_void,
657) -> i32 {
658 -1
659}
660
661// Keep the `RuntimeActionFn` type name referenced for doc clarity / future wiring.
662#[allow(dead_code)]
663type _RuntimeActionFnDoc = RuntimeActionFn;