diff --git a/examples/trx-plugin-example/Cargo.toml b/examples/trx-plugin-example/Cargo.toml new file mode 100644 index 0000000..48a46c6 --- /dev/null +++ b/examples/trx-plugin-example/Cargo.toml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2025 Stanislaw Grams +# +# SPDX-License-Identifier: BSD-2-Clause + +[package] +name = "trx-plugin-example" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +trx-backend = { path = "../../src/trx-backend" } +trx-core = { path = "../../src/trx-core" } +trx-frontend = { path = "../../src/trx-frontend" } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } diff --git a/examples/trx-plugin-example/README.md b/examples/trx-plugin-example/README.md new file mode 100644 index 0000000..427a922 --- /dev/null +++ b/examples/trx-plugin-example/README.md @@ -0,0 +1,19 @@ +# trx-plugin-example + +This is a minimal shared-library plugin that registers a backend and frontend. +The backend is a stub that returns an error; the frontend is a no-op spawner. + +Build: + +```bash +cargo build -p trx-plugin-example --release +``` + +Install (example): + +```bash +mkdir -p plugins +cp target/release/libtrx_plugin_example.* plugins/ +``` + +Run `trx-bin` with `TRX_PLUGIN_DIRS=./plugins` to discover the plugin. diff --git a/examples/trx-plugin-example/src/lib.rs b/examples/trx-plugin-example/src/lib.rs new file mode 100644 index 0000000..5c661dd --- /dev/null +++ b/examples/trx-plugin-example/src/lib.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +use std::net::SocketAddr; + +use tokio::sync::{mpsc, watch}; +use tokio::task::JoinHandle; +use tracing::info; + +use trx_backend::{register_backend, RigAccess}; +use trx_core::{DynResult, RigRequest, RigState}; +use trx_frontend::{register_frontend, FrontendSpawner}; + +const BACKEND_NAME: &str = "example"; +const FRONTEND_NAME: &str = "example-frontend"; + +/// Entry point called by trx-bin when the plugin is loaded. +#[no_mangle] +pub extern "C" fn trx_register() { + register_backend(BACKEND_NAME, example_backend_factory); + register_frontend(FRONTEND_NAME, ExampleFrontend::spawn_frontend); +} + +fn example_backend_factory(_access: RigAccess) -> DynResult> { + Err("example plugin backend not implemented".into()) +} + +struct ExampleFrontend; + +impl FrontendSpawner for ExampleFrontend { + fn spawn_frontend( + _state_rx: watch::Receiver, + _rig_tx: mpsc::Sender, + _callsign: Option, + listen_addr: SocketAddr, + ) -> JoinHandle<()> { + tokio::spawn(async move { + info!("example frontend loaded at {} (no-op)", listen_addr); + }) + } +} diff --git a/src/trx-backend/Cargo.toml b/src/trx-backend/Cargo.toml index 73dd6e5..e98231f 100644 --- a/src/trx-backend/Cargo.toml +++ b/src/trx-backend/Cargo.toml @@ -18,4 +18,3 @@ tokio = { workspace = true, features = ["full"] } tokio-serial = { workspace = true } serde = { workspace = true, features = ["derive"] } tracing = { workspace = true } -clap = { workspace = true, features = ["derive"] } diff --git a/src/trx-backend/src/lib.rs b/src/trx-backend/src/lib.rs index 01bdd0b..469b6ef 100644 --- a/src/trx-backend/src/lib.rs +++ b/src/trx-backend/src/lib.rs @@ -2,39 +2,15 @@ // // SPDX-License-Identifier: BSD-2-Clause -use clap::ValueEnum; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + use trx_core::rig::RigCat; use trx_core::DynResult; #[cfg(feature = "ft817")] use trx_backend_ft817::Ft817; -/// Supported rig backends selectable at runtime. -#[derive(Debug, Clone, Copy, ValueEnum)] -pub enum RigKind { - #[cfg(feature = "ft817")] - #[value(alias = "ft-817")] - Ft817, -} - -impl RigKind { - pub fn all() -> &'static [RigKind] { - &[ - #[cfg(feature = "ft817")] - RigKind::Ft817, - ] - } -} - -impl std::fmt::Display for RigKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - #[cfg(feature = "ft817")] - RigKind::Ft817 => write!(f, "ft817"), - } - } -} - /// Connection details for instantiating a rig backend. #[derive(Debug, Clone)] pub enum RigAccess { @@ -42,21 +18,75 @@ pub enum RigAccess { Tcp { addr: String }, } -/// Instantiate a rig backend based on the selected kind and access method. -pub fn build_rig(kind: RigKind, access: RigAccess) -> DynResult> { - match (kind, access) { - // Yaesu FT-817 - #[cfg(feature = "ft817")] - (RigKind::Ft817, RigAccess::Serial { path, baud }) => { - Ok(Box::new(Ft817::new(&path, baud)?)) - } - #[cfg(feature = "ft817")] - (RigKind::Ft817, RigAccess::Tcp { .. }) => { - Err("FT-817 only supports serial CAT access".into()) - } +type BackendFactory = fn(RigAccess) -> DynResult>; - // Fallback for unsupported combinations - #[allow(unreachable_patterns)] - _ => Err("Selected rig is not enabled/available".into()), +struct BackendRegistry { + factories: HashMap, +} + +impl BackendRegistry { + fn new() -> Self { + Self { + factories: HashMap::new(), + } + } +} + +fn registry() -> &'static Mutex { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(BackendRegistry::new())) +} + +fn normalize_name(name: &str) -> String { + name.to_ascii_lowercase() + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect() +} + +/// Register a backend factory under a stable name (e.g. "ft817"). +pub fn register_backend(name: &str, factory: BackendFactory) { + let key = normalize_name(name); + let mut reg = registry().lock().expect("backend registry mutex poisoned"); + reg.factories.insert(key, factory); +} + +/// Register all built-in backends enabled by features. +pub fn register_builtin_backends() { + #[cfg(feature = "ft817")] + register_backend("ft817", ft817_factory); +} + +/// Check whether a backend name is registered. +pub fn is_backend_registered(name: &str) -> bool { + let key = normalize_name(name); + let reg = registry().lock().expect("backend registry mutex poisoned"); + reg.factories.contains_key(&key) +} + +/// List registered backend names. +pub fn registered_backends() -> Vec { + let reg = registry().lock().expect("backend registry mutex poisoned"); + let mut names: Vec = reg.factories.keys().cloned().collect(); + names.sort(); + names +} + +/// Instantiate a rig backend based on the selected name and access method. +pub fn build_rig(name: &str, access: RigAccess) -> DynResult> { + let key = normalize_name(name); + let reg = registry().lock().expect("backend registry mutex poisoned"); + let factory = reg + .factories + .get(&key) + .ok_or_else(|| format!("Unknown rig backend: {}", name))?; + factory(access) +} + +#[cfg(feature = "ft817")] +fn ft817_factory(access: RigAccess) -> DynResult> { + match access { + RigAccess::Serial { path, baud } => Ok(Box::new(Ft817::new(&path, baud)?)), + RigAccess::Tcp { .. } => Err("FT-817 only supports serial CAT access".into()), } } diff --git a/src/trx-bin/src/plugins.rs b/src/trx-bin/src/plugins.rs new file mode 100644 index 0000000..4456f17 --- /dev/null +++ b/src/trx-bin/src/plugins.rs @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use libloading::{Library, Symbol}; +use tracing::{info, warn}; + +const PLUGIN_ENV: &str = "TRX_PLUGIN_DIRS"; +const PLUGIN_ENTRYPOINT: &str = "trx_register"; + +#[cfg(windows)] +const PATH_SEPARATOR: char = ';'; +#[cfg(not(windows))] +const PATH_SEPARATOR: char = ':'; + +#[cfg(windows)] +const PLUGIN_EXTENSIONS: &[&str] = &["dll"]; +#[cfg(target_os = "macos")] +const PLUGIN_EXTENSIONS: &[&str] = &["dylib"]; +#[cfg(all(unix, not(target_os = "macos")))] +const PLUGIN_EXTENSIONS: &[&str] = &["so"]; + +pub fn load_plugins() -> Vec { + let mut libraries = Vec::new(); + let search_paths = plugin_search_paths(); + + if search_paths.is_empty() { + return libraries; + } + + info!("Plugin search paths: {:?}", search_paths); + + for path in search_paths { + if let Err(err) = load_plugins_from_dir(&path, &mut libraries) { + warn!("Plugin scan failed for {:?}: {}", path, err); + } + } + + libraries +} + +fn load_plugins_from_dir(path: &Path, libraries: &mut Vec) -> std::io::Result<()> { + if !path.exists() { + return Ok(()); + } + + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + if !is_plugin_file(&path) { + continue; + } + + unsafe { + match Library::new(&path) { + Ok(lib) => { + if let Err(err) = register_library(&lib, &path) { + warn!("Plugin {:?} failed to register: {}", path, err); + continue; + } + info!("Loaded plugin {:?}", path); + libraries.push(lib); + } + Err(err) => { + warn!("Failed to load plugin {:?}: {}", path, err); + } + } + } + } + + Ok(()) +} + +unsafe fn register_library(lib: &Library, path: &Path) -> Result<(), String> { + let entry: Symbol = lib + .get(PLUGIN_ENTRYPOINT.as_bytes()) + .map_err(|e| format!("missing entrypoint {}: {}", PLUGIN_ENTRYPOINT, e))?; + entry(); + info!("Registered plugin {:?}", path); + Ok(()) +} + +fn plugin_search_paths() -> Vec { + let mut paths = Vec::new(); + + if let Ok(env_paths) = std::env::var(PLUGIN_ENV) { + for raw in env_paths.split(PATH_SEPARATOR) { + if raw.trim().is_empty() { + continue; + } + paths.push(PathBuf::from(raw)); + } + } + + paths.push(PathBuf::from("plugins")); + + if let Some(config_dir) = dirs::config_dir() { + paths.push(config_dir.join("trx-rs").join("plugins")); + } + + paths +} + +fn is_plugin_file(path: &Path) -> bool { + path.extension() + .and_then(OsStr::to_str) + .map(|ext| PLUGIN_EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e))) + .unwrap_or(false) +} diff --git a/src/trx-frontend/src/lib.rs b/src/trx-frontend/src/lib.rs index 7570745..cb2c0e6 100644 --- a/src/trx-frontend/src/lib.rs +++ b/src/trx-frontend/src/lib.rs @@ -2,10 +2,14 @@ // // SPDX-License-Identifier: BSD-2-Clause +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Mutex, OnceLock}; + use tokio::sync::{mpsc, watch}; use tokio::task::JoinHandle; -use trx_core::{RigRequest, RigState}; +use trx_core::{DynResult, RigRequest, RigState}; /// Trait implemented by concrete frontends to expose a runner entrypoint. pub trait FrontendSpawner { @@ -13,5 +17,76 @@ pub trait FrontendSpawner { state_rx: watch::Receiver, rig_tx: mpsc::Sender, callsign: Option, + listen_addr: SocketAddr, ) -> JoinHandle<()>; } + +type FrontendSpawnFn = fn( + watch::Receiver, + mpsc::Sender, + Option, + SocketAddr, +) -> JoinHandle<()>; + +struct FrontendRegistry { + spawners: HashMap, +} + +impl FrontendRegistry { + fn new() -> Self { + Self { + spawners: HashMap::new(), + } + } +} + +fn registry() -> &'static Mutex { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(FrontendRegistry::new())) +} + +fn normalize_name(name: &str) -> String { + name.to_ascii_lowercase() + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect() +} + +/// Register a frontend spawner under a stable name (e.g. "http"). +pub fn register_frontend(name: &str, spawner: FrontendSpawnFn) { + let key = normalize_name(name); + let mut reg = registry().lock().expect("frontend registry mutex poisoned"); + reg.spawners.insert(key, spawner); +} + +/// Check whether a frontend name is registered. +pub fn is_frontend_registered(name: &str) -> bool { + let key = normalize_name(name); + let reg = registry().lock().expect("frontend registry mutex poisoned"); + reg.spawners.contains_key(&key) +} + +/// List registered frontend names. +pub fn registered_frontends() -> Vec { + let reg = registry().lock().expect("frontend registry mutex poisoned"); + let mut names: Vec = reg.spawners.keys().cloned().collect(); + names.sort(); + names +} + +/// Spawn a registered frontend by name. +pub fn spawn_frontend( + name: &str, + state_rx: watch::Receiver, + rig_tx: mpsc::Sender, + callsign: Option, + listen_addr: SocketAddr, +) -> DynResult> { + let key = normalize_name(name); + let reg = registry().lock().expect("frontend registry mutex poisoned"); + let spawner = reg + .spawners + .get(&key) + .ok_or_else(|| format!("Unknown frontend: {}", name))?; + Ok(spawner(state_rx, rig_tx, callsign, listen_addr)) +}