feat: move all plugin and runtime crates from owlry

This commit is contained in:
2026-03-26 13:11:41 +01:00
parent 07a02b20e7
commit cdb3370873
41 changed files with 11907 additions and 0 deletions

3724
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
[package]
name = "owlry-lua"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Lua runtime for owlry plugins - enables loading user-created Lua plugins"
keywords = ["owlry", "plugin", "lua", "runtime"]
categories = ["development-tools"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry (shared types)
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types
abi_stable = "0.11"
# Lua runtime
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"] }
# Plugin manifest parsing
toml = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Version compatibility
semver = "1"
# HTTP client for plugins
reqwest = { version = "0.13", features = ["blocking", "json"] }
# Math expression evaluation
meval = "0.2"
# Date/time for os.date
chrono = "0.4"
# XDG paths
dirs = "5.0"
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,52 @@
//! Lua API implementations for plugins
//!
//! This module provides the `owlry` global table and its submodules
//! that plugins can use to interact with owlry.
mod provider;
mod utils;
use mlua::{Lua, Result as LuaResult};
use owlry_plugin_api::PluginItem;
use crate::loader::ProviderRegistration;
/// Register all owlry APIs in the Lua runtime
pub fn register_apis(lua: &Lua, plugin_dir: &std::path::Path, plugin_id: &str) -> LuaResult<()> {
let globals = lua.globals();
// Create the main owlry table
let owlry = lua.create_table()?;
// Register utility APIs (log, path, fs, json)
utils::register_log_api(lua, &owlry)?;
utils::register_path_api(lua, &owlry, plugin_dir)?;
utils::register_fs_api(lua, &owlry, plugin_dir)?;
utils::register_json_api(lua, &owlry)?;
// Register provider API
provider::register_provider_api(lua, &owlry)?;
// Set owlry as global
globals.set("owlry", owlry)?;
// Suppress unused warnings
let _ = plugin_id;
Ok(())
}
/// Get provider registrations from the Lua runtime
pub fn get_provider_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
provider::get_registrations(lua)
}
/// Call a provider's refresh function
pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>> {
provider::call_refresh(lua, provider_name)
}
/// Call a provider's query function
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
provider::call_query(lua, provider_name, query)
}

View File

@@ -0,0 +1,237 @@
//! Provider registration API for Lua plugins
use mlua::{Function, Lua, Result as LuaResult, Table, Value};
use owlry_plugin_api::PluginItem;
use std::cell::RefCell;
use crate::loader::ProviderRegistration;
thread_local! {
static REGISTRATIONS: RefCell<Vec<ProviderRegistration>> = const { RefCell::new(Vec::new()) };
}
/// Register the provider API in the owlry table
pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let provider = lua.create_table()?;
// owlry.provider.register(config)
provider.set("register", lua.create_function(register_provider)?)?;
owlry.set("provider", provider)?;
Ok(())
}
/// Implementation of owlry.provider.register()
fn register_provider(_lua: &Lua, config: Table) -> LuaResult<()> {
let name: String = config.get("name")?;
let display_name: String = config.get::<Option<String>>("display_name")?
.unwrap_or_else(|| name.clone());
let type_id: String = config.get::<Option<String>>("type_id")?
.unwrap_or_else(|| name.replace('-', "_"));
let default_icon: String = config.get::<Option<String>>("default_icon")?
.unwrap_or_else(|| "application-x-addon".to_string());
let prefix: Option<String> = config.get("prefix")?;
// Check if it's a dynamic provider (has query function) or static (has refresh)
let has_query: bool = config.contains_key("query")?;
let has_refresh: bool = config.contains_key("refresh")?;
if !has_query && !has_refresh {
return Err(mlua::Error::external(
"Provider must have either 'refresh' or 'query' function",
));
}
let is_dynamic = has_query;
REGISTRATIONS.with(|regs| {
regs.borrow_mut().push(ProviderRegistration {
name,
display_name,
type_id,
default_icon,
prefix,
is_dynamic,
});
});
Ok(())
}
/// Get all registered providers
pub fn get_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
// Suppress unused warning
let _ = lua;
REGISTRATIONS.with(|regs| Ok(regs.borrow().clone()))
}
/// Call a provider's refresh function
pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>> {
let globals = lua.globals();
let owlry: Table = globals.get("owlry")?;
let provider: Table = owlry.get("provider")?;
// Get the registered providers table (internal)
let registrations: Table = match provider.get::<Value>("_registrations")? {
Value::Table(t) => t,
_ => {
// Try to find the config directly from the global scope
// This happens when register was called with the config table
return call_provider_function(lua, provider_name, "refresh", None);
}
};
let config: Table = match registrations.get(provider_name)? {
Value::Table(t) => t,
_ => return Ok(Vec::new()),
};
let refresh_fn: Function = match config.get("refresh")? {
Value::Function(f) => f,
_ => return Ok(Vec::new()),
};
let result: Value = refresh_fn.call(())?;
parse_items_result(result)
}
/// Call a provider's query function
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
call_provider_function(lua, provider_name, "query", Some(query))
}
/// Call a provider function by name
fn call_provider_function(
lua: &Lua,
provider_name: &str,
function_name: &str,
query: Option<&str>,
) -> LuaResult<Vec<PluginItem>> {
// Search through all registered providers in the Lua globals
// This is a workaround since we store registrations thread-locally
let globals = lua.globals();
// Try to find a registered provider with matching name
// First check if there's a _providers table
if let Ok(Value::Table(providers)) = globals.get::<Value>("_owlry_providers")
&& let Ok(Value::Table(config)) = providers.get::<Value>(provider_name)
&& let Ok(Value::Function(func)) = config.get::<Value>(function_name) {
let result: Value = match query {
Some(q) => func.call(q)?,
None => func.call(())?,
};
return parse_items_result(result);
}
// Fall back: search through globals for functions
// This is less reliable but handles simple cases
Ok(Vec::new())
}
/// Parse items from Lua return value
fn parse_items_result(result: Value) -> LuaResult<Vec<PluginItem>> {
let mut items = Vec::new();
if let Value::Table(table) = result {
for pair in table.pairs::<i32, Table>() {
let (_, item_table) = pair?;
if let Ok(item) = parse_item(&item_table) {
items.push(item);
}
}
}
Ok(items)
}
/// Parse a single item from a Lua table
fn parse_item(table: &Table) -> LuaResult<PluginItem> {
let id: String = table.get("id")?;
let name: String = table.get("name")?;
let command: String = table.get::<Option<String>>("command")?.unwrap_or_default();
let description: Option<String> = table.get("description")?;
let icon: Option<String> = table.get("icon")?;
let terminal: bool = table.get::<Option<bool>>("terminal")?.unwrap_or(false);
let tags: Vec<String> = table.get::<Option<Vec<String>>>("tags")?.unwrap_or_default();
let mut item = PluginItem::new(id, name, command);
if let Some(desc) = description {
item = item.with_description(desc);
}
if let Some(ic) = icon {
item = item.with_icon(&ic);
}
if terminal {
item = item.with_terminal(true);
}
if !tags.is_empty() {
item = item.with_keywords(tags);
}
Ok(item)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{create_lua_runtime, SandboxConfig};
#[test]
fn test_register_static_provider() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_provider_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let code = r#"
owlry.provider.register({
name = "test-provider",
display_name = "Test Provider",
refresh = function()
return {
{ id = "1", name = "Item 1" }
}
end
})
"#;
lua.load(code).set_name("test").call::<()>(()).unwrap();
let regs = get_registrations(&lua).unwrap();
assert_eq!(regs.len(), 1);
assert_eq!(regs[0].name, "test-provider");
assert!(!regs[0].is_dynamic);
}
#[test]
fn test_register_dynamic_provider() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_provider_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let code = r#"
owlry.provider.register({
name = "query-provider",
prefix = "?",
query = function(q)
return {
{ id = "search", name = "Search: " .. q }
}
end
})
"#;
lua.load(code).set_name("test").call::<()>(()).unwrap();
let regs = get_registrations(&lua).unwrap();
assert_eq!(regs.len(), 1);
assert_eq!(regs[0].name, "query-provider");
assert!(regs[0].is_dynamic);
assert_eq!(regs[0].prefix, Some("?".to_string()));
}
}

View File

@@ -0,0 +1,370 @@
//! Utility APIs: logging, paths, filesystem, JSON
use mlua::{Lua, Result as LuaResult, Table, Value};
use std::path::{Path, PathBuf};
// ============================================================================
// Logging API
// ============================================================================
/// Register the log API in the owlry table
pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let log = lua.create_table()?;
log.set("debug", lua.create_function(|_, msg: String| {
eprintln!("[DEBUG] {}", msg);
Ok(())
})?)?;
log.set("info", lua.create_function(|_, msg: String| {
eprintln!("[INFO] {}", msg);
Ok(())
})?)?;
log.set("warn", lua.create_function(|_, msg: String| {
eprintln!("[WARN] {}", msg);
Ok(())
})?)?;
log.set("error", lua.create_function(|_, msg: String| {
eprintln!("[ERROR] {}", msg);
Ok(())
})?)?;
owlry.set("log", log)?;
Ok(())
}
// ============================================================================
// Path API
// ============================================================================
/// Register the path API in the owlry table
pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> {
let path = lua.create_table()?;
// owlry.path.config() -> ~/.config/owlry
path.set("config", lua.create_function(|_, ()| {
Ok(dirs::config_dir()
.map(|d| d.join("owlry"))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default())
})?)?;
// owlry.path.data() -> ~/.local/share/owlry
path.set("data", lua.create_function(|_, ()| {
Ok(dirs::data_dir()
.map(|d| d.join("owlry"))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default())
})?)?;
// owlry.path.cache() -> ~/.cache/owlry
path.set("cache", lua.create_function(|_, ()| {
Ok(dirs::cache_dir()
.map(|d| d.join("owlry"))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default())
})?)?;
// owlry.path.home() -> ~
path.set("home", lua.create_function(|_, ()| {
Ok(dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default())
})?)?;
// owlry.path.join(...) -> joined path
path.set("join", lua.create_function(|_, parts: mlua::Variadic<String>| {
let mut path = PathBuf::new();
for part in parts {
path.push(part);
}
Ok(path.to_string_lossy().to_string())
})?)?;
// owlry.path.plugin_dir() -> plugin directory
let plugin_dir_str = plugin_dir.to_string_lossy().to_string();
path.set("plugin_dir", lua.create_function(move |_, ()| {
Ok(plugin_dir_str.clone())
})?)?;
// owlry.path.expand(path) -> expanded path (~ -> home)
path.set("expand", lua.create_function(|_, path: String| {
if path.starts_with("~/")
&& let Some(home) = dirs::home_dir() {
return Ok(home.join(&path[2..]).to_string_lossy().to_string());
}
Ok(path)
})?)?;
owlry.set("path", path)?;
Ok(())
}
// ============================================================================
// Filesystem API
// ============================================================================
/// Register the fs API in the owlry table
pub fn register_fs_api(lua: &Lua, owlry: &Table, _plugin_dir: &Path) -> LuaResult<()> {
let fs = lua.create_table()?;
// owlry.fs.exists(path) -> bool
fs.set("exists", lua.create_function(|_, path: String| {
let path = expand_path(&path);
Ok(Path::new(&path).exists())
})?)?;
// owlry.fs.is_dir(path) -> bool
fs.set("is_dir", lua.create_function(|_, path: String| {
let path = expand_path(&path);
Ok(Path::new(&path).is_dir())
})?)?;
// owlry.fs.read(path) -> string or nil
fs.set("read", lua.create_function(|_, path: String| {
let path = expand_path(&path);
match std::fs::read_to_string(&path) {
Ok(content) => Ok(Some(content)),
Err(_) => Ok(None),
}
})?)?;
// owlry.fs.read_lines(path) -> table of strings or nil
fs.set("read_lines", lua.create_function(|lua, path: String| {
let path = expand_path(&path);
match std::fs::read_to_string(&path) {
Ok(content) => {
let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
Ok(Some(lua.create_sequence_from(lines)?))
}
Err(_) => Ok(None),
}
})?)?;
// owlry.fs.list_dir(path) -> table of filenames or nil
fs.set("list_dir", lua.create_function(|lua, path: String| {
let path = expand_path(&path);
match std::fs::read_dir(&path) {
Ok(entries) => {
let names: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
Ok(Some(lua.create_sequence_from(names)?))
}
Err(_) => Ok(None),
}
})?)?;
// owlry.fs.read_json(path) -> table or nil
fs.set("read_json", lua.create_function(|lua, path: String| {
let path = expand_path(&path);
match std::fs::read_to_string(&path) {
Ok(content) => {
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => json_to_lua(lua, &value),
Err(_) => Ok(Value::Nil),
}
}
Err(_) => Ok(Value::Nil),
}
})?)?;
// owlry.fs.write(path, content) -> bool
fs.set("write", lua.create_function(|_, (path, content): (String, String)| {
let path = expand_path(&path);
// Create parent directories if needed
if let Some(parent) = Path::new(&path).parent() {
let _ = std::fs::create_dir_all(parent);
}
Ok(std::fs::write(&path, content).is_ok())
})?)?;
owlry.set("fs", fs)?;
Ok(())
}
// ============================================================================
// JSON API
// ============================================================================
/// Register the json API in the owlry table
pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let json = lua.create_table()?;
// owlry.json.encode(value) -> string
json.set("encode", lua.create_function(|lua, value: Value| {
let json_value = lua_to_json(lua, &value)?;
Ok(serde_json::to_string(&json_value).unwrap_or_else(|_| "null".to_string()))
})?)?;
// owlry.json.decode(string) -> value or nil
json.set("decode", lua.create_function(|lua, s: String| {
match serde_json::from_str::<serde_json::Value>(&s) {
Ok(value) => json_to_lua(lua, &value),
Err(_) => Ok(Value::Nil),
}
})?)?;
owlry.set("json", json)?;
Ok(())
}
// ============================================================================
// Helper Functions
// ============================================================================
/// Expand ~ in paths
fn expand_path(path: &str) -> String {
if path.starts_with("~/")
&& let Some(home) = dirs::home_dir() {
return home.join(&path[2..]).to_string_lossy().to_string();
}
path.to_string()
}
/// Convert JSON value to Lua value
fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<Value> {
match value {
serde_json::Value::Null => Ok(Value::Nil),
serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(Value::Number(f))
} else {
Ok(Value::Nil)
}
}
serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)),
serde_json::Value::Array(arr) => {
let table = lua.create_table()?;
for (i, v) in arr.iter().enumerate() {
table.set(i + 1, json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
serde_json::Value::Object(obj) => {
let table = lua.create_table()?;
for (k, v) in obj {
table.set(k.as_str(), json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
}
}
/// Convert Lua value to JSON value
fn lua_to_json(_lua: &Lua, value: &Value) -> LuaResult<serde_json::Value> {
match value {
Value::Nil => Ok(serde_json::Value::Null),
Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
Value::Number(n) => Ok(serde_json::json!(*n)),
Value::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_string())),
Value::Table(t) => {
// Check if it's an array (sequential integer keys starting from 1)
let mut is_array = true;
let mut max_key = 0i64;
for pair in t.clone().pairs::<Value, Value>() {
let (k, _) = pair?;
match k {
Value::Integer(i) if i > 0 => {
max_key = max_key.max(i);
}
_ => {
is_array = false;
break;
}
}
}
if is_array && max_key > 0 {
let mut arr = Vec::new();
for i in 1..=max_key {
let v: Value = t.get(i)?;
arr.push(lua_to_json(_lua, &v)?);
}
Ok(serde_json::Value::Array(arr))
} else {
let mut obj = serde_json::Map::new();
for pair in t.clone().pairs::<String, Value>() {
let (k, v) = pair?;
obj.insert(k, lua_to_json(_lua, &v)?);
}
Ok(serde_json::Value::Object(obj))
}
}
_ => Ok(serde_json::Value::Null),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{create_lua_runtime, SandboxConfig};
#[test]
fn test_log_api() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_log_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
// Just verify it doesn't panic
lua.load("owlry.log.info('test message')").set_name("test").call::<()>(()).unwrap();
}
#[test]
fn test_path_api() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_path_api(&lua, &owlry, Path::new("/tmp/test-plugin")).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let home: String = lua.load("return owlry.path.home()").set_name("test").call(()).unwrap();
assert!(!home.is_empty());
let plugin_dir: String = lua.load("return owlry.path.plugin_dir()").set_name("test").call(()).unwrap();
assert_eq!(plugin_dir, "/tmp/test-plugin");
}
#[test]
fn test_fs_api() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_fs_api(&lua, &owlry, Path::new("/tmp")).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let exists: bool = lua.load("return owlry.fs.exists('/tmp')").set_name("test").call(()).unwrap();
assert!(exists);
let is_dir: bool = lua.load("return owlry.fs.is_dir('/tmp')").set_name("test").call(()).unwrap();
assert!(is_dir);
}
#[test]
fn test_json_api() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_json_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let code = r#"
local t = { name = "test", value = 42 }
local json = owlry.json.encode(t)
local decoded = owlry.json.decode(json)
return decoded.name, decoded.value
"#;
let (name, value): (String, i32) = lua.load(code).set_name("test").call(()).unwrap();
assert_eq!(name, "test");
assert_eq!(value, 42);
}
}

349
crates/owlry-lua/src/lib.rs Normal file
View File

@@ -0,0 +1,349 @@
//! Owlry Lua Runtime
//!
//! This crate provides Lua plugin support for owlry. It is loaded dynamically
//! by the core when Lua plugins need to be executed.
//!
//! # Architecture
//!
//! The runtime acts as a "meta-plugin" that:
//! 1. Discovers Lua plugins in `~/.config/owlry/plugins/`
//! 2. Creates sandboxed Lua VMs for each plugin
//! 3. Registers the `owlry` API table
//! 4. Bridges Lua providers to native `PluginItem` format
//!
//! # Plugin Structure
//!
//! Each plugin lives in its own directory:
//! ```text
//! ~/.config/owlry/plugins/
//! my-plugin/
//! plugin.toml # Plugin manifest
//! init.lua # Entry point
//! ```
mod api;
mod loader;
mod manifest;
mod runtime;
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{PluginItem, ProviderKind};
use std::collections::HashMap;
use std::path::PathBuf;
use loader::LoadedPlugin;
// Runtime metadata
const RUNTIME_ID: &str = "lua";
const RUNTIME_NAME: &str = "Lua Runtime";
const RUNTIME_VERSION: &str = env!("CARGO_PKG_VERSION");
const RUNTIME_DESCRIPTION: &str = "Lua 5.4 runtime for user plugins";
/// API version for compatibility checking
pub const LUA_RUNTIME_API_VERSION: u32 = 1;
/// Runtime vtable - exported interface for the core to use
#[repr(C)]
pub struct LuaRuntimeVTable {
/// Get runtime info
pub info: extern "C" fn() -> RuntimeInfo,
/// Initialize the runtime with plugins directory
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
/// Get provider infos from all loaded plugins
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<LuaProviderInfo>,
/// Refresh a provider's items
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
/// Query a dynamic provider
pub query: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>, query: RStr<'_>) -> RVec<PluginItem>,
/// Cleanup and drop the runtime
pub drop: extern "C" fn(handle: RuntimeHandle),
}
/// Runtime info returned by the runtime
#[repr(C)]
pub struct RuntimeInfo {
pub id: RString,
pub name: RString,
pub version: RString,
pub description: RString,
pub api_version: u32,
}
/// Opaque handle to the runtime state
#[repr(C)]
#[derive(Clone, Copy)]
pub struct RuntimeHandle {
pub ptr: *mut (),
}
unsafe impl Send for RuntimeHandle {}
unsafe impl Sync for RuntimeHandle {}
impl RuntimeHandle {
/// Create a null handle (reserved for error cases)
#[allow(dead_code)]
fn null() -> Self {
Self { ptr: std::ptr::null_mut() }
}
fn from_box<T>(state: Box<T>) -> Self {
Self { ptr: Box::into_raw(state) as *mut () }
}
unsafe fn drop_as<T>(&self) {
if !self.ptr.is_null() {
unsafe { drop(Box::from_raw(self.ptr as *mut T)) };
}
}
}
/// Provider info from a Lua plugin
#[repr(C)]
pub struct LuaProviderInfo {
/// Full provider ID: "plugin_id:provider_name"
pub id: RString,
/// Plugin ID this provider belongs to
pub plugin_id: RString,
/// Provider name within the plugin
pub provider_name: RString,
/// Display name
pub display_name: RString,
/// Optional prefix trigger
pub prefix: ROption<RString>,
/// Icon name
pub icon: RString,
/// Provider type (static/dynamic)
pub provider_type: ProviderKind,
/// Type ID for filtering
pub type_id: RString,
}
/// Internal runtime state
struct LuaRuntimeState {
plugins_dir: PathBuf,
plugins: HashMap<String, LoadedPlugin>,
/// Maps "plugin_id:provider_name" to plugin_id for lookup
provider_map: HashMap<String, String>,
}
impl LuaRuntimeState {
fn new(plugins_dir: PathBuf) -> Self {
Self {
plugins_dir,
plugins: HashMap::new(),
provider_map: HashMap::new(),
}
}
fn discover_and_load(&mut self, owlry_version: &str) {
let discovered = match loader::discover_plugins(&self.plugins_dir) {
Ok(d) => d,
Err(e) => {
eprintln!("owlry-lua: Failed to discover plugins: {}", e);
return;
}
};
for (id, (manifest, path)) in discovered {
// Check version compatibility
if !manifest.is_compatible_with(owlry_version) {
eprintln!("owlry-lua: Plugin '{}' not compatible with owlry {}", id, owlry_version);
continue;
}
let mut plugin = LoadedPlugin::new(manifest, path);
if let Err(e) = plugin.initialize() {
eprintln!("owlry-lua: Failed to initialize plugin '{}': {}", id, e);
continue;
}
// Build provider map
if let Ok(registrations) = plugin.get_provider_registrations() {
for reg in &registrations {
let full_id = format!("{}:{}", id, reg.name);
self.provider_map.insert(full_id, id.clone());
}
}
self.plugins.insert(id, plugin);
}
}
fn get_providers(&self) -> Vec<LuaProviderInfo> {
let mut providers = Vec::new();
for (plugin_id, plugin) in &self.plugins {
if let Ok(registrations) = plugin.get_provider_registrations() {
for reg in registrations {
let full_id = format!("{}:{}", plugin_id, reg.name);
let provider_type = if reg.is_dynamic {
ProviderKind::Dynamic
} else {
ProviderKind::Static
};
providers.push(LuaProviderInfo {
id: RString::from(full_id),
plugin_id: RString::from(plugin_id.as_str()),
provider_name: RString::from(reg.name.as_str()),
display_name: RString::from(reg.display_name.as_str()),
prefix: reg.prefix.map(RString::from).into(),
icon: RString::from(reg.default_icon.as_str()),
provider_type,
type_id: RString::from(reg.type_id.as_str()),
});
}
}
}
providers
}
fn refresh_provider(&self, provider_id: &str) -> Vec<PluginItem> {
// Parse "plugin_id:provider_name"
let parts: Vec<&str> = provider_id.splitn(2, ':').collect();
if parts.len() != 2 {
return Vec::new();
}
let (plugin_id, provider_name) = (parts[0], parts[1]);
if let Some(plugin) = self.plugins.get(plugin_id) {
match plugin.call_provider_refresh(provider_name) {
Ok(items) => items,
Err(e) => {
eprintln!("owlry-lua: Refresh failed for {}: {}", provider_id, e);
Vec::new()
}
}
} else {
Vec::new()
}
}
fn query_provider(&self, provider_id: &str, query: &str) -> Vec<PluginItem> {
// Parse "plugin_id:provider_name"
let parts: Vec<&str> = provider_id.splitn(2, ':').collect();
if parts.len() != 2 {
return Vec::new();
}
let (plugin_id, provider_name) = (parts[0], parts[1]);
if let Some(plugin) = self.plugins.get(plugin_id) {
match plugin.call_provider_query(provider_name, query) {
Ok(items) => items,
Err(e) => {
eprintln!("owlry-lua: Query failed for {}: {}", provider_id, e);
Vec::new()
}
}
} else {
Vec::new()
}
}
}
// ============================================================================
// Exported Functions
// ============================================================================
extern "C" fn runtime_info() -> RuntimeInfo {
RuntimeInfo {
id: RString::from(RUNTIME_ID),
name: RString::from(RUNTIME_NAME),
version: RString::from(RUNTIME_VERSION),
description: RString::from(RUNTIME_DESCRIPTION),
api_version: LUA_RUNTIME_API_VERSION,
}
}
extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle {
let plugins_dir = PathBuf::from(plugins_dir.as_str());
let mut state = Box::new(LuaRuntimeState::new(plugins_dir));
// TODO: Get owlry version from core somehow
// For now, use a reasonable default
state.discover_and_load("0.3.0");
RuntimeHandle::from_box(state)
}
extern "C" fn runtime_providers(handle: RuntimeHandle) -> RVec<LuaProviderInfo> {
if handle.ptr.is_null() {
return RVec::new();
}
let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) };
state.get_providers().into()
}
extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) };
state.refresh_provider(provider_id.as_str()).into()
}
extern "C" fn runtime_query(handle: RuntimeHandle, provider_id: RStr<'_>, query: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) };
state.query_provider(provider_id.as_str(), query.as_str()).into()
}
extern "C" fn runtime_drop(handle: RuntimeHandle) {
if !handle.ptr.is_null() {
unsafe {
handle.drop_as::<LuaRuntimeState>();
}
}
}
/// Static vtable instance
static LUA_RUNTIME_VTABLE: LuaRuntimeVTable = LuaRuntimeVTable {
info: runtime_info,
init: runtime_init,
providers: runtime_providers,
refresh: runtime_refresh,
query: runtime_query,
drop: runtime_drop,
};
/// Entry point - returns the runtime vtable
#[unsafe(no_mangle)]
pub extern "C" fn owlry_lua_runtime_vtable() -> &'static LuaRuntimeVTable {
&LUA_RUNTIME_VTABLE
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runtime_info() {
let info = runtime_info();
assert_eq!(info.id.as_str(), "lua");
assert_eq!(info.api_version, LUA_RUNTIME_API_VERSION);
}
#[test]
fn test_runtime_handle_null() {
let handle = RuntimeHandle::null();
assert!(handle.ptr.is_null());
}
#[test]
fn test_runtime_handle_from_box() {
let state = Box::new(42u32);
let handle = RuntimeHandle::from_box(state);
assert!(!handle.ptr.is_null());
unsafe { handle.drop_as::<u32>() };
}
}

View File

@@ -0,0 +1,212 @@
//! Plugin discovery and loading
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use mlua::Lua;
use owlry_plugin_api::PluginItem;
use crate::api;
use crate::manifest::PluginManifest;
use crate::runtime::{create_lua_runtime, load_file, SandboxConfig};
/// Provider registration info from Lua
#[derive(Debug, Clone)]
pub struct ProviderRegistration {
pub name: String,
pub display_name: String,
pub type_id: String,
pub default_icon: String,
pub prefix: Option<String>,
pub is_dynamic: bool,
}
/// A loaded plugin instance
pub struct LoadedPlugin {
/// Plugin manifest
pub manifest: PluginManifest,
/// Path to plugin directory
pub path: PathBuf,
/// Whether plugin is enabled
pub enabled: bool,
/// Lua runtime (None if not yet initialized)
lua: Option<Lua>,
}
impl std::fmt::Debug for LoadedPlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoadedPlugin")
.field("manifest", &self.manifest)
.field("path", &self.path)
.field("enabled", &self.enabled)
.field("lua", &self.lua.is_some())
.finish()
}
}
impl LoadedPlugin {
/// Create a new loaded plugin (not yet initialized)
pub fn new(manifest: PluginManifest, path: PathBuf) -> Self {
Self {
manifest,
path,
enabled: true,
lua: None,
}
}
/// Get the plugin ID
pub fn id(&self) -> &str {
&self.manifest.plugin.id
}
/// Initialize the Lua runtime and load the entry point
pub fn initialize(&mut self) -> Result<(), String> {
if self.lua.is_some() {
return Ok(()); // Already initialized
}
let sandbox = SandboxConfig::from_permissions(&self.manifest.permissions);
let lua = create_lua_runtime(&sandbox)
.map_err(|e| format!("Failed to create Lua runtime: {}", e))?;
// Register owlry APIs before loading entry point
api::register_apis(&lua, &self.path, self.id())
.map_err(|e| format!("Failed to register APIs: {}", e))?;
// Load the entry point file
let entry_path = self.path.join(&self.manifest.plugin.entry);
if !entry_path.exists() {
return Err(format!("Entry point '{}' not found", self.manifest.plugin.entry));
}
load_file(&lua, &entry_path)
.map_err(|e| format!("Failed to load entry point: {}", e))?;
self.lua = Some(lua);
Ok(())
}
/// Get provider registrations from this plugin
pub fn get_provider_registrations(&self) -> Result<Vec<ProviderRegistration>, String> {
let lua = self.lua.as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?;
api::get_provider_registrations(lua)
.map_err(|e| format!("Failed to get registrations: {}", e))
}
/// Call a provider's refresh function
pub fn call_provider_refresh(&self, provider_name: &str) -> Result<Vec<PluginItem>, String> {
let lua = self.lua.as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?;
api::call_refresh(lua, provider_name)
.map_err(|e| format!("Refresh failed: {}", e))
}
/// Call a provider's query function
pub fn call_provider_query(&self, provider_name: &str, query: &str) -> Result<Vec<PluginItem>, String> {
let lua = self.lua.as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?;
api::call_query(lua, provider_name, query)
.map_err(|e| format!("Query failed: {}", e))
}
}
/// Discover plugins in a directory
pub fn discover_plugins(plugins_dir: &Path) -> Result<HashMap<String, (PluginManifest, PathBuf)>, String> {
let mut plugins = HashMap::new();
if !plugins_dir.exists() {
return Ok(plugins);
}
let entries = std::fs::read_dir(plugins_dir)
.map_err(|e| format!("Failed to read plugins directory: {}", e))?;
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("plugin.toml");
if !manifest_path.exists() {
continue;
}
match PluginManifest::load(&manifest_path) {
Ok(manifest) => {
let id = manifest.plugin.id.clone();
if plugins.contains_key(&id) {
eprintln!("owlry-lua: Duplicate plugin ID '{}', skipping {}", id, path.display());
continue;
}
plugins.insert(id, (manifest, path));
}
Err(e) => {
eprintln!("owlry-lua: Failed to load plugin at {}: {}", path.display(), e);
}
}
}
Ok(plugins)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_plugin(dir: &Path, id: &str) {
let plugin_dir = dir.join(id);
fs::create_dir_all(&plugin_dir).unwrap();
let manifest = format!(
r#"
[plugin]
id = "{}"
name = "Test {}"
version = "1.0.0"
"#,
id, id
);
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
fs::write(plugin_dir.join("init.lua"), "-- empty plugin").unwrap();
}
#[test]
fn test_discover_plugins() {
let temp = TempDir::new().unwrap();
let plugins_dir = temp.path();
create_test_plugin(plugins_dir, "test-plugin");
create_test_plugin(plugins_dir, "another-plugin");
let plugins = discover_plugins(plugins_dir).unwrap();
assert_eq!(plugins.len(), 2);
assert!(plugins.contains_key("test-plugin"));
assert!(plugins.contains_key("another-plugin"));
}
#[test]
fn test_discover_plugins_empty_dir() {
let temp = TempDir::new().unwrap();
let plugins = discover_plugins(temp.path()).unwrap();
assert!(plugins.is_empty());
}
#[test]
fn test_discover_plugins_nonexistent_dir() {
let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap();
assert!(plugins.is_empty());
}
}

View File

@@ -0,0 +1,173 @@
//! Plugin manifest (plugin.toml) parsing
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
/// Plugin manifest loaded from plugin.toml
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
#[serde(default)]
pub provides: PluginProvides,
#[serde(default)]
pub permissions: PluginPermissions,
#[serde(default)]
pub settings: HashMap<String, toml::Value>,
}
/// Core plugin information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
/// Unique plugin identifier (lowercase, alphanumeric, hyphens)
pub id: String,
/// Human-readable name
pub name: String,
/// Semantic version
pub version: String,
/// Short description
#[serde(default)]
pub description: String,
/// Plugin author
#[serde(default)]
pub author: String,
/// License identifier
#[serde(default)]
pub license: String,
/// Repository URL
#[serde(default)]
pub repository: Option<String>,
/// Required owlry version (semver constraint)
#[serde(default = "default_owlry_version")]
pub owlry_version: String,
/// Entry point file (relative to plugin directory)
#[serde(default = "default_entry")]
pub entry: String,
}
fn default_owlry_version() -> String {
">=0.1.0".to_string()
}
fn default_entry() -> String {
"init.lua".to_string()
}
/// What the plugin provides
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginProvides {
/// Provider names this plugin registers
#[serde(default)]
pub providers: Vec<String>,
/// Whether this plugin registers actions
#[serde(default)]
pub actions: bool,
/// Theme names this plugin contributes
#[serde(default)]
pub themes: Vec<String>,
/// Whether this plugin registers hooks
#[serde(default)]
pub hooks: bool,
}
/// Plugin permissions/capabilities
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginPermissions {
/// Allow network/HTTP requests
#[serde(default)]
pub network: bool,
/// Filesystem paths the plugin can access (beyond its own directory)
#[serde(default)]
pub filesystem: Vec<String>,
/// Commands the plugin is allowed to run
#[serde(default)]
pub run_commands: Vec<String>,
/// Environment variables the plugin reads
#[serde(default)]
pub environment: Vec<String>,
}
impl PluginManifest {
/// Load a plugin manifest from a plugin.toml file
pub fn load(path: &Path) -> Result<Self, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read manifest: {}", e))?;
let manifest: PluginManifest = toml::from_str(&content)
.map_err(|e| format!("Failed to parse manifest: {}", e))?;
manifest.validate()?;
Ok(manifest)
}
/// Validate the manifest
fn validate(&self) -> Result<(), String> {
// Validate plugin ID format
if self.plugin.id.is_empty() {
return Err("Plugin ID cannot be empty".to_string());
}
if !self.plugin.id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
return Err("Plugin ID must be lowercase alphanumeric with hyphens".to_string());
}
// Validate version format
if semver::Version::parse(&self.plugin.version).is_err() {
return Err(format!("Invalid version format: {}", self.plugin.version));
}
// Validate owlry_version constraint
if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() {
return Err(format!("Invalid owlry_version constraint: {}", self.plugin.owlry_version));
}
Ok(())
}
/// Check if this plugin is compatible with the given owlry version
pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
Ok(r) => r,
Err(_) => return false,
};
let version = match semver::Version::parse(owlry_version) {
Ok(v) => v,
Err(_) => return false,
};
req.matches(&version)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_manifest() {
let toml_str = r#"
[plugin]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.name, "Test Plugin");
assert_eq!(manifest.plugin.version, "1.0.0");
assert_eq!(manifest.plugin.entry, "init.lua");
}
#[test]
fn test_version_compatibility() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
owlry_version = ">=0.3.0, <1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.is_compatible_with("0.3.5"));
assert!(manifest.is_compatible_with("0.4.0"));
assert!(!manifest.is_compatible_with("0.2.0"));
assert!(!manifest.is_compatible_with("1.0.0"));
}
}

View File

@@ -0,0 +1,153 @@
//! Lua runtime setup and sandboxing
use mlua::{Lua, Result as LuaResult, StdLib};
use crate::manifest::PluginPermissions;
/// Configuration for the Lua sandbox
///
/// Note: Some fields are reserved for future sandbox enforcement.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SandboxConfig {
/// Allow shell command running (reserved for future enforcement)
pub allow_commands: bool,
/// Allow HTTP requests (reserved for future enforcement)
pub allow_network: bool,
/// Allow filesystem access outside plugin directory (reserved for future enforcement)
pub allow_external_fs: bool,
/// Maximum run time per call (ms) (reserved for future enforcement)
pub max_run_time_ms: u64,
/// Memory limit (bytes, 0 = unlimited) (reserved for future enforcement)
pub max_memory: usize,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
allow_commands: false,
allow_network: false,
allow_external_fs: false,
max_run_time_ms: 5000, // 5 seconds
max_memory: 64 * 1024 * 1024, // 64 MB
}
}
}
impl SandboxConfig {
/// Create a sandbox config from plugin permissions
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
Self {
allow_commands: !permissions.run_commands.is_empty(),
allow_network: permissions.network,
allow_external_fs: !permissions.filesystem.is_empty(),
..Default::default()
}
}
}
/// Create a new sandboxed Lua runtime
pub fn create_lua_runtime(_sandbox: &SandboxConfig) -> LuaResult<Lua> {
// Create Lua with safe standard libraries only
// We exclude: debug, io, os (dangerous parts), package (loadlib), ffi
let libs = StdLib::COROUTINE
| StdLib::TABLE
| StdLib::STRING
| StdLib::UTF8
| StdLib::MATH;
let lua = Lua::new_with(libs, mlua::LuaOptions::default())?;
// Set up safe environment
setup_safe_globals(&lua)?;
Ok(lua)
}
/// Set up safe global environment by removing/replacing dangerous functions
fn setup_safe_globals(lua: &Lua) -> LuaResult<()> {
let globals = lua.globals();
// Remove dangerous globals
globals.set("dofile", mlua::Value::Nil)?;
globals.set("loadfile", mlua::Value::Nil)?;
// Create a restricted os table with only safe functions
let os_table = lua.create_table()?;
os_table.set("clock", lua.create_function(|_, ()| {
Ok(std::time::Instant::now().elapsed().as_secs_f64())
})?)?;
os_table.set("date", lua.create_function(os_date)?)?;
os_table.set("difftime", lua.create_function(|_, (t2, t1): (f64, f64)| Ok(t2 - t1))?)?;
os_table.set("time", lua.create_function(os_time)?)?;
globals.set("os", os_table)?;
// Remove print (plugins should use owlry.log instead)
globals.set("print", mlua::Value::Nil)?;
Ok(())
}
/// Safe os.date implementation
fn os_date(_lua: &Lua, format: Option<String>) -> LuaResult<String> {
use chrono::Local;
let now = Local::now();
let fmt = format.unwrap_or_else(|| "%c".to_string());
Ok(now.format(&fmt).to_string())
}
/// Safe os.time implementation
fn os_time(_lua: &Lua, _args: ()) -> LuaResult<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
Ok(duration.as_secs() as i64)
}
/// Load and run a Lua file in the given runtime
pub fn load_file(lua: &Lua, path: &std::path::Path) -> LuaResult<()> {
let content = std::fs::read_to_string(path)
.map_err(mlua::Error::external)?;
lua.load(&content)
.set_name(path.file_name().and_then(|n| n.to_str()).unwrap_or("chunk"))
.into_function()?
.call(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_sandboxed_runtime() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
// Verify dangerous functions are removed
let result: LuaResult<mlua::Value> = lua.globals().get("dofile");
assert!(matches!(result, Ok(mlua::Value::Nil)));
// Verify safe functions work
let result: String = lua.load("return os.date('%Y')").call(()).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_basic_lua_operations() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
// Test basic math
let result: i32 = lua.load("return 2 + 2").call(()).unwrap();
assert_eq!(result, 4);
// Test table operations
let result: i32 = lua.load("local t = {1,2,3}; return #t").call(()).unwrap();
assert_eq!(result, 3);
// Test string operations
let result: String = lua.load("return string.upper('hello')").call(()).unwrap();
assert_eq!(result, "HELLO");
}
}

View File

@@ -0,0 +1,31 @@
[package]
name = "owlry-plugin-bookmarks"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Bookmarks plugin for owlry - browser bookmark search"
keywords = ["owlry", "plugin", "bookmarks", "browser"]
categories = ["web-programming"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"
# For finding browser config directories
dirs = "5.0"
# For parsing Chrome bookmarks JSON
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# For reading Firefox bookmarks (places.sqlite)
# Use bundled SQLite to avoid system library version conflicts
rusqlite = { version = "0.39", features = ["bundled"] }

View File

@@ -0,0 +1,662 @@
//! Bookmarks Plugin for Owlry
//!
//! A static provider that reads browser bookmarks from various browsers.
//!
//! Supported browsers:
//! - Firefox (via places.sqlite using rusqlite with bundled SQLite)
//! - Chrome
//! - Chromium
//! - Brave
//! - Edge
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
use rusqlite::{Connection, OpenFlags};
use serde::Deserialize;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
// Plugin metadata
const PLUGIN_ID: &str = "bookmarks";
const PLUGIN_NAME: &str = "Bookmarks";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Browser bookmark search";
// Provider metadata
const PROVIDER_ID: &str = "bookmarks";
const PROVIDER_NAME: &str = "Bookmarks";
const PROVIDER_PREFIX: &str = ":bm";
const PROVIDER_ICON: &str = "user-bookmarks-symbolic";
const PROVIDER_TYPE_ID: &str = "bookmarks";
/// Bookmarks provider state - holds cached items
struct BookmarksState {
/// Cached bookmark items (returned immediately on refresh)
items: Vec<PluginItem>,
/// Flag to prevent concurrent background loads
loading: Arc<AtomicBool>,
}
impl BookmarksState {
fn new() -> Self {
Self {
items: Vec::new(),
loading: Arc::new(AtomicBool::new(false)),
}
}
/// Get or create the favicon cache directory
fn favicon_cache_dir() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("owlry/favicons"))
}
/// Ensure the favicon cache directory exists
fn ensure_favicon_cache_dir() -> Option<PathBuf> {
Self::favicon_cache_dir().and_then(|dir| {
fs::create_dir_all(&dir).ok()?;
Some(dir)
})
}
/// Hash a URL to create a cache filename
fn url_to_cache_filename(url: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
format!("{:016x}.png", hasher.finish())
}
/// Get the bookmark cache file path
fn bookmark_cache_file() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("owlry/bookmarks.json"))
}
/// Load cached bookmarks from disk (fast)
fn load_cached_bookmarks() -> Vec<PluginItem> {
let cache_file = match Self::bookmark_cache_file() {
Some(f) => f,
None => return Vec::new(),
};
if !cache_file.exists() {
return Vec::new();
}
let content = match fs::read_to_string(&cache_file) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
// Parse cached bookmarks (simple JSON format)
#[derive(serde::Deserialize)]
struct CachedBookmark {
id: String,
name: String,
command: String,
description: Option<String>,
icon: String,
}
let cached: Vec<CachedBookmark> = match serde_json::from_str(&content) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
cached
.into_iter()
.map(|b| {
let mut item = PluginItem::new(b.id, b.name, b.command)
.with_icon(&b.icon)
.with_keywords(vec!["bookmark".to_string()]);
if let Some(desc) = b.description {
item = item.with_description(desc);
}
item
})
.collect()
}
/// Save bookmarks to cache file
fn save_cached_bookmarks(items: &[PluginItem]) {
let cache_file = match Self::bookmark_cache_file() {
Some(f) => f,
None => return,
};
// Ensure cache directory exists
if let Some(parent) = cache_file.parent() {
let _ = fs::create_dir_all(parent);
}
#[derive(serde::Serialize)]
struct CachedBookmark {
id: String,
name: String,
command: String,
description: Option<String>,
icon: String,
}
let cached: Vec<CachedBookmark> = items
.iter()
.map(|item| {
let desc: Option<String> = match &item.description {
abi_stable::std_types::ROption::RSome(s) => Some(s.to_string()),
abi_stable::std_types::ROption::RNone => None,
};
let icon: String = match &item.icon {
abi_stable::std_types::ROption::RSome(s) => s.to_string(),
abi_stable::std_types::ROption::RNone => PROVIDER_ICON.to_string(),
};
CachedBookmark {
id: item.id.to_string(),
name: item.name.to_string(),
command: item.command.to_string(),
description: desc,
icon,
}
})
.collect();
if let Ok(json) = serde_json::to_string(&cached) {
let _ = fs::write(&cache_file, json);
}
}
fn chromium_bookmark_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(config_dir) = dirs::config_dir() {
// Chrome
paths.push(config_dir.join("google-chrome/Default/Bookmarks"));
paths.push(config_dir.join("google-chrome-stable/Default/Bookmarks"));
// Chromium
paths.push(config_dir.join("chromium/Default/Bookmarks"));
// Brave
paths.push(config_dir.join("BraveSoftware/Brave-Browser/Default/Bookmarks"));
// Edge
paths.push(config_dir.join("microsoft-edge/Default/Bookmarks"));
}
paths
}
fn firefox_places_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(home) = dirs::home_dir() {
let firefox_dir = home.join(".mozilla/firefox");
if firefox_dir.exists() {
// Find all profile directories
if let Ok(entries) = fs::read_dir(&firefox_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let places = path.join("places.sqlite");
if places.exists() {
paths.push(places);
}
}
}
}
}
}
paths
}
/// Find Firefox favicons.sqlite paths (paired with places.sqlite)
fn firefox_favicons_path(places_path: &Path) -> Option<PathBuf> {
let favicons = places_path.parent()?.join("favicons.sqlite");
if favicons.exists() {
Some(favicons)
} else {
None
}
}
fn load_bookmarks(&mut self) {
// Fast path: load from cache immediately
if self.items.is_empty() {
self.items = Self::load_cached_bookmarks();
}
// Don't start another background load if one is already running
if self.loading.swap(true, Ordering::SeqCst) {
return;
}
// Spawn background thread to refresh bookmarks
let loading = self.loading.clone();
thread::spawn(move || {
let mut items = Vec::new();
// Load Chrome/Chromium bookmarks (fast - just JSON parsing)
for path in Self::chromium_bookmark_paths() {
if path.exists() {
Self::read_chrome_bookmarks_static(&path, &mut items);
}
}
// Load Firefox bookmarks with favicons (synchronous with rusqlite)
for path in Self::firefox_places_paths() {
Self::read_firefox_bookmarks(&path, &mut items);
}
// Save to cache for next startup
Self::save_cached_bookmarks(&items);
loading.store(false, Ordering::SeqCst);
});
}
/// Read Chrome bookmarks (static helper for background thread)
fn read_chrome_bookmarks_static(path: &PathBuf, items: &mut Vec<PluginItem>) {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return,
};
let bookmarks: ChromeBookmarks = match serde_json::from_str(&content) {
Ok(b) => b,
Err(_) => return,
};
if let Some(roots) = bookmarks.roots {
if let Some(bar) = roots.bookmark_bar {
Self::process_chrome_folder_static(&bar, items);
}
if let Some(other) = roots.other {
Self::process_chrome_folder_static(&other, items);
}
if let Some(synced) = roots.synced {
Self::process_chrome_folder_static(&synced, items);
}
}
}
fn process_chrome_folder_static(folder: &ChromeBookmarkNode, items: &mut Vec<PluginItem>) {
if let Some(ref children) = folder.children {
for child in children {
match child.node_type.as_deref() {
Some("url") => {
if let Some(ref url) = child.url {
let name = child.name.clone().unwrap_or_else(|| url.clone());
items.push(
PluginItem::new(
format!("bookmark:{}", url),
name,
format!("xdg-open '{}'", url.replace('\'', "'\\''")),
)
.with_description(url.clone())
.with_icon(PROVIDER_ICON)
.with_keywords(vec!["bookmark".to_string(), "chrome".to_string()]),
);
}
}
Some("folder") => {
Self::process_chrome_folder_static(child, items);
}
_ => {}
}
}
}
}
/// Read Firefox bookmarks using rusqlite (synchronous, bundled SQLite)
fn read_firefox_bookmarks(places_path: &PathBuf, items: &mut Vec<PluginItem>) {
let temp_dir = std::env::temp_dir();
let temp_db = temp_dir.join("owlry_places_temp.sqlite");
// Copy database to temp location to avoid locking issues
if fs::copy(places_path, &temp_db).is_err() {
return;
}
// Also copy WAL file if it exists
let wal_path = places_path.with_extension("sqlite-wal");
if wal_path.exists() {
let temp_wal = temp_db.with_extension("sqlite-wal");
let _ = fs::copy(&wal_path, &temp_wal);
}
// Copy favicons database if available
let favicons_path = Self::firefox_favicons_path(places_path);
let temp_favicons = temp_dir.join("owlry_favicons_temp.sqlite");
if let Some(ref fp) = favicons_path {
let _ = fs::copy(fp, &temp_favicons);
let fav_wal = fp.with_extension("sqlite-wal");
if fav_wal.exists() {
let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal"));
}
}
let cache_dir = Self::ensure_favicon_cache_dir();
// Read bookmarks from places.sqlite
let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref());
// Clean up temp files
let _ = fs::remove_file(&temp_db);
let _ = fs::remove_file(temp_db.with_extension("sqlite-wal"));
let _ = fs::remove_file(&temp_favicons);
let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal"));
for (title, url, favicon_path) in bookmarks {
let icon = favicon_path.unwrap_or_else(|| PROVIDER_ICON.to_string());
items.push(
PluginItem::new(
format!("bookmark:firefox:{}", url),
title,
format!("xdg-open '{}'", url.replace('\'', "'\\''")),
)
.with_description(url)
.with_icon(&icon)
.with_keywords(vec!["bookmark".to_string(), "firefox".to_string()]),
);
}
}
/// Fetch Firefox bookmarks with optional favicons
fn fetch_firefox_bookmarks(
places_path: &Path,
favicons_path: &Path,
cache_dir: Option<&PathBuf>,
) -> Vec<(String, String, Option<String>)> {
// Open places.sqlite in read-only mode
let conn = match Connection::open_with_flags(
places_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
// Query bookmarks joining moz_bookmarks with moz_places
// type=1 means URL bookmarks (not folders, separators, etc.)
let query = r#"
SELECT b.title, p.url
FROM moz_bookmarks b
JOIN moz_places p ON b.fk = p.id
WHERE b.type = 1
AND p.url NOT LIKE 'place:%'
AND p.url NOT LIKE 'about:%'
AND b.title IS NOT NULL
AND b.title != ''
ORDER BY b.dateAdded DESC
LIMIT 500
"#;
let mut stmt = match conn.prepare(query) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let bookmarks: Vec<(String, String)> = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.ok()
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default();
// If no favicons or cache dir, return without favicons
let cache_dir = match cache_dir {
Some(c) => c,
None => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
};
// Try to open favicons database
let fav_conn = match Connection::open_with_flags(
favicons_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(c) => c,
Err(_) => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
};
// Fetch favicons for each URL
let mut results = Vec::new();
for (title, url) in bookmarks {
let favicon_path = Self::get_favicon_for_url(&fav_conn, &url, cache_dir);
results.push((title, url, favicon_path));
}
results
}
/// Get favicon for a URL, caching to file if needed
fn get_favicon_for_url(
conn: &Connection,
page_url: &str,
cache_dir: &Path,
) -> Option<String> {
// Check if already cached
let cache_filename = Self::url_to_cache_filename(page_url);
let cache_path = cache_dir.join(&cache_filename);
if cache_path.exists() {
return Some(cache_path.to_string_lossy().to_string());
}
// Query favicon data from database
// Join moz_pages_w_icons -> moz_icons_to_pages -> moz_icons
// Prefer smaller icons (32px) for efficiency
let query = r#"
SELECT i.data
FROM moz_pages_w_icons p
JOIN moz_icons_to_pages ip ON p.id = ip.page_id
JOIN moz_icons i ON ip.icon_id = i.id
WHERE p.page_url = ?
AND i.data IS NOT NULL
ORDER BY ABS(i.width - 32) ASC
LIMIT 1
"#;
let data: Option<Vec<u8>> = conn
.query_row(query, [page_url], |row| row.get(0))
.ok();
let data = data?;
if data.is_empty() {
return None;
}
// Write favicon data to cache file
let mut file = fs::File::create(&cache_path).ok()?;
file.write_all(&data).ok()?;
Some(cache_path.to_string_lossy().to_string())
}
}
// Chrome bookmark JSON structures
#[derive(Debug, Deserialize)]
struct ChromeBookmarks {
roots: Option<ChromeBookmarkRoots>,
}
#[derive(Debug, Deserialize)]
struct ChromeBookmarkRoots {
bookmark_bar: Option<ChromeBookmarkNode>,
other: Option<ChromeBookmarkNode>,
synced: Option<ChromeBookmarkNode>,
}
#[derive(Debug, Deserialize)]
struct ChromeBookmarkNode {
name: Option<String>,
url: Option<String>,
#[serde(rename = "type")]
node_type: Option<String>,
children: Option<Vec<ChromeBookmarkNode>>,
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(BookmarksState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<BookmarksState>
let state = unsafe { &mut *(handle.ptr as *mut BookmarksState) };
// Load bookmarks
state.load_bookmarks();
// Return items
state.items.to_vec().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query is handled by the core using cached items
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<BookmarksState>
unsafe {
handle.drop_as::<BookmarksState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bookmarks_state_new() {
let state = BookmarksState::new();
assert!(state.items.is_empty());
}
#[test]
fn test_chromium_paths() {
let paths = BookmarksState::chromium_bookmark_paths();
// Should have at least some paths configured
assert!(!paths.is_empty());
}
#[test]
fn test_firefox_paths() {
// This will find paths if Firefox is installed
let paths = BookmarksState::firefox_places_paths();
// Path detection should work (may be empty if Firefox not installed)
let _ = paths.len(); // Just ensure it doesn't panic
}
#[test]
fn test_parse_chrome_bookmarks() {
let json = r#"{
"roots": {
"bookmark_bar": {
"type": "folder",
"children": [
{
"type": "url",
"name": "Example",
"url": "https://example.com"
}
]
}
}
}"#;
let bookmarks: ChromeBookmarks = serde_json::from_str(json).unwrap();
assert!(bookmarks.roots.is_some());
let roots = bookmarks.roots.unwrap();
assert!(roots.bookmark_bar.is_some());
let bar = roots.bookmark_bar.unwrap();
assert!(bar.children.is_some());
assert_eq!(bar.children.unwrap().len(), 1);
}
#[test]
fn test_process_folder() {
let mut items = Vec::new();
let folder = ChromeBookmarkNode {
name: Some("Test Folder".to_string()),
url: None,
node_type: Some("folder".to_string()),
children: Some(vec![
ChromeBookmarkNode {
name: Some("Test Bookmark".to_string()),
url: Some("https://test.com".to_string()),
node_type: Some("url".to_string()),
children: None,
},
]),
};
BookmarksState::process_chrome_folder_static(&folder, &mut items);
assert_eq!(items.len(), 1);
assert_eq!(items[0].name.as_str(), "Test Bookmark");
}
#[test]
fn test_url_escaping() {
let url = "https://example.com/path?query='test'";
let command = format!("xdg-open '{}'", url.replace('\'', "'\\''"));
assert!(command.contains("'\\''"));
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "owlry-plugin-calculator"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Calculator plugin for owlry - evaluates mathematical expressions"
keywords = ["owlry", "plugin", "calculator"]
categories = ["mathematics"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# Math expression evaluation
meval = "0.2"
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -0,0 +1,231 @@
//! Calculator Plugin for Owlry
//!
//! A dynamic provider that evaluates mathematical expressions.
//! Supports queries prefixed with `=` or `calc `.
//!
//! Examples:
//! - `= 5 + 3` → 8
//! - `calc sqrt(16)` → 4
//! - `= pi * 2` → 6.283185...
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
// Plugin metadata
const PLUGIN_ID: &str = "calculator";
const PLUGIN_NAME: &str = "Calculator";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Evaluate mathematical expressions";
// Provider metadata
const PROVIDER_ID: &str = "calculator";
const PROVIDER_NAME: &str = "Calculator";
const PROVIDER_PREFIX: &str = "=";
const PROVIDER_ICON: &str = "accessories-calculator";
const PROVIDER_TYPE_ID: &str = "calc";
/// Calculator provider state (empty for now, but could cache results)
struct CalculatorState;
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Dynamic,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 10000, // Dynamic: calculator results first
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
// Create state and return handle
let state = Box::new(CalculatorState);
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
// Dynamic provider - refresh does nothing
RVec::new()
}
extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
let query_str = query.as_str();
// Extract expression from query
let expr = match extract_expression(query_str) {
Some(e) if !e.is_empty() => e,
_ => return RVec::new(),
};
// Evaluate the expression
match evaluate_expression(expr) {
Some(item) => vec![item].into(),
None => RVec::new(),
}
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<CalculatorState>
unsafe {
handle.drop_as::<CalculatorState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Calculator Logic
// ============================================================================
/// Extract expression from query (handles `= expr` and `calc expr` formats)
fn extract_expression(query: &str) -> Option<&str> {
let trimmed = query.trim();
// Support both "= expr" and "=expr" (with or without space)
if let Some(expr) = trimmed.strip_prefix("= ") {
Some(expr.trim())
} else if let Some(expr) = trimmed.strip_prefix('=') {
Some(expr.trim())
} else if let Some(expr) = trimmed.strip_prefix("calc ") {
Some(expr.trim())
} else {
// For filter mode - accept raw expressions
Some(trimmed)
}
}
/// Evaluate a mathematical expression and return a PluginItem
fn evaluate_expression(expr: &str) -> Option<PluginItem> {
match meval::eval_str(expr) {
Ok(result) => {
// Format result nicely
let result_str = format_result(result);
Some(
PluginItem::new(
format!("calc:{}", expr),
result_str.clone(),
format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str),
)
.with_description(format!("= {}", expr))
.with_icon(PROVIDER_ICON)
.with_keywords(vec!["math".to_string(), "calculator".to_string()]),
)
}
Err(_) => None,
}
}
/// Format a numeric result nicely
fn format_result(result: f64) -> String {
if result.fract() == 0.0 && result.abs() < 1e15 {
// Integer result
format!("{}", result as i64)
} else {
// Float result with reasonable precision, trimming trailing zeros
let formatted = format!("{:.10}", result);
formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_expression() {
assert_eq!(extract_expression("= 5+3"), Some("5+3"));
assert_eq!(extract_expression("=5+3"), Some("5+3"));
assert_eq!(extract_expression("calc 5+3"), Some("5+3"));
assert_eq!(extract_expression(" = 5 + 3 "), Some("5 + 3"));
assert_eq!(extract_expression("5+3"), Some("5+3")); // Raw expression
}
#[test]
fn test_format_result() {
assert_eq!(format_result(8.0), "8");
assert_eq!(format_result(2.5), "2.5");
assert_eq!(format_result(3.14159265358979), "3.1415926536");
}
#[test]
fn test_evaluate_basic() {
let item = evaluate_expression("5+3").unwrap();
assert_eq!(item.name.as_str(), "8");
let item = evaluate_expression("10 * 2").unwrap();
assert_eq!(item.name.as_str(), "20");
let item = evaluate_expression("15 / 3").unwrap();
assert_eq!(item.name.as_str(), "5");
}
#[test]
fn test_evaluate_float() {
let item = evaluate_expression("5/2").unwrap();
assert_eq!(item.name.as_str(), "2.5");
}
#[test]
fn test_evaluate_functions() {
let item = evaluate_expression("sqrt(16)").unwrap();
assert_eq!(item.name.as_str(), "4");
let item = evaluate_expression("abs(-5)").unwrap();
assert_eq!(item.name.as_str(), "5");
}
#[test]
fn test_evaluate_constants() {
let item = evaluate_expression("pi").unwrap();
assert!(item.name.as_str().starts_with("3.14159"));
let item = evaluate_expression("e").unwrap();
assert!(item.name.as_str().starts_with("2.718"));
}
#[test]
fn test_evaluate_invalid() {
assert!(evaluate_expression("").is_none());
assert!(evaluate_expression("invalid").is_none());
assert!(evaluate_expression("5 +").is_none());
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "owlry-plugin-clipboard"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Clipboard plugin for owlry - clipboard history via cliphist"
keywords = ["owlry", "plugin", "clipboard"]
categories = ["os"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -0,0 +1,259 @@
//! Clipboard Plugin for Owlry
//!
//! A static provider that integrates with cliphist to show clipboard history.
//! Requires cliphist and wl-clipboard to be installed.
//!
//! Dependencies:
//! - cliphist: clipboard history manager
//! - wl-clipboard: Wayland clipboard utilities (wl-copy)
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
use std::process::Command;
// Plugin metadata
const PLUGIN_ID: &str = "clipboard";
const PLUGIN_NAME: &str = "Clipboard";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Clipboard history via cliphist";
// Provider metadata
const PROVIDER_ID: &str = "clipboard";
const PROVIDER_NAME: &str = "Clipboard";
const PROVIDER_PREFIX: &str = ":clip";
const PROVIDER_ICON: &str = "edit-paste";
const PROVIDER_TYPE_ID: &str = "clipboard";
// Default max entries to show
const DEFAULT_MAX_ENTRIES: usize = 50;
/// Clipboard provider state - holds cached items
struct ClipboardState {
items: Vec<PluginItem>,
max_entries: usize,
}
impl ClipboardState {
fn new() -> Self {
Self {
items: Vec::new(),
max_entries: DEFAULT_MAX_ENTRIES,
}
}
/// Check if cliphist is available
fn has_cliphist() -> bool {
Command::new("which")
.arg("cliphist")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn load_clipboard_history(&mut self) {
self.items.clear();
if !Self::has_cliphist() {
return;
}
// Get clipboard history from cliphist
let output = match Command::new("cliphist").arg("list").output() {
Ok(o) => o,
Err(_) => return,
};
if !output.status.success() {
return;
}
let content = String::from_utf8_lossy(&output.stdout);
for (idx, line) in content.lines().take(self.max_entries).enumerate() {
// cliphist format: "id\tpreview"
let parts: Vec<&str> = line.splitn(2, '\t').collect();
if parts.is_empty() {
continue;
}
let clip_id = parts[0];
let preview = if parts.len() > 1 {
// Truncate long previews (char-safe for UTF-8)
let p = parts[1];
if p.chars().count() > 80 {
let truncated: String = p.chars().take(77).collect();
format!("{}...", truncated)
} else {
p.to_string()
}
} else {
"[binary data]".to_string()
};
// Clean up preview - replace newlines with spaces
let preview_clean = preview
.replace('\n', " ")
.replace('\r', "")
.replace('\t', " ");
// Command to paste this entry
// echo "id" | cliphist decode | wl-copy
let command = format!(
"echo '{}' | cliphist decode | wl-copy",
clip_id.replace('\'', "'\\''")
);
self.items.push(
PluginItem::new(format!("clipboard:{}", idx), preview_clean, command)
.with_description("Copy to clipboard")
.with_icon(PROVIDER_ICON),
);
}
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(ClipboardState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<ClipboardState>
let state = unsafe { &mut *(handle.ptr as *mut ClipboardState) };
// Load clipboard history
state.load_clipboard_history();
// Return items
state.items.to_vec().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query is handled by the core using cached items
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<ClipboardState>
unsafe {
handle.drop_as::<ClipboardState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clipboard_state_new() {
let state = ClipboardState::new();
assert!(state.items.is_empty());
assert_eq!(state.max_entries, DEFAULT_MAX_ENTRIES);
}
#[test]
fn test_preview_truncation() {
// Test that long strings would be truncated (char-safe)
let long_text = "a".repeat(100);
let truncated = if long_text.chars().count() > 80 {
let t: String = long_text.chars().take(77).collect();
format!("{}...", t)
} else {
long_text.clone()
};
assert_eq!(truncated.chars().count(), 80);
assert!(truncated.ends_with("..."));
}
#[test]
fn test_preview_truncation_utf8() {
// Test with multi-byte UTF-8 characters (box-drawing chars are 3 bytes each)
let utf8_text = "├── ".repeat(30); // Each "├── " is 7 bytes but 4 chars
let truncated = if utf8_text.chars().count() > 80 {
let t: String = utf8_text.chars().take(77).collect();
format!("{}...", t)
} else {
utf8_text.clone()
};
assert_eq!(truncated.chars().count(), 80);
assert!(truncated.ends_with("..."));
}
#[test]
fn test_preview_cleaning() {
let dirty = "line1\nline2\tcolumn\rend";
let clean = dirty
.replace('\n', " ")
.replace('\r', "")
.replace('\t', " ");
assert_eq!(clean, "line1 line2 columnend");
}
#[test]
fn test_command_escaping() {
let clip_id = "test'id";
let command = format!(
"echo '{}' | cliphist decode | wl-copy",
clip_id.replace('\'', "'\\''")
);
assert!(command.contains("test'\\''id"));
}
#[test]
fn test_has_cliphist_runs() {
// Just ensure it doesn't panic - cliphist may or may not be installed
let _ = ClipboardState::has_cliphist();
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "owlry-plugin-emoji"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Emoji plugin for owlry - search and copy emojis"
keywords = ["owlry", "plugin", "emoji"]
categories = ["text-processing"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -0,0 +1,565 @@
//! Emoji Plugin for Owlry
//!
//! A static provider that provides emoji search and copy functionality.
//! Requires wl-clipboard (wl-copy) for copying to clipboard.
//!
//! Examples:
//! - Search "smile" → 😀 😃 😄 etc.
//! - Search "heart" → ❤️ 💙 💚 etc.
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
// Plugin metadata
const PLUGIN_ID: &str = "emoji";
const PLUGIN_NAME: &str = "Emoji";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Search and copy emojis";
// Provider metadata
const PROVIDER_ID: &str = "emoji";
const PROVIDER_NAME: &str = "Emoji";
const PROVIDER_PREFIX: &str = ":emoji";
const PROVIDER_ICON: &str = "face-smile";
const PROVIDER_TYPE_ID: &str = "emoji";
/// Emoji provider state - holds cached items
struct EmojiState {
items: Vec<PluginItem>,
}
impl EmojiState {
fn new() -> Self {
Self { items: Vec::new() }
}
fn load_emojis(&mut self) {
self.items.clear();
// Common emojis with searchable names
// Format: (emoji, name, keywords)
let emojis: &[(&str, &str, &str)] = &[
// Smileys & Emotion
("😀", "grinning face", "smile happy"),
("😃", "grinning face with big eyes", "smile happy"),
("😄", "grinning face with smiling eyes", "smile happy laugh"),
("😁", "beaming face with smiling eyes", "smile happy grin"),
("😅", "grinning face with sweat", "smile nervous"),
("🤣", "rolling on the floor laughing", "lol rofl funny"),
("😂", "face with tears of joy", "laugh cry funny lol"),
("🙂", "slightly smiling face", "smile"),
("😊", "smiling face with smiling eyes", "blush happy"),
("😇", "smiling face with halo", "angel innocent"),
("🥰", "smiling face with hearts", "love adore"),
("😍", "smiling face with heart-eyes", "love crush"),
("🤩", "star-struck", "excited wow amazing"),
("😘", "face blowing a kiss", "kiss love"),
("😜", "winking face with tongue", "playful silly"),
("🤪", "zany face", "crazy silly wild"),
("😎", "smiling face with sunglasses", "cool"),
("🤓", "nerd face", "geek glasses"),
("🧐", "face with monocle", "thinking inspect"),
("😏", "smirking face", "smug"),
("😒", "unamused face", "meh annoyed"),
("🙄", "face with rolling eyes", "whatever annoyed"),
("😬", "grimacing face", "awkward nervous"),
("😮‍💨", "face exhaling", "sigh relief"),
("🤥", "lying face", "pinocchio lie"),
("😌", "relieved face", "relaxed peaceful"),
("😔", "pensive face", "sad thoughtful"),
("😪", "sleepy face", "tired"),
("🤤", "drooling face", "hungry yummy"),
("😴", "sleeping face", "zzz tired"),
("😷", "face with medical mask", "sick covid"),
("🤒", "face with thermometer", "sick fever"),
("🤕", "face with head-bandage", "hurt injured"),
("🤢", "nauseated face", "sick gross"),
("🤮", "face vomiting", "sick puke"),
("🤧", "sneezing face", "achoo sick"),
("🥵", "hot face", "sweating heat"),
("🥶", "cold face", "freezing"),
("😵", "face with crossed-out eyes", "dizzy dead"),
("🤯", "exploding head", "mind blown wow"),
("🤠", "cowboy hat face", "yeehaw western"),
("🥳", "partying face", "celebration party"),
("🥸", "disguised face", "incognito"),
("🤡", "clown face", "circus"),
("👻", "ghost", "halloween spooky"),
("💀", "skull", "dead death"),
("☠️", "skull and crossbones", "danger death"),
("👽", "alien", "ufo extraterrestrial"),
("🤖", "robot", "bot android"),
("💩", "pile of poo", "poop"),
("😈", "smiling face with horns", "devil evil"),
("👿", "angry face with horns", "devil evil"),
// Gestures & People
("👋", "waving hand", "hello hi bye wave"),
("🤚", "raised back of hand", "stop"),
("🖐️", "hand with fingers splayed", "five high"),
("", "raised hand", "stop high five"),
("🖖", "vulcan salute", "spock trek"),
("👌", "ok hand", "okay perfect"),
("🤌", "pinched fingers", "italian"),
("🤏", "pinching hand", "small tiny"),
("✌️", "victory hand", "peace two"),
("🤞", "crossed fingers", "luck hope"),
("🤟", "love-you gesture", "ily rock"),
("🤘", "sign of the horns", "rock metal"),
("🤙", "call me hand", "shaka hang loose"),
("👈", "backhand index pointing left", "left point"),
("👉", "backhand index pointing right", "right point"),
("👆", "backhand index pointing up", "up point"),
("👇", "backhand index pointing down", "down point"),
("☝️", "index pointing up", "one point"),
("👍", "thumbs up", "like yes good approve"),
("👎", "thumbs down", "dislike no bad"),
("", "raised fist", "power solidarity"),
("👊", "oncoming fist", "punch bump"),
("🤛", "left-facing fist", "fist bump"),
("🤜", "right-facing fist", "fist bump"),
("👏", "clapping hands", "applause bravo"),
("🙌", "raising hands", "hooray celebrate"),
("👐", "open hands", "hug"),
("🤲", "palms up together", "prayer"),
("🤝", "handshake", "agreement deal"),
("🙏", "folded hands", "prayer please thanks"),
("✍️", "writing hand", "write"),
("💪", "flexed biceps", "strong muscle"),
("🦾", "mechanical arm", "robot prosthetic"),
("🦵", "leg", "kick"),
("🦶", "foot", "kick"),
("👂", "ear", "listen hear"),
("👃", "nose", "smell"),
("🧠", "brain", "smart think"),
("👀", "eyes", "look see watch"),
("👁️", "eye", "see look"),
("👅", "tongue", "taste lick"),
("👄", "mouth", "lips kiss"),
// Hearts & Love
("❤️", "red heart", "love"),
("🧡", "orange heart", "love"),
("💛", "yellow heart", "love friendship"),
("💚", "green heart", "love"),
("💙", "blue heart", "love"),
("💜", "purple heart", "love"),
("🖤", "black heart", "love dark"),
("🤍", "white heart", "love pure"),
("🤎", "brown heart", "love"),
("💔", "broken heart", "heartbreak sad"),
("❤️‍🔥", "heart on fire", "passion love"),
("❤️‍🩹", "mending heart", "healing recovery"),
("💕", "two hearts", "love"),
("💞", "revolving hearts", "love"),
("💓", "beating heart", "love"),
("💗", "growing heart", "love"),
("💖", "sparkling heart", "love"),
("💘", "heart with arrow", "love cupid"),
("💝", "heart with ribbon", "love gift"),
("💟", "heart decoration", "love"),
// Animals
("🐶", "dog face", "puppy"),
("🐱", "cat face", "kitty"),
("🐭", "mouse face", ""),
("🐹", "hamster", ""),
("🐰", "rabbit face", "bunny"),
("🦊", "fox", ""),
("🐻", "bear", ""),
("🐼", "panda", ""),
("🐨", "koala", ""),
("🐯", "tiger face", ""),
("🦁", "lion", ""),
("🐮", "cow face", ""),
("🐷", "pig face", ""),
("🐸", "frog", ""),
("🐵", "monkey face", ""),
("🦄", "unicorn", "magic"),
("🐝", "bee", "honeybee"),
("🦋", "butterfly", ""),
("🐌", "snail", "slow"),
("🐛", "bug", "caterpillar"),
("🦀", "crab", ""),
("🐙", "octopus", ""),
("🐠", "tropical fish", ""),
("🐟", "fish", ""),
("🐬", "dolphin", ""),
("🐳", "whale", ""),
("🦈", "shark", ""),
("🐊", "crocodile", "alligator"),
("🐢", "turtle", ""),
("🦎", "lizard", ""),
("🐍", "snake", ""),
("🦖", "t-rex", "dinosaur"),
("🦕", "sauropod", "dinosaur"),
("🐔", "chicken", ""),
("🐧", "penguin", ""),
("🦅", "eagle", "bird"),
("🦆", "duck", ""),
("🦉", "owl", ""),
// Food & Drink
("🍎", "red apple", "fruit"),
("🍐", "pear", "fruit"),
("🍊", "orange", "tangerine fruit"),
("🍋", "lemon", "fruit"),
("🍌", "banana", "fruit"),
("🍉", "watermelon", "fruit"),
("🍇", "grapes", "fruit"),
("🍓", "strawberry", "fruit"),
("🍒", "cherries", "fruit"),
("🍑", "peach", "fruit"),
("🥭", "mango", "fruit"),
("🍍", "pineapple", "fruit"),
("🥥", "coconut", "fruit"),
("🥝", "kiwi", "fruit"),
("🍅", "tomato", "vegetable"),
("🥑", "avocado", ""),
("🥦", "broccoli", "vegetable"),
("🥬", "leafy green", "vegetable salad"),
("🥒", "cucumber", "vegetable"),
("🌶️", "hot pepper", "spicy chili"),
("🌽", "corn", ""),
("🥕", "carrot", "vegetable"),
("🧄", "garlic", ""),
("🧅", "onion", ""),
("🥔", "potato", ""),
("🍞", "bread", ""),
("🥐", "croissant", ""),
("🥖", "baguette", "bread french"),
("🥨", "pretzel", ""),
("🧀", "cheese", ""),
("🥚", "egg", ""),
("🍳", "cooking", "frying pan egg"),
("🥞", "pancakes", "breakfast"),
("🧇", "waffle", "breakfast"),
("🥓", "bacon", "breakfast"),
("🍔", "hamburger", "burger"),
("🍟", "french fries", ""),
("🍕", "pizza", ""),
("🌭", "hot dog", ""),
("🥪", "sandwich", ""),
("🌮", "taco", "mexican"),
("🌯", "burrito", "mexican"),
("🍜", "steaming bowl", "ramen noodles"),
("🍝", "spaghetti", "pasta"),
("🍣", "sushi", "japanese"),
("🍱", "bento box", "japanese"),
("🍩", "doughnut", "donut dessert"),
("🍪", "cookie", "dessert"),
("🎂", "birthday cake", "dessert"),
("🍰", "shortcake", "dessert"),
("🧁", "cupcake", "dessert"),
("🍫", "chocolate bar", "dessert"),
("🍬", "candy", "sweet"),
("🍭", "lollipop", "candy sweet"),
("🍦", "soft ice cream", "dessert"),
("🍨", "ice cream", "dessert"),
("", "hot beverage", "coffee tea"),
("🍵", "teacup", "tea"),
("🧃", "juice box", ""),
("🥤", "cup with straw", "soda drink"),
("🍺", "beer mug", "drink alcohol"),
("🍻", "clinking beer mugs", "cheers drink"),
("🥂", "clinking glasses", "champagne cheers"),
("🍷", "wine glass", "drink alcohol"),
("🥃", "tumbler glass", "whiskey drink"),
("🍸", "cocktail glass", "martini drink"),
// Objects & Symbols
("💻", "laptop", "computer"),
("🖥️", "desktop computer", "pc"),
("⌨️", "keyboard", ""),
("🖱️", "computer mouse", ""),
("💾", "floppy disk", "save"),
("💿", "optical disk", "cd"),
("📱", "mobile phone", "smartphone"),
("☎️", "telephone", "phone"),
("📧", "email", "mail"),
("📨", "incoming envelope", "email"),
("📩", "envelope with arrow", "email send"),
("📝", "memo", "note write"),
("📄", "page facing up", "document"),
("📃", "page with curl", "document"),
("📑", "bookmark tabs", ""),
("📚", "books", "library read"),
("📖", "open book", "read"),
("🔗", "link", "chain url"),
("📎", "paperclip", "attachment"),
("🔒", "locked", "security"),
("🔓", "unlocked", "security open"),
("🔑", "key", "password"),
("🔧", "wrench", "tool fix"),
("🔨", "hammer", "tool"),
("⚙️", "gear", "settings"),
("🧲", "magnet", ""),
("💡", "light bulb", "idea"),
("🔦", "flashlight", ""),
("🔋", "battery", "power"),
("🔌", "electric plug", "power"),
("💰", "money bag", ""),
("💵", "dollar", "money cash"),
("💳", "credit card", "payment"),
("", "alarm clock", "time"),
("⏱️", "stopwatch", "timer"),
("📅", "calendar", "date"),
("📆", "tear-off calendar", "date"),
("", "check mark", "done yes"),
("", "cross mark", "no wrong delete"),
("", "question mark", "help"),
("", "exclamation mark", "important warning"),
("⚠️", "warning", "caution alert"),
("🚫", "prohibited", "no ban forbidden"),
("", "hollow circle", ""),
("🔴", "red circle", ""),
("🟠", "orange circle", ""),
("🟡", "yellow circle", ""),
("🟢", "green circle", ""),
("🔵", "blue circle", ""),
("🟣", "purple circle", ""),
("", "black circle", ""),
("", "white circle", ""),
("🟤", "brown circle", ""),
("", "black square", ""),
("", "white square", ""),
("🔶", "large orange diamond", ""),
("🔷", "large blue diamond", ""),
("", "star", "favorite"),
("🌟", "glowing star", "sparkle"),
("", "sparkles", "magic shine"),
("💫", "dizzy", "star"),
("🔥", "fire", "hot lit"),
("💧", "droplet", "water"),
("🌊", "wave", "water ocean"),
("🎵", "musical note", "music"),
("🎶", "musical notes", "music"),
("🎤", "microphone", "sing karaoke"),
("🎧", "headphones", "music"),
("🎮", "video game", "gaming controller"),
("🕹️", "joystick", "gaming"),
("🎯", "direct hit", "target bullseye"),
("🏆", "trophy", "winner award"),
("🥇", "1st place medal", "gold winner"),
("🥈", "2nd place medal", "silver"),
("🥉", "3rd place medal", "bronze"),
("🎁", "wrapped gift", "present"),
("🎈", "balloon", "party"),
("🎉", "party popper", "celebration tada"),
("🎊", "confetti ball", "celebration"),
// Arrows & Misc
("➡️", "right arrow", ""),
("⬅️", "left arrow", ""),
("⬆️", "up arrow", ""),
("⬇️", "down arrow", ""),
("↗️", "up-right arrow", ""),
("↘️", "down-right arrow", ""),
("↙️", "down-left arrow", ""),
("↖️", "up-left arrow", ""),
("↕️", "up-down arrow", ""),
("↔️", "left-right arrow", ""),
("🔄", "counterclockwise arrows", "refresh reload"),
("🔃", "clockwise arrows", "refresh reload"),
("", "plus", "add"),
("", "minus", "subtract"),
("", "division", "divide"),
("✖️", "multiply", "times"),
("♾️", "infinity", "forever"),
("💯", "hundred points", "100 perfect"),
("🆗", "ok button", "okay"),
("🆕", "new button", ""),
("🆓", "free button", ""),
("", "information", "info"),
("🅿️", "parking", ""),
("🚀", "rocket", "launch startup"),
("✈️", "airplane", "travel flight"),
("🚗", "car", "automobile"),
("🚕", "taxi", "cab"),
("🚌", "bus", ""),
("🚂", "locomotive", "train"),
("🏠", "house", "home"),
("🏢", "office building", "work"),
("🏥", "hospital", ""),
("🏫", "school", ""),
("🏛️", "classical building", ""),
("", "church", ""),
("🕌", "mosque", ""),
("🕍", "synagogue", ""),
("🗽", "statue of liberty", "usa america"),
("🗼", "tokyo tower", "japan"),
("🗾", "map of japan", ""),
("🌍", "globe europe-africa", "earth world"),
("🌎", "globe americas", "earth world"),
("🌏", "globe asia-australia", "earth world"),
("🌑", "new moon", ""),
("🌕", "full moon", ""),
("☀️", "sun", "sunny"),
("🌙", "crescent moon", "night"),
("☁️", "cloud", ""),
("🌧️", "cloud with rain", "rainy"),
("⛈️", "cloud with lightning", "storm thunder"),
("🌈", "rainbow", ""),
("❄️", "snowflake", "cold winter"),
("☃️", "snowman", "winter"),
("🎄", "christmas tree", "xmas holiday"),
("🎃", "jack-o-lantern", "halloween pumpkin"),
("🐚", "shell", "beach"),
("🌸", "cherry blossom", "flower spring"),
("🌺", "hibiscus", "flower"),
("🌻", "sunflower", "flower"),
("🌹", "rose", "flower love"),
("🌷", "tulip", "flower"),
("🌱", "seedling", "plant grow"),
("🌲", "evergreen tree", ""),
("🌳", "deciduous tree", ""),
("🌴", "palm tree", "tropical"),
("🌵", "cactus", "desert"),
("🍀", "four leaf clover", "luck irish"),
("🍁", "maple leaf", "fall autumn canada"),
("🍂", "fallen leaf", "fall autumn"),
];
for (emoji, name, keywords) in emojis {
self.items.push(
PluginItem::new(
format!("emoji:{}", emoji),
name.to_string(),
format!("printf '%s' '{}' | wl-copy", emoji),
)
.with_icon(*emoji) // Use emoji character as icon
.with_description(format!("{} {}", emoji, keywords))
.with_keywords(vec![name.to_string(), keywords.to_string()]),
);
}
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(EmojiState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<EmojiState>
let state = unsafe { &mut *(handle.ptr as *mut EmojiState) };
// Load emojis
state.load_emojis();
// Return items
state.items.to_vec().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query is handled by the core using cached items
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<EmojiState>
unsafe {
handle.drop_as::<EmojiState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emoji_state_new() {
let state = EmojiState::new();
assert!(state.items.is_empty());
}
#[test]
fn test_emoji_count() {
let mut state = EmojiState::new();
state.load_emojis();
assert!(state.items.len() > 100, "Should have more than 100 emojis");
}
#[test]
fn test_emoji_has_grinning_face() {
let mut state = EmojiState::new();
state.load_emojis();
let grinning = state
.items
.iter()
.find(|i| i.name.as_str() == "grinning face");
assert!(grinning.is_some());
let item = grinning.unwrap();
assert!(item.description.as_ref().unwrap().as_str().contains("😀"));
}
#[test]
fn test_emoji_command_format() {
let mut state = EmojiState::new();
state.load_emojis();
let item = &state.items[0];
assert!(item.command.as_str().contains("wl-copy"));
assert!(item.command.as_str().contains("printf"));
}
#[test]
fn test_emojis_have_keywords() {
let mut state = EmojiState::new();
state.load_emojis();
// Check that items have keywords for searching
let heart = state
.items
.iter()
.find(|i| i.name.as_str() == "red heart");
assert!(heart.is_some());
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "owlry-plugin-filesearch"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "File search plugin for owlry - find files with fd or locate"
keywords = ["owlry", "plugin", "files", "search"]
categories = ["filesystem"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"
# For finding home directory
dirs = "5.0"

View File

@@ -0,0 +1,322 @@
//! File Search Plugin for Owlry
//!
//! A dynamic provider that searches for files using `fd` or `locate`.
//!
//! Examples:
//! - `/ config.toml` → Search for files matching "config.toml"
//! - `file bashrc` → Search for files matching "bashrc"
//! - `find readme` → Search for files matching "readme"
//!
//! Dependencies:
//! - fd (preferred) or locate
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
use std::path::Path;
use std::process::Command;
// Plugin metadata
const PLUGIN_ID: &str = "filesearch";
const PLUGIN_NAME: &str = "File Search";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Find files with fd or locate";
// Provider metadata
const PROVIDER_ID: &str = "filesearch";
const PROVIDER_NAME: &str = "Files";
const PROVIDER_PREFIX: &str = "/";
const PROVIDER_ICON: &str = "folder";
const PROVIDER_TYPE_ID: &str = "filesearch";
// Maximum results to return
const MAX_RESULTS: usize = 20;
#[derive(Debug, Clone, Copy)]
enum SearchTool {
Fd,
Locate,
None,
}
/// File search provider state
struct FileSearchState {
search_tool: SearchTool,
home: String,
}
impl FileSearchState {
fn new() -> Self {
let search_tool = Self::detect_search_tool();
let home = dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "/".to_string());
Self { search_tool, home }
}
fn detect_search_tool() -> SearchTool {
// Prefer fd (faster, respects .gitignore)
if Self::command_exists("fd") {
return SearchTool::Fd;
}
// Fall back to locate (requires updatedb)
if Self::command_exists("locate") {
return SearchTool::Locate;
}
SearchTool::None
}
fn command_exists(cmd: &str) -> bool {
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Extract the search term from the query
fn extract_search_term(query: &str) -> Option<&str> {
let trimmed = query.trim();
if let Some(rest) = trimmed.strip_prefix("/ ") {
Some(rest.trim())
} else if let Some(rest) = trimmed.strip_prefix("/") {
Some(rest.trim())
} else {
// Handle "file " and "find " prefixes (case-insensitive), or raw query in filter mode
let lower = trimmed.to_lowercase();
if lower.starts_with("file ") || lower.starts_with("find ") {
Some(trimmed[5..].trim())
} else {
Some(trimmed)
}
}
}
/// Evaluate a query and return file results
fn evaluate(&self, query: &str) -> Vec<PluginItem> {
let search_term = match Self::extract_search_term(query) {
Some(t) if !t.is_empty() => t,
_ => return Vec::new(),
};
self.search_files(search_term)
}
fn search_files(&self, pattern: &str) -> Vec<PluginItem> {
match self.search_tool {
SearchTool::Fd => self.search_with_fd(pattern),
SearchTool::Locate => self.search_with_locate(pattern),
SearchTool::None => Vec::new(),
}
}
fn search_with_fd(&self, pattern: &str) -> Vec<PluginItem> {
let output = match Command::new("fd")
.args([
"--max-results",
&MAX_RESULTS.to_string(),
"--type",
"f", // Files only
"--type",
"d", // And directories
pattern,
])
.current_dir(&self.home)
.output()
{
Ok(o) => o,
Err(_) => return Vec::new(),
};
self.parse_file_results(&String::from_utf8_lossy(&output.stdout))
}
fn search_with_locate(&self, pattern: &str) -> Vec<PluginItem> {
let output = match Command::new("locate")
.args([
"--limit",
&MAX_RESULTS.to_string(),
"--ignore-case",
pattern,
])
.output()
{
Ok(o) => o,
Err(_) => return Vec::new(),
};
self.parse_file_results(&String::from_utf8_lossy(&output.stdout))
}
fn parse_file_results(&self, output: &str) -> Vec<PluginItem> {
output
.lines()
.filter(|line| !line.is_empty())
.map(|path| {
let path = path.trim();
let full_path = if path.starts_with('/') {
path.to_string()
} else {
format!("{}/{}", self.home, path)
};
// Get filename for display
let filename = Path::new(&full_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| full_path.clone());
// Determine icon based on whether it's a directory
let is_dir = Path::new(&full_path).is_dir();
let icon = if is_dir { "folder" } else { "text-x-generic" };
// Command to open with xdg-open
let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''"));
PluginItem::new(format!("file:{}", full_path), filename, command)
.with_description(full_path.clone())
.with_icon(icon)
.with_keywords(vec!["file".to_string()])
})
.collect()
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Dynamic,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 8000, // Dynamic: file search
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(FileSearchState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
// Dynamic provider - refresh does nothing
RVec::new()
}
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<FileSearchState>
let state = unsafe { &*(handle.ptr as *const FileSearchState) };
let query_str = query.as_str();
state.evaluate(query_str).into()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<FileSearchState>
unsafe {
handle.drop_as::<FileSearchState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_search_term() {
assert_eq!(
FileSearchState::extract_search_term("/ config.toml"),
Some("config.toml")
);
assert_eq!(
FileSearchState::extract_search_term("/config"),
Some("config")
);
assert_eq!(
FileSearchState::extract_search_term("file bashrc"),
Some("bashrc")
);
assert_eq!(
FileSearchState::extract_search_term("find readme"),
Some("readme")
);
}
#[test]
fn test_extract_search_term_empty() {
assert_eq!(FileSearchState::extract_search_term("/"), Some(""));
assert_eq!(FileSearchState::extract_search_term("/ "), Some(""));
}
#[test]
fn test_command_exists() {
// 'which' should exist on any Unix system
assert!(FileSearchState::command_exists("which"));
// This should not exist
assert!(!FileSearchState::command_exists("nonexistent-command-12345"));
}
#[test]
fn test_detect_search_tool() {
// Just ensure it doesn't panic
let _ = FileSearchState::detect_search_tool();
}
#[test]
fn test_state_new() {
let state = FileSearchState::new();
assert!(!state.home.is_empty());
}
#[test]
fn test_evaluate_empty() {
let state = FileSearchState::new();
let results = state.evaluate("/");
assert!(results.is_empty());
let results = state.evaluate("/ ");
assert!(results.is_empty());
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "owlry-plugin-media"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "MPRIS media player widget plugin for owlry - shows and controls currently playing media. Requires playerctl."
keywords = ["owlry", "plugin", "media", "mpris", "widget", "playerctl"]
categories = ["gui"]
# System dependencies (for packagers):
# - playerctl: for media control commands
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -0,0 +1,468 @@
//! MPRIS Media Player Widget Plugin for Owlry
//!
//! Shows currently playing track as a single row with play/pause action.
//! Uses D-Bus via dbus-send to communicate with MPRIS-compatible players.
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
use std::process::Command;
// Plugin metadata
const PLUGIN_ID: &str = "media";
const PLUGIN_NAME: &str = "Media Player";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "MPRIS media player widget - shows and controls currently playing media";
// Provider metadata
const PROVIDER_ID: &str = "media";
const PROVIDER_NAME: &str = "Media";
const PROVIDER_ICON: &str = "applications-multimedia";
const PROVIDER_TYPE_ID: &str = "media";
#[derive(Debug, Default, Clone)]
struct MediaState {
player_name: String,
title: String,
artist: String,
is_playing: bool,
}
/// Media provider state
struct MediaProviderState {
items: Vec<PluginItem>,
/// Current player name for submenu actions
current_player: Option<String>,
/// Current playback state
is_playing: bool,
}
impl MediaProviderState {
fn new() -> Self {
// Don't query D-Bus during init - defer to first refresh() call
// This prevents blocking the main thread during startup
Self {
items: Vec::new(),
current_player: None,
is_playing: false,
}
}
fn refresh(&mut self) {
self.items.clear();
let players = Self::find_players();
if players.is_empty() {
return;
}
// Find first active player
for player in &players {
if let Some(state) = Self::get_player_state(player) {
self.generate_items(&state);
return;
}
}
}
/// Find active MPRIS players via dbus-send
fn find_players() -> Vec<String> {
let output = Command::new("dbus-send")
.args([
"--session",
"--dest=org.freedesktop.DBus",
"--type=method_call",
"--print-reply",
"/org/freedesktop/DBus",
"org.freedesktop.DBus.ListNames",
])
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("string \"org.mpris.MediaPlayer2.") {
let start = "string \"org.mpris.MediaPlayer2.".len();
let end = trimmed.len() - 1;
Some(trimmed[start..end].to_string())
} else {
None
}
})
.collect()
}
Err(_) => Vec::new(),
}
}
/// Get metadata from an MPRIS player
fn get_player_state(player: &str) -> Option<MediaState> {
let dest = format!("org.mpris.MediaPlayer2.{}", player);
// Get playback status
let status_output = Command::new("dbus-send")
.args([
"--session",
&format!("--dest={}", dest),
"--type=method_call",
"--print-reply",
"/org/mpris/MediaPlayer2",
"org.freedesktop.DBus.Properties.Get",
"string:org.mpris.MediaPlayer2.Player",
"string:PlaybackStatus",
])
.output()
.ok()?;
let status_str = String::from_utf8_lossy(&status_output.stdout);
let is_playing = status_str.contains("\"Playing\"");
let is_paused = status_str.contains("\"Paused\"");
// Only show if playing or paused (not stopped)
if !is_playing && !is_paused {
return None;
}
// Get metadata
let metadata_output = Command::new("dbus-send")
.args([
"--session",
&format!("--dest={}", dest),
"--type=method_call",
"--print-reply",
"/org/mpris/MediaPlayer2",
"org.freedesktop.DBus.Properties.Get",
"string:org.mpris.MediaPlayer2.Player",
"string:Metadata",
])
.output()
.ok()?;
let metadata_str = String::from_utf8_lossy(&metadata_output.stdout);
let title = Self::extract_string(&metadata_str, "xesam:title")
.unwrap_or_else(|| "Unknown".to_string());
let artist = Self::extract_array(&metadata_str, "xesam:artist")
.unwrap_or_else(|| "Unknown".to_string());
Some(MediaState {
player_name: player.to_string(),
title,
artist,
is_playing,
})
}
/// Extract string value from D-Bus output
fn extract_string(output: &str, key: &str) -> Option<String> {
let key_pattern = format!("\"{}\"", key);
let mut found = false;
for line in output.lines() {
let trimmed = line.trim();
if trimmed.contains(&key_pattern) {
found = true;
continue;
}
if found {
if let Some(pos) = trimmed.find("string \"") {
let start = pos + "string \"".len();
if let Some(end) = trimmed[start..].find('"') {
let value = &trimmed[start..start + end];
if !value.is_empty() {
return Some(value.to_string());
}
}
}
if !trimmed.starts_with("variant") {
found = false;
}
}
}
None
}
/// Extract array value from D-Bus output
fn extract_array(output: &str, key: &str) -> Option<String> {
let key_pattern = format!("\"{}\"", key);
let mut found = false;
let mut in_array = false;
let mut values = Vec::new();
for line in output.lines() {
let trimmed = line.trim();
if trimmed.contains(&key_pattern) {
found = true;
continue;
}
if found && trimmed.contains("array [") {
in_array = true;
continue;
}
if in_array {
if let Some(pos) = trimmed.find("string \"") {
let start = pos + "string \"".len();
if let Some(end) = trimmed[start..].find('"') {
values.push(trimmed[start..start + end].to_string());
}
}
if trimmed.contains(']') {
break;
}
}
}
if values.is_empty() {
None
} else {
Some(values.join(", "))
}
}
/// Generate single LaunchItem for media state (opens submenu)
fn generate_items(&mut self, state: &MediaState) {
self.items.clear();
// Store state for submenu
self.current_player = Some(state.player_name.clone());
self.is_playing = state.is_playing;
// Single row: "Title — Artist"
let name = format!("{}{}", state.title, state.artist);
// Extract player display name (e.g., "firefox.instance_1_94" -> "Firefox")
let player_display = Self::format_player_name(&state.player_name);
// Opens submenu with media controls
self.items.push(
PluginItem::new("media-now-playing", name, "SUBMENU:media:controls")
.with_description(format!("{} · Select for controls", player_display))
.with_icon("/org/owlry/launcher/icons/media/music-note.svg")
.with_keywords(vec!["media".to_string(), "widget".to_string()]),
);
}
/// Format player name for display
fn format_player_name(player_name: &str) -> String {
let player_display = player_name.split('.').next().unwrap_or(player_name);
if player_display.is_empty() {
"Player".to_string()
} else {
let mut chars = player_display.chars();
match chars.next() {
None => "Player".to_string(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
}
}
/// Generate submenu items for media controls
fn generate_submenu_items(&self) -> Vec<PluginItem> {
let player = match &self.current_player {
Some(p) => p,
None => return Vec::new(),
};
let mut items = Vec::new();
// Use playerctl for simpler, more reliable media control
// playerctl -p <player> <command>
// Play/Pause
if self.is_playing {
items.push(
PluginItem::new(
"media-pause",
"Pause",
format!("playerctl -p {} pause", player),
)
.with_description("Pause playback")
.with_icon("media-playback-pause"),
);
} else {
items.push(
PluginItem::new(
"media-play",
"Play",
format!("playerctl -p {} play", player),
)
.with_description("Resume playback")
.with_icon("media-playback-start"),
);
}
// Next track
items.push(
PluginItem::new(
"media-next",
"Next",
format!("playerctl -p {} next", player),
)
.with_description("Skip to next track")
.with_icon("media-skip-forward"),
);
// Previous track
items.push(
PluginItem::new(
"media-previous",
"Previous",
format!("playerctl -p {} previous", player),
)
.with_description("Go to previous track")
.with_icon("media-skip-backward"),
);
// Stop
items.push(
PluginItem::new(
"media-stop",
"Stop",
format!("playerctl -p {} stop", player),
)
.with_description("Stop playback")
.with_icon("media-playback-stop"),
);
items
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RNone,
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Widget,
priority: 11000, // Widget: media player
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(MediaProviderState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<MediaProviderState>
let state = unsafe { &mut *(handle.ptr as *mut MediaProviderState) };
state.refresh();
state.items.clone().into()
}
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
let query_str = query.as_str();
let state = unsafe { &*(handle.ptr as *const MediaProviderState) };
// Handle submenu request
if query_str == "?SUBMENU:controls" {
return state.generate_submenu_items().into();
}
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<MediaProviderState>
unsafe {
handle.drop_as::<MediaProviderState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_string() {
let output = r#"
string "xesam:title"
variant string "My Song Title"
"#;
assert_eq!(
MediaProviderState::extract_string(output, "xesam:title"),
Some("My Song Title".to_string())
);
}
#[test]
fn test_extract_array() {
let output = r#"
string "xesam:artist"
variant array [
string "Artist One"
string "Artist Two"
]
"#;
assert_eq!(
MediaProviderState::extract_array(output, "xesam:artist"),
Some("Artist One, Artist Two".to_string())
);
}
#[test]
fn test_extract_string_not_found() {
let output = "some other output";
assert_eq!(
MediaProviderState::extract_string(output, "xesam:title"),
None
);
}
#[test]
fn test_find_players_empty() {
// This will return empty on systems without D-Bus
let players = MediaProviderState::find_players();
// Just verify it doesn't panic
let _ = players;
}
}

View File

@@ -0,0 +1,30 @@
[package]
name = "owlry-plugin-pomodoro"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Pomodoro timer widget plugin for owlry - work/break cycles with persistent state"
keywords = ["owlry", "plugin", "pomodoro", "timer", "widget"]
categories = ["gui"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"
# JSON serialization for persistent state
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# TOML config parsing
toml = "0.8"
# For finding data directory
dirs = "5.0"

View File

@@ -0,0 +1,478 @@
//! Pomodoro Timer Widget Plugin for Owlry
//!
//! Shows timer with work/break cycles. Select to open controls submenu.
//! State persists across sessions via JSON file.
//!
//! ## Configuration
//!
//! Configure via `~/.config/owlry/config.toml`:
//!
//! ```toml
//! [plugins.pomodoro]
//! work_mins = 25 # Work session duration (default: 25)
//! break_mins = 5 # Break duration (default: 5)
//! ```
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
notify_with_urgency, owlry_plugin, NotifyUrgency, PluginInfo, PluginItem, ProviderHandle,
ProviderInfo, ProviderKind, ProviderPosition, API_VERSION,
};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
// Plugin metadata
const PLUGIN_ID: &str = "pomodoro";
const PLUGIN_NAME: &str = "Pomodoro Timer";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Pomodoro timer widget with work/break cycles";
// Provider metadata
const PROVIDER_ID: &str = "pomodoro";
const PROVIDER_NAME: &str = "Pomodoro";
const PROVIDER_ICON: &str = "alarm";
const PROVIDER_TYPE_ID: &str = "pomodoro";
// Default timing (in minutes)
const DEFAULT_WORK_MINS: u32 = 25;
const DEFAULT_BREAK_MINS: u32 = 5;
/// Pomodoro configuration
#[derive(Debug, Clone)]
struct PomodoroConfig {
work_mins: u32,
break_mins: u32,
}
impl PomodoroConfig {
/// Load config from ~/.config/owlry/config.toml
///
/// Reads from [plugins.pomodoro] section, with fallback to [providers] for compatibility.
fn load() -> Self {
let config_path = dirs::config_dir()
.map(|d| d.join("owlry").join("config.toml"));
let config_content = config_path
.and_then(|p| fs::read_to_string(p).ok());
if let Some(content) = config_content
&& let Ok(toml) = content.parse::<toml::Table>()
{
// Try [plugins.pomodoro] first (new format)
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(pomodoro) = plugins.get("pomodoro").and_then(|v| v.as_table())
{
return Self::from_toml_table(pomodoro);
}
// Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let work_mins = providers
.get("pomodoro_work_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_WORK_MINS);
let break_mins = providers
.get("pomodoro_break_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_BREAK_MINS);
return Self { work_mins, break_mins };
}
}
// Default config
Self {
work_mins: DEFAULT_WORK_MINS,
break_mins: DEFAULT_BREAK_MINS,
}
}
/// Parse config from a TOML table
fn from_toml_table(table: &toml::Table) -> Self {
let work_mins = table
.get("work_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_WORK_MINS);
let break_mins = table
.get("break_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_BREAK_MINS);
Self { work_mins, break_mins }
}
}
/// Timer phase
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
enum PomodoroPhase {
#[default]
Idle,
Working,
WorkPaused,
Break,
BreakPaused,
}
/// Persistent state (saved to disk)
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct PomodoroState {
phase: PomodoroPhase,
remaining_secs: u32,
sessions: u32,
last_update: u64,
}
/// Pomodoro provider state
struct PomodoroProviderState {
items: Vec<PluginItem>,
state: PomodoroState,
work_mins: u32,
break_mins: u32,
}
impl PomodoroProviderState {
fn new() -> Self {
let config = PomodoroConfig::load();
let state = Self::load_state().unwrap_or_else(|| PomodoroState {
phase: PomodoroPhase::Idle,
remaining_secs: config.work_mins * 60,
sessions: 0,
last_update: Self::now_secs(),
});
let mut provider = Self {
items: Vec::new(),
state,
work_mins: config.work_mins,
break_mins: config.break_mins,
};
provider.update_elapsed_time();
provider.generate_items();
provider
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn data_dir() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("owlry"))
}
fn load_state() -> Option<PomodoroState> {
let path = Self::data_dir()?.join("pomodoro.json");
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
fn save_state(&self) {
if let Some(data_dir) = Self::data_dir() {
let path = data_dir.join("pomodoro.json");
if fs::create_dir_all(&data_dir).is_err() {
return;
}
let mut state = self.state.clone();
state.last_update = Self::now_secs();
if let Ok(json) = serde_json::to_string_pretty(&state) {
let _ = fs::write(&path, json);
}
}
}
fn update_elapsed_time(&mut self) {
let now = Self::now_secs();
let elapsed = now.saturating_sub(self.state.last_update);
match self.state.phase {
PomodoroPhase::Working | PomodoroPhase::Break => {
if elapsed >= self.state.remaining_secs as u64 {
self.complete_phase();
} else {
self.state.remaining_secs -= elapsed as u32;
}
}
_ => {}
}
self.state.last_update = now;
}
fn complete_phase(&mut self) {
match self.state.phase {
PomodoroPhase::Working => {
self.state.sessions += 1;
self.state.phase = PomodoroPhase::Break;
self.state.remaining_secs = self.break_mins * 60;
notify_with_urgency(
"Pomodoro Complete!",
&format!(
"Great work! Session {} complete. Time for a {}-minute break.",
self.state.sessions, self.break_mins
),
"alarm",
NotifyUrgency::Normal,
);
}
PomodoroPhase::Break => {
self.state.phase = PomodoroPhase::Idle;
self.state.remaining_secs = self.work_mins * 60;
notify_with_urgency(
"Break Complete",
"Break time's over! Ready for another work session?",
"alarm",
NotifyUrgency::Normal,
);
}
_ => {}
}
self.save_state();
}
fn refresh(&mut self) {
self.update_elapsed_time();
self.generate_items();
}
fn handle_action(&mut self, action: &str) {
match action {
"start" => {
self.state.phase = PomodoroPhase::Working;
self.state.remaining_secs = self.work_mins * 60;
self.state.last_update = Self::now_secs();
}
"pause" => match self.state.phase {
PomodoroPhase::Working => self.state.phase = PomodoroPhase::WorkPaused,
PomodoroPhase::Break => self.state.phase = PomodoroPhase::BreakPaused,
_ => {}
},
"resume" => {
self.state.last_update = Self::now_secs();
match self.state.phase {
PomodoroPhase::WorkPaused => self.state.phase = PomodoroPhase::Working,
PomodoroPhase::BreakPaused => self.state.phase = PomodoroPhase::Break,
_ => {}
}
}
"skip" => self.complete_phase(),
"reset" => {
self.state.phase = PomodoroPhase::Idle;
self.state.remaining_secs = self.work_mins * 60;
self.state.sessions = 0;
}
_ => {}
}
self.save_state();
self.generate_items();
}
fn format_time(secs: u32) -> String {
let mins = secs / 60;
let secs = secs % 60;
format!("{:02}:{:02}", mins, secs)
}
/// Generate single main item with submenu for controls
fn generate_items(&mut self) {
self.items.clear();
let (phase_name, _is_running) = match self.state.phase {
PomodoroPhase::Idle => ("Ready", false),
PomodoroPhase::Working => ("Work", true),
PomodoroPhase::WorkPaused => ("Paused", false),
PomodoroPhase::Break => ("Break", true),
PomodoroPhase::BreakPaused => ("Paused", false),
};
let time_str = Self::format_time(self.state.remaining_secs);
let name = format!("{}: {}", phase_name, time_str);
let description = if self.state.sessions > 0 {
format!(
"Sessions: {} | {}min work / {}min break",
self.state.sessions, self.work_mins, self.break_mins
)
} else {
format!("{}min work / {}min break", self.work_mins, self.break_mins)
};
// Single item that opens submenu with controls
self.items.push(
PluginItem::new("pomo-timer", name, "SUBMENU:pomodoro:controls")
.with_description(description)
.with_icon("/org/owlry/launcher/icons/pomodoro/tomato.svg")
.with_keywords(vec![
"pomodoro".to_string(),
"widget".to_string(),
"timer".to_string(),
]),
);
}
/// Generate submenu items for controls
fn generate_submenu_items(&self) -> Vec<PluginItem> {
let mut items = Vec::new();
let is_running = matches!(
self.state.phase,
PomodoroPhase::Working | PomodoroPhase::Break
);
// Primary control: Start/Pause/Resume
if is_running {
items.push(
PluginItem::new("pomo-pause", "Pause", "POMODORO:pause")
.with_description("Pause the timer")
.with_icon("media-playback-pause"),
);
} else {
match self.state.phase {
PomodoroPhase::Idle => {
items.push(
PluginItem::new("pomo-start", "Start Work", "POMODORO:start")
.with_description("Start a new work session")
.with_icon("media-playback-start"),
);
}
_ => {
items.push(
PluginItem::new("pomo-resume", "Resume", "POMODORO:resume")
.with_description("Resume the timer")
.with_icon("media-playback-start"),
);
}
}
}
// Skip (only when not idle)
if self.state.phase != PomodoroPhase::Idle {
items.push(
PluginItem::new("pomo-skip", "Skip", "POMODORO:skip")
.with_description("Skip to next phase")
.with_icon("media-skip-forward"),
);
}
// Reset
items.push(
PluginItem::new("pomo-reset", "Reset", "POMODORO:reset")
.with_description("Reset timer and sessions")
.with_icon("view-refresh"),
);
items
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RNone,
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Widget,
priority: 11500, // Widget: pomodoro timer
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(PomodoroProviderState::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 PomodoroProviderState) };
state.refresh();
state.items.clone().into()
}
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
let query_str = query.as_str();
let state = unsafe { &mut *(handle.ptr as *mut PomodoroProviderState) };
// Handle submenu request
if query_str == "?SUBMENU:controls" {
return state.generate_submenu_items().into();
}
// Handle action commands
if let Some(action) = query_str.strip_prefix("!POMODORO:") {
state.handle_action(action);
}
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
let state = unsafe { &*(handle.ptr as *const PomodoroProviderState) };
state.save_state();
unsafe {
handle.drop_as::<PomodoroProviderState>();
}
}
}
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_time() {
assert_eq!(PomodoroProviderState::format_time(0), "00:00");
assert_eq!(PomodoroProviderState::format_time(60), "01:00");
assert_eq!(PomodoroProviderState::format_time(90), "01:30");
assert_eq!(PomodoroProviderState::format_time(1500), "25:00");
assert_eq!(PomodoroProviderState::format_time(3599), "59:59");
}
#[test]
fn test_default_phase() {
let phase: PomodoroPhase = Default::default();
assert_eq!(phase, PomodoroPhase::Idle);
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "owlry-plugin-scripts"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Scripts plugin for owlry - run user scripts from ~/.local/share/owlry/scripts/"
keywords = ["owlry", "plugin", "scripts"]
categories = ["os"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"
# For finding ~/.local/share/owlry/scripts
dirs = "5.0"

View File

@@ -0,0 +1,290 @@
//! Scripts Plugin for Owlry
//!
//! A static provider that scans `~/.local/share/owlry/scripts/` for executable
//! scripts and provides them as launch items.
//!
//! Scripts can include a description by adding a comment after the shebang:
//! ```bash
//! #!/bin/bash
//! # This is my script description
//! echo "Hello"
//! ```
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
// Plugin metadata
const PLUGIN_ID: &str = "scripts";
const PLUGIN_NAME: &str = "Scripts";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Run user scripts from ~/.local/share/owlry/scripts/";
// Provider metadata
const PROVIDER_ID: &str = "scripts";
const PROVIDER_NAME: &str = "Scripts";
const PROVIDER_PREFIX: &str = ":script";
const PROVIDER_ICON: &str = "utilities-terminal";
const PROVIDER_TYPE_ID: &str = "scripts";
/// Scripts provider state - holds cached items
struct ScriptsState {
items: Vec<PluginItem>,
}
impl ScriptsState {
fn new() -> Self {
Self { items: Vec::new() }
}
fn scripts_dir() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("owlry").join("scripts"))
}
fn load_scripts(&mut self) {
self.items.clear();
let scripts_dir = match Self::scripts_dir() {
Some(p) => p,
None => return,
};
if !scripts_dir.exists() {
// Create the directory for the user
let _ = fs::create_dir_all(&scripts_dir);
return;
}
let entries = match fs::read_dir(&scripts_dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
// Skip directories
if path.is_dir() {
continue;
}
// Check if executable
let metadata = match path.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let is_executable = metadata.permissions().mode() & 0o111 != 0;
if !is_executable {
continue;
}
// Get script name without extension
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let name = path
.file_stem()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(filename.clone());
// Try to read description from first line comment
let description = Self::read_script_description(&path);
// Determine icon based on extension or shebang
let icon = Self::determine_icon(&path);
let mut item = PluginItem::new(
format!("script:{}", filename),
format!("Script: {}", name),
path.to_string_lossy().to_string(),
)
.with_icon(icon)
.with_keywords(vec!["script".to_string()]);
if let Some(desc) = description {
item = item.with_description(desc);
}
self.items.push(item);
}
}
fn read_script_description(path: &PathBuf) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let mut lines = content.lines();
// Skip shebang if present
let first_line = lines.next()?;
let check_line = if first_line.starts_with("#!") {
lines.next()?
} else {
first_line
};
// Look for a comment description
if let Some(desc) = check_line.strip_prefix("# ") {
Some(desc.trim().to_string())
} else { check_line.strip_prefix("// ").map(|desc| desc.trim().to_string()) }
}
fn determine_icon(path: &PathBuf) -> String {
// Check extension first
if let Some(ext) = path.extension() {
match ext.to_string_lossy().as_ref() {
"sh" | "bash" | "zsh" => return "utilities-terminal".to_string(),
"py" | "python" => return "text-x-python".to_string(),
"js" | "ts" => return "text-x-javascript".to_string(),
"rb" => return "text-x-ruby".to_string(),
"pl" => return "text-x-perl".to_string(),
_ => {}
}
}
// Check shebang
if let Ok(content) = fs::read_to_string(path)
&& let Some(first_line) = content.lines().next() {
if first_line.contains("bash") || first_line.contains("sh") {
return "utilities-terminal".to_string();
} else if first_line.contains("python") {
return "text-x-python".to_string();
} else if first_line.contains("node") {
return "text-x-javascript".to_string();
} else if first_line.contains("ruby") {
return "text-x-ruby".to_string();
}
}
"application-x-executable".to_string()
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(ScriptsState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<ScriptsState>
let state = unsafe { &mut *(handle.ptr as *mut ScriptsState) };
// Load scripts
state.load_scripts();
// Return items
state.items.to_vec().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query is handled by the core using cached items
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<ScriptsState>
unsafe {
handle.drop_as::<ScriptsState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scripts_state_new() {
let state = ScriptsState::new();
assert!(state.items.is_empty());
}
#[test]
fn test_determine_icon_sh() {
let path = PathBuf::from("/test/script.sh");
let icon = ScriptsState::determine_icon(&path);
assert_eq!(icon, "utilities-terminal");
}
#[test]
fn test_determine_icon_python() {
let path = PathBuf::from("/test/script.py");
let icon = ScriptsState::determine_icon(&path);
assert_eq!(icon, "text-x-python");
}
#[test]
fn test_determine_icon_js() {
let path = PathBuf::from("/test/script.js");
let icon = ScriptsState::determine_icon(&path);
assert_eq!(icon, "text-x-javascript");
}
#[test]
fn test_determine_icon_unknown() {
let path = PathBuf::from("/test/script.xyz");
let icon = ScriptsState::determine_icon(&path);
assert_eq!(icon, "application-x-executable");
}
#[test]
fn test_scripts_dir() {
// Should return Some path
let dir = ScriptsState::scripts_dir();
assert!(dir.is_some());
assert!(dir.unwrap().ends_with("owlry/scripts"));
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "owlry-plugin-ssh"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "SSH plugin for owlry - quick connect to SSH hosts from ~/.ssh/config"
keywords = ["owlry", "plugin", "ssh"]
categories = ["network-programming"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"
# For finding ~/.ssh/config
dirs = "5.0"

View File

@@ -0,0 +1,328 @@
//! SSH Plugin for Owlry
//!
//! A static provider that parses ~/.ssh/config and provides quick-connect
//! entries for SSH hosts.
//!
//! Examples:
//! - `SSH: myserver` → Connect to myserver
//! - `SSH: work-box` → Connect to work-box with configured user/port
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
use std::fs;
use std::path::PathBuf;
// Plugin metadata
const PLUGIN_ID: &str = "ssh";
const PLUGIN_NAME: &str = "SSH";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Quick connect to SSH hosts from ~/.ssh/config";
// Provider metadata
const PROVIDER_ID: &str = "ssh";
const PROVIDER_NAME: &str = "SSH";
const PROVIDER_PREFIX: &str = ":ssh";
const PROVIDER_ICON: &str = "utilities-terminal";
const PROVIDER_TYPE_ID: &str = "ssh";
// Default terminal command (TODO: make configurable via plugin config)
const DEFAULT_TERMINAL: &str = "kitty";
/// SSH provider state - holds cached items
struct SshState {
items: Vec<PluginItem>,
terminal_command: String,
}
impl SshState {
fn new() -> Self {
// Try to detect terminal from environment, fall back to default
let terminal = std::env::var("TERMINAL")
.unwrap_or_else(|_| DEFAULT_TERMINAL.to_string());
Self {
items: Vec::new(),
terminal_command: terminal,
}
}
fn ssh_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".ssh").join("config"))
}
fn parse_ssh_config(&mut self) {
self.items.clear();
let config_path = match Self::ssh_config_path() {
Some(p) => p,
None => return,
};
if !config_path.exists() {
return;
}
let content = match fs::read_to_string(&config_path) {
Ok(c) => c,
Err(_) => return,
};
let mut current_host: Option<String> = None;
let mut current_hostname: Option<String> = None;
let mut current_user: Option<String> = None;
let mut current_port: Option<String> = None;
for line in content.lines() {
let line = line.trim();
// Skip comments and empty lines
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split on whitespace or '='
let parts: Vec<&str> = line
.splitn(2, |c: char| c.is_whitespace() || c == '=')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if parts.len() < 2 {
continue;
}
let key = parts[0].to_lowercase();
let value = parts[1];
match key.as_str() {
"host" => {
// Save previous host if exists
if let Some(host) = current_host.take() {
self.add_host_item(
&host,
current_hostname.take(),
current_user.take(),
current_port.take(),
);
}
// Skip wildcards and patterns
if !value.contains('*') && !value.contains('?') && value != "*" {
current_host = Some(value.to_string());
}
current_hostname = None;
current_user = None;
current_port = None;
}
"hostname" => {
current_hostname = Some(value.to_string());
}
"user" => {
current_user = Some(value.to_string());
}
"port" => {
current_port = Some(value.to_string());
}
_ => {}
}
}
// Don't forget the last host
if let Some(host) = current_host.take() {
self.add_host_item(&host, current_hostname, current_user, current_port);
}
}
fn add_host_item(
&mut self,
host: &str,
hostname: Option<String>,
user: Option<String>,
port: Option<String>,
) {
// Build description
let mut desc_parts = Vec::new();
if let Some(ref h) = hostname {
desc_parts.push(h.clone());
}
if let Some(ref u) = user {
desc_parts.push(format!("user: {}", u));
}
if let Some(ref p) = port {
desc_parts.push(format!("port: {}", p));
}
let description = if desc_parts.is_empty() {
None
} else {
Some(desc_parts.join(", "))
};
// Build SSH command - just use the host alias, SSH will resolve the rest
let ssh_command = format!("ssh {}", host);
// Wrap in terminal
let command = format!("{} -e {}", self.terminal_command, ssh_command);
let mut item = PluginItem::new(
format!("ssh:{}", host),
format!("SSH: {}", host),
command,
)
.with_icon(PROVIDER_ICON)
.with_keywords(vec!["ssh".to_string(), "remote".to_string()]);
if let Some(desc) = description {
item = item.with_description(desc);
}
self.items.push(item);
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(SshState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<SshState>
let state = unsafe { &mut *(handle.ptr as *mut SshState) };
// Parse SSH config
state.parse_ssh_config();
// Return items
state.items.to_vec().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query is handled by the core using cached items
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<SshState>
unsafe {
handle.drop_as::<SshState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssh_state_new() {
let state = SshState::new();
assert!(state.items.is_empty());
}
#[test]
fn test_parse_simple_config() {
let mut state = SshState::new();
// We can't easily test the full flow without mocking file paths,
// but we can test the add_host_item method
state.add_host_item(
"myserver",
Some("192.168.1.100".to_string()),
Some("admin".to_string()),
Some("2222".to_string()),
);
assert_eq!(state.items.len(), 1);
assert_eq!(state.items[0].name.as_str(), "SSH: myserver");
assert!(state.items[0].command.as_str().contains("ssh myserver"));
}
#[test]
fn test_add_host_without_details() {
let mut state = SshState::new();
state.add_host_item("simple-host", None, None, None);
assert_eq!(state.items.len(), 1);
assert_eq!(state.items[0].name.as_str(), "SSH: simple-host");
assert!(state.items[0].description.is_none());
}
#[test]
fn test_add_host_with_partial_details() {
let mut state = SshState::new();
state.add_host_item("partial", Some("example.com".to_string()), None, None);
assert_eq!(state.items.len(), 1);
let desc = state.items[0].description.as_ref().unwrap();
assert_eq!(desc.as_str(), "example.com");
}
#[test]
fn test_items_have_icons() {
let mut state = SshState::new();
state.add_host_item("test", None, None, None);
assert!(state.items[0].icon.is_some());
assert_eq!(state.items[0].icon.as_ref().unwrap().as_str(), PROVIDER_ICON);
}
#[test]
fn test_items_have_keywords() {
let mut state = SshState::new();
state.add_host_item("test", None, None, None);
assert!(!state.items[0].keywords.is_empty());
let keywords: Vec<&str> = state.items[0].keywords.iter().map(|s| s.as_str()).collect();
assert!(keywords.contains(&"ssh"));
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "owlry-plugin-system"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "System plugin for owlry - power and session management commands"
keywords = ["owlry", "plugin", "system", "power"]
categories = ["os"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -0,0 +1,254 @@
//! System Plugin for Owlry
//!
//! A static provider that provides system power and session management commands.
//!
//! Commands:
//! - Shutdown - Power off the system
//! - Reboot - Restart the system
//! - Reboot into BIOS - Restart into UEFI/BIOS setup
//! - Suspend - Suspend to RAM
//! - Hibernate - Suspend to disk
//! - Lock Screen - Lock the session
//! - Log Out - End the current session
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
// Plugin metadata
const PLUGIN_ID: &str = "system";
const PLUGIN_NAME: &str = "System";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Power and session management commands";
// Provider metadata
const PROVIDER_ID: &str = "system";
const PROVIDER_NAME: &str = "System";
const PROVIDER_PREFIX: &str = ":sys";
const PROVIDER_ICON: &str = "system-shutdown";
const PROVIDER_TYPE_ID: &str = "system";
/// System provider state - holds cached items
struct SystemState {
items: Vec<PluginItem>,
}
impl SystemState {
fn new() -> Self {
Self { items: Vec::new() }
}
fn load_commands(&mut self) {
self.items.clear();
// Define system commands
// Format: (id, name, description, icon, command)
let commands: &[(&str, &str, &str, &str, &str)] = &[
(
"system:shutdown",
"Shutdown",
"Power off the system",
"system-shutdown",
"systemctl poweroff",
),
(
"system:reboot",
"Reboot",
"Restart the system",
"system-reboot",
"systemctl reboot",
),
(
"system:reboot-bios",
"Reboot into BIOS",
"Restart into UEFI/BIOS setup",
"system-reboot",
"systemctl reboot --firmware-setup",
),
(
"system:suspend",
"Suspend",
"Suspend to RAM",
"system-suspend",
"systemctl suspend",
),
(
"system:hibernate",
"Hibernate",
"Suspend to disk",
"system-suspend-hibernate",
"systemctl hibernate",
),
(
"system:lock",
"Lock Screen",
"Lock the session",
"system-lock-screen",
"loginctl lock-session",
),
(
"system:logout",
"Log Out",
"End the current session",
"system-log-out",
"loginctl terminate-session self",
),
];
for (id, name, description, icon, command) in commands {
self.items.push(
PluginItem::new(*id, *name, *command)
.with_description(*description)
.with_icon(*icon)
.with_keywords(vec!["power".to_string(), "system".to_string()]),
);
}
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(SystemState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<SystemState>
let state = unsafe { &mut *(handle.ptr as *mut SystemState) };
// Load/reload commands
state.load_commands();
// Return items
state.items.to_vec().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query is handled by the core using cached items
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<SystemState>
unsafe {
handle.drop_as::<SystemState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_system_state_new() {
let state = SystemState::new();
assert!(state.items.is_empty());
}
#[test]
fn test_system_commands_loaded() {
let mut state = SystemState::new();
state.load_commands();
assert!(state.items.len() >= 6);
// Check for specific commands
let names: Vec<&str> = state.items.iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"Shutdown"));
assert!(names.contains(&"Reboot"));
assert!(names.contains(&"Suspend"));
assert!(names.contains(&"Lock Screen"));
assert!(names.contains(&"Log Out"));
}
#[test]
fn test_reboot_bios_command() {
let mut state = SystemState::new();
state.load_commands();
let bios_cmd = state
.items
.iter()
.find(|i| i.name.as_str() == "Reboot into BIOS")
.expect("Reboot into BIOS should exist");
assert_eq!(bios_cmd.command.as_str(), "systemctl reboot --firmware-setup");
}
#[test]
fn test_commands_have_icons() {
let mut state = SystemState::new();
state.load_commands();
for item in &state.items {
assert!(
item.icon.is_some(),
"Item '{}' should have an icon",
item.name.as_str()
);
}
}
#[test]
fn test_commands_have_descriptions() {
let mut state = SystemState::new();
state.load_commands();
for item in &state.items {
assert!(
item.description.is_some(),
"Item '{}' should have a description",
item.name.as_str()
);
}
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "owlry-plugin-systemd"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "systemd user services plugin for owlry - list and control user-level systemd services"
keywords = ["owlry", "plugin", "systemd", "services"]
categories = ["os"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -0,0 +1,457 @@
//! systemd User Services Plugin for Owlry
//!
//! Lists and controls systemd user-level services.
//! Uses `systemctl --user` commands to interact with services.
//!
//! Each service item opens a submenu with actions like:
//! - Start/Stop/Restart/Reload/Kill
//! - Enable/Disable on startup
//! - View status and journal logs
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
use std::process::Command;
// Plugin metadata
const PLUGIN_ID: &str = "systemd";
const PLUGIN_NAME: &str = "systemd Services";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "List and control systemd user services";
// Provider metadata
const PROVIDER_ID: &str = "systemd";
const PROVIDER_NAME: &str = "User Units";
const PROVIDER_PREFIX: &str = ":uuctl";
const PROVIDER_ICON: &str = "system-run";
const PROVIDER_TYPE_ID: &str = "uuctl";
/// systemd provider state
struct SystemdState {
items: Vec<PluginItem>,
}
impl SystemdState {
fn new() -> Self {
let mut state = Self { items: Vec::new() };
state.refresh();
state
}
fn refresh(&mut self) {
self.items.clear();
if !Self::systemctl_available() {
return;
}
// List all user services (both running and available)
let output = match Command::new("systemctl")
.args([
"--user",
"list-units",
"--type=service",
"--all",
"--no-legend",
"--no-pager",
])
.output()
{
Ok(o) if o.status.success() => o,
_ => return,
};
let stdout = String::from_utf8_lossy(&output.stdout);
self.items = Self::parse_systemctl_output(&stdout);
// Sort by name
self.items.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
}
fn systemctl_available() -> bool {
Command::new("systemctl")
.args(["--user", "--version"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn parse_systemctl_output(output: &str) -> Vec<PluginItem> {
let mut items = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
// Parse systemctl output - handle variable whitespace
// Format: UNIT LOAD ACTIVE SUB DESCRIPTION...
let mut parts = line.split_whitespace();
let unit_name = match parts.next() {
Some(u) => u,
None => continue,
};
// Skip if not a proper service name
if !unit_name.ends_with(".service") {
continue;
}
let _load_state = parts.next().unwrap_or("");
let active_state = parts.next().unwrap_or("");
let sub_state = parts.next().unwrap_or("");
let description: String = parts.collect::<Vec<_>>().join(" ");
// Create a clean display name
let display_name = unit_name
.trim_end_matches(".service")
.replace("app-", "")
.replace("@autostart", "")
.replace("\\x2d", "-");
let is_active = active_state == "active";
let status_icon = if is_active { "" } else { "" };
let status_desc = if description.is_empty() {
format!("{} {} ({})", status_icon, sub_state, active_state)
} else {
format!("{} {} ({})", status_icon, description, sub_state)
};
// Store service info in the command field as encoded data
// Format: SUBMENU:type_id:data where data is "unit_name:is_active"
let submenu_data = format!("SUBMENU:uuctl:{}:{}", unit_name, is_active);
let icon = if is_active {
"emblem-ok-symbolic"
} else {
"emblem-pause-symbolic"
};
items.push(
PluginItem::new(
format!("systemd:service:{}", unit_name),
display_name,
submenu_data,
)
.with_description(status_desc)
.with_icon(icon)
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
}
items
}
}
// ============================================================================
// Submenu Action Generation (exported for core to use)
// ============================================================================
/// Generate submenu actions for a given service
/// This function is called by the core when a service is selected
pub fn actions_for_service(unit_name: &str, display_name: &str, is_active: bool) -> Vec<PluginItem> {
let mut actions = Vec::new();
if is_active {
actions.push(
PluginItem::new(
format!("systemd:restart:{}", unit_name),
"↻ Restart",
format!("systemctl --user restart {}", unit_name),
)
.with_description(format!("Restart {}", display_name))
.with_icon("view-refresh")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
actions.push(
PluginItem::new(
format!("systemd:stop:{}", unit_name),
"■ Stop",
format!("systemctl --user stop {}", unit_name),
)
.with_description(format!("Stop {}", display_name))
.with_icon("process-stop")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
actions.push(
PluginItem::new(
format!("systemd:reload:{}", unit_name),
"⟳ Reload",
format!("systemctl --user reload {}", unit_name),
)
.with_description(format!("Reload {} configuration", display_name))
.with_icon("view-refresh")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
actions.push(
PluginItem::new(
format!("systemd:kill:{}", unit_name),
"✗ Kill",
format!("systemctl --user kill {}", unit_name),
)
.with_description(format!("Force kill {}", display_name))
.with_icon("edit-delete")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
} else {
actions.push(
PluginItem::new(
format!("systemd:start:{}", unit_name),
"▶ Start",
format!("systemctl --user start {}", unit_name),
)
.with_description(format!("Start {}", display_name))
.with_icon("media-playback-start")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
}
// Always available actions
actions.push(
PluginItem::new(
format!("systemd:status:{}", unit_name),
" Status",
format!("systemctl --user status {}", unit_name),
)
.with_description(format!("Show {} status", display_name))
.with_icon("dialog-information")
.with_keywords(vec!["systemd".to_string(), "service".to_string()])
.with_terminal(true),
);
actions.push(
PluginItem::new(
format!("systemd:journal:{}", unit_name),
"📋 Journal",
format!("journalctl --user -u {} -f", unit_name),
)
.with_description(format!("Show {} logs", display_name))
.with_icon("utilities-system-monitor")
.with_keywords(vec!["systemd".to_string(), "service".to_string()])
.with_terminal(true),
);
actions.push(
PluginItem::new(
format!("systemd:enable:{}", unit_name),
"⊕ Enable",
format!("systemctl --user enable {}", unit_name),
)
.with_description(format!("Enable {} on startup", display_name))
.with_icon("emblem-default")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
actions.push(
PluginItem::new(
format!("systemd:disable:{}", unit_name),
"⊖ Disable",
format!("systemctl --user disable {}", unit_name),
)
.with_description(format!("Disable {} on startup", display_name))
.with_icon("emblem-unreadable")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
actions
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(SystemdState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<SystemdState>
let state = unsafe { &mut *(handle.ptr as *mut SystemdState) };
state.refresh();
state.items.clone().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
let query_str = query.as_str();
// Handle submenu action requests: ?SUBMENU:unit.service:is_active
if let Some(data) = query_str.strip_prefix("?SUBMENU:") {
// Parse data format: "unit_name:is_active"
let parts: Vec<&str> = data.splitn(2, ':').collect();
if parts.len() >= 2 {
let unit_name = parts[0];
let is_active = parts[1] == "true";
let display_name = unit_name
.trim_end_matches(".service")
.replace("app-", "")
.replace("@autostart", "")
.replace("\\x2d", "-");
return actions_for_service(unit_name, &display_name, is_active).into();
} else if !data.is_empty() {
// Fallback: just unit name, assume not active
let display_name = data
.trim_end_matches(".service")
.replace("app-", "")
.replace("@autostart", "")
.replace("\\x2d", "-");
return actions_for_service(data, &display_name, false).into();
}
}
// Static provider - normal queries not used
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<SystemdState>
unsafe {
handle.drop_as::<SystemdState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_systemctl_output() {
let output = r#"
foo.service loaded active running Foo Service
bar.service loaded inactive dead Bar Service
baz@autostart.service loaded active running Baz App
"#;
let items = SystemdState::parse_systemctl_output(output);
assert_eq!(items.len(), 3);
// Check first item
assert_eq!(items[0].name.as_str(), "foo");
assert!(items[0].command.as_str().contains("SUBMENU:uuctl:foo.service:true"));
// Check second item (inactive)
assert_eq!(items[1].name.as_str(), "bar");
assert!(items[1].command.as_str().contains("SUBMENU:uuctl:bar.service:false"));
// Check third item (cleaned name)
assert_eq!(items[2].name.as_str(), "baz");
}
#[test]
fn test_actions_for_active_service() {
let actions = actions_for_service("test.service", "Test", true);
// Active services should have restart, stop, reload, kill + common actions
let action_ids: Vec<_> = actions.iter().map(|a| a.id.as_str()).collect();
assert!(action_ids.contains(&"systemd:restart:test.service"));
assert!(action_ids.contains(&"systemd:stop:test.service"));
assert!(action_ids.contains(&"systemd:status:test.service"));
assert!(!action_ids.contains(&"systemd:start:test.service")); // Not for active
}
#[test]
fn test_actions_for_inactive_service() {
let actions = actions_for_service("test.service", "Test", false);
// Inactive services should have start + common actions
let action_ids: Vec<_> = actions.iter().map(|a| a.id.as_str()).collect();
assert!(action_ids.contains(&"systemd:start:test.service"));
assert!(action_ids.contains(&"systemd:status:test.service"));
assert!(!action_ids.contains(&"systemd:stop:test.service")); // Not for inactive
}
#[test]
fn test_terminal_actions() {
let actions = actions_for_service("test.service", "Test", true);
// Status and journal should have terminal=true
for action in &actions {
let id = action.id.as_str();
if id.contains(":status:") || id.contains(":journal:") {
assert!(action.terminal, "Action {} should have terminal=true", id);
}
}
}
#[test]
fn test_submenu_query() {
// Test that provider_query handles ?SUBMENU: queries correctly
let handle = ProviderHandle { ptr: std::ptr::null_mut() };
// Query for active service
let query = RStr::from_str("?SUBMENU:test.service:true");
let actions = provider_query(handle, query);
assert!(!actions.is_empty(), "Should return actions for submenu query");
// Should have restart action for active service
let has_restart = actions.iter().any(|a| a.id.as_str().contains(":restart:"));
assert!(has_restart, "Active service should have restart action");
// Query for inactive service
let query = RStr::from_str("?SUBMENU:test.service:false");
let actions = provider_query(handle, query);
assert!(!actions.is_empty(), "Should return actions for submenu query");
// Should have start action for inactive service
let has_start = actions.iter().any(|a| a.id.as_str().contains(":start:"));
assert!(has_start, "Inactive service should have start action");
// Normal query should return empty
let query = RStr::from_str("some search");
let actions = provider_query(handle, query);
assert!(actions.is_empty(), "Normal query should return empty");
}
}

View File

@@ -0,0 +1,33 @@
[package]
name = "owlry-plugin-weather"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Weather widget plugin for owlry - shows current weather with multiple API support"
keywords = ["owlry", "plugin", "weather", "widget"]
categories = ["gui"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"
# HTTP client for weather API requests
reqwest = { version = "0.13", features = ["blocking", "json"] }
# JSON parsing for API responses
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# TOML config parsing
toml = "0.8"
# XDG directories for cache persistence
dirs = "5.0"

View File

@@ -0,0 +1,754 @@
//! Weather Widget Plugin for Owlry
//!
//! Shows current weather with support for multiple APIs:
//! - wttr.in (default, no API key required)
//! - OpenWeatherMap (requires API key)
//! - Open-Meteo (no API key required)
//!
//! Weather data is cached for 15 minutes.
//!
//! ## Configuration
//!
//! Configure via `~/.config/owlry/config.toml`:
//!
//! ```toml
//! [plugins.weather]
//! provider = "wttr.in" # or: openweathermap, open-meteo
//! location = "Berlin" # city name or "lat,lon"
//! # api_key = "..." # Required for OpenWeatherMap
//! ```
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
// Plugin metadata
const PLUGIN_ID: &str = "weather";
const PLUGIN_NAME: &str = "Weather";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Weather widget with multiple API support";
// Provider metadata
const PROVIDER_ID: &str = "weather";
const PROVIDER_NAME: &str = "Weather";
const PROVIDER_ICON: &str = "weather-clear";
const PROVIDER_TYPE_ID: &str = "weather";
// Timing constants
const CACHE_DURATION_SECS: u64 = 900; // 15 minutes
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
const USER_AGENT: &str = "owlry-launcher/0.3";
#[derive(Debug, Clone, PartialEq)]
enum WeatherProviderType {
WttrIn,
OpenWeatherMap,
OpenMeteo,
}
impl std::str::FromStr for WeatherProviderType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"wttr.in" | "wttr" | "wttrin" => Ok(Self::WttrIn),
"openweathermap" | "owm" => Ok(Self::OpenWeatherMap),
"open-meteo" | "openmeteo" | "meteo" => Ok(Self::OpenMeteo),
_ => Err(format!("Unknown weather provider: {}", s)),
}
}
}
#[derive(Debug, Clone)]
struct WeatherConfig {
provider: WeatherProviderType,
api_key: Option<String>,
location: String,
}
impl WeatherConfig {
/// Load config from ~/.config/owlry/config.toml
///
/// Reads from [plugins.weather] section, with fallback to [providers] for compatibility.
fn load() -> Self {
let config_path = dirs::config_dir()
.map(|d| d.join("owlry").join("config.toml"));
let config_content = config_path
.and_then(|p| fs::read_to_string(p).ok());
if let Some(content) = config_content
&& let Ok(toml) = content.parse::<toml::Table>()
{
// Try [plugins.weather] first (new format)
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(weather) = plugins.get("weather").and_then(|v| v.as_table())
{
return Self::from_toml_table(weather);
}
// Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let provider_str = providers
.get("weather_provider")
.and_then(|v| v.as_str())
.unwrap_or("wttr.in");
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
let api_key = providers
.get("weather_api_key")
.and_then(|v| v.as_str())
.map(String::from);
let location = providers
.get("weather_location")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
return Self {
provider,
api_key,
location,
};
}
}
// Default config
Self {
provider: WeatherProviderType::WttrIn,
api_key: None,
location: String::new(),
}
}
/// Parse config from a TOML table
fn from_toml_table(table: &toml::Table) -> Self {
let provider_str = table
.get("provider")
.and_then(|v| v.as_str())
.unwrap_or("wttr.in");
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
let api_key = table
.get("api_key")
.and_then(|v| v.as_str())
.map(String::from);
let location = table
.get("location")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Self {
provider,
api_key,
location,
}
}
}
/// Cached weather data (persisted to disk)
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WeatherData {
temperature: f32,
feels_like: Option<f32>,
condition: String,
humidity: Option<u8>,
wind_speed: Option<f32>,
icon: String,
location: String,
}
/// Persistent cache structure (saved to ~/.local/share/owlry/weather_cache.json)
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WeatherCache {
last_fetch_epoch: u64,
data: WeatherData,
}
/// Weather provider state
struct WeatherState {
items: Vec<PluginItem>,
config: WeatherConfig,
last_fetch_epoch: u64,
cached_data: Option<WeatherData>,
}
impl WeatherState {
fn new() -> Self {
Self::with_config(WeatherConfig::load())
}
fn with_config(config: WeatherConfig) -> Self {
// Load cached weather from disk if available
// This prevents blocking HTTP requests on every app open
let (last_fetch_epoch, cached_data) = Self::load_cache()
.map(|c| (c.last_fetch_epoch, Some(c.data)))
.unwrap_or((0, None));
Self {
items: Vec::new(),
config,
last_fetch_epoch,
cached_data,
}
}
fn data_dir() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("owlry"))
}
fn cache_path() -> Option<PathBuf> {
Self::data_dir().map(|d| d.join("weather_cache.json"))
}
fn load_cache() -> Option<WeatherCache> {
let path = Self::cache_path()?;
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
fn save_cache(&self) {
if let (Some(data_dir), Some(cache_path), Some(data)) =
(Self::data_dir(), Self::cache_path(), &self.cached_data)
{
if fs::create_dir_all(&data_dir).is_err() {
return;
}
let cache = WeatherCache {
last_fetch_epoch: self.last_fetch_epoch,
data: data.clone(),
};
if let Ok(json) = serde_json::to_string_pretty(&cache) {
let _ = fs::write(&cache_path, json);
}
}
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn is_cache_valid(&self) -> bool {
if self.last_fetch_epoch == 0 {
return false;
}
let now = Self::now_epoch();
now.saturating_sub(self.last_fetch_epoch) < CACHE_DURATION_SECS
}
fn refresh(&mut self) {
// Use cache if still valid (works across app restarts)
if self.is_cache_valid()
&& let Some(data) = self.cached_data.clone() {
self.generate_items(&data);
return;
}
// Fetch new data from API
if let Some(data) = self.fetch_weather() {
self.cached_data = Some(data.clone());
self.last_fetch_epoch = Self::now_epoch();
self.save_cache(); // Persist to disk for next app open
self.generate_items(&data);
} else {
// On fetch failure, try to use stale cache if available
if let Some(data) = self.cached_data.clone() {
self.generate_items(&data);
} else {
self.items.clear();
}
}
}
fn fetch_weather(&self) -> Option<WeatherData> {
match self.config.provider {
WeatherProviderType::WttrIn => self.fetch_wttr_in(),
WeatherProviderType::OpenWeatherMap => self.fetch_openweathermap(),
WeatherProviderType::OpenMeteo => self.fetch_open_meteo(),
}
}
fn fetch_wttr_in(&self) -> Option<WeatherData> {
let location = if self.config.location.is_empty() {
String::new()
} else {
self.config.location.clone()
};
let url = format!("https://wttr.in/{}?format=j1", location);
let client = reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.user_agent(USER_AGENT)
.build()
.ok()?;
let response = client.get(&url).send().ok()?;
let json: WttrInResponse = response.json().ok()?;
let current = json.current_condition.first()?;
let nearest = json.nearest_area.first()?;
let location_name = nearest
.area_name
.first()
.map(|a| a.value.clone())
.unwrap_or_else(|| "Unknown".to_string());
Some(WeatherData {
temperature: current.temp_c.parse().unwrap_or(0.0),
feels_like: current.feels_like_c.parse().ok(),
condition: current
.weather_desc
.first()
.map(|d| d.value.clone())
.unwrap_or_else(|| "Unknown".to_string()),
humidity: current.humidity.parse().ok(),
wind_speed: current.windspeed_kmph.parse().ok(),
icon: Self::wttr_code_to_icon(&current.weather_code),
location: location_name,
})
}
fn fetch_openweathermap(&self) -> Option<WeatherData> {
let api_key = self.config.api_key.as_ref()?;
if self.config.location.is_empty() {
return None; // OWM requires a location
}
let url = format!(
"https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric",
self.config.location, api_key
);
let client = reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.ok()?;
let response = client.get(&url).send().ok()?;
let json: OpenWeatherMapResponse = response.json().ok()?;
let weather = json.weather.first()?;
Some(WeatherData {
temperature: json.main.temp,
feels_like: Some(json.main.feels_like),
condition: weather.description.clone(),
humidity: Some(json.main.humidity),
wind_speed: Some(json.wind.speed * 3.6), // m/s to km/h
icon: Self::owm_icon_to_freedesktop(&weather.icon),
location: json.name,
})
}
fn fetch_open_meteo(&self) -> Option<WeatherData> {
let (lat, lon, location_name) = self.get_coordinates()?;
let url = format!(
"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=auto",
lat, lon
);
let client = reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.ok()?;
let response = client.get(&url).send().ok()?;
let json: OpenMeteoResponse = response.json().ok()?;
let current = json.current;
Some(WeatherData {
temperature: current.temperature_2m,
feels_like: None,
condition: Self::wmo_code_to_description(current.weather_code),
humidity: Some(current.relative_humidity_2m as u8),
wind_speed: Some(current.wind_speed_10m),
icon: Self::wmo_code_to_icon(current.weather_code),
location: location_name,
})
}
fn get_coordinates(&self) -> Option<(f64, f64, String)> {
let location = &self.config.location;
// Check if location is already coordinates (lat,lon)
if location.contains(',') {
let parts: Vec<&str> = location.split(',').collect();
if parts.len() == 2
&& let (Ok(lat), Ok(lon)) = (
parts[0].trim().parse::<f64>(),
parts[1].trim().parse::<f64>(),
) {
return Some((lat, lon, location.clone()));
}
}
// Use Open-Meteo geocoding API
let url = format!(
"https://geocoding-api.open-meteo.com/v1/search?name={}&count=1",
location
);
let client = reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.ok()?;
let response = client.get(&url).send().ok()?;
let json: GeocodingResponse = response.json().ok()?;
let result = json.results?.into_iter().next()?;
Some((result.latitude, result.longitude, result.name))
}
fn wttr_code_to_icon(code: &str) -> String {
match code {
"113" => "weather-clear",
"116" => "weather-few-clouds",
"119" => "weather-overcast",
"122" => "weather-overcast",
"143" | "248" | "260" => "weather-fog",
"176" | "263" | "266" | "293" | "296" | "299" | "302" | "305" | "308" => {
"weather-showers"
}
"179" | "182" | "185" | "227" | "230" | "323" | "326" | "329" | "332" | "335"
| "338" | "350" | "368" | "371" | "374" | "377" => "weather-snow",
"200" | "386" | "389" | "392" | "395" => "weather-storm",
_ => "weather-clear",
}
.to_string()
}
fn owm_icon_to_freedesktop(icon: &str) -> String {
match icon {
"01d" | "01n" => "weather-clear",
"02d" | "02n" => "weather-few-clouds",
"03d" | "03n" | "04d" | "04n" => "weather-overcast",
"09d" | "09n" | "10d" | "10n" => "weather-showers",
"11d" | "11n" => "weather-storm",
"13d" | "13n" => "weather-snow",
"50d" | "50n" => "weather-fog",
_ => "weather-clear",
}
.to_string()
}
fn wmo_code_to_description(code: i32) -> String {
match code {
0 => "Clear sky",
1 => "Mainly clear",
2 => "Partly cloudy",
3 => "Overcast",
45 | 48 => "Foggy",
51 | 53 | 55 => "Drizzle",
61 | 63 | 65 => "Rain",
66 | 67 => "Freezing rain",
71 | 73 | 75 | 77 => "Snow",
80..=82 => "Rain showers",
85 | 86 => "Snow showers",
95 | 96 | 99 => "Thunderstorm",
_ => "Unknown",
}
.to_string()
}
fn wmo_code_to_icon(code: i32) -> String {
match code {
0 | 1 => "weather-clear",
2 => "weather-few-clouds",
3 => "weather-overcast",
45 | 48 => "weather-fog",
51 | 53 | 55 | 61 | 63 | 65 | 80 | 81 | 82 => "weather-showers",
66 | 67 | 71 | 73 | 75 | 77 | 85 | 86 => "weather-snow",
95 | 96 | 99 => "weather-storm",
_ => "weather-clear",
}
.to_string()
}
fn icon_to_resource_path(icon: &str) -> String {
let weather_icon = if icon.contains("clear") {
"wi-day-sunny"
} else if icon.contains("few-clouds") {
"wi-day-cloudy"
} else if icon.contains("overcast") || icon.contains("clouds") {
"wi-cloudy"
} else if icon.contains("fog") {
"wi-fog"
} else if icon.contains("showers") || icon.contains("rain") {
"wi-rain"
} else if icon.contains("snow") {
"wi-snow"
} else if icon.contains("storm") {
"wi-thunderstorm"
} else {
"wi-thermometer"
};
format!("/org/owlry/launcher/icons/weather/{}.svg", weather_icon)
}
fn generate_items(&mut self, data: &WeatherData) {
self.items.clear();
let temp_str = format!("{}°C", data.temperature.round() as i32);
let name = format!("{} {}", temp_str, data.condition);
let mut details = vec![data.location.clone()];
if let Some(humidity) = data.humidity {
details.push(format!("Humidity {}%", humidity));
}
if let Some(wind) = data.wind_speed {
details.push(format!("Wind {} km/h", wind.round() as i32));
}
if let Some(feels) = data.feels_like
&& (feels - data.temperature).abs() > 2.0 {
details.push(format!("Feels like {}°C", feels.round() as i32));
}
let encoded_location = data.location.replace(' ', "+");
let command = format!("xdg-open 'https://wttr.in/{}'", encoded_location);
self.items.push(
PluginItem::new("weather-current", name, command)
.with_description(details.join(" | "))
.with_icon(Self::icon_to_resource_path(&data.icon))
.with_keywords(vec!["weather".to_string(), "widget".to_string()]),
);
}
}
// ============================================================================
// API Response Types
// ============================================================================
#[derive(Debug, Deserialize)]
struct WttrInResponse {
current_condition: Vec<WttrInCurrent>,
nearest_area: Vec<WttrInArea>,
}
#[derive(Debug, Deserialize)]
struct WttrInCurrent {
#[serde(rename = "temp_C")]
temp_c: String,
#[serde(rename = "FeelsLikeC")]
feels_like_c: String,
humidity: String,
#[serde(rename = "weatherCode")]
weather_code: String,
#[serde(rename = "weatherDesc")]
weather_desc: Vec<WttrInValue>,
#[serde(rename = "windspeedKmph")]
windspeed_kmph: String,
}
#[derive(Debug, Deserialize)]
struct WttrInValue {
value: String,
}
#[derive(Debug, Deserialize)]
struct WttrInArea {
#[serde(rename = "areaName")]
area_name: Vec<WttrInValue>,
}
#[derive(Debug, Deserialize)]
struct OpenWeatherMapResponse {
main: OwmMain,
weather: Vec<OwmWeather>,
wind: OwmWind,
name: String,
}
#[derive(Debug, Deserialize)]
struct OwmMain {
temp: f32,
feels_like: f32,
humidity: u8,
}
#[derive(Debug, Deserialize)]
struct OwmWeather {
description: String,
icon: String,
}
#[derive(Debug, Deserialize)]
struct OwmWind {
speed: f32,
}
#[derive(Debug, Deserialize)]
struct OpenMeteoResponse {
current: OpenMeteoCurrent,
}
#[derive(Debug, Deserialize)]
struct OpenMeteoCurrent {
temperature_2m: f32,
relative_humidity_2m: f32,
weather_code: i32,
wind_speed_10m: f32,
}
#[derive(Debug, Deserialize)]
struct GeocodingResponse {
results: Option<Vec<GeocodingResult>>,
}
#[derive(Debug, Deserialize)]
struct GeocodingResult {
name: String,
latitude: f64,
longitude: f64,
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RNone,
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Widget,
priority: 12000, // Widget: highest priority
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(WeatherState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<WeatherState>
let state = unsafe { &mut *(handle.ptr as *mut WeatherState) };
state.refresh();
state.items.clone().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query not used, return empty
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<WeatherState>
unsafe {
handle.drop_as::<WeatherState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_weather_provider_type_from_str() {
assert_eq!(
"wttr.in".parse::<WeatherProviderType>().unwrap(),
WeatherProviderType::WttrIn
);
assert_eq!(
"owm".parse::<WeatherProviderType>().unwrap(),
WeatherProviderType::OpenWeatherMap
);
assert_eq!(
"open-meteo".parse::<WeatherProviderType>().unwrap(),
WeatherProviderType::OpenMeteo
);
}
#[test]
fn test_wttr_code_to_icon() {
assert_eq!(WeatherState::wttr_code_to_icon("113"), "weather-clear");
assert_eq!(WeatherState::wttr_code_to_icon("116"), "weather-few-clouds");
assert_eq!(WeatherState::wttr_code_to_icon("176"), "weather-showers");
assert_eq!(WeatherState::wttr_code_to_icon("200"), "weather-storm");
}
#[test]
fn test_wmo_code_to_description() {
assert_eq!(WeatherState::wmo_code_to_description(0), "Clear sky");
assert_eq!(WeatherState::wmo_code_to_description(3), "Overcast");
assert_eq!(WeatherState::wmo_code_to_description(95), "Thunderstorm");
}
#[test]
fn test_icon_to_resource_path() {
assert_eq!(
WeatherState::icon_to_resource_path("weather-clear"),
"/org/owlry/launcher/icons/weather/wi-day-sunny.svg"
);
}
#[test]
fn test_cache_validity() {
let state = WeatherState {
items: Vec::new(),
config: WeatherConfig {
provider: WeatherProviderType::WttrIn,
api_key: None,
location: String::new(),
},
last_fetch_epoch: 0,
cached_data: None,
};
assert!(!state.is_cache_valid());
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "owlry-plugin-websearch"
version = "0.4.10"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Web search plugin for owlry - search the web with configurable search engines"
keywords = ["owlry", "plugin", "websearch", "search"]
categories = ["web-programming"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -0,0 +1,299 @@
//! Web Search Plugin for Owlry
//!
//! A dynamic provider that opens web searches in the browser.
//! Supports multiple search engines.
//!
//! Examples:
//! - `? rust programming` → Search DuckDuckGo for "rust programming"
//! - `web rust docs` → Search for "rust docs"
//! - `search how to rust` → Search for "how to rust"
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
// Plugin metadata
const PLUGIN_ID: &str = "websearch";
const PLUGIN_NAME: &str = "Web Search";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Search the web with configurable search engines";
// Provider metadata
const PROVIDER_ID: &str = "websearch";
const PROVIDER_NAME: &str = "Web Search";
const PROVIDER_PREFIX: &str = "?";
const PROVIDER_ICON: &str = "web-browser";
const PROVIDER_TYPE_ID: &str = "websearch";
/// Common search engine URL templates
/// {query} is replaced with the URL-encoded search term
const SEARCH_ENGINES: &[(&str, &str)] = &[
("google", "https://www.google.com/search?q={query}"),
("duckduckgo", "https://duckduckgo.com/?q={query}"),
("bing", "https://www.bing.com/search?q={query}"),
("startpage", "https://www.startpage.com/search?q={query}"),
("searxng", "https://searx.be/search?q={query}"),
("brave", "https://search.brave.com/search?q={query}"),
("ecosia", "https://www.ecosia.org/search?q={query}"),
];
/// Default search engine if not configured
const DEFAULT_ENGINE: &str = "duckduckgo";
/// Web search provider state
struct WebSearchState {
/// URL template with {query} placeholder
url_template: String,
}
impl WebSearchState {
fn new() -> Self {
Self::with_engine(DEFAULT_ENGINE)
}
fn with_engine(engine_name: &str) -> Self {
let url_template = SEARCH_ENGINES
.iter()
.find(|(name, _)| *name == engine_name.to_lowercase())
.map(|(_, url)| url.to_string())
.unwrap_or_else(|| {
// If not a known engine, treat it as a custom URL template
if engine_name.contains("{query}") {
engine_name.to_string()
} else {
// Fall back to default
SEARCH_ENGINES
.iter()
.find(|(name, _)| *name == DEFAULT_ENGINE)
.map(|(_, url)| url.to_string())
.unwrap()
}
});
Self { url_template }
}
/// Extract the search term from the query
fn extract_search_term(query: &str) -> Option<&str> {
let trimmed = query.trim();
if let Some(rest) = trimmed.strip_prefix("? ") {
Some(rest.trim())
} else if let Some(rest) = trimmed.strip_prefix("?") {
Some(rest.trim())
} else if trimmed.to_lowercase().starts_with("web ") {
Some(trimmed[4..].trim())
} else if trimmed.to_lowercase().starts_with("search ") {
Some(trimmed[7..].trim())
} else {
// In filter mode, accept raw query
Some(trimmed)
}
}
/// URL-encode a search query
fn url_encode(query: &str) -> String {
query
.chars()
.map(|c| match c {
' ' => "+".to_string(),
'&' => "%26".to_string(),
'=' => "%3D".to_string(),
'?' => "%3F".to_string(),
'#' => "%23".to_string(),
'+' => "%2B".to_string(),
'%' => "%25".to_string(),
c if c.is_ascii_alphanumeric() || "-_.~".contains(c) => c.to_string(),
c => format!("%{:02X}", c as u32),
})
.collect()
}
/// Build the search URL from a query
fn build_search_url(&self, search_term: &str) -> String {
let encoded = Self::url_encode(search_term);
self.url_template.replace("{query}", &encoded)
}
/// Evaluate a query and return a PluginItem if valid
fn evaluate(&self, query: &str) -> Option<PluginItem> {
let search_term = Self::extract_search_term(query)?;
if search_term.is_empty() {
return None;
}
let url = self.build_search_url(search_term);
// Use xdg-open to open the browser
let command = format!("xdg-open '{}'", url);
Some(
PluginItem::new(
format!("websearch:{}", search_term),
format!("Search: {}", search_term),
command,
)
.with_description("Open in browser")
.with_icon(PROVIDER_ICON)
.with_keywords(vec!["web".to_string(), "search".to_string()]),
)
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Dynamic,
type_id: RString::from(PROVIDER_TYPE_ID),
position: ProviderPosition::Normal,
priority: 9000, // Dynamic: web search
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
// TODO: Read search engine from config when plugin config is available
let state = Box::new(WebSearchState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
// Dynamic provider - refresh does nothing
RVec::new()
}
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<WebSearchState>
let state = unsafe { &*(handle.ptr as *const WebSearchState) };
let query_str = query.as_str();
match state.evaluate(query_str) {
Some(item) => vec![item].into(),
None => RVec::new(),
}
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<WebSearchState>
unsafe {
handle.drop_as::<WebSearchState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_search_term() {
assert_eq!(
WebSearchState::extract_search_term("? rust programming"),
Some("rust programming")
);
assert_eq!(
WebSearchState::extract_search_term("?rust"),
Some("rust")
);
assert_eq!(
WebSearchState::extract_search_term("web rust docs"),
Some("rust docs")
);
assert_eq!(
WebSearchState::extract_search_term("search how to rust"),
Some("how to rust")
);
}
#[test]
fn test_url_encode() {
assert_eq!(WebSearchState::url_encode("hello world"), "hello+world");
assert_eq!(WebSearchState::url_encode("foo&bar"), "foo%26bar");
assert_eq!(WebSearchState::url_encode("a=b"), "a%3Db");
assert_eq!(WebSearchState::url_encode("test?query"), "test%3Fquery");
}
#[test]
fn test_build_search_url() {
let state = WebSearchState::with_engine("duckduckgo");
let url = state.build_search_url("rust programming");
assert_eq!(url, "https://duckduckgo.com/?q=rust+programming");
}
#[test]
fn test_build_search_url_google() {
let state = WebSearchState::with_engine("google");
let url = state.build_search_url("rust");
assert_eq!(url, "https://www.google.com/search?q=rust");
}
#[test]
fn test_evaluate() {
let state = WebSearchState::new();
let item = state.evaluate("? rust docs").unwrap();
assert_eq!(item.name.as_str(), "Search: rust docs");
assert!(item.command.as_str().contains("xdg-open"));
assert!(item.command.as_str().contains("duckduckgo"));
}
#[test]
fn test_evaluate_empty() {
let state = WebSearchState::new();
assert!(state.evaluate("?").is_none());
assert!(state.evaluate("? ").is_none());
}
#[test]
fn test_custom_url_template() {
let state = WebSearchState::with_engine("https://custom.search/q={query}");
let url = state.build_search_url("test");
assert_eq!(url, "https://custom.search/q=test");
}
#[test]
fn test_fallback_to_default() {
let state = WebSearchState::with_engine("nonexistent");
let url = state.build_search_url("test");
assert!(url.contains("duckduckgo")); // Falls back to default
}
}

View File

@@ -0,0 +1,44 @@
[package]
name = "owlry-rune"
version = "0.4.10"
edition = "2024"
rust-version = "1.90"
description = "Rune scripting runtime for owlry plugins"
license = "GPL-3.0-or-later"
[lib]
crate-type = ["cdylib"]
[dependencies]
# Shared plugin API
owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" }
# Rune scripting language
rune = "0.14"
rune-modules = { version = "0.14", features = ["full"] }
# Logging
log = "0.4"
env_logger = "0.11"
# HTTP client for network API
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Configuration parsing
toml = "0.8"
# Semantic versioning
semver = "1"
# Date/time
chrono = "0.4"
# Directory paths
dirs = "5"
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,130 @@
//! Owlry API bindings for Rune plugins
//!
//! This module provides the `owlry` module that Rune plugins can use.
use rune::{ContextError, Module};
use std::sync::Mutex;
use owlry_plugin_api::{PluginItem, RString};
/// Provider registration info
#[derive(Debug, Clone)]
pub struct ProviderRegistration {
pub name: String,
pub display_name: String,
pub type_id: String,
pub default_icon: String,
pub is_static: bool,
pub prefix: Option<String>,
}
/// An item returned by a provider
///
/// Used for converting Rune plugin items to FFI format.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Item {
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub command: String,
pub terminal: bool,
pub keywords: Vec<String>,
}
impl Item {
/// Convert to PluginItem for FFI
#[allow(dead_code)]
pub fn to_plugin_item(&self) -> PluginItem {
let mut item = PluginItem::new(
RString::from(self.id.as_str()),
RString::from(self.name.as_str()),
RString::from(self.command.as_str()),
);
if let Some(ref desc) = self.description {
item = item.with_description(desc.clone());
}
if let Some(ref icon) = self.icon {
item = item.with_icon(icon.clone());
}
item.with_terminal(self.terminal)
.with_keywords(self.keywords.clone())
}
}
/// Global state for provider registrations (thread-safe)
pub static REGISTRATIONS: Mutex<Vec<ProviderRegistration>> = Mutex::new(Vec::new());
/// Create the owlry module for Rune
pub fn module() -> Result<Module, ContextError> {
let mut module = Module::with_crate("owlry")?;
// Register logging functions using builder pattern
module.function("log_info", log_info).build()?;
module.function("log_debug", log_debug).build()?;
module.function("log_warn", log_warn).build()?;
module.function("log_error", log_error).build()?;
Ok(module)
}
// ============================================================================
// Logging Functions
// ============================================================================
fn log_info(message: &str) {
log::info!("[Rune] {}", message);
}
fn log_debug(message: &str) {
log::debug!("[Rune] {}", message);
}
fn log_warn(message: &str) {
log::warn!("[Rune] {}", message);
}
fn log_error(message: &str) {
log::error!("[Rune] {}", message);
}
/// Get all provider registrations
pub fn get_registrations() -> Vec<ProviderRegistration> {
REGISTRATIONS.lock().unwrap().clone()
}
/// Clear all registrations (for testing or reloading)
pub fn clear_registrations() {
REGISTRATIONS.lock().unwrap().clear();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_item_creation() {
let item = Item {
id: "test-1".to_string(),
name: "Test Item".to_string(),
description: Some("A test".to_string()),
icon: Some("test-icon".to_string()),
command: "echo test".to_string(),
terminal: false,
keywords: vec!["test".to_string()],
};
let plugin_item = item.to_plugin_item();
assert_eq!(plugin_item.id.as_str(), "test-1");
assert_eq!(plugin_item.name.as_str(), "Test Item");
}
#[test]
fn test_module_creation() {
let module = module();
assert!(module.is_ok());
}
}

View File

@@ -0,0 +1,251 @@
//! Owlry Rune Runtime
//!
//! This crate provides a Rune scripting runtime for owlry user plugins.
//! It is loaded dynamically by the core when installed.
//!
//! # Architecture
//!
//! The runtime exports a C-compatible vtable that the core uses to:
//! 1. Initialize the runtime with a plugins directory
//! 2. Get a list of providers from loaded plugins
//! 3. Refresh/query providers
//! 4. Clean up resources
//!
//! # Plugin Structure
//!
//! Rune plugins live in `~/.config/owlry/plugins/<plugin-name>/`:
//! ```text
//! my-plugin/
//! plugin.toml # Manifest
//! init.rn # Entry point (Rune script)
//! ```
mod api;
mod loader;
mod manifest;
mod runtime;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use owlry_plugin_api::{PluginItem, ROption, RStr, RString, RVec};
pub use loader::LoadedPlugin;
pub use manifest::PluginManifest;
// ============================================================================
// Runtime VTable (C-compatible interface)
// ============================================================================
/// Information about this runtime
#[repr(C)]
pub struct RuntimeInfo {
pub name: RString,
pub version: RString,
}
/// Information about a provider from a plugin
#[repr(C)]
#[derive(Clone)]
pub struct RuneProviderInfo {
pub name: RString,
pub display_name: RString,
pub type_id: RString,
pub default_icon: RString,
pub is_static: bool,
pub prefix: ROption<RString>,
}
/// Opaque handle to runtime state
#[repr(transparent)]
#[derive(Clone, Copy)]
pub struct RuntimeHandle(pub *mut ());
/// Runtime state managed by the handle
struct RuntimeState {
plugins: HashMap<String, LoadedPlugin>,
providers: Vec<RuneProviderInfo>,
}
/// VTable for the Rune runtime
#[repr(C)]
pub struct RuneRuntimeVTable {
pub info: extern "C" fn() -> RuntimeInfo,
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<RuneProviderInfo>,
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
pub query: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>, query: RStr<'_>) -> RVec<PluginItem>,
pub drop: extern "C" fn(handle: RuntimeHandle),
}
// ============================================================================
// VTable Implementation
// ============================================================================
extern "C" fn runtime_info() -> RuntimeInfo {
RuntimeInfo {
name: RString::from("rune"),
version: RString::from(env!("CARGO_PKG_VERSION")),
}
}
extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle {
let _ = env_logger::try_init();
let plugins_dir = PathBuf::from(plugins_dir.as_str());
log::info!("Initializing Rune runtime with plugins from: {}", plugins_dir.display());
let mut state = RuntimeState {
plugins: HashMap::new(),
providers: Vec::new(),
};
// Discover and load Rune plugins
match loader::discover_rune_plugins(&plugins_dir) {
Ok(plugins) => {
for (id, plugin) in plugins {
// Collect provider info before storing plugin
for reg in plugin.provider_registrations() {
state.providers.push(RuneProviderInfo {
name: RString::from(reg.name.as_str()),
display_name: RString::from(reg.display_name.as_str()),
type_id: RString::from(reg.type_id.as_str()),
default_icon: RString::from(reg.default_icon.as_str()),
is_static: reg.is_static,
prefix: reg.prefix.as_ref()
.map(|p| RString::from(p.as_str()))
.into(),
});
}
state.plugins.insert(id, plugin);
}
log::info!("Loaded {} Rune plugin(s) with {} provider(s)",
state.plugins.len(), state.providers.len());
}
Err(e) => {
log::error!("Failed to discover Rune plugins: {}", e);
}
}
// Box and leak the state, returning an opaque handle
let boxed = Box::new(Mutex::new(state));
RuntimeHandle(Box::into_raw(boxed) as *mut ())
}
extern "C" fn runtime_providers(handle: RuntimeHandle) -> RVec<RuneProviderInfo> {
let state = unsafe { &*(handle.0 as *const Mutex<RuntimeState>) };
let guard = state.lock().unwrap();
guard.providers.clone().into_iter().collect()
}
extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem> {
let state = unsafe { &*(handle.0 as *const Mutex<RuntimeState>) };
let mut guard = state.lock().unwrap();
let provider_name = provider_id.as_str();
// Find the plugin that provides this provider
for plugin in guard.plugins.values_mut() {
if plugin.provides_provider(provider_name) {
match plugin.refresh_provider(provider_name) {
Ok(items) => return items.into_iter().collect(),
Err(e) => {
log::error!("Failed to refresh provider '{}': {}", provider_name, e);
return RVec::new();
}
}
}
}
log::warn!("Provider '{}' not found", provider_name);
RVec::new()
}
extern "C" fn runtime_query(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem> {
let state = unsafe { &*(handle.0 as *const Mutex<RuntimeState>) };
let mut guard = state.lock().unwrap();
let provider_name = provider_id.as_str();
let query_str = query.as_str();
// Find the plugin that provides this provider
for plugin in guard.plugins.values_mut() {
if plugin.provides_provider(provider_name) {
match plugin.query_provider(provider_name, query_str) {
Ok(items) => return items.into_iter().collect(),
Err(e) => {
log::error!("Failed to query provider '{}': {}", provider_name, e);
return RVec::new();
}
}
}
}
log::warn!("Provider '{}' not found", provider_name);
RVec::new()
}
extern "C" fn runtime_drop(handle: RuntimeHandle) {
if !handle.0.is_null() {
// SAFETY: We created this box in runtime_init
unsafe {
let _ = Box::from_raw(handle.0 as *mut Mutex<RuntimeState>);
}
log::info!("Rune runtime cleaned up");
}
}
/// Static vtable instance
static RUNE_RUNTIME_VTABLE: RuneRuntimeVTable = RuneRuntimeVTable {
info: runtime_info,
init: runtime_init,
providers: runtime_providers,
refresh: runtime_refresh,
query: runtime_query,
drop: runtime_drop,
};
/// Entry point - returns the runtime vtable
#[unsafe(no_mangle)]
pub extern "C" fn owlry_rune_runtime_vtable() -> &'static RuneRuntimeVTable {
&RUNE_RUNTIME_VTABLE
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runtime_info() {
let info = runtime_info();
assert_eq!(info.name.as_str(), "rune");
assert!(!info.version.as_str().is_empty());
}
#[test]
fn test_runtime_lifecycle() {
// Create a temp directory for plugins
let temp = tempfile::TempDir::new().unwrap();
let plugins_dir = temp.path().to_string_lossy();
// Initialize runtime
let handle = runtime_init(RStr::from_str(&plugins_dir));
assert!(!handle.0.is_null());
// Get providers (should be empty with no plugins)
let providers = runtime_providers(handle);
assert!(providers.is_empty());
// Clean up
runtime_drop(handle);
}
}

View File

@@ -0,0 +1,175 @@
//! Rune plugin discovery and loading
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rune::{Context, Unit};
use crate::api::{self, ProviderRegistration};
use crate::manifest::PluginManifest;
use crate::runtime::{compile_source, create_context, create_vm, SandboxConfig};
use owlry_plugin_api::PluginItem;
/// A loaded Rune plugin
pub struct LoadedPlugin {
pub manifest: PluginManifest,
pub path: PathBuf,
/// Context for creating new VMs (reserved for refresh/query implementation)
#[allow(dead_code)]
context: Context,
/// Compiled unit (reserved for refresh/query implementation)
#[allow(dead_code)]
unit: Arc<Unit>,
registrations: Vec<ProviderRegistration>,
}
impl LoadedPlugin {
/// Create and initialize a new plugin
pub fn new(manifest: PluginManifest, path: PathBuf) -> Result<Self, String> {
let sandbox = SandboxConfig::from_permissions(&manifest.permissions);
let context = create_context(&sandbox)
.map_err(|e| format!("Failed to create context: {}", e))?;
let entry_path = path.join(&manifest.plugin.entry);
if !entry_path.exists() {
return Err(format!("Entry point not found: {}", entry_path.display()));
}
// Clear previous registrations before loading
api::clear_registrations();
// Compile the source
let unit = compile_source(&context, &entry_path)
.map_err(|e| format!("Failed to compile: {}", e))?;
// Run the entry point to register providers
let mut vm = create_vm(&context, unit.clone())
.map_err(|e| format!("Failed to create VM: {}", e))?;
// Execute the main function if it exists
match vm.call(rune::Hash::type_hash(["main"]), ()) {
Ok(result) => {
// Try to complete the execution
let _: () = rune::from_value(result)
.unwrap_or(());
}
Err(_) => {
// No main function is okay
}
}
// Collect registrations
let registrations = api::get_registrations();
log::info!(
"Loaded Rune plugin '{}' with {} provider(s)",
manifest.plugin.id,
registrations.len()
);
Ok(Self {
manifest,
path,
context,
unit,
registrations,
})
}
/// Get plugin ID
pub fn id(&self) -> &str {
&self.manifest.plugin.id
}
/// Get provider registrations
pub fn provider_registrations(&self) -> &[ProviderRegistration] {
&self.registrations
}
/// Check if this plugin provides a specific provider
pub fn provides_provider(&self, name: &str) -> bool {
self.registrations.iter().any(|r| r.name == name)
}
/// Refresh a static provider (stub for now)
pub fn refresh_provider(&mut self, _name: &str) -> Result<Vec<PluginItem>, String> {
// TODO: Implement provider refresh by calling Rune function
Ok(Vec::new())
}
/// Query a dynamic provider (stub for now)
pub fn query_provider(&mut self, _name: &str, _query: &str) -> Result<Vec<PluginItem>, String> {
// TODO: Implement provider query by calling Rune function
Ok(Vec::new())
}
}
/// Discover Rune plugins in a directory
pub fn discover_rune_plugins(plugins_dir: &Path) -> Result<HashMap<String, LoadedPlugin>, String> {
let mut plugins = HashMap::new();
if !plugins_dir.exists() {
log::debug!("Plugins directory does not exist: {}", plugins_dir.display());
return Ok(plugins);
}
let entries = std::fs::read_dir(plugins_dir)
.map_err(|e| format!("Failed to read plugins directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("plugin.toml");
if !manifest_path.exists() {
continue;
}
// Load manifest
let manifest = match PluginManifest::load(&manifest_path) {
Ok(m) => m,
Err(e) => {
log::warn!("Failed to load manifest at {}: {}", manifest_path.display(), e);
continue;
}
};
// Check if this is a Rune plugin (entry ends with .rn)
if !manifest.plugin.entry.ends_with(".rn") {
log::debug!("Skipping non-Rune plugin: {}", manifest.plugin.id);
continue;
}
// Load the plugin
match LoadedPlugin::new(manifest.clone(), path.clone()) {
Ok(plugin) => {
let id = manifest.plugin.id.clone();
plugins.insert(id, plugin);
}
Err(e) => {
log::warn!("Failed to load plugin '{}': {}", manifest.plugin.id, e);
}
}
}
Ok(plugins)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_discover_empty_dir() {
let temp = TempDir::new().unwrap();
let plugins = discover_rune_plugins(temp.path()).unwrap();
assert!(plugins.is_empty());
}
}

View File

@@ -0,0 +1,155 @@
//! Plugin manifest parsing for Rune plugins
use serde::Deserialize;
use std::path::Path;
/// Plugin manifest from plugin.toml
#[derive(Debug, Clone, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
#[serde(default)]
pub provides: PluginProvides,
#[serde(default)]
pub permissions: PluginPermissions,
}
/// Core plugin information
#[derive(Debug, Clone, Deserialize)]
pub struct PluginInfo {
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub author: String,
#[serde(default = "default_owlry_version")]
pub owlry_version: String,
#[serde(default = "default_entry")]
pub entry: String,
}
fn default_owlry_version() -> String {
">=0.1.0".to_string()
}
fn default_entry() -> String {
"init.rn".to_string()
}
/// What the plugin provides
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PluginProvides {
#[serde(default)]
pub providers: Vec<String>,
#[serde(default)]
pub actions: bool,
#[serde(default)]
pub themes: Vec<String>,
#[serde(default)]
pub hooks: bool,
}
/// Plugin permissions
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PluginPermissions {
#[serde(default)]
pub network: bool,
#[serde(default)]
pub filesystem: Vec<String>,
#[serde(default)]
pub run_commands: Vec<String>,
}
impl PluginManifest {
/// Load manifest from a plugin.toml file
pub fn load(path: &Path) -> Result<Self, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read manifest: {}", e))?;
let manifest: PluginManifest = toml::from_str(&content)
.map_err(|e| format!("Failed to parse manifest: {}", e))?;
manifest.validate()?;
Ok(manifest)
}
/// Validate the manifest
fn validate(&self) -> Result<(), String> {
if self.plugin.id.is_empty() {
return Err("Plugin ID cannot be empty".to_string());
}
if !self.plugin.id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
return Err("Plugin ID must be lowercase alphanumeric with hyphens".to_string());
}
// Validate version format
if semver::Version::parse(&self.plugin.version).is_err() {
return Err(format!("Invalid version format: {}", self.plugin.version));
}
// Rune plugins must have .rn entry point
if !self.plugin.entry.ends_with(".rn") {
return Err("Entry point must be a .rn file for Rune plugins".to_string());
}
Ok(())
}
/// Check compatibility with owlry version
pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
Ok(r) => r,
Err(_) => return false,
};
let version = match semver::Version::parse(owlry_version) {
Ok(v) => v,
Err(_) => return false,
};
req.matches(&version)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_manifest() {
let toml_str = r#"
[plugin]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.entry, "init.rn");
}
#[test]
fn test_validate_entry_point() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
entry = "main.lua"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.validate().is_err()); // .lua not allowed for Rune
}
#[test]
fn test_version_compatibility() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
owlry_version = ">=0.3.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.is_compatible_with("0.3.5"));
assert!(!manifest.is_compatible_with("0.2.0"));
}
}

View File

@@ -0,0 +1,160 @@
//! Rune VM runtime creation and sandboxing
use rune::{Context, Diagnostics, Source, Sources, Unit, Vm};
use std::path::Path;
use std::sync::Arc;
use crate::manifest::PluginPermissions;
/// Configuration for the Rune sandbox
///
/// Some fields are reserved for future sandbox enforcement.
#[derive(Debug, Clone)]
#[allow(dead_code)]
#[derive(Default)]
pub struct SandboxConfig {
/// Allow network/HTTP operations
pub network: bool,
/// Allow filesystem operations
pub filesystem: bool,
/// Allowed filesystem paths (reserved for future sandbox enforcement)
pub allowed_paths: Vec<String>,
/// Allow running external commands (reserved for future sandbox enforcement)
pub run_commands: bool,
/// Allowed commands (reserved for future sandbox enforcement)
pub allowed_commands: Vec<String>,
}
impl SandboxConfig {
/// Create sandbox config from plugin permissions
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
Self {
network: permissions.network,
filesystem: !permissions.filesystem.is_empty(),
allowed_paths: permissions.filesystem.clone(),
run_commands: !permissions.run_commands.is_empty(),
allowed_commands: permissions.run_commands.clone(),
}
}
}
/// Create a Rune context with owlry API modules
pub fn create_context(sandbox: &SandboxConfig) -> Result<Context, rune::ContextError> {
let mut context = Context::with_default_modules()?;
// Add standard modules based on permissions
if sandbox.network {
log::debug!("Network access enabled for Rune plugin");
}
if sandbox.filesystem {
log::debug!("Filesystem access enabled for Rune plugin");
}
// Add owlry API module
context.install(crate::api::module()?)?;
Ok(context)
}
/// Compile Rune source code into a Unit
pub fn compile_source(
context: &Context,
source_path: &Path,
) -> Result<Arc<Unit>, CompileError> {
let source_content = std::fs::read_to_string(source_path)
.map_err(|e| CompileError::Io(e.to_string()))?;
let source_name = source_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("init.rn");
let mut sources = Sources::new();
sources
.insert(Source::new(source_name, &source_content).map_err(|e| CompileError::Compile(e.to_string()))?)
.map_err(|e| CompileError::Compile(format!("Failed to insert source: {}", e)))?;
let mut diagnostics = Diagnostics::new();
let result = rune::prepare(&mut sources)
.with_context(context)
.with_diagnostics(&mut diagnostics)
.build();
match result {
Ok(unit) => Ok(Arc::new(unit)),
Err(e) => {
// Collect error messages
let mut error_msg = format!("Compilation failed: {}", e);
for diagnostic in diagnostics.diagnostics() {
error_msg.push_str(&format!("\n {:?}", diagnostic));
}
Err(CompileError::Compile(error_msg))
}
}
}
/// Create a new Rune VM from compiled unit
pub fn create_vm(
context: &Context,
unit: Arc<Unit>,
) -> Result<Vm, CompileError> {
let runtime = Arc::new(
context.runtime()
.map_err(|e| CompileError::Compile(format!("Failed to get runtime: {}", e)))?
);
Ok(Vm::new(runtime, unit))
}
/// Error type for compilation
#[derive(Debug)]
pub enum CompileError {
Io(String),
Compile(String),
}
impl std::fmt::Display for CompileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CompileError::Io(e) => write!(f, "IO error: {}", e),
CompileError::Compile(e) => write!(f, "Compile error: {}", e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sandbox_config_default() {
let config = SandboxConfig::default();
assert!(!config.network);
assert!(!config.filesystem);
assert!(!config.run_commands);
}
#[test]
fn test_sandbox_from_permissions() {
let permissions = PluginPermissions {
network: true,
filesystem: vec!["~/.config".to_string()],
run_commands: vec!["notify-send".to_string()],
};
let config = SandboxConfig::from_permissions(&permissions);
assert!(config.network);
assert!(config.filesystem);
assert!(config.run_commands);
assert_eq!(config.allowed_paths, vec!["~/.config"]);
assert_eq!(config.allowed_commands, vec!["notify-send"]);
}
#[test]
fn test_create_context() {
let config = SandboxConfig::default();
let context = create_context(&config);
assert!(context.is_ok());
}
}