Files
owlry-plugins/docs/PLUGIN_DEVELOPMENT.md

16 KiB

Plugin Development Guide

This guide covers creating plugins for Owlry. There are three ways to extend Owlry:

  1. Native plugins (Rust) — Best performance, ABI-stable interface
  2. Lua plugins — Easy scripting, requires owlry-lua runtime
  3. Rune plugins — Safe scripting with Rust-like syntax, requires owlry-rune runtime

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

  1. Static providers: Do expensive work in refresh(), not items()
  2. Dynamic providers: Keep query() fast (<50ms)
  3. Cache data: Use persistent cache for API responses
  4. 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 executable
  • folder — Directories
  • text-x-generic — Text files
  • face-smile — Emoji/reactions
  • system-shutdown — Power actions
  • network-server — SSH/network
  • edit-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 (.so files) — 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.