16 KiB
Plugin Development Guide
This guide covers creating plugins for Owlry. There are three ways to extend Owlry:
- Native plugins (Rust) — Best performance, ABI-stable interface
- Lua plugins — Easy scripting, requires
owlry-luaruntime - Rune plugins — Safe scripting with Rust-like syntax, requires
owlry-runeruntime
Quick Start
Native Plugin (Rust)
# Create a new plugin crate
cargo new --lib owlry-plugin-myplugin
cd owlry-plugin-myplugin
Edit Cargo.toml:
[package]
name = "owlry-plugin-myplugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
owlry-plugin-api = { git = "https://somegit.dev/Owlibou/owlry" }
abi_stable = "0.11"
Edit src/lib.rs:
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo,
ProviderKind, ProviderPosition, API_VERSION,
};
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from("myplugin"),
name: RString::from("My Plugin"),
version: RString::from(env!("CARGO_PKG_VERSION")),
description: RString::from("A custom plugin"),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from("myplugin"),
name: RString::from("My Plugin"),
prefix: ROption::RSome(RString::from(":my")),
icon: RString::from("application-x-executable"),
provider_type: ProviderKind::Static,
type_id: RString::from("myplugin"),
position: ProviderPosition::Normal,
priority: 0, // Use frecency-based ordering
}].into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
ProviderHandle::null()
}
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
vec![
PluginItem::new("item-1", "Hello World", "echo 'Hello!'")
.with_description("A greeting")
.with_icon("face-smile"),
].into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
RVec::new()
}
extern "C" fn provider_drop(_handle: ProviderHandle) {}
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
Build and install:
cargo build --release
sudo cp target/release/libowlry_plugin_myplugin.so /usr/lib/owlry/plugins/
Lua Plugin
# Requires owlry-lua runtime
yay -S owlry-lua
# Create plugin directory
mkdir -p ~/.config/owlry/plugins/my-lua-plugin
Create ~/.config/owlry/plugins/my-lua-plugin/plugin.toml:
[plugin]
id = "my-lua-plugin"
name = "My Lua Plugin"
version = "0.1.0"
description = "A custom Lua plugin"
entry = "main.lua"
[[providers]]
id = "myluaprovider"
name = "My Lua Provider"
prefix = ":mylua"
icon = "application-x-executable"
type = "static"
type_id = "mylua"
Create ~/.config/owlry/plugins/my-lua-plugin/main.lua:
-- owlry table is pre-registered globally (no require needed)
owlry.provider.register({
name = "myluaprovider",
display_name = "My Lua Provider",
type_id = "mylua",
default_icon = "application-x-executable",
prefix = ":mylua",
refresh = function()
return {
{
id = "item-1",
name = "Hello from Lua",
command = "echo 'Hello Lua!'",
description = "A Lua greeting",
icon = "face-smile",
},
}
end,
})
Native Plugin API
Plugin VTable
Every native plugin must export a function that returns a vtable:
#[repr(C)]
pub struct PluginVTable {
pub info: extern "C" fn() -> PluginInfo,
pub providers: extern "C" fn() -> RVec<ProviderInfo>,
pub provider_init: extern "C" fn(provider_id: RStr<'_>) -> ProviderHandle,
pub provider_refresh: extern "C" fn(handle: ProviderHandle) -> RVec<PluginItem>,
pub provider_query: extern "C" fn(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem>,
pub provider_drop: extern "C" fn(handle: ProviderHandle),
}
Use the owlry_plugin! macro to generate the export:
owlry_plugin! {
info: my_info_fn,
providers: my_providers_fn,
init: my_init_fn,
refresh: my_refresh_fn,
query: my_query_fn,
drop: my_drop_fn,
}
PluginInfo
pub struct PluginInfo {
pub id: RString, // Unique ID (e.g., "calculator")
pub name: RString, // Display name
pub version: RString, // Semantic version
pub description: RString, // Short description
pub api_version: u32, // Must match API_VERSION
}
ProviderInfo
pub struct ProviderInfo {
pub id: RString, // Provider ID within plugin
pub name: RString, // Display name
pub prefix: ROption<RString>, // Activation prefix (e.g., ":calc")
pub icon: RString, // Default icon name
pub provider_type: ProviderKind, // Static or Dynamic
pub type_id: RString, // Short ID for badges
pub position: ProviderPosition, // Normal or Widget
pub priority: i32, // Result ordering (higher = first)
}
pub enum ProviderKind {
Static, // Items loaded at startup via refresh()
Dynamic, // Items computed per-query via query()
}
pub enum ProviderPosition {
Normal, // Standard results (sorted by score/frecency)
Widget, // Displayed at top when query is empty
}
PluginItem
pub struct PluginItem {
pub id: RString, // Unique item ID
pub name: RString, // Display name
pub description: ROption<RString>, // Optional description
pub icon: ROption<RString>, // Optional icon
pub command: RString, // Command to execute
pub terminal: bool, // Run in terminal?
pub keywords: RVec<RString>, // Search keywords
pub score_boost: i32, // Frecency boost
}
// Builder pattern
let item = PluginItem::new("id", "Name", "command")
.with_description("Description")
.with_icon("icon-name")
.with_terminal(true)
.with_keywords(vec!["tag1".to_string(), "tag2".to_string()])
.with_score_boost(100);
ProviderHandle
For stateful providers, use ProviderHandle to store state:
struct MyState {
items: Vec<PluginItem>,
cache: HashMap<String, String>,
}
extern "C" fn provider_init(_: RStr<'_>) -> ProviderHandle {
let state = Box::new(MyState {
items: Vec::new(),
cache: HashMap::new(),
});
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
let state = unsafe { &mut *(handle.ptr as *mut MyState) };
state.items = load_items();
state.items.clone().into()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
unsafe { handle.drop_as::<MyState>(); }
}
}
Host API
Plugins can use host-provided functions:
use owlry_plugin_api::{notify, notify_with_icon, log_info, log_warn, log_error};
// Send notifications
notify("Title", "Body text");
notify_with_icon("Title", "Body", "dialog-information");
// Logging
log_info("Plugin loaded successfully");
log_warn("Cache miss, fetching data");
log_error("Failed to connect to API");
Submenu Support
Plugins can provide submenus for detailed actions:
// Return an item that opens a submenu
PluginItem::new(
"service-docker",
"Docker",
"SUBMENU:systemd:docker.service", // Special command format
)
// Handle submenu query (query starts with "?SUBMENU:")
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
let q = query.as_str();
if let Some(data) = q.strip_prefix("?SUBMENU:") {
// Return submenu actions
return vec![
PluginItem::new("start", "Start", format!("systemctl start {}", data)),
PluginItem::new("stop", "Stop", format!("systemctl stop {}", data)),
].into();
}
RVec::new()
}
Lua Plugin API
Plugin Manifest (plugin.toml)
[plugin]
id = "my-plugin"
name = "My Plugin"
version = "1.0.0"
description = "Plugin description"
entry = "main.lua" # Canonical field; entry_point is accepted as an alias
owlry_version = ">=1.0.0" # Optional version constraint
[permissions]
fs = ["read"] # File system access
http = true # HTTP requests
process = true # Spawn processes
[[providers]]
id = "provider1"
name = "Provider Name"
prefix = ":prefix"
icon = "icon-name"
type = "static" # or "dynamic"
type_id = "shortid"
Lua API
-- owlry table is pre-registered globally (no require needed)
-- Items are plain Lua tables returned from refresh/query callbacks:
-- { id = "...", name = "...", command = "...", description = "...", icon = "...", terminal = false, tags = {"...", "..."} }
-- Notifications
owlry.notify("Title", "Body")
owlry.notify_icon("Title", "Body", "icon-name")
-- Logging
owlry.log.info("Message")
owlry.log.warn("Warning")
owlry.log.error("Error")
-- File operations (requires fs permission)
local content = owlry.fs.read("/path/to/file")
local files = owlry.fs.list("/path/to/dir")
local exists = owlry.fs.exists("/path")
-- HTTP requests (requires http permission)
local response = owlry.http.get("https://api.example.com/data")
local json = owlry.json.decode(response)
-- Process execution (requires process permission)
local output = owlry.process.run("ls", {"-la"})
-- Cache (persistent across sessions)
owlry.cache.set("key", value, ttl_seconds)
local value = owlry.cache.get("key")
Provider Functions
-- Static provider: called once at startup and on reload
owlry.provider.register({
name = "my-provider",
display_name = "My Provider",
prefix = ":my",
refresh = function()
return {
{ id = "id1", name = "Item 1", command = "command1" },
{ id = "id2", name = "Item 2", command = "command2" },
}
end,
})
-- Dynamic provider: called on each keystroke
owlry.provider.register({
name = "my-search",
display_name = "My Search",
prefix = "?my",
query = function(q)
if q == "" then return {} end
return {
{ id = "result", name = "Result for: " .. q, command = "echo " .. q },
}
end,
})
Rune Plugin API
Rune plugins use a Rust-like syntax with memory safety. Requires owlry-rune runtime.
# Install Rune runtime
yay -S owlry-rune
# Create plugin directory
mkdir -p ~/.config/owlry/plugins/my-rune-plugin
Plugin Manifest
Rune plugins declare providers in [[providers]] sections of plugin.toml. The runtime reads these declarations and maps them to the plugin's refresh() and query() functions.
[plugin]
id = "my-rune-plugin"
name = "My Rune Plugin"
version = "1.0.0"
description = "A custom Rune plugin"
entry = "main.rn" # Default: main.rn; entry_point also accepted
[[providers]]
id = "myrune"
name = "My Rune Provider"
prefix = ":myrune" # Activates with :myrune prefix in search
icon = "application-x-executable"
type = "static" # "static" (refresh at startup) or "dynamic" (query per keystroke)
type_id = "myrune" # Short ID shown as badge in UI
Rune API
use owlry::Item;
/// Called once at startup and on each hot-reload for static providers
pub fn refresh() {
let items = [];
items.push(Item::new("item-1", "Hello from Rune", "echo 'Hello!'")
.description("A Rune greeting")
.icon("face-smile")
.keywords(["hello", "rune"]));
items.push(Item::new("item-2", "Another Item", "notify-send 'Hi'")
.description("Send a notification")
.icon("dialog-information"));
items
}
/// Called per keystroke for dynamic providers
pub fn query(q) {
if q.is_empty() {
return [];
}
[Item::new("result", `Result: {q}`, `echo {q}`)
.description("Search result")]
}
Item Builder
The Item type is provided by the owlry module:
// Create an item with required fields
let item = Item::new(id, name, command);
// Optional builder methods (all return Item for chaining)
item = item.description("Description text");
item = item.icon("icon-name"); // Freedesktop icon name
item = item.keywords(["tag1", "tag2"]); // Search keywords
Logging
owlry::log_info("Info message");
owlry::log_warn("Warning message");
owlry::log_error("Error message");
owlry::log_debug("Debug message");
Best Practices
Performance
- Static providers: Do expensive work in
refresh(), notitems() - Dynamic providers: Keep
query()fast (<50ms) - Cache data: Use persistent cache for API responses
- Lazy loading: Don't load all items if only a few are needed
Error Handling
// Native: Return empty vec on error, log the issue
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
match load_data() {
Ok(items) => items.into(),
Err(e) => {
log_error(&format!("Failed to load: {}", e));
RVec::new()
}
}
}
-- Lua: Wrap refresh callback in pcall for safety
owlry.provider.register({
name = "safe-provider",
refresh = function()
local ok, result = pcall(function()
return load_items()
end)
if not ok then
owlry.log.error("Failed: " .. result)
return {}
end
return result
end,
})
Icons
Use freedesktop icon names for consistency:
application-x-executable— Generic executablefolder— Directoriestext-x-generic— Text filesface-smile— Emoji/reactionssystem-shutdown— Power actionsnetwork-server— SSH/networkedit-paste— Clipboard
Testing
# Build and test native plugin
cargo build --release -p owlry-plugin-myplugin
cargo test -p owlry-plugin-myplugin
# Install for testing
sudo cp target/release/libowlry_plugin_myplugin.so /usr/lib/owlry/plugins/
# Test with verbose logging
RUST_LOG=debug owlry
Hot Reload
User plugins in ~/.config/owlry/plugins/ are automatically reloaded when files change.
The daemon watches the plugins directory and reloads all script runtimes when any file
is created, modified, or deleted. No daemon restart is needed.
What triggers a reload:
- Creating a new plugin directory with
plugin.toml - Editing a plugin's script files (
main.lua,main.rn, etc.) - Editing a plugin's
plugin.toml - Deleting a plugin directory
What does NOT trigger a reload:
- Changes to native plugins (
.sofiles) — these require a daemon restart - Changes to runtime libraries in
/usr/lib/owlry/runtimes/— daemon restart needed
Reload behavior:
- All script runtimes (Lua, Rune) are fully reloaded
- Existing search results may briefly show stale data during reload
- Errors in plugins are logged but don't affect other plugins
Publishing to AUR
PKGBUILD Template
# Maintainer: Your Name <email@example.com>
pkgname=owlry-plugin-myplugin
pkgver=0.1.0
pkgrel=1
pkgdesc="My custom Owlry plugin"
arch=('x86_64')
url="https://github.com/you/owlry-plugin-myplugin"
license=('GPL-3.0-or-later')
depends=('owlry')
makedepends=('rust' 'cargo')
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
sha256sums=('...')
build() {
cd "$pkgname-$pkgver"
cargo build --release
}
package() {
cd "$pkgname-$pkgver"
install -Dm755 "target/release/lib${pkgname//-/_}.so" \
"$pkgdir/usr/lib/owlry/plugins/lib${pkgname//-/_}.so"
}
Example Plugins
The owlry repository includes 13 native plugins as reference implementations:
| Plugin | Type | Highlights |
|---|---|---|
owlry-plugin-calculator |
Dynamic | Math parsing, expression evaluation |
owlry-plugin-weather |
Static/Widget | HTTP API, JSON parsing, caching |
owlry-plugin-systemd |
Static | Submenu actions, service management |
owlry-plugin-pomodoro |
Static/Widget | State persistence, notifications |
owlry-plugin-clipboard |
Static | External process integration |
Browse the source at crates/owlry-plugin-*/ for implementation details.