feat: convert to workspace with native plugin architecture

BREAKING: Restructure from monolithic binary to modular plugin ecosystem

Architecture changes:
- Convert to Cargo workspace with crates/ directory
- Create owlry-plugin-api crate with ABI-stable interface (abi_stable)
- Move core binary to crates/owlry/
- Extract providers to native plugin crates (13 plugins)
- Add owlry-lua crate for Lua plugin runtime

Plugin system:
- Plugins loaded from /usr/lib/owlry/plugins/*.so
- Widget providers refresh automatically (universal, not hardcoded)
- Per-plugin config via [plugins.<name>] sections in config.toml
- Backwards compatible with [providers] config format

New features:
- just install-local: build and install core + all plugins
- Plugin config: weather and pomodoro read from [plugins.*]
- HostAPI for plugins: notifications, logging

Documentation:
- Update README with new package structure
- Add docs/PLUGINS.md with all plugin documentation
- Add docs/PLUGIN_DEVELOPMENT.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 03:01:37 +01:00
parent a582f0181c
commit 384dd016a0
124 changed files with 18609 additions and 3692 deletions

View File

@@ -0,0 +1,432 @@
//! # Owlry Plugin API
//!
//! This crate provides the ABI-stable interface for owlry native plugins.
//! Plugins are compiled as dynamic libraries (.so) and loaded at runtime.
//!
//! ## Creating a Plugin
//!
//! ```ignore
//! use owlry_plugin_api::*;
//!
//! // Define your plugin's vtable
//! static VTABLE: PluginVTable = PluginVTable {
//! info: plugin_info,
//! providers: plugin_providers,
//! provider_init: my_provider_init,
//! provider_refresh: my_provider_refresh,
//! provider_query: my_provider_query,
//! provider_drop: my_provider_drop,
//! };
//!
//! // Export the vtable
//! #[no_mangle]
//! pub extern "C" fn owlry_plugin_vtable() -> &'static PluginVTable {
//! &VTABLE
//! }
//! ```
use abi_stable::StableAbi;
// Re-export abi_stable types for use by consumers (runtime loader, plugins)
pub use abi_stable::std_types::{ROption, RStr, RString, RVec};
/// Current plugin API version - plugins must match this
pub const API_VERSION: u32 = 1;
/// Plugin metadata returned by the info function
#[repr(C)]
#[derive(StableAbi, Clone, Debug)]
pub struct PluginInfo {
/// Unique plugin identifier (e.g., "calculator", "weather")
pub id: RString,
/// Human-readable plugin name
pub name: RString,
/// Plugin version string
pub version: RString,
/// Short description of what the plugin provides
pub description: RString,
/// Plugin API version (must match API_VERSION)
pub api_version: u32,
}
/// Information about a provider offered by a plugin
#[repr(C)]
#[derive(StableAbi, Clone, Debug)]
pub struct ProviderInfo {
/// Unique provider identifier within the plugin
pub id: RString,
/// Human-readable provider name
pub name: RString,
/// Optional prefix that activates this provider (e.g., "=" for calculator)
pub prefix: ROption<RString>,
/// Default icon name for results from this provider
pub icon: RString,
/// Provider type (static or dynamic)
pub provider_type: ProviderKind,
/// Short type identifier for UI badges (e.g., "calc", "web")
pub type_id: RString,
}
/// Provider behavior type
#[repr(C)]
#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderKind {
/// Static providers load items once at startup via refresh()
Static,
/// Dynamic providers evaluate queries in real-time via query()
Dynamic,
}
/// A single searchable/launchable item returned by providers
#[repr(C)]
#[derive(StableAbi, Clone, Debug)]
pub struct PluginItem {
/// Unique item identifier
pub id: RString,
/// Display name
pub name: RString,
/// Optional description shown below the name
pub description: ROption<RString>,
/// Optional icon name or path
pub icon: ROption<RString>,
/// Command to execute when selected
pub command: RString,
/// Whether to run in a terminal
pub terminal: bool,
/// Search keywords/tags for filtering
pub keywords: RVec<RString>,
/// Score boost for frecency (higher = more prominent)
pub score_boost: i32,
}
impl PluginItem {
/// Create a new plugin item with required fields
pub fn new(id: impl Into<String>, name: impl Into<String>, command: impl Into<String>) -> Self {
Self {
id: RString::from(id.into()),
name: RString::from(name.into()),
description: ROption::RNone,
icon: ROption::RNone,
command: RString::from(command.into()),
terminal: false,
keywords: RVec::new(),
score_boost: 0,
}
}
/// Set the description
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = ROption::RSome(RString::from(desc.into()));
self
}
/// Set the icon
pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
self.icon = ROption::RSome(RString::from(icon.into()));
self
}
/// Set terminal mode
pub fn with_terminal(mut self, terminal: bool) -> Self {
self.terminal = terminal;
self
}
/// Add keywords
pub fn with_keywords(mut self, keywords: Vec<String>) -> Self {
self.keywords = keywords.into_iter().map(RString::from).collect();
self
}
/// Set score boost
pub fn with_score_boost(mut self, boost: i32) -> Self {
self.score_boost = boost;
self
}
}
/// Plugin function table - defines the interface between owlry and plugins
///
/// Every native plugin must export a function `owlry_plugin_vtable` that returns
/// a static reference to this structure.
#[repr(C)]
#[derive(StableAbi)]
pub struct PluginVTable {
/// Return plugin metadata
pub info: extern "C" fn() -> PluginInfo,
/// Return list of providers this plugin offers
pub providers: extern "C" fn() -> RVec<ProviderInfo>,
/// Initialize a provider by ID, returns an opaque handle
/// The handle is passed to refresh/query/drop functions
pub provider_init: extern "C" fn(provider_id: RStr<'_>) -> ProviderHandle,
/// Refresh a static provider's items
/// Called once at startup and when user requests refresh
pub provider_refresh: extern "C" fn(handle: ProviderHandle) -> RVec<PluginItem>,
/// Query a dynamic provider
/// Called on each keystroke for dynamic providers
pub provider_query: extern "C" fn(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem>,
/// Clean up a provider handle
pub provider_drop: extern "C" fn(handle: ProviderHandle),
}
/// Opaque handle to a provider instance
/// Plugins can use this to store state between calls
#[repr(C)]
#[derive(StableAbi, Clone, Copy, Debug)]
pub struct ProviderHandle {
/// Opaque pointer to provider state
pub ptr: *mut (),
}
impl ProviderHandle {
/// Create a null handle
pub fn null() -> Self {
Self {
ptr: std::ptr::null_mut(),
}
}
/// Create a handle from a boxed value
/// The caller is responsible for calling drop to free the memory
pub fn from_box<T>(value: Box<T>) -> Self {
Self {
ptr: Box::into_raw(value) as *mut (),
}
}
/// Convert handle back to a reference (unsafe)
///
/// # Safety
/// The handle must have been created from a Box<T> of the same type
pub unsafe fn as_ref<T>(&self) -> Option<&T> {
// SAFETY: Caller guarantees the pointer was created from Box<T>
unsafe { (self.ptr as *const T).as_ref() }
}
/// Convert handle back to a mutable reference (unsafe)
///
/// # Safety
/// The handle must have been created from a Box<T> of the same type
pub unsafe fn as_mut<T>(&mut self) -> Option<&mut T> {
// SAFETY: Caller guarantees the pointer was created from Box<T>
unsafe { (self.ptr as *mut T).as_mut() }
}
/// Drop the handle and free its memory (unsafe)
///
/// # Safety
/// The handle must have been created from a Box<T> of the same type
/// and must not be used after this call
pub unsafe fn drop_as<T>(self) {
if !self.ptr.is_null() {
// SAFETY: Caller guarantees the pointer was created from Box<T>
unsafe { drop(Box::from_raw(self.ptr as *mut T)) };
}
}
}
// ProviderHandle contains a raw pointer but we manage it carefully
unsafe impl Send for ProviderHandle {}
unsafe impl Sync for ProviderHandle {}
// ============================================================================
// Host API - Functions the host provides to plugins
// ============================================================================
/// Notification urgency level
#[repr(C)]
#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum NotifyUrgency {
/// Low priority notification
Low = 0,
/// Normal priority notification (default)
#[default]
Normal = 1,
/// Critical/urgent notification
Critical = 2,
}
/// Host API function table
///
/// This structure contains functions that the host (owlry) provides to plugins.
/// Plugins can call these functions to interact with the system.
#[repr(C)]
#[derive(StableAbi, Clone, Copy)]
pub struct HostAPI {
/// Send a notification to the user
/// Parameters: summary, body, icon (optional, empty string for none), urgency
pub notify: extern "C" fn(
summary: RStr<'_>,
body: RStr<'_>,
icon: RStr<'_>,
urgency: NotifyUrgency,
),
/// Log a message at info level
pub log_info: extern "C" fn(message: RStr<'_>),
/// Log a message at warning level
pub log_warn: extern "C" fn(message: RStr<'_>),
/// Log a message at error level
pub log_error: extern "C" fn(message: RStr<'_>),
}
// Global host API pointer - set by the host when loading plugins
static mut HOST_API: Option<&'static HostAPI> = None;
/// Initialize the host API (called by the host)
///
/// # Safety
/// Must only be called once by the host before any plugins use the API
pub unsafe fn init_host_api(api: &'static HostAPI) {
// SAFETY: Caller guarantees this is called once before any plugins use the API
unsafe {
HOST_API = Some(api);
}
}
/// Get the host API
///
/// Returns None if the host hasn't initialized the API yet
pub fn host_api() -> Option<&'static HostAPI> {
// SAFETY: We only read the pointer, and it's set once at startup
unsafe { HOST_API }
}
// ============================================================================
// Convenience functions for plugins
// ============================================================================
/// Send a notification (convenience wrapper)
pub fn notify(summary: &str, body: &str) {
if let Some(api) = host_api() {
(api.notify)(
RStr::from_str(summary),
RStr::from_str(body),
RStr::from_str(""),
NotifyUrgency::Normal,
);
}
}
/// Send a notification with an icon (convenience wrapper)
pub fn notify_with_icon(summary: &str, body: &str, icon: &str) {
if let Some(api) = host_api() {
(api.notify)(
RStr::from_str(summary),
RStr::from_str(body),
RStr::from_str(icon),
NotifyUrgency::Normal,
);
}
}
/// Send a notification with full options (convenience wrapper)
pub fn notify_with_urgency(summary: &str, body: &str, icon: &str, urgency: NotifyUrgency) {
if let Some(api) = host_api() {
(api.notify)(
RStr::from_str(summary),
RStr::from_str(body),
RStr::from_str(icon),
urgency,
);
}
}
/// Log an info message (convenience wrapper)
pub fn log_info(message: &str) {
if let Some(api) = host_api() {
(api.log_info)(RStr::from_str(message));
}
}
/// Log a warning message (convenience wrapper)
pub fn log_warn(message: &str) {
if let Some(api) = host_api() {
(api.log_warn)(RStr::from_str(message));
}
}
/// Log an error message (convenience wrapper)
pub fn log_error(message: &str) {
if let Some(api) = host_api() {
(api.log_error)(RStr::from_str(message));
}
}
/// Helper macro for defining plugin vtables
///
/// Usage:
/// ```ignore
/// owlry_plugin! {
/// info: my_plugin_info,
/// providers: my_providers,
/// init: my_init,
/// refresh: my_refresh,
/// query: my_query,
/// drop: my_drop,
/// }
/// ```
#[macro_export]
macro_rules! owlry_plugin {
(
info: $info:expr,
providers: $providers:expr,
init: $init:expr,
refresh: $refresh:expr,
query: $query:expr,
drop: $drop:expr $(,)?
) => {
static OWLRY_PLUGIN_VTABLE: $crate::PluginVTable = $crate::PluginVTable {
info: $info,
providers: $providers,
provider_init: $init,
provider_refresh: $refresh,
provider_query: $query,
provider_drop: $drop,
};
#[unsafe(no_mangle)]
pub extern "C" fn owlry_plugin_vtable() -> &'static $crate::PluginVTable {
&OWLRY_PLUGIN_VTABLE
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_item_builder() {
let item = PluginItem::new("test-id", "Test Item", "echo hello")
.with_description("A test item")
.with_icon("test-icon")
.with_terminal(true)
.with_keywords(vec!["test".to_string(), "example".to_string()])
.with_score_boost(100);
assert_eq!(item.id.as_str(), "test-id");
assert_eq!(item.name.as_str(), "Test Item");
assert_eq!(item.command.as_str(), "echo hello");
assert!(item.terminal);
assert_eq!(item.score_boost, 100);
}
#[test]
fn test_provider_handle() {
let value = Box::new(42i32);
let handle = ProviderHandle::from_box(value);
unsafe {
assert_eq!(*handle.as_ref::<i32>().unwrap(), 42);
handle.drop_as::<i32>();
}
}
}