Skip to main content

linsight_plugin_sdk/
export.rs

1// SPDX-FileCopyrightText: 2026 VisorCraft LLC
2// SPDX-License-Identifier: GPL-3.0-only
3
4/// Define the entry points an out-of-tree plugin's `cdylib` must export.
5///
6/// Pass the type that implements [`LinsightPlugin`](crate::LinsightPlugin).
7/// The type must implement `Default`.
8///
9/// ABI v6: the factory returns a stabby dyn-ptr
10/// (`stabby::dynptr!(Box<dyn LinsightPlugin + Send + Sync>)`) annotated
11/// with `#[stabby::export]`. The host loads the symbol via
12/// [`StabbyLibrary::get_stabbied`](stabby::libloading::StabbyLibrary::get_stabbied)
13/// so the entire signature is validated by stabby's reflection report.
14///
15/// A plain `extern "C" fn() -> u32` named `linsight_plugin_abi_version`
16/// is emitted alongside so loaders can do a cheap version-compatibility
17/// short-circuit before paying the stabby cost. The factory symbol is
18/// `linsight_plugin_v6` — renamed from `linsight_plugin_v5` so a v5
19/// plugin's `.so` will fail symbol lookup at load time rather than
20/// silently exchanging vtables whose methods differ in unwind ABI (v6
21/// switches the trait methods to `extern "C-unwind"` for panic isolation).
22///
23/// The metadata form emits an additional optional
24/// `linsight_plugin_metadata_v1` symbol. The daemon uses it, when present,
25/// to look up per-plugin config before constructing or initializing the
26/// plugin. The one-argument form is kept for ABI-v6 plugins built before
27/// metadata existed; those plugins still load through the daemon's probe
28/// fallback.
29#[macro_export]
30macro_rules! export_plugin {
31    ($ty:ty) => {
32        #[unsafe(no_mangle)]
33        pub extern "C" fn linsight_plugin_abi_version() -> u32 {
34            $crate::LINSIGHT_PLUGIN_ABI_VERSION
35        }
36
37        #[$crate::stabby::export]
38        pub extern "C" fn linsight_plugin_v6() -> $crate::stabby::dynptr!(
39            $crate::stabby::boxed::Box<dyn $crate::LinsightPlugin + Send + Sync>
40        ) {
41            let boxed: $crate::stabby::boxed::Box<$ty> =
42                $crate::stabby::boxed::Box::new(<$ty as Default>::default());
43            boxed.into()
44        }
45    };
46    (
47        $ty:ty,
48        metadata: {
49            plugin_id: $plugin_id:expr,
50            display_name: $display_name:expr,
51            version: $version:expr $(,)?
52        } $(,)?
53    ) => {
54        #[unsafe(no_mangle)]
55        pub extern "C" fn linsight_plugin_abi_version() -> u32 {
56            $crate::LINSIGHT_PLUGIN_ABI_VERSION
57        }
58
59        #[$crate::stabby::export]
60        pub extern "C" fn linsight_plugin_metadata_v1() -> $crate::RPluginMetadata {
61            $crate::PluginMetadata {
62                plugin_id: $plugin_id.into(),
63                display_name: $display_name.into(),
64                version: $version.into(),
65            }
66            .into()
67        }
68
69        #[$crate::stabby::export]
70        pub extern "C" fn linsight_plugin_v6() -> $crate::stabby::dynptr!(
71            $crate::stabby::boxed::Box<dyn $crate::LinsightPlugin + Send + Sync>
72        ) {
73            let boxed: $crate::stabby::boxed::Box<$ty> =
74                $crate::stabby::boxed::Box::new(<$ty as Default>::default());
75            boxed.into()
76        }
77    };
78}
79
80#[cfg(test)]
81mod tests {
82    use linsight_core::SensorId;
83    use stabby::result::Result as SResult;
84
85    use crate::{
86        LinsightPlugin, PluginCtx, PluginError, PluginManifest, RInitResult, RPluginCtx,
87        RPluginError, RPluginManifest, RReading, RSampleResult, RSensorId, host_init, host_sample,
88    };
89
90    #[derive(Default)]
91    struct EchoPlugin;
92
93    impl LinsightPlugin for EchoPlugin {
94        extern "C-unwind" fn init(&self, _ctx: &RPluginCtx) -> RInitResult {
95            let m = PluginManifest {
96                plugin_id: "echo".into(),
97                display_name: "Echo".into(),
98                version: "0.0.1".into(),
99                sensors: vec![],
100                devices: vec![],
101            };
102            let r: RPluginManifest = m.into();
103            SResult::Ok(r)
104        }
105
106        extern "C-unwind" fn sample(&self, _: RSensorId) -> RSampleResult {
107            let r: RReading = linsight_core::Reading::Scalar(1.0).into();
108            SResult::Ok(r)
109        }
110    }
111
112    // We don't `export_plugin!(EchoPlugin)` in cfg(test) inside the SDK
113    // itself, because the macro emits `#[no_mangle]` symbols that would
114    // collide with the SDK's own test harness when other crates link it.
115    // The macro is exercised via the dynamic plugin in
116    // `examples/echo-plugin` (exercised by the SDK's dynamic_load test)
117    // and via the in-tree sensor crates which call it through their own cdylibs.
118
119    #[test]
120    fn host_init_round_trips() {
121        let p = EchoPlugin;
122        let m = host_init(&p, &PluginCtx::default()).unwrap();
123        assert_eq!(m.plugin_id, "echo");
124    }
125
126    #[test]
127    fn host_sample_round_trips() {
128        let p = EchoPlugin;
129        let r = host_sample(&p, &SensorId::new("anything")).unwrap();
130        assert!(matches!(r, linsight_core::Reading::Scalar(v) if v == 1.0));
131        // Unused-by-this-test error type — keep the import live.
132        let _ = std::any::TypeId::of::<PluginError>();
133        let _ = std::any::TypeId::of::<RPluginError>();
134    }
135}