refactor: wire owlry to use owlry-core as library dependency
- Add owlry-core dependency to owlry Cargo.toml - Remove dependencies from owlry that moved to owlry-core: fuzzy-matcher, freedesktop-desktop-entry, libloading, notify-rust, thiserror, mlua, meval, reqwest - Forward feature flags (dev-logging, lua) to owlry-core - Update all imports in owlry source files to use owlry_core:: for moved modules (config, data, filter, providers, plugins, notify, paths) - Delete original source files from owlry that were moved - Create minimal providers/mod.rs that only re-exports DmenuProvider - Move plugins/commands.rs to plugin_commands.rs (stays in owlry since it depends on CLI types from clap) - Restructure app.rs to build core providers externally and pass them to ProviderManager::new() instead of using the old with_native_plugins() constructor
This commit is contained in:
19
Cargo.lock
generated
19
Cargo.lock
generated
@@ -2439,12 +2439,27 @@ dependencies = [
|
||||
"clap",
|
||||
"dirs",
|
||||
"env_logger",
|
||||
"freedesktop-desktop-entry",
|
||||
"fuzzy-matcher",
|
||||
"glib-build-tools",
|
||||
"gtk4",
|
||||
"gtk4-layer-shell",
|
||||
"libc",
|
||||
"log",
|
||||
"owlry-core",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml 0.8.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-core"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs",
|
||||
"env_logger",
|
||||
"freedesktop-desktop-entry",
|
||||
"fuzzy-matcher",
|
||||
"libloading 0.8.9",
|
||||
"log",
|
||||
"meval",
|
||||
|
||||
@@ -29,6 +29,9 @@ toml = "0.8"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "5"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
# Logging & notifications
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
@@ -39,6 +42,9 @@ mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"]
|
||||
meval = { version = "0.2", optional = true }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
lua = ["dep:mlua", "dep:meval", "dep:reqwest"]
|
||||
|
||||
@@ -11,8 +11,8 @@ keywords = ["launcher", "wayland", "gtk4", "linux"]
|
||||
categories = ["gui"]
|
||||
|
||||
[dependencies]
|
||||
# Shared plugin API
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
# Core backend library
|
||||
owlry-core = { path = "../owlry-core" }
|
||||
|
||||
# GTK4 for the UI
|
||||
gtk4 = { version = "0.10", features = ["v4_12"] }
|
||||
@@ -20,60 +20,32 @@ gtk4 = { version = "0.10", features = ["v4_12"] }
|
||||
# Layer shell support for Wayland overlay behavior
|
||||
gtk4-layer-shell = "0.7"
|
||||
|
||||
# Fuzzy matching for search
|
||||
fuzzy-matcher = "0.3"
|
||||
|
||||
# XDG desktop entry parsing
|
||||
freedesktop-desktop-entry = "0.8"
|
||||
|
||||
# Directory utilities
|
||||
dirs = "5"
|
||||
|
||||
# Low-level syscalls for stdin detection
|
||||
# Low-level syscalls for stdin detection (dmenu mode)
|
||||
libc = "0.2"
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
# Configuration
|
||||
# Configuration (needed for config types used in app.rs/theme.rs)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
|
||||
# CLI argument parsing
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# Math expression evaluation (for Lua plugins)
|
||||
meval = { version = "0.2", optional = true }
|
||||
|
||||
# JSON serialization for data persistence
|
||||
# JSON serialization (needed by plugin commands in CLI)
|
||||
serde_json = "1"
|
||||
|
||||
# Date/time for frecency calculations
|
||||
# Date/time (needed by plugin commands in CLI)
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# HTTP client (for Lua plugins)
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true }
|
||||
# Directory utilities (needed by plugin commands)
|
||||
dirs = "5"
|
||||
|
||||
# Lua runtime for plugin system (optional - can be loaded dynamically via owlry-lua)
|
||||
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true }
|
||||
|
||||
# Semantic versioning for plugin compatibility
|
||||
# Semantic versioning (needed by plugin commands)
|
||||
semver = "1"
|
||||
|
||||
# Dynamic library loading for native plugins
|
||||
libloading = "0.8"
|
||||
|
||||
# Desktop notifications (freedesktop notification spec)
|
||||
notify-rust = "4"
|
||||
|
||||
[dev-dependencies]
|
||||
# Temporary directories for tests
|
||||
tempfile = "3"
|
||||
|
||||
[build-dependencies]
|
||||
# GResource compilation for bundled icons
|
||||
glib-build-tools = "0.20"
|
||||
@@ -81,7 +53,6 @@ glib-build-tools = "0.20"
|
||||
[features]
|
||||
default = []
|
||||
# Enable verbose debug logging (for development/testing builds)
|
||||
dev-logging = []
|
||||
dev-logging = ["owlry-core/dev-logging"]
|
||||
# Enable built-in Lua runtime (disable to use external owlry-lua package)
|
||||
# Includes: mlua, meval (math), reqwest (http)
|
||||
lua = ["dep:mlua", "dep:meval", "dep:reqwest"]
|
||||
lua = ["owlry-core/lua"]
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
use crate::cli::CliArgs;
|
||||
use crate::config::Config;
|
||||
use crate::data::FrecencyStore;
|
||||
use crate::filter::ProviderFilter;
|
||||
use crate::paths;
|
||||
use crate::plugins::native_loader::NativePluginLoader;
|
||||
#[cfg(feature = "lua")]
|
||||
use crate::plugins::PluginManager;
|
||||
use crate::providers::native_provider::NativeProvider;
|
||||
use crate::providers::Provider; // For name() method
|
||||
use crate::providers::ProviderManager;
|
||||
use crate::providers::DmenuProvider;
|
||||
use crate::theme;
|
||||
use crate::ui::MainWindow;
|
||||
use owlry_core::config::Config;
|
||||
use owlry_core::data::FrecencyStore;
|
||||
use owlry_core::filter::ProviderFilter;
|
||||
use owlry_core::paths;
|
||||
use owlry_core::plugins::native_loader::NativePluginLoader;
|
||||
#[cfg(feature = "lua")]
|
||||
use owlry_core::plugins::PluginManager;
|
||||
use owlry_core::providers::native_provider::NativeProvider;
|
||||
use owlry_core::providers::Provider; // For name() method
|
||||
use owlry_core::providers::{ApplicationProvider, CommandProvider, ProviderManager};
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{gio, Application, CssProvider};
|
||||
use gtk4_layer_shell::{Edge, Layer, LayerShell};
|
||||
@@ -55,11 +56,25 @@ impl OwlryApp {
|
||||
// Load native plugins from /usr/lib/owlry/plugins/
|
||||
let native_providers = Self::load_native_plugins(&config.borrow());
|
||||
|
||||
// Create provider manager with native plugins
|
||||
// Build core providers based on mode
|
||||
let dmenu_mode = DmenuProvider::has_stdin_data();
|
||||
let core_providers: Vec<Box<dyn Provider>> = if dmenu_mode {
|
||||
let mut dmenu = DmenuProvider::new();
|
||||
dmenu.enable();
|
||||
vec![Box::new(dmenu)]
|
||||
} else {
|
||||
vec![
|
||||
Box::new(ApplicationProvider::new()),
|
||||
Box::new(CommandProvider::new()),
|
||||
]
|
||||
};
|
||||
|
||||
// Create provider manager with core providers and native plugins
|
||||
let native_for_manager = if dmenu_mode { Vec::new() } else { native_providers };
|
||||
#[cfg(feature = "lua")]
|
||||
let mut provider_manager = ProviderManager::with_native_plugins(native_providers);
|
||||
let mut provider_manager = ProviderManager::new(core_providers, native_for_manager);
|
||||
#[cfg(not(feature = "lua"))]
|
||||
let provider_manager = ProviderManager::with_native_plugins(native_providers);
|
||||
let provider_manager = ProviderManager::new(core_providers, native_for_manager);
|
||||
|
||||
// Load Lua plugins if enabled (requires lua feature)
|
||||
#[cfg(feature = "lua")]
|
||||
@@ -117,7 +132,7 @@ impl OwlryApp {
|
||||
Ok(count) => {
|
||||
if count == 0 {
|
||||
debug!("No native plugins found in {}",
|
||||
crate::plugins::native_loader::SYSTEM_PLUGINS_DIR);
|
||||
owlry_core::plugins::native_loader::SYSTEM_PLUGINS_DIR);
|
||||
return Vec::new();
|
||||
}
|
||||
info!("Discovered {} native plugin(s)", count);
|
||||
@@ -129,7 +144,7 @@ impl OwlryApp {
|
||||
}
|
||||
|
||||
// Get all plugins and create providers
|
||||
let plugins: Vec<Arc<crate::plugins::native_loader::NativePlugin>> =
|
||||
let plugins: Vec<Arc<owlry_core::plugins::native_loader::NativePlugin>> =
|
||||
loader.into_plugins();
|
||||
|
||||
// Create NativeProvider instances from loaded plugins
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::providers::ProviderType;
|
||||
use owlry_core::providers::ProviderType;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(
|
||||
|
||||
@@ -1,574 +0,0 @@
|
||||
use log::{debug, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub general: GeneralConfig,
|
||||
#[serde(default)]
|
||||
pub appearance: AppearanceConfig,
|
||||
#[serde(default)]
|
||||
pub providers: ProvidersConfig,
|
||||
#[serde(default)]
|
||||
pub plugins: PluginsConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneralConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub show_icons: bool,
|
||||
#[serde(default = "default_max_results")]
|
||||
pub max_results: usize,
|
||||
/// Terminal command (auto-detected if not specified)
|
||||
#[serde(default)]
|
||||
pub terminal_command: Option<String>,
|
||||
/// Enable uwsm (Universal Wayland Session Manager) for launching apps.
|
||||
/// When enabled, desktop files are launched via `uwsm app -- <file>`
|
||||
/// which starts apps in a proper systemd user session.
|
||||
/// When disabled (default), apps are launched via `gio launch`.
|
||||
#[serde(default)]
|
||||
pub use_uwsm: bool,
|
||||
/// Provider tabs shown in the header bar.
|
||||
/// Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
|
||||
#[serde(default = "default_tabs")]
|
||||
pub tabs: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for GeneralConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_icons: true,
|
||||
max_results: 100,
|
||||
terminal_command: None,
|
||||
use_uwsm: false,
|
||||
tabs: default_tabs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_max_results() -> usize {
|
||||
100
|
||||
}
|
||||
|
||||
fn default_tabs() -> Vec<String> {
|
||||
vec![
|
||||
"app".to_string(),
|
||||
"cmd".to_string(),
|
||||
"uuctl".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// User-customizable theme colors
|
||||
/// All fields are optional - unset values inherit from theme or GTK defaults
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ThemeColors {
|
||||
// Core colors
|
||||
pub background: Option<String>,
|
||||
pub background_secondary: Option<String>,
|
||||
pub border: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub text_secondary: Option<String>,
|
||||
pub accent: Option<String>,
|
||||
pub accent_bright: Option<String>,
|
||||
// Provider badge colors
|
||||
pub badge_app: Option<String>,
|
||||
pub badge_bookmark: Option<String>,
|
||||
pub badge_calc: Option<String>,
|
||||
pub badge_clip: Option<String>,
|
||||
pub badge_cmd: Option<String>,
|
||||
pub badge_dmenu: Option<String>,
|
||||
pub badge_emoji: Option<String>,
|
||||
pub badge_file: Option<String>,
|
||||
pub badge_script: Option<String>,
|
||||
pub badge_ssh: Option<String>,
|
||||
pub badge_sys: Option<String>,
|
||||
pub badge_uuctl: Option<String>,
|
||||
pub badge_web: Option<String>,
|
||||
// Widget badge colors
|
||||
pub badge_media: Option<String>,
|
||||
pub badge_weather: Option<String>,
|
||||
pub badge_pomo: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppearanceConfig {
|
||||
#[serde(default = "default_width")]
|
||||
pub width: i32,
|
||||
#[serde(default = "default_height")]
|
||||
pub height: i32,
|
||||
#[serde(default = "default_font_size")]
|
||||
pub font_size: u32,
|
||||
#[serde(default = "default_border_radius")]
|
||||
pub border_radius: u32,
|
||||
/// Theme name: None = GTK default, "owl" = built-in owl theme
|
||||
#[serde(default)]
|
||||
pub theme: Option<String>,
|
||||
/// Individual color overrides
|
||||
#[serde(default)]
|
||||
pub colors: ThemeColors,
|
||||
}
|
||||
|
||||
impl Default for AppearanceConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 850,
|
||||
height: 650,
|
||||
font_size: 14,
|
||||
border_radius: 12,
|
||||
theme: None,
|
||||
colors: ThemeColors::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_width() -> i32 { 850 }
|
||||
fn default_height() -> i32 { 650 }
|
||||
fn default_font_size() -> u32 { 14 }
|
||||
fn default_border_radius() -> u32 { 12 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProvidersConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub applications: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub commands: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub uuctl: bool,
|
||||
/// Enable calculator provider (= expression or calc expression)
|
||||
#[serde(default = "default_true")]
|
||||
pub calculator: bool,
|
||||
/// Enable frecency-based result ranking
|
||||
#[serde(default = "default_true")]
|
||||
pub frecency: bool,
|
||||
/// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost)
|
||||
#[serde(default = "default_frecency_weight")]
|
||||
pub frecency_weight: f64,
|
||||
/// Enable web search provider (? query or web query)
|
||||
#[serde(default = "default_true")]
|
||||
pub websearch: bool,
|
||||
/// Search engine for web search
|
||||
/// Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
|
||||
/// Or custom URL with {query} placeholder
|
||||
#[serde(default = "default_search_engine")]
|
||||
pub search_engine: String,
|
||||
/// Enable system commands (shutdown, reboot, etc.)
|
||||
#[serde(default = "default_true")]
|
||||
pub system: bool,
|
||||
/// Enable SSH connections from ~/.ssh/config
|
||||
#[serde(default = "default_true")]
|
||||
pub ssh: bool,
|
||||
/// Enable clipboard history (requires cliphist)
|
||||
#[serde(default = "default_true")]
|
||||
pub clipboard: bool,
|
||||
/// Enable browser bookmarks
|
||||
#[serde(default = "default_true")]
|
||||
pub bookmarks: bool,
|
||||
/// Enable emoji picker
|
||||
#[serde(default = "default_true")]
|
||||
pub emoji: bool,
|
||||
/// Enable custom scripts from ~/.config/owlry/scripts/
|
||||
#[serde(default = "default_true")]
|
||||
pub scripts: bool,
|
||||
/// Enable file search (requires fd or locate)
|
||||
#[serde(default = "default_true")]
|
||||
pub files: bool,
|
||||
|
||||
// ─── Widget Providers ───────────────────────────────────────────────
|
||||
|
||||
/// Enable MPRIS media player widget
|
||||
#[serde(default = "default_true")]
|
||||
pub media: bool,
|
||||
|
||||
/// Enable weather widget
|
||||
#[serde(default)]
|
||||
pub weather: bool,
|
||||
|
||||
/// Weather provider: wttr.in (default), openweathermap, open-meteo
|
||||
#[serde(default = "default_weather_provider")]
|
||||
pub weather_provider: String,
|
||||
|
||||
/// API key for weather services that require it (e.g., OpenWeatherMap)
|
||||
#[serde(default)]
|
||||
pub weather_api_key: Option<String>,
|
||||
|
||||
/// Location for weather (city name or coordinates)
|
||||
#[serde(default)]
|
||||
pub weather_location: Option<String>,
|
||||
|
||||
/// Enable pomodoro timer widget
|
||||
#[serde(default)]
|
||||
pub pomodoro: bool,
|
||||
|
||||
/// Pomodoro work duration in minutes
|
||||
#[serde(default = "default_pomodoro_work")]
|
||||
pub pomodoro_work_mins: u32,
|
||||
|
||||
/// Pomodoro break duration in minutes
|
||||
#[serde(default = "default_pomodoro_break")]
|
||||
pub pomodoro_break_mins: u32,
|
||||
}
|
||||
|
||||
impl Default for ProvidersConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
applications: true,
|
||||
commands: true,
|
||||
uuctl: true,
|
||||
calculator: true,
|
||||
frecency: true,
|
||||
frecency_weight: 0.3,
|
||||
websearch: true,
|
||||
search_engine: "duckduckgo".to_string(),
|
||||
system: true,
|
||||
ssh: true,
|
||||
clipboard: true,
|
||||
bookmarks: true,
|
||||
emoji: true,
|
||||
scripts: true,
|
||||
files: true,
|
||||
media: true,
|
||||
weather: false,
|
||||
weather_provider: "wttr.in".to_string(),
|
||||
weather_api_key: None,
|
||||
weather_location: Some("Berlin".to_string()),
|
||||
pomodoro: false,
|
||||
pomodoro_work_mins: 25,
|
||||
pomodoro_break_mins: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for plugins
|
||||
///
|
||||
/// Supports per-plugin configuration via `[plugins.<name>]` sections:
|
||||
/// ```toml
|
||||
/// [plugins]
|
||||
/// enabled = true
|
||||
///
|
||||
/// [plugins.weather]
|
||||
/// location = "Berlin"
|
||||
/// units = "metric"
|
||||
///
|
||||
/// [plugins.pomodoro]
|
||||
/// work_mins = 25
|
||||
/// break_mins = 5
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginsConfig {
|
||||
/// Whether plugins are enabled globally
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// List of plugin IDs to enable (empty = all discovered plugins)
|
||||
#[serde(default)]
|
||||
pub enabled_plugins: Vec<String>,
|
||||
|
||||
/// List of plugin IDs to explicitly disable
|
||||
#[serde(default)]
|
||||
pub disabled_plugins: Vec<String>,
|
||||
|
||||
/// Sandbox settings for plugin execution
|
||||
#[serde(default)]
|
||||
pub sandbox: SandboxConfig,
|
||||
|
||||
/// Plugin registry URL (for `owlry plugin search` and registry installs)
|
||||
/// Defaults to the official owlry plugin registry if not specified.
|
||||
#[serde(default)]
|
||||
pub registry_url: Option<String>,
|
||||
|
||||
/// Per-plugin configuration tables
|
||||
/// Accessed via `[plugins.<plugin_name>]` sections in config.toml
|
||||
/// Each plugin can define its own config schema
|
||||
#[serde(flatten)]
|
||||
pub plugin_configs: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
/// Sandbox settings for plugin security
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxConfig {
|
||||
/// Allow plugins to access the filesystem (beyond their own directory)
|
||||
#[serde(default)]
|
||||
pub allow_filesystem: bool,
|
||||
|
||||
/// Allow plugins to make network requests
|
||||
#[serde(default)]
|
||||
pub allow_network: bool,
|
||||
|
||||
/// Allow plugins to run shell commands
|
||||
#[serde(default)]
|
||||
pub allow_commands: bool,
|
||||
|
||||
/// Memory limit for Lua runtime in bytes (0 = unlimited)
|
||||
#[serde(default = "default_memory_limit")]
|
||||
pub memory_limit: usize,
|
||||
}
|
||||
|
||||
impl Default for PluginsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
enabled_plugins: Vec::new(),
|
||||
disabled_plugins: Vec::new(),
|
||||
sandbox: SandboxConfig::default(),
|
||||
registry_url: None,
|
||||
plugin_configs: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginsConfig {
|
||||
/// Get configuration for a specific plugin by name
|
||||
///
|
||||
/// Returns the plugin's config table if it exists in `[plugins.<name>]`
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_config(&self, plugin_name: &str) -> Option<&toml::Value> {
|
||||
self.plugin_configs.get(plugin_name)
|
||||
}
|
||||
|
||||
/// Get a string value from a plugin's config
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_string(&self, plugin_name: &str, key: &str) -> Option<&str> {
|
||||
self.plugin_configs
|
||||
.get(plugin_name)?
|
||||
.get(key)?
|
||||
.as_str()
|
||||
}
|
||||
|
||||
/// Get an integer value from a plugin's config
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_int(&self, plugin_name: &str, key: &str) -> Option<i64> {
|
||||
self.plugin_configs
|
||||
.get(plugin_name)?
|
||||
.get(key)?
|
||||
.as_integer()
|
||||
}
|
||||
|
||||
/// Get a boolean value from a plugin's config
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_bool(&self, plugin_name: &str, key: &str) -> Option<bool> {
|
||||
self.plugin_configs
|
||||
.get(plugin_name)?
|
||||
.get(key)?
|
||||
.as_bool()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SandboxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_filesystem: false,
|
||||
allow_network: false,
|
||||
allow_commands: false,
|
||||
memory_limit: default_memory_limit(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_memory_limit() -> usize {
|
||||
64 * 1024 * 1024 // 64 MB
|
||||
}
|
||||
|
||||
fn default_search_engine() -> String {
|
||||
"duckduckgo".to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_frecency_weight() -> f64 {
|
||||
0.3
|
||||
}
|
||||
|
||||
fn default_weather_provider() -> String {
|
||||
"wttr.in".to_string()
|
||||
}
|
||||
|
||||
fn default_pomodoro_work() -> u32 {
|
||||
25
|
||||
}
|
||||
|
||||
fn default_pomodoro_break() -> u32 {
|
||||
5
|
||||
}
|
||||
|
||||
|
||||
/// Detect the best available terminal emulator
|
||||
/// Fallback chain:
|
||||
/// 1. $TERMINAL env var (user's explicit preference)
|
||||
/// 2. xdg-terminal-exec (freedesktop standard - if available)
|
||||
/// 3. Desktop-environment native terminal (GNOME→gnome-terminal, KDE→konsole, etc.)
|
||||
/// 4. Common Wayland-native terminals (kitty, alacritty, wezterm, foot)
|
||||
/// 5. Common X11/legacy terminals
|
||||
/// 6. x-terminal-emulator (Debian alternatives)
|
||||
/// 7. xterm (ultimate fallback - the cockroach of terminals)
|
||||
fn detect_terminal() -> String {
|
||||
// 1. Check $TERMINAL env var first (user's explicit preference)
|
||||
if let Ok(term) = std::env::var("TERMINAL")
|
||||
&& !term.is_empty() && command_exists(&term) {
|
||||
debug!("Using $TERMINAL: {}", term);
|
||||
return term;
|
||||
}
|
||||
|
||||
// 2. Try xdg-terminal-exec (freedesktop standard)
|
||||
if command_exists("xdg-terminal-exec") {
|
||||
debug!("Using xdg-terminal-exec");
|
||||
return "xdg-terminal-exec".to_string();
|
||||
}
|
||||
|
||||
// 3. Desktop-environment aware detection
|
||||
if let Some(term) = detect_de_terminal() {
|
||||
debug!("Using DE-native terminal: {}", term);
|
||||
return term;
|
||||
}
|
||||
|
||||
// 4. Common Wayland-native terminals (preferred for modern setups)
|
||||
let wayland_terminals = ["kitty", "alacritty", "wezterm", "foot"];
|
||||
for term in wayland_terminals {
|
||||
if command_exists(term) {
|
||||
debug!("Found Wayland terminal: {}", term);
|
||||
return term.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Common X11/legacy terminals
|
||||
let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "mate-terminal", "tilix", "terminator"];
|
||||
for term in legacy_terminals {
|
||||
if command_exists(term) {
|
||||
debug!("Found legacy terminal: {}", term);
|
||||
return term.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Try x-terminal-emulator (Debian alternatives system)
|
||||
if command_exists("x-terminal-emulator") {
|
||||
debug!("Using x-terminal-emulator");
|
||||
return "x-terminal-emulator".to_string();
|
||||
}
|
||||
|
||||
// 7. Ultimate fallback - xterm exists everywhere
|
||||
debug!("Falling back to xterm");
|
||||
"xterm".to_string()
|
||||
}
|
||||
|
||||
/// Detect desktop environment and return its native terminal
|
||||
fn detect_de_terminal() -> Option<String> {
|
||||
// Check XDG_CURRENT_DESKTOP first
|
||||
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase());
|
||||
|
||||
// Also check for Wayland compositor-specific env vars
|
||||
let is_hyprland = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok();
|
||||
let is_sway = std::env::var("SWAYSOCK").is_ok();
|
||||
|
||||
// Map desktop environments to their native/preferred terminals
|
||||
let candidates: &[&str] = if is_hyprland {
|
||||
// Hyprland: foot and kitty are most popular in the community
|
||||
&["foot", "kitty", "alacritty", "wezterm"]
|
||||
} else if is_sway {
|
||||
// Sway: foot is the recommended terminal (lightweight, Wayland-native)
|
||||
&["foot", "alacritty", "kitty", "wezterm"]
|
||||
} else if let Some(ref de) = desktop {
|
||||
match de.as_str() {
|
||||
s if s.contains("gnome") => &["gnome-terminal", "gnome-console", "kgx"],
|
||||
s if s.contains("kde") || s.contains("plasma") => &["konsole"],
|
||||
s if s.contains("xfce") => &["xfce4-terminal"],
|
||||
s if s.contains("mate") => &["mate-terminal"],
|
||||
s if s.contains("lxqt") => &["qterminal"],
|
||||
s if s.contains("lxde") => &["lxterminal"],
|
||||
s if s.contains("cinnamon") => &["gnome-terminal"],
|
||||
s if s.contains("budgie") => &["tilix", "gnome-terminal"],
|
||||
s if s.contains("pantheon") => &["io.elementary.terminal", "pantheon-terminal"],
|
||||
s if s.contains("deepin") => &["deepin-terminal"],
|
||||
s if s.contains("hyprland") => &["foot", "kitty", "alacritty", "wezterm"],
|
||||
s if s.contains("sway") => &["foot", "alacritty", "kitty", "wezterm"],
|
||||
_ => return None,
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
for term in candidates {
|
||||
if command_exists(term) {
|
||||
return Some(term.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a command exists in PATH
|
||||
fn command_exists(cmd: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default
|
||||
|
||||
impl Config {
|
||||
pub fn config_path() -> Option<PathBuf> {
|
||||
paths::config_file()
|
||||
}
|
||||
|
||||
pub fn load_or_default() -> Self {
|
||||
Self::load().unwrap_or_else(|e| {
|
||||
warn!("Failed to load config: {}, using defaults", e);
|
||||
Self::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let path = Self::config_path().ok_or("Could not determine config path")?;
|
||||
|
||||
let mut config = if !path.exists() {
|
||||
info!("Config file not found, using defaults");
|
||||
Self::default()
|
||||
} else {
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
let config: Config = toml::from_str(&content)?;
|
||||
info!("Loaded config from {:?}", path);
|
||||
config
|
||||
};
|
||||
|
||||
// Auto-detect terminal if not configured or configured terminal doesn't exist
|
||||
match &config.general.terminal_command {
|
||||
None => {
|
||||
let terminal = detect_terminal();
|
||||
info!("Detected terminal: {}", terminal);
|
||||
config.general.terminal_command = Some(terminal);
|
||||
}
|
||||
Some(term) if !command_exists(term) => {
|
||||
warn!("Configured terminal '{}' not found, auto-detecting", term);
|
||||
let terminal = detect_terminal();
|
||||
info!("Using detected terminal: {}", terminal);
|
||||
config.general.terminal_command = Some(terminal);
|
||||
}
|
||||
Some(term) => {
|
||||
debug!("Using configured terminal: {}", term);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let path = Self::config_path().ok_or("Could not determine config path")?;
|
||||
|
||||
paths::ensure_parent_dir(&path)?;
|
||||
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
std::fs::write(&path, content)?;
|
||||
info!("Saved config to {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::{debug, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// A single frecency entry tracking launch count and recency
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FrecencyEntry {
|
||||
pub launch_count: u32,
|
||||
pub last_launch: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Persistent frecency data store
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FrecencyData {
|
||||
pub version: u32,
|
||||
pub entries: HashMap<String, FrecencyEntry>,
|
||||
}
|
||||
|
||||
impl Default for FrecencyData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frecency store for tracking and boosting recently/frequently used items
|
||||
pub struct FrecencyStore {
|
||||
data: FrecencyData,
|
||||
path: PathBuf,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
impl FrecencyStore {
|
||||
/// Create a new frecency store, loading existing data if available
|
||||
pub fn new() -> Self {
|
||||
let path = Self::data_path();
|
||||
let data = Self::load_from_path(&path).unwrap_or_default();
|
||||
|
||||
info!("Frecency store loaded with {} entries", data.entries.len());
|
||||
|
||||
Self {
|
||||
data,
|
||||
path,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Alias for new() - loads from disk or creates default
|
||||
pub fn load_or_default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
|
||||
/// Get the path to the frecency data file
|
||||
fn data_path() -> PathBuf {
|
||||
paths::frecency_file().unwrap_or_else(|| PathBuf::from("frecency.json"))
|
||||
}
|
||||
|
||||
/// Load frecency data from a file
|
||||
fn load_from_path(path: &PathBuf) -> Option<FrecencyData> {
|
||||
if !path.exists() {
|
||||
debug!("Frecency file not found at {:?}", path);
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
match serde_json::from_str(&content) {
|
||||
Ok(data) => Some(data),
|
||||
Err(e) => {
|
||||
warn!("Failed to parse frecency data: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save frecency data to disk
|
||||
pub fn save(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !self.dirty {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
paths::ensure_parent_dir(&self.path)?;
|
||||
|
||||
let content = serde_json::to_string_pretty(&self.data)?;
|
||||
std::fs::write(&self.path, content)?;
|
||||
self.dirty = false;
|
||||
|
||||
debug!("Frecency data saved to {:?}", self.path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a launch event for an item
|
||||
pub fn record_launch(&mut self, item_id: &str) {
|
||||
let now = Utc::now();
|
||||
|
||||
let entry = self
|
||||
.data
|
||||
.entries
|
||||
.entry(item_id.to_string())
|
||||
.or_insert(FrecencyEntry {
|
||||
launch_count: 0,
|
||||
last_launch: now,
|
||||
});
|
||||
|
||||
entry.launch_count += 1;
|
||||
entry.last_launch = now;
|
||||
self.dirty = true;
|
||||
|
||||
debug!(
|
||||
"Recorded launch for '{}': count={}, last={}",
|
||||
item_id, entry.launch_count, entry.last_launch
|
||||
);
|
||||
|
||||
// Auto-save after recording
|
||||
if let Err(e) = self.save() {
|
||||
warn!("Failed to save frecency data: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate frecency score for an item
|
||||
/// Uses Firefox-style algorithm: score = launch_count * recency_weight
|
||||
pub fn get_score(&self, item_id: &str) -> f64 {
|
||||
match self.data.entries.get(item_id) {
|
||||
Some(entry) => Self::calculate_frecency(entry.launch_count, entry.last_launch),
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate frecency using Firefox-style algorithm
|
||||
fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 {
|
||||
let now = Utc::now();
|
||||
let age = now.signed_duration_since(last_launch);
|
||||
let age_days = age.num_hours() as f64 / 24.0;
|
||||
|
||||
// Recency weight based on how recently the item was used
|
||||
let recency_weight = if age_days < 1.0 {
|
||||
100.0 // Today
|
||||
} else if age_days < 7.0 {
|
||||
70.0 // This week
|
||||
} else if age_days < 30.0 {
|
||||
50.0 // This month
|
||||
} else if age_days < 90.0 {
|
||||
30.0 // This quarter
|
||||
} else {
|
||||
10.0 // Older
|
||||
};
|
||||
|
||||
launch_count as f64 * recency_weight
|
||||
}
|
||||
|
||||
/// Get all entries (for debugging/display)
|
||||
#[allow(dead_code)]
|
||||
pub fn entries(&self) -> &HashMap<String, FrecencyEntry> {
|
||||
&self.data.entries
|
||||
}
|
||||
|
||||
/// Clear all frecency data
|
||||
#[allow(dead_code)]
|
||||
pub fn clear(&mut self) {
|
||||
self.data.entries.clear();
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FrecencyStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FrecencyStore {
|
||||
fn drop(&mut self) {
|
||||
// Attempt to save on drop
|
||||
if let Err(e) = self.save() {
|
||||
warn!("Failed to save frecency data on drop: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_frecency_calculation() {
|
||||
let now = Utc::now();
|
||||
|
||||
// Recent launch should have high score
|
||||
let score_today = FrecencyStore::calculate_frecency(10, now);
|
||||
assert!(score_today > 900.0); // 10 * 100
|
||||
|
||||
// Older launch should have lower score
|
||||
let week_ago = now - chrono::Duration::days(5);
|
||||
let score_week = FrecencyStore::calculate_frecency(10, week_ago);
|
||||
assert!(score_week < score_today);
|
||||
assert!(score_week > 600.0); // 10 * 70
|
||||
|
||||
// Much older launch
|
||||
let month_ago = now - chrono::Duration::days(45);
|
||||
let score_month = FrecencyStore::calculate_frecency(10, month_ago);
|
||||
assert!(score_month < score_week);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_count_matters() {
|
||||
let now = Utc::now();
|
||||
|
||||
let score_few = FrecencyStore::calculate_frecency(2, now);
|
||||
let score_many = FrecencyStore::calculate_frecency(20, now);
|
||||
|
||||
assert!(score_many > score_few);
|
||||
assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
mod frecency;
|
||||
|
||||
pub use frecency::FrecencyStore;
|
||||
@@ -1,409 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
|
||||
use crate::config::ProvidersConfig;
|
||||
use crate::providers::ProviderType;
|
||||
|
||||
/// Tracks which providers are enabled and handles prefix-based filtering
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderFilter {
|
||||
enabled: HashSet<ProviderType>,
|
||||
active_prefix: Option<ProviderType>,
|
||||
}
|
||||
|
||||
/// Result of parsing a query for prefix syntax
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedQuery {
|
||||
pub prefix: Option<ProviderType>,
|
||||
pub tag_filter: Option<String>,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
impl ProviderFilter {
|
||||
/// Create filter from CLI args and config
|
||||
pub fn new(
|
||||
cli_mode: Option<ProviderType>,
|
||||
cli_providers: Option<Vec<ProviderType>>,
|
||||
config_providers: &ProvidersConfig,
|
||||
) -> Self {
|
||||
let enabled = if let Some(mode) = cli_mode {
|
||||
// --mode overrides everything: single provider
|
||||
HashSet::from([mode])
|
||||
} else if let Some(providers) = cli_providers {
|
||||
// --providers overrides config
|
||||
providers.into_iter().collect()
|
||||
} else {
|
||||
// Use config file settings, default to apps only
|
||||
let mut set = HashSet::new();
|
||||
// Core providers
|
||||
if config_providers.applications {
|
||||
set.insert(ProviderType::Application);
|
||||
}
|
||||
if config_providers.commands {
|
||||
set.insert(ProviderType::Command);
|
||||
}
|
||||
// Plugin providers - use Plugin(type_id) for all
|
||||
if config_providers.uuctl {
|
||||
set.insert(ProviderType::Plugin("uuctl".to_string()));
|
||||
}
|
||||
if config_providers.system {
|
||||
set.insert(ProviderType::Plugin("system".to_string()));
|
||||
}
|
||||
if config_providers.ssh {
|
||||
set.insert(ProviderType::Plugin("ssh".to_string()));
|
||||
}
|
||||
if config_providers.clipboard {
|
||||
set.insert(ProviderType::Plugin("clipboard".to_string()));
|
||||
}
|
||||
if config_providers.bookmarks {
|
||||
set.insert(ProviderType::Plugin("bookmarks".to_string()));
|
||||
}
|
||||
if config_providers.emoji {
|
||||
set.insert(ProviderType::Plugin("emoji".to_string()));
|
||||
}
|
||||
if config_providers.scripts {
|
||||
set.insert(ProviderType::Plugin("scripts".to_string()));
|
||||
}
|
||||
// Dynamic providers
|
||||
if config_providers.files {
|
||||
set.insert(ProviderType::Plugin("filesearch".to_string()));
|
||||
}
|
||||
if config_providers.calculator {
|
||||
set.insert(ProviderType::Plugin("calc".to_string()));
|
||||
}
|
||||
if config_providers.websearch {
|
||||
set.insert(ProviderType::Plugin("websearch".to_string()));
|
||||
}
|
||||
// Default to apps if nothing enabled
|
||||
if set.is_empty() {
|
||||
set.insert(ProviderType::Application);
|
||||
}
|
||||
set
|
||||
};
|
||||
|
||||
let filter = Self {
|
||||
enabled,
|
||||
active_prefix: None,
|
||||
};
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] Created with enabled providers: {:?}", filter.enabled);
|
||||
|
||||
filter
|
||||
}
|
||||
|
||||
/// Default filter: apps only
|
||||
#[allow(dead_code)]
|
||||
pub fn apps_only() -> Self {
|
||||
Self {
|
||||
enabled: HashSet::from([ProviderType::Application]),
|
||||
active_prefix: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a provider on/off
|
||||
pub fn toggle(&mut self, provider: ProviderType) {
|
||||
if self.enabled.contains(&provider) {
|
||||
self.enabled.remove(&provider);
|
||||
// Ensure at least one provider is always enabled
|
||||
if self.enabled.is_empty() {
|
||||
self.enabled.insert(ProviderType::Application);
|
||||
}
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled);
|
||||
} else {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
let provider_debug = format!("{:?}", provider);
|
||||
self.enabled.insert(provider);
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] Toggled ON {}, enabled: {:?}", provider_debug, self.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable a specific provider
|
||||
pub fn enable(&mut self, provider: ProviderType) {
|
||||
self.enabled.insert(provider);
|
||||
}
|
||||
|
||||
/// Disable a specific provider (ensures at least one remains)
|
||||
pub fn disable(&mut self, provider: ProviderType) {
|
||||
self.enabled.remove(&provider);
|
||||
if self.enabled.is_empty() {
|
||||
self.enabled.insert(ProviderType::Application);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set to single provider mode
|
||||
pub fn set_single_mode(&mut self, provider: ProviderType) {
|
||||
self.enabled.clear();
|
||||
self.enabled.insert(provider);
|
||||
}
|
||||
|
||||
/// Set prefix mode (from :app, :cmd, etc.)
|
||||
pub fn set_prefix(&mut self, prefix: Option<ProviderType>) {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
if self.active_prefix != prefix {
|
||||
debug!("[Filter] Prefix changed: {:?} -> {:?}", self.active_prefix, prefix);
|
||||
}
|
||||
self.active_prefix = prefix;
|
||||
}
|
||||
|
||||
/// Check if a provider should be searched
|
||||
pub fn is_active(&self, provider: ProviderType) -> bool {
|
||||
if let Some(ref prefix) = self.active_prefix {
|
||||
&provider == prefix
|
||||
} else {
|
||||
self.enabled.contains(&provider)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if provider is in enabled set (ignoring prefix)
|
||||
pub fn is_enabled(&self, provider: ProviderType) -> bool {
|
||||
self.enabled.contains(&provider)
|
||||
}
|
||||
|
||||
/// Get current active prefix if any
|
||||
#[allow(dead_code)]
|
||||
pub fn active_prefix(&self) -> Option<ProviderType> {
|
||||
self.active_prefix.clone()
|
||||
}
|
||||
|
||||
/// Parse query for prefix syntax
|
||||
/// Prefixes map to Plugin(type_id) for plugin providers
|
||||
pub fn parse_query(query: &str) -> ParsedQuery {
|
||||
let trimmed = query.trim_start();
|
||||
|
||||
// Check for tag filter pattern: ":tag:XXX query" or ":tag:XXX"
|
||||
if let Some(rest) = trimmed.strip_prefix(":tag:") {
|
||||
// Find the end of the tag (space or end of string)
|
||||
if let Some(space_idx) = rest.find(' ') {
|
||||
let tag = rest[..space_idx].to_lowercase();
|
||||
let query_part = rest[space_idx + 1..].to_string();
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] parse_query({:?}) -> tag={:?}, query={:?}", query, tag, query_part);
|
||||
return ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: Some(tag),
|
||||
query: query_part,
|
||||
};
|
||||
} else {
|
||||
// Just the tag, no query yet
|
||||
let tag = rest.to_lowercase();
|
||||
return ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: Some(tag),
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Core provider prefixes
|
||||
let core_prefixes: &[(&str, ProviderType)] = &[
|
||||
(":app ", ProviderType::Application),
|
||||
(":apps ", ProviderType::Application),
|
||||
(":cmd ", ProviderType::Command),
|
||||
(":command ", ProviderType::Command),
|
||||
];
|
||||
|
||||
// Plugin provider prefixes - mapped to Plugin(type_id)
|
||||
let plugin_prefixes: &[(&str, &str)] = &[
|
||||
(":bm ", "bookmarks"),
|
||||
(":bookmark ", "bookmarks"),
|
||||
(":bookmarks ", "bookmarks"),
|
||||
(":calc ", "calc"),
|
||||
(":calculator ", "calc"),
|
||||
(":clip ", "clipboard"),
|
||||
(":clipboard ", "clipboard"),
|
||||
(":emoji ", "emoji"),
|
||||
(":emojis ", "emoji"),
|
||||
(":file ", "filesearch"),
|
||||
(":files ", "filesearch"),
|
||||
(":find ", "filesearch"),
|
||||
(":script ", "scripts"),
|
||||
(":scripts ", "scripts"),
|
||||
(":ssh ", "ssh"),
|
||||
(":sys ", "system"),
|
||||
(":system ", "system"),
|
||||
(":power ", "system"),
|
||||
(":uuctl ", "uuctl"),
|
||||
(":systemd ", "uuctl"),
|
||||
(":web ", "websearch"),
|
||||
(":search ", "websearch"),
|
||||
];
|
||||
|
||||
// Check core prefixes
|
||||
for (prefix_str, provider) in core_prefixes {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider.clone()),
|
||||
tag_filter: None,
|
||||
query: rest.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check plugin prefixes
|
||||
for (prefix_str, type_id) in plugin_prefixes {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
|
||||
let provider = ProviderType::Plugin(type_id.to_string());
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider),
|
||||
tag_filter: None,
|
||||
query: rest.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle partial prefixes (still typing)
|
||||
let partial_core: &[(&str, ProviderType)] = &[
|
||||
(":app", ProviderType::Application),
|
||||
(":apps", ProviderType::Application),
|
||||
(":cmd", ProviderType::Command),
|
||||
(":command", ProviderType::Command),
|
||||
];
|
||||
|
||||
let partial_plugin: &[(&str, &str)] = &[
|
||||
(":bm", "bookmarks"),
|
||||
(":bookmark", "bookmarks"),
|
||||
(":bookmarks", "bookmarks"),
|
||||
(":calc", "calc"),
|
||||
(":calculator", "calc"),
|
||||
(":clip", "clipboard"),
|
||||
(":clipboard", "clipboard"),
|
||||
(":emoji", "emoji"),
|
||||
(":emojis", "emoji"),
|
||||
(":file", "filesearch"),
|
||||
(":files", "filesearch"),
|
||||
(":find", "filesearch"),
|
||||
(":script", "scripts"),
|
||||
(":scripts", "scripts"),
|
||||
(":ssh", "ssh"),
|
||||
(":sys", "system"),
|
||||
(":system", "system"),
|
||||
(":power", "system"),
|
||||
(":uuctl", "uuctl"),
|
||||
(":systemd", "uuctl"),
|
||||
(":web", "websearch"),
|
||||
(":search", "websearch"),
|
||||
];
|
||||
|
||||
for (prefix_str, provider) in partial_core {
|
||||
if trimmed == *prefix_str {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider.clone()),
|
||||
tag_filter: None,
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (prefix_str, type_id) in partial_plugin {
|
||||
if trimmed == *prefix_str {
|
||||
let provider = ProviderType::Plugin(type_id.to_string());
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider),
|
||||
tag_filter: None,
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let result = ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: None,
|
||||
query: query.to_string(),
|
||||
};
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}", query, result.prefix, result.tag_filter, result.query);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Get enabled providers for UI display (sorted)
|
||||
pub fn enabled_providers(&self) -> Vec<ProviderType> {
|
||||
let mut providers: Vec<_> = self.enabled.iter().cloned().collect();
|
||||
providers.sort_by_key(|p| match p {
|
||||
ProviderType::Application => 0,
|
||||
ProviderType::Command => 1,
|
||||
ProviderType::Dmenu => 2,
|
||||
ProviderType::Plugin(_) => 100, // Plugin providers sort after core
|
||||
});
|
||||
providers
|
||||
}
|
||||
|
||||
/// Get display name for current mode
|
||||
pub fn mode_display_name(&self) -> &'static str {
|
||||
if let Some(ref prefix) = self.active_prefix {
|
||||
return match prefix {
|
||||
ProviderType::Application => "Apps",
|
||||
ProviderType::Command => "Commands",
|
||||
ProviderType::Dmenu => "dmenu",
|
||||
ProviderType::Plugin(_) => "Plugin",
|
||||
};
|
||||
}
|
||||
|
||||
let enabled: Vec<_> = self.enabled_providers();
|
||||
if enabled.len() == 1 {
|
||||
match &enabled[0] {
|
||||
ProviderType::Application => "Apps",
|
||||
ProviderType::Command => "Commands",
|
||||
ProviderType::Dmenu => "dmenu",
|
||||
ProviderType::Plugin(_) => "Plugin",
|
||||
}
|
||||
} else {
|
||||
"All"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_with_prefix() {
|
||||
let result = ProviderFilter::parse_query(":app firefox");
|
||||
assert_eq!(result.prefix, Some(ProviderType::Application));
|
||||
assert_eq!(result.query, "firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_without_prefix() {
|
||||
let result = ProviderFilter::parse_query("firefox");
|
||||
assert_eq!(result.prefix, None);
|
||||
assert_eq!(result.query, "firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_partial_prefix() {
|
||||
let result = ProviderFilter::parse_query(":cmd");
|
||||
assert_eq!(result.prefix, Some(ProviderType::Command));
|
||||
assert_eq!(result.query, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_plugin_prefix() {
|
||||
let result = ProviderFilter::parse_query(":calc 5+3");
|
||||
assert_eq!(result.prefix, Some(ProviderType::Plugin("calc".to_string())));
|
||||
assert_eq!(result.query, "5+3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_ensures_one_enabled() {
|
||||
let mut filter = ProviderFilter::apps_only();
|
||||
filter.toggle(ProviderType::Application);
|
||||
// Should still have apps enabled as fallback
|
||||
assert!(filter.is_enabled(ProviderType::Application));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
mod app;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod data;
|
||||
mod filter;
|
||||
mod notify;
|
||||
mod paths;
|
||||
mod plugins;
|
||||
mod plugin_commands;
|
||||
mod providers;
|
||||
mod theme;
|
||||
mod ui;
|
||||
@@ -25,7 +20,7 @@ fn main() {
|
||||
// CLI commands don't need full logging
|
||||
match command {
|
||||
Command::Plugin(plugin_cmd) => {
|
||||
if let Err(e) = plugins::commands::execute(plugin_cmd.clone()) {
|
||||
if let Err(e) = plugin_commands::execute(plugin_cmd.clone()) {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
//! Desktop notification system
|
||||
//!
|
||||
//! Provides system notifications for owlry and its plugins.
|
||||
//! Uses the freedesktop notification specification via notify-rust.
|
||||
//!
|
||||
//! Note: Some convenience functions are provided for future use and
|
||||
//! are currently unused by the core (plugins use the Host API instead).
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use notify_rust::{Notification, Urgency};
|
||||
|
||||
/// Notification urgency level
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum NotifyUrgency {
|
||||
/// Low priority notification
|
||||
Low,
|
||||
/// Normal priority notification (default)
|
||||
#[default]
|
||||
Normal,
|
||||
/// Critical/urgent notification
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl From<NotifyUrgency> for Urgency {
|
||||
fn from(urgency: NotifyUrgency) -> Self {
|
||||
match urgency {
|
||||
NotifyUrgency::Low => Urgency::Low,
|
||||
NotifyUrgency::Normal => Urgency::Normal,
|
||||
NotifyUrgency::Critical => Urgency::Critical,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a simple notification
|
||||
pub fn notify(summary: &str, body: &str) {
|
||||
notify_with_options(summary, body, None, NotifyUrgency::Normal);
|
||||
}
|
||||
|
||||
/// Send a notification with an icon
|
||||
pub fn notify_with_icon(summary: &str, body: &str, icon: &str) {
|
||||
notify_with_options(summary, body, Some(icon), NotifyUrgency::Normal);
|
||||
}
|
||||
|
||||
/// Send a notification with full options
|
||||
pub fn notify_with_options(summary: &str, body: &str, icon: Option<&str>, urgency: NotifyUrgency) {
|
||||
let mut notification = Notification::new();
|
||||
notification
|
||||
.appname("Owlry")
|
||||
.summary(summary)
|
||||
.body(body)
|
||||
.urgency(urgency.into());
|
||||
|
||||
if let Some(icon_name) = icon {
|
||||
notification.icon(icon_name);
|
||||
}
|
||||
|
||||
if let Err(e) = notification.show() {
|
||||
log::warn!("Failed to show notification: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a notification with a timeout
|
||||
pub fn notify_with_timeout(summary: &str, body: &str, icon: Option<&str>, timeout_ms: i32) {
|
||||
let mut notification = Notification::new();
|
||||
notification
|
||||
.appname("Owlry")
|
||||
.summary(summary)
|
||||
.body(body)
|
||||
.timeout(timeout_ms);
|
||||
|
||||
if let Some(icon_name) = icon {
|
||||
notification.icon(icon_name);
|
||||
}
|
||||
|
||||
if let Err(e) = notification.show() {
|
||||
log::warn!("Failed to show notification: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_urgency_conversion() {
|
||||
assert_eq!(Urgency::from(NotifyUrgency::Low), Urgency::Low);
|
||||
assert_eq!(Urgency::from(NotifyUrgency::Normal), Urgency::Normal);
|
||||
assert_eq!(Urgency::from(NotifyUrgency::Critical), Urgency::Critical);
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
//! Centralized path handling following XDG Base Directory Specification.
|
||||
//!
|
||||
//! XDG directories used:
|
||||
//! - `$XDG_CONFIG_HOME/owlry/` - User configuration (config.toml, themes/, style.css)
|
||||
//! - `$XDG_DATA_HOME/owlry/` - User data (scripts/, frecency.json)
|
||||
//! - `$XDG_CACHE_HOME/owlry/` - Cache files (future use)
|
||||
//!
|
||||
//! See: https://specifications.freedesktop.org/basedir-spec/latest/
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Application name used in XDG paths
|
||||
const APP_NAME: &str = "owlry";
|
||||
|
||||
// =============================================================================
|
||||
// XDG Base Directories
|
||||
// =============================================================================
|
||||
|
||||
/// Get XDG config home: `$XDG_CONFIG_HOME` or `~/.config`
|
||||
pub fn config_home() -> Option<PathBuf> {
|
||||
dirs::config_dir()
|
||||
}
|
||||
|
||||
/// Get XDG data home: `$XDG_DATA_HOME` or `~/.local/share`
|
||||
pub fn data_home() -> Option<PathBuf> {
|
||||
dirs::data_dir()
|
||||
}
|
||||
|
||||
/// Get XDG cache home: `$XDG_CACHE_HOME` or `~/.cache`
|
||||
#[allow(dead_code)]
|
||||
pub fn cache_home() -> Option<PathBuf> {
|
||||
dirs::cache_dir()
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Owlry-specific directories
|
||||
// =============================================================================
|
||||
|
||||
/// Owlry config directory: `$XDG_CONFIG_HOME/owlry/`
|
||||
pub fn owlry_config_dir() -> Option<PathBuf> {
|
||||
config_home().map(|p| p.join(APP_NAME))
|
||||
}
|
||||
|
||||
/// Owlry data directory: `$XDG_DATA_HOME/owlry/`
|
||||
pub fn owlry_data_dir() -> Option<PathBuf> {
|
||||
data_home().map(|p| p.join(APP_NAME))
|
||||
}
|
||||
|
||||
/// Owlry cache directory: `$XDG_CACHE_HOME/owlry/`
|
||||
#[allow(dead_code)]
|
||||
pub fn owlry_cache_dir() -> Option<PathBuf> {
|
||||
cache_home().map(|p| p.join(APP_NAME))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config files
|
||||
// =============================================================================
|
||||
|
||||
/// Main config file: `$XDG_CONFIG_HOME/owlry/config.toml`
|
||||
pub fn config_file() -> Option<PathBuf> {
|
||||
owlry_config_dir().map(|p| p.join("config.toml"))
|
||||
}
|
||||
|
||||
/// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css`
|
||||
pub fn custom_style_file() -> Option<PathBuf> {
|
||||
owlry_config_dir().map(|p| p.join("style.css"))
|
||||
}
|
||||
|
||||
/// User themes directory: `$XDG_CONFIG_HOME/owlry/themes/`
|
||||
pub fn themes_dir() -> Option<PathBuf> {
|
||||
owlry_config_dir().map(|p| p.join("themes"))
|
||||
}
|
||||
|
||||
/// Get path for a specific theme: `$XDG_CONFIG_HOME/owlry/themes/{name}.css`
|
||||
pub fn theme_file(name: &str) -> Option<PathBuf> {
|
||||
themes_dir().map(|p| p.join(format!("{}.css", name)))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Data files
|
||||
// =============================================================================
|
||||
|
||||
/// User plugins directory: `$XDG_CONFIG_HOME/owlry/plugins/`
|
||||
///
|
||||
/// Plugins are stored in config because they contain user-installed code
|
||||
/// that the user explicitly chose to add (similar to themes).
|
||||
pub fn plugins_dir() -> Option<PathBuf> {
|
||||
owlry_config_dir().map(|p| p.join("plugins"))
|
||||
}
|
||||
|
||||
/// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json`
|
||||
pub fn frecency_file() -> Option<PathBuf> {
|
||||
owlry_data_dir().map(|p| p.join("frecency.json"))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// System directories
|
||||
// =============================================================================
|
||||
|
||||
/// System data directories for applications (XDG_DATA_DIRS)
|
||||
///
|
||||
/// Follows the XDG Base Directory Specification:
|
||||
/// - $XDG_DATA_HOME/applications (defaults to ~/.local/share/applications)
|
||||
/// - $XDG_DATA_DIRS/*/applications (defaults to /usr/local/share:/usr/share)
|
||||
/// - Additional Flatpak and Snap directories
|
||||
pub fn system_data_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
// Helper to add unique directories
|
||||
let mut add_dir = |path: PathBuf| {
|
||||
if seen.insert(path.clone()) {
|
||||
dirs.push(path);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. User data directory first (highest priority)
|
||||
if let Some(data) = data_home() {
|
||||
add_dir(data.join("applications"));
|
||||
}
|
||||
|
||||
// 2. XDG_DATA_DIRS - parse the environment variable
|
||||
// Default per spec: /usr/local/share:/usr/share
|
||||
let xdg_data_dirs = std::env::var("XDG_DATA_DIRS")
|
||||
.unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
|
||||
|
||||
for dir in xdg_data_dirs.split(':') {
|
||||
if !dir.is_empty() {
|
||||
add_dir(PathBuf::from(dir).join("applications"));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Always include standard system directories as fallback
|
||||
// Some environments set XDG_DATA_DIRS without including these
|
||||
add_dir(PathBuf::from("/usr/share/applications"));
|
||||
add_dir(PathBuf::from("/usr/local/share/applications"));
|
||||
|
||||
// 4. Flatpak directories (user and system)
|
||||
if let Some(data) = data_home() {
|
||||
add_dir(data.join("flatpak/exports/share/applications"));
|
||||
}
|
||||
add_dir(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
|
||||
|
||||
// 5. Snap directories
|
||||
add_dir(PathBuf::from("/var/lib/snapd/desktop/applications"));
|
||||
|
||||
// 6. Nix directories (common on NixOS)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
add_dir(home.join(".nix-profile/share/applications"));
|
||||
}
|
||||
add_dir(PathBuf::from("/run/current-system/sw/share/applications"));
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper functions
|
||||
// =============================================================================
|
||||
|
||||
/// Ensure parent directory of a file exists
|
||||
pub fn ensure_parent_dir(path: &std::path::Path) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_paths_are_consistent() {
|
||||
// All owlry paths should be under XDG directories
|
||||
if let (Some(config), Some(data)) = (owlry_config_dir(), owlry_data_dir()) {
|
||||
assert!(config.ends_with("owlry"));
|
||||
assert!(data.ends_with("owlry"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_file_path() {
|
||||
if let Some(path) = config_file() {
|
||||
assert!(path.ends_with("config.toml"));
|
||||
assert!(path.to_string_lossy().contains("owlry"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frecency_in_data_dir() {
|
||||
if let Some(path) = frecency_file() {
|
||||
assert!(path.ends_with("frecency.json"));
|
||||
// Should be in data dir, not config dir
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(
|
||||
path_str.contains(".local/share") || path_str.contains("XDG_DATA_HOME"),
|
||||
"frecency should be in data directory"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,11 @@ use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::cli::{PluginCommand as CliPluginCommand, PluginRuntime};
|
||||
use crate::config::Config;
|
||||
use crate::paths;
|
||||
use crate::plugins::manifest::{discover_plugins, PluginManifest};
|
||||
use crate::plugins::registry::{self, RegistryClient};
|
||||
use crate::plugins::runtime_loader::{lua_runtime_available, rune_runtime_available};
|
||||
use owlry_core::config::Config;
|
||||
use owlry_core::paths;
|
||||
use owlry_core::plugins::manifest::{discover_plugins, PluginManifest};
|
||||
use owlry_core::plugins::registry::{self, RegistryClient};
|
||||
use owlry_core::plugins::runtime_loader::{lua_runtime_available, rune_runtime_available};
|
||||
|
||||
/// Result type for plugin commands
|
||||
pub type CommandResult = Result<(), String>;
|
||||
@@ -932,7 +932,7 @@ fn cmd_validate(path: Option<&str>) -> CommandResult {
|
||||
|
||||
/// Show available script runtimes
|
||||
fn cmd_runtimes() -> CommandResult {
|
||||
use crate::plugins::runtime_loader::SYSTEM_RUNTIMES_DIR;
|
||||
use owlry_core::plugins::runtime_loader::SYSTEM_RUNTIMES_DIR;
|
||||
|
||||
println!("Script Runtimes:\n");
|
||||
|
||||
@@ -1024,7 +1024,7 @@ fn execute_plugin_command(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
) -> CommandResult {
|
||||
use crate::plugins::runtime_loader::{LoadedRuntime, SYSTEM_RUNTIMES_DIR};
|
||||
use owlry_core::plugins::runtime_loader::{LoadedRuntime, SYSTEM_RUNTIMES_DIR};
|
||||
|
||||
let runtime = detect_runtime(manifest);
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
//! Action API for Lua plugins
|
||||
//!
|
||||
//! Allows plugins to register custom actions for result items:
|
||||
//! - `owlry.action.register(config)` - Register a custom action
|
||||
|
||||
use mlua::{Function, Lua, Result as LuaResult, Table, Value};
|
||||
|
||||
/// Action registration data
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Used by UI integration
|
||||
pub struct ActionRegistration {
|
||||
/// Unique action ID
|
||||
pub id: String,
|
||||
/// Human-readable name shown in UI
|
||||
pub display_name: String,
|
||||
/// Icon name (optional)
|
||||
pub icon: Option<String>,
|
||||
/// Keyboard shortcut hint (optional, e.g., "Ctrl+C")
|
||||
pub shortcut: Option<String>,
|
||||
/// Plugin that registered this action
|
||||
pub plugin_id: String,
|
||||
}
|
||||
|
||||
/// Register action APIs
|
||||
pub fn register_action_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> {
|
||||
let action_table = lua.create_table()?;
|
||||
let plugin_id_owned = plugin_id.to_string();
|
||||
|
||||
// Initialize action storage in Lua registry
|
||||
if lua.named_registry_value::<Value>("actions")?.is_nil() {
|
||||
let actions: Table = lua.create_table()?;
|
||||
lua.set_named_registry_value("actions", actions)?;
|
||||
}
|
||||
|
||||
// owlry.action.register(config) -> string (action_id)
|
||||
// config = {
|
||||
// id = "copy-url",
|
||||
// name = "Copy URL",
|
||||
// icon = "edit-copy", -- optional
|
||||
// shortcut = "Ctrl+C", -- optional
|
||||
// filter = function(item) return item.provider == "bookmarks" end, -- optional
|
||||
// handler = function(item) ... end
|
||||
// }
|
||||
let plugin_id_for_register = plugin_id_owned.clone();
|
||||
action_table.set(
|
||||
"register",
|
||||
lua.create_function(move |lua, config: Table| {
|
||||
// Extract required fields
|
||||
let id: String = config
|
||||
.get("id")
|
||||
.map_err(|_| mlua::Error::external("action.register: 'id' is required"))?;
|
||||
|
||||
let name: String = config
|
||||
.get("name")
|
||||
.map_err(|_| mlua::Error::external("action.register: 'name' is required"))?;
|
||||
|
||||
let _handler: Function = config
|
||||
.get("handler")
|
||||
.map_err(|_| mlua::Error::external("action.register: 'handler' function is required"))?;
|
||||
|
||||
// Extract optional fields
|
||||
let icon: Option<String> = config.get("icon").ok();
|
||||
let shortcut: Option<String> = config.get("shortcut").ok();
|
||||
|
||||
// Store action in registry
|
||||
let actions: Table = lua.named_registry_value("actions")?;
|
||||
|
||||
// Create full action ID with plugin prefix
|
||||
let full_id = format!("{}:{}", plugin_id_for_register, id);
|
||||
|
||||
// Store config with full ID
|
||||
let action_entry = lua.create_table()?;
|
||||
action_entry.set("id", full_id.clone())?;
|
||||
action_entry.set("name", name.clone())?;
|
||||
action_entry.set("plugin_id", plugin_id_for_register.clone())?;
|
||||
if let Some(ref i) = icon {
|
||||
action_entry.set("icon", i.clone())?;
|
||||
}
|
||||
if let Some(ref s) = shortcut {
|
||||
action_entry.set("shortcut", s.clone())?;
|
||||
}
|
||||
// Store filter and handler functions
|
||||
if let Ok(filter) = config.get::<Function>("filter") {
|
||||
action_entry.set("filter", filter)?;
|
||||
}
|
||||
action_entry.set("handler", config.get::<Function>("handler")?)?;
|
||||
|
||||
actions.set(full_id.clone(), action_entry)?;
|
||||
|
||||
log::info!(
|
||||
"[plugin:{}] Registered action '{}' ({})",
|
||||
plugin_id_for_register,
|
||||
name,
|
||||
full_id
|
||||
);
|
||||
|
||||
Ok(full_id)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.action.unregister(id) -> boolean
|
||||
let plugin_id_for_unregister = plugin_id_owned.clone();
|
||||
action_table.set(
|
||||
"unregister",
|
||||
lua.create_function(move |lua, id: String| {
|
||||
let actions: Table = lua.named_registry_value("actions")?;
|
||||
let full_id = format!("{}:{}", plugin_id_for_unregister, id);
|
||||
|
||||
if actions.contains_key(full_id.clone())? {
|
||||
actions.set(full_id, Value::Nil)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("action", action_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all registered actions from a Lua runtime
|
||||
#[allow(dead_code)] // Will be used by UI
|
||||
pub fn get_actions(lua: &Lua) -> LuaResult<Vec<ActionRegistration>> {
|
||||
let actions: Table = match lua.named_registry_value("actions") {
|
||||
Ok(a) => a,
|
||||
Err(_) => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for pair in actions.pairs::<String, Table>() {
|
||||
let (_, entry) = pair?;
|
||||
|
||||
let id: String = entry.get("id")?;
|
||||
let display_name: String = entry.get("name")?;
|
||||
let plugin_id: String = entry.get("plugin_id")?;
|
||||
let icon: Option<String> = entry.get("icon").ok();
|
||||
let shortcut: Option<String> = entry.get("shortcut").ok();
|
||||
|
||||
result.push(ActionRegistration {
|
||||
id,
|
||||
display_name,
|
||||
icon,
|
||||
shortcut,
|
||||
plugin_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get actions that apply to a specific item
|
||||
#[allow(dead_code)] // Will be used by UI context menu
|
||||
pub fn get_actions_for_item(lua: &Lua, item: &Table) -> LuaResult<Vec<ActionRegistration>> {
|
||||
let actions: Table = match lua.named_registry_value("actions") {
|
||||
Ok(a) => a,
|
||||
Err(_) => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for pair in actions.pairs::<String, Table>() {
|
||||
let (_, entry) = pair?;
|
||||
|
||||
// Check filter if present
|
||||
if let Ok(filter) = entry.get::<Function>("filter") {
|
||||
match filter.call::<bool>(item.clone()) {
|
||||
Ok(true) => {} // Include this action
|
||||
Ok(false) => continue, // Skip this action
|
||||
Err(e) => {
|
||||
log::warn!("Action filter failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id: String = entry.get("id")?;
|
||||
let display_name: String = entry.get("name")?;
|
||||
let plugin_id: String = entry.get("plugin_id")?;
|
||||
let icon: Option<String> = entry.get("icon").ok();
|
||||
let shortcut: Option<String> = entry.get("shortcut").ok();
|
||||
|
||||
result.push(ActionRegistration {
|
||||
id,
|
||||
display_name,
|
||||
icon,
|
||||
shortcut,
|
||||
plugin_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Execute an action by ID
|
||||
#[allow(dead_code)] // Will be used by UI
|
||||
pub fn execute_action(lua: &Lua, action_id: &str, item: &Table) -> LuaResult<()> {
|
||||
let actions: Table = lua.named_registry_value("actions")?;
|
||||
let action: Table = actions.get(action_id)?;
|
||||
let handler: Function = action.get("handler")?;
|
||||
|
||||
handler.call::<()>(item.clone())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_lua(plugin_id: &str) -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_action_api(&lua, &owlry, plugin_id).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_registration() {
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
return owlry.action.register({
|
||||
id = "copy-name",
|
||||
name = "Copy Name",
|
||||
icon = "edit-copy",
|
||||
handler = function(item)
|
||||
-- copy logic here
|
||||
end
|
||||
})
|
||||
"#);
|
||||
let action_id: String = chunk.call(()).unwrap();
|
||||
assert_eq!(action_id, "test-plugin:copy-name");
|
||||
|
||||
// Verify action is registered
|
||||
let actions = get_actions(&lua).unwrap();
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert_eq!(actions[0].display_name, "Copy Name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_with_filter() {
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
owlry.action.register({
|
||||
id = "bookmark-action",
|
||||
name = "Open in Browser",
|
||||
filter = function(item)
|
||||
return item.provider == "bookmarks"
|
||||
end,
|
||||
handler = function(item) end
|
||||
})
|
||||
"#);
|
||||
chunk.call::<()>(()).unwrap();
|
||||
|
||||
// Create bookmark item
|
||||
let bookmark_item = lua.create_table().unwrap();
|
||||
bookmark_item.set("provider", "bookmarks").unwrap();
|
||||
bookmark_item.set("name", "Test Bookmark").unwrap();
|
||||
|
||||
let actions = get_actions_for_item(&lua, &bookmark_item).unwrap();
|
||||
assert_eq!(actions.len(), 1);
|
||||
|
||||
// Create non-bookmark item
|
||||
let app_item = lua.create_table().unwrap();
|
||||
app_item.set("provider", "applications").unwrap();
|
||||
app_item.set("name", "Test App").unwrap();
|
||||
|
||||
let actions2 = get_actions_for_item(&lua, &app_item).unwrap();
|
||||
assert_eq!(actions2.len(), 0); // Filtered out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_unregister() {
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
owlry.action.register({
|
||||
id = "temp-action",
|
||||
name = "Temporary",
|
||||
handler = function(item) end
|
||||
})
|
||||
return owlry.action.unregister("temp-action")
|
||||
"#);
|
||||
let unregistered: bool = chunk.call(()).unwrap();
|
||||
assert!(unregistered);
|
||||
|
||||
let actions = get_actions(&lua).unwrap();
|
||||
assert_eq!(actions.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_action() {
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
// Register action that sets a global
|
||||
let chunk = lua.load(r#"
|
||||
result = nil
|
||||
owlry.action.register({
|
||||
id = "test-exec",
|
||||
name = "Test Execute",
|
||||
handler = function(item)
|
||||
result = item.name
|
||||
end
|
||||
})
|
||||
"#);
|
||||
chunk.call::<()>(()).unwrap();
|
||||
|
||||
// Create test item
|
||||
let item = lua.create_table().unwrap();
|
||||
item.set("name", "TestItem").unwrap();
|
||||
|
||||
// Execute action
|
||||
execute_action(&lua, "test-plugin:test-exec", &item).unwrap();
|
||||
|
||||
// Verify handler was called
|
||||
let result: String = lua.globals().get("result").unwrap();
|
||||
assert_eq!(result, "TestItem");
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
//! Cache API for Lua plugins
|
||||
//!
|
||||
//! Provides in-memory caching with optional TTL:
|
||||
//! - `owlry.cache.get(key)` - Get cached value
|
||||
//! - `owlry.cache.set(key, value, ttl_seconds?)` - Set cached value
|
||||
//! - `owlry.cache.delete(key)` - Delete cached value
|
||||
//! - `owlry.cache.clear()` - Clear all cached values
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Cached entry with optional expiration
|
||||
struct CacheEntry {
|
||||
value: String, // Store as JSON string for simplicity
|
||||
expires_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl CacheEntry {
|
||||
fn is_expired(&self) -> bool {
|
||||
self.expires_at.map(|e| Instant::now() > e).unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global cache storage (shared across all plugins)
|
||||
static CACHE: LazyLock<Mutex<HashMap<String, CacheEntry>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Register cache APIs
|
||||
pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let cache_table = lua.create_table()?;
|
||||
|
||||
// owlry.cache.get(key) -> value or nil
|
||||
cache_table.set(
|
||||
"get",
|
||||
lua.create_function(|lua, key: String| {
|
||||
let cache = CACHE.lock().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to lock cache: {}", e))
|
||||
})?;
|
||||
|
||||
if let Some(entry) = cache.get(&key) {
|
||||
if entry.is_expired() {
|
||||
drop(cache);
|
||||
// Remove expired entry
|
||||
if let Ok(mut cache) = CACHE.lock() {
|
||||
cache.remove(&key);
|
||||
}
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
|
||||
// Parse JSON back to Lua value
|
||||
let json_value: serde_json::Value = serde_json::from_str(&entry.value)
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to parse cached value: {}", e)))?;
|
||||
|
||||
json_to_lua(lua, &json_value)
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.cache.set(key, value, ttl_seconds?) -> boolean
|
||||
cache_table.set(
|
||||
"set",
|
||||
lua.create_function(|_lua, (key, value, ttl): (String, Value, Option<u64>)| {
|
||||
let json_value = lua_value_to_json(&value)?;
|
||||
let json_str = serde_json::to_string(&json_value)
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to serialize value: {}", e)))?;
|
||||
|
||||
let expires_at = ttl.map(|secs| Instant::now() + Duration::from_secs(secs));
|
||||
|
||||
let entry = CacheEntry {
|
||||
value: json_str,
|
||||
expires_at,
|
||||
};
|
||||
|
||||
let mut cache = CACHE.lock().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to lock cache: {}", e))
|
||||
})?;
|
||||
|
||||
cache.insert(key, entry);
|
||||
Ok(true)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.cache.delete(key) -> boolean (true if key existed)
|
||||
cache_table.set(
|
||||
"delete",
|
||||
lua.create_function(|_lua, key: String| {
|
||||
let mut cache = CACHE.lock().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to lock cache: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(cache.remove(&key).is_some())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.cache.clear() -> number of entries removed
|
||||
cache_table.set(
|
||||
"clear",
|
||||
lua.create_function(|_lua, ()| {
|
||||
let mut cache = CACHE.lock().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to lock cache: {}", e))
|
||||
})?;
|
||||
|
||||
let count = cache.len();
|
||||
cache.clear();
|
||||
Ok(count)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.cache.has(key) -> boolean
|
||||
cache_table.set(
|
||||
"has",
|
||||
lua.create_function(|_lua, key: String| {
|
||||
let cache = CACHE.lock().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to lock cache: {}", e))
|
||||
})?;
|
||||
|
||||
if let Some(entry) = cache.get(&key) {
|
||||
Ok(!entry.is_expired())
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("cache", cache_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert Lua value to serde_json::Value
|
||||
fn lua_value_to_json(value: &Value) -> LuaResult<serde_json::Value> {
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
match value {
|
||||
Value::Nil => Ok(JsonValue::Null),
|
||||
Value::Boolean(b) => Ok(JsonValue::Bool(*b)),
|
||||
Value::Integer(i) => Ok(JsonValue::Number((*i).into())),
|
||||
Value::Number(n) => Ok(serde_json::Number::from_f64(*n)
|
||||
.map(JsonValue::Number)
|
||||
.unwrap_or(JsonValue::Null)),
|
||||
Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())),
|
||||
Value::Table(t) => lua_table_to_json(t),
|
||||
_ => Err(mlua::Error::external("Unsupported Lua type for cache")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Lua table to serde_json::Value
|
||||
fn lua_table_to_json(table: &Table) -> LuaResult<serde_json::Value> {
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
|
||||
// Check if it's an array (sequential integer keys starting from 1)
|
||||
let is_array = table
|
||||
.clone()
|
||||
.pairs::<i64, Value>()
|
||||
.enumerate()
|
||||
.all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false));
|
||||
|
||||
if is_array {
|
||||
let mut arr = Vec::new();
|
||||
for pair in table.clone().pairs::<i64, Value>() {
|
||||
let (_, v) = pair?;
|
||||
arr.push(lua_value_to_json(&v)?);
|
||||
}
|
||||
Ok(JsonValue::Array(arr))
|
||||
} else {
|
||||
let mut map = Map::new();
|
||||
for pair in table.clone().pairs::<String, Value>() {
|
||||
let (k, v) = pair?;
|
||||
map.insert(k, lua_value_to_json(&v)?);
|
||||
}
|
||||
Ok(JsonValue::Object(map))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert serde_json::Value to Lua value
|
||||
fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<Value> {
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
match value {
|
||||
JsonValue::Null => Ok(Value::Nil),
|
||||
JsonValue::Bool(b) => Ok(Value::Boolean(*b)),
|
||||
JsonValue::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)
|
||||
}
|
||||
}
|
||||
JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)),
|
||||
JsonValue::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))
|
||||
}
|
||||
JsonValue::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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_lua() -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_cache_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
|
||||
// Clear cache between tests
|
||||
CACHE.lock().unwrap().clear();
|
||||
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_set_get() {
|
||||
let lua = setup_lua();
|
||||
|
||||
// Set a value
|
||||
let chunk = lua.load(r#"return owlry.cache.set("test_key", "test_value")"#);
|
||||
let result: bool = chunk.call(()).unwrap();
|
||||
assert!(result);
|
||||
|
||||
// Get the value back
|
||||
let chunk = lua.load(r#"return owlry.cache.get("test_key")"#);
|
||||
let value: String = chunk.call(()).unwrap();
|
||||
assert_eq!(value, "test_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_table_value() {
|
||||
let lua = setup_lua();
|
||||
|
||||
// Set a table value
|
||||
let chunk = lua.load(r#"return owlry.cache.set("table_key", {name = "test", value = 42})"#);
|
||||
let _: bool = chunk.call(()).unwrap();
|
||||
|
||||
// Get and verify
|
||||
let chunk = lua.load(r#"
|
||||
local t = owlry.cache.get("table_key")
|
||||
return t.name, t.value
|
||||
"#);
|
||||
let (name, value): (String, i32) = chunk.call(()).unwrap();
|
||||
assert_eq!(name, "test");
|
||||
assert_eq!(value, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_delete() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
owlry.cache.set("delete_key", "value")
|
||||
local existed = owlry.cache.delete("delete_key")
|
||||
local value = owlry.cache.get("delete_key")
|
||||
return existed, value
|
||||
"#);
|
||||
let (existed, value): (bool, Option<String>) = chunk.call(()).unwrap();
|
||||
assert!(existed);
|
||||
assert!(value.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_has() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
local before = owlry.cache.has("has_key")
|
||||
owlry.cache.set("has_key", "value")
|
||||
local after = owlry.cache.has("has_key")
|
||||
return before, after
|
||||
"#);
|
||||
let (before, after): (bool, bool) = chunk.call(()).unwrap();
|
||||
assert!(!before);
|
||||
assert!(after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_missing_key() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"return owlry.cache.get("nonexistent_key")"#);
|
||||
let value: Value = chunk.call(()).unwrap();
|
||||
assert!(matches!(value, Value::Nil));
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
//! Hook API for Lua plugins
|
||||
//!
|
||||
//! Allows plugins to register callbacks for application events:
|
||||
//! - `owlry.hook.on(event, callback)` - Register a hook
|
||||
//! - Events: init, query, results, select, pre_launch, post_launch, shutdown
|
||||
|
||||
use mlua::{Function, Lua, Result as LuaResult, Table, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
/// Hook event types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum HookEvent {
|
||||
/// Called when plugin is initialized
|
||||
Init,
|
||||
/// Called when query changes, can modify query
|
||||
Query,
|
||||
/// Called after results are gathered, can filter/modify results
|
||||
Results,
|
||||
/// Called when an item is selected (highlighted)
|
||||
Select,
|
||||
/// Called before launching an item, can cancel launch
|
||||
PreLaunch,
|
||||
/// Called after launching an item
|
||||
PostLaunch,
|
||||
/// Called when application is shutting down
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
impl HookEvent {
|
||||
fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"init" => Some(Self::Init),
|
||||
"query" => Some(Self::Query),
|
||||
"results" => Some(Self::Results),
|
||||
"select" => Some(Self::Select),
|
||||
"pre_launch" | "prelaunch" => Some(Self::PreLaunch),
|
||||
"post_launch" | "postlaunch" => Some(Self::PostLaunch),
|
||||
"shutdown" => Some(Self::Shutdown),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Init => "init",
|
||||
Self::Query => "query",
|
||||
Self::Results => "results",
|
||||
Self::Select => "select",
|
||||
Self::PreLaunch => "pre_launch",
|
||||
Self::PostLaunch => "post_launch",
|
||||
Self::Shutdown => "shutdown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registered hook information
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Will be used for hook inspection
|
||||
pub struct HookRegistration {
|
||||
pub event: HookEvent,
|
||||
pub plugin_id: String,
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
/// Type alias for hook handlers: (plugin_id, priority)
|
||||
type HookHandlers = Vec<(String, i32)>;
|
||||
|
||||
/// Global hook registry
|
||||
/// Maps event -> list of (plugin_id, priority)
|
||||
static HOOK_REGISTRY: LazyLock<Mutex<HashMap<HookEvent, HookHandlers>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Register hook APIs
|
||||
pub fn register_hook_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> {
|
||||
let hook_table = lua.create_table()?;
|
||||
let plugin_id_owned = plugin_id.to_string();
|
||||
|
||||
// Store plugin_id in registry for later use
|
||||
lua.set_named_registry_value("plugin_id", plugin_id_owned.clone())?;
|
||||
|
||||
// Initialize hook storage in Lua registry
|
||||
if lua.named_registry_value::<Value>("hooks")?.is_nil() {
|
||||
let hooks: Table = lua.create_table()?;
|
||||
lua.set_named_registry_value("hooks", hooks)?;
|
||||
}
|
||||
|
||||
// owlry.hook.on(event, callback, priority?) -> boolean
|
||||
// Register a hook for an event
|
||||
let plugin_id_for_closure = plugin_id_owned.clone();
|
||||
hook_table.set(
|
||||
"on",
|
||||
lua.create_function(move |lua, (event_name, callback, priority): (String, Function, Option<i32>)| {
|
||||
let event = HookEvent::from_str(&event_name).ok_or_else(|| {
|
||||
mlua::Error::external(format!(
|
||||
"Unknown hook event '{}'. Valid events: init, query, results, select, pre_launch, post_launch, shutdown",
|
||||
event_name
|
||||
))
|
||||
})?;
|
||||
|
||||
let priority = priority.unwrap_or(0);
|
||||
|
||||
// Store callback in Lua registry
|
||||
let hooks: Table = lua.named_registry_value("hooks")?;
|
||||
let event_key = event.as_str();
|
||||
|
||||
let event_hooks: Table = if let Ok(t) = hooks.get::<Table>(event_key) {
|
||||
t
|
||||
} else {
|
||||
let t = lua.create_table()?;
|
||||
hooks.set(event_key, t.clone())?;
|
||||
t
|
||||
};
|
||||
|
||||
// Add callback to event hooks
|
||||
let len = event_hooks.len()? + 1;
|
||||
let hook_entry = lua.create_table()?;
|
||||
hook_entry.set("callback", callback)?;
|
||||
hook_entry.set("priority", priority)?;
|
||||
event_hooks.set(len, hook_entry)?;
|
||||
|
||||
// Register in global registry
|
||||
let mut registry = HOOK_REGISTRY.lock().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to lock hook registry: {}", e))
|
||||
})?;
|
||||
|
||||
let hooks_list = registry.entry(event).or_insert_with(Vec::new);
|
||||
hooks_list.push((plugin_id_for_closure.clone(), priority));
|
||||
// Sort by priority (higher priority first)
|
||||
hooks_list.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
log::debug!(
|
||||
"[plugin:{}] Registered hook for '{}' with priority {}",
|
||||
plugin_id_for_closure,
|
||||
event_name,
|
||||
priority
|
||||
);
|
||||
|
||||
Ok(true)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.hook.off(event) -> boolean
|
||||
// Unregister all hooks for an event from this plugin
|
||||
let plugin_id_for_off = plugin_id_owned.clone();
|
||||
hook_table.set(
|
||||
"off",
|
||||
lua.create_function(move |lua, event_name: String| {
|
||||
let event = HookEvent::from_str(&event_name).ok_or_else(|| {
|
||||
mlua::Error::external(format!("Unknown hook event '{}'", event_name))
|
||||
})?;
|
||||
|
||||
// Remove from Lua registry
|
||||
let hooks: Table = lua.named_registry_value("hooks")?;
|
||||
hooks.set(event.as_str(), Value::Nil)?;
|
||||
|
||||
// Remove from global registry
|
||||
let mut registry = HOOK_REGISTRY.lock().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to lock hook registry: {}", e))
|
||||
})?;
|
||||
|
||||
if let Some(hooks_list) = registry.get_mut(&event) {
|
||||
hooks_list.retain(|(id, _)| id != &plugin_id_for_off);
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[plugin:{}] Unregistered hooks for '{}'",
|
||||
plugin_id_for_off,
|
||||
event_name
|
||||
);
|
||||
|
||||
Ok(true)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("hook", hook_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call hooks for a specific event in a Lua runtime
|
||||
/// Returns the (possibly modified) value
|
||||
#[allow(dead_code)] // Will be used by UI integration
|
||||
pub fn call_hooks<T>(lua: &Lua, event: HookEvent, value: T) -> LuaResult<T>
|
||||
where
|
||||
T: mlua::IntoLua + mlua::FromLua,
|
||||
{
|
||||
let hooks: Table = match lua.named_registry_value("hooks") {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(value), // No hooks registered
|
||||
};
|
||||
|
||||
let event_hooks: Table = match hooks.get(event.as_str()) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(value), // No hooks for this event
|
||||
};
|
||||
|
||||
let mut current_value = value.into_lua(lua)?;
|
||||
|
||||
// Collect hooks with priorities
|
||||
let mut hook_entries: Vec<(i32, Function)> = Vec::new();
|
||||
for pair in event_hooks.pairs::<i64, Table>() {
|
||||
let (_, entry) = pair?;
|
||||
let priority: i32 = entry.get("priority").unwrap_or(0);
|
||||
let callback: Function = entry.get("callback")?;
|
||||
hook_entries.push((priority, callback));
|
||||
}
|
||||
|
||||
// Sort by priority (higher first)
|
||||
hook_entries.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
// Call each hook
|
||||
for (_, callback) in hook_entries {
|
||||
match callback.call::<Value>(current_value.clone()) {
|
||||
Ok(result) => {
|
||||
// If hook returns non-nil, use it as the new value
|
||||
if !result.is_nil() {
|
||||
current_value = result;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e);
|
||||
// Continue with other hooks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
T::from_lua(current_value, lua)
|
||||
}
|
||||
|
||||
/// Call hooks that return a boolean (for pre_launch cancellation)
|
||||
#[allow(dead_code)] // Will be used for pre_launch hooks
|
||||
pub fn call_hooks_bool(lua: &Lua, event: HookEvent, value: Value) -> LuaResult<bool> {
|
||||
let hooks: Table = match lua.named_registry_value("hooks") {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(true), // No hooks, allow
|
||||
};
|
||||
|
||||
let event_hooks: Table = match hooks.get(event.as_str()) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(true), // No hooks for this event
|
||||
};
|
||||
|
||||
// Collect and sort hooks
|
||||
let mut hook_entries: Vec<(i32, Function)> = Vec::new();
|
||||
for pair in event_hooks.pairs::<i64, Table>() {
|
||||
let (_, entry) = pair?;
|
||||
let priority: i32 = entry.get("priority").unwrap_or(0);
|
||||
let callback: Function = entry.get("callback")?;
|
||||
hook_entries.push((priority, callback));
|
||||
}
|
||||
hook_entries.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
// Call each hook - if any returns false, cancel
|
||||
for (_, callback) in hook_entries {
|
||||
match callback.call::<Value>(value.clone()) {
|
||||
Ok(result) => {
|
||||
if let Value::Boolean(false) = result {
|
||||
return Ok(false); // Cancel
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Call hooks with no return value (for notifications)
|
||||
#[allow(dead_code)] // Will be used for notification hooks
|
||||
pub fn call_hooks_void(lua: &Lua, event: HookEvent, value: Value) -> LuaResult<()> {
|
||||
let hooks: Table = match lua.named_registry_value("hooks") {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(()), // No hooks
|
||||
};
|
||||
|
||||
let event_hooks: Table = match hooks.get(event.as_str()) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(()), // No hooks for this event
|
||||
};
|
||||
|
||||
for pair in event_hooks.pairs::<i64, Table>() {
|
||||
let (_, entry) = pair?;
|
||||
let callback: Function = entry.get("callback")?;
|
||||
if let Err(e) = callback.call::<()>(value.clone()) {
|
||||
log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get list of plugins that have registered for an event
|
||||
#[allow(dead_code)]
|
||||
pub fn get_registered_plugins(event: HookEvent) -> Vec<String> {
|
||||
HOOK_REGISTRY
|
||||
.lock()
|
||||
.map(|r| {
|
||||
r.get(&event)
|
||||
.map(|v| v.iter().map(|(id, _)| id.clone()).collect())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Clear all hooks (used when reloading plugins)
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_all_hooks() {
|
||||
if let Ok(mut registry) = HOOK_REGISTRY.lock() {
|
||||
registry.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_lua(plugin_id: &str) -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_hook_api(&lua, &owlry, plugin_id).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_registration() {
|
||||
clear_all_hooks();
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
local called = false
|
||||
owlry.hook.on("init", function()
|
||||
called = true
|
||||
end)
|
||||
return true
|
||||
"#);
|
||||
let result: bool = chunk.call(()).unwrap();
|
||||
assert!(result);
|
||||
|
||||
// Verify hook was registered
|
||||
let plugins = get_registered_plugins(HookEvent::Init);
|
||||
assert!(plugins.contains(&"test-plugin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_with_priority() {
|
||||
clear_all_hooks();
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
owlry.hook.on("query", function(q) return q .. "1" end, 10)
|
||||
owlry.hook.on("query", function(q) return q .. "2" end, 20)
|
||||
return true
|
||||
"#);
|
||||
chunk.call::<()>(()).unwrap();
|
||||
|
||||
// Call hooks - higher priority (20) should run first
|
||||
let result: String = call_hooks(&lua, HookEvent::Query, "test".to_string()).unwrap();
|
||||
// Priority 20 adds "2" first, then priority 10 adds "1"
|
||||
assert_eq!(result, "test21");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_off() {
|
||||
clear_all_hooks();
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
owlry.hook.on("select", function() end)
|
||||
owlry.hook.off("select")
|
||||
return true
|
||||
"#);
|
||||
chunk.call::<()>(()).unwrap();
|
||||
|
||||
let plugins = get_registered_plugins(HookEvent::Select);
|
||||
assert!(!plugins.contains(&"test-plugin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pre_launch_cancel() {
|
||||
clear_all_hooks();
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
owlry.hook.on("pre_launch", function(item)
|
||||
if item.name == "blocked" then
|
||||
return false -- cancel launch
|
||||
end
|
||||
return true
|
||||
end)
|
||||
"#);
|
||||
chunk.call::<()>(()).unwrap();
|
||||
|
||||
// Create a test item table
|
||||
let item = lua.create_table().unwrap();
|
||||
item.set("name", "blocked").unwrap();
|
||||
|
||||
let allow = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item)).unwrap();
|
||||
assert!(!allow); // Should be blocked
|
||||
|
||||
// Test with allowed item
|
||||
let item2 = lua.create_table().unwrap();
|
||||
item2.set("name", "allowed").unwrap();
|
||||
|
||||
let allow2 = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item2)).unwrap();
|
||||
assert!(allow2); // Should be allowed
|
||||
}
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
//! HTTP client API for Lua plugins
|
||||
//!
|
||||
//! Provides:
|
||||
//! - `owlry.http.get(url, opts)` - HTTP GET request
|
||||
//! - `owlry.http.post(url, body, opts)` - HTTP POST request
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Register HTTP client APIs
|
||||
pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let http_table = lua.create_table()?;
|
||||
|
||||
// owlry.http.get(url, opts?) -> { status, body, headers }
|
||||
http_table.set(
|
||||
"get",
|
||||
lua.create_function(|lua, (url, opts): (String, Option<Table>)| {
|
||||
log::debug!("[plugin] http.get: {}", url);
|
||||
|
||||
let timeout_secs = opts
|
||||
.as_ref()
|
||||
.and_then(|o| o.get::<u64>("timeout").ok())
|
||||
.unwrap_or(30);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to create HTTP client: {}", e)))?;
|
||||
|
||||
let mut request = client.get(&url);
|
||||
|
||||
// Add custom headers if provided
|
||||
if let Some(ref opts) = opts
|
||||
&& let Ok(headers) = opts.get::<Table>("headers") {
|
||||
for pair in headers.pairs::<String, String>() {
|
||||
let (key, value) = pair?;
|
||||
request = request.header(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let headers = extract_headers(&response);
|
||||
let body = response
|
||||
.text()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to read response body: {}", e)))?;
|
||||
|
||||
let result = lua.create_table()?;
|
||||
result.set("status", status)?;
|
||||
result.set("body", body)?;
|
||||
result.set("ok", (200..300).contains(&status))?;
|
||||
|
||||
let headers_table = lua.create_table()?;
|
||||
for (key, value) in headers {
|
||||
headers_table.set(key, value)?;
|
||||
}
|
||||
result.set("headers", headers_table)?;
|
||||
|
||||
Ok(result)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.http.post(url, body, opts?) -> { status, body, headers }
|
||||
http_table.set(
|
||||
"post",
|
||||
lua.create_function(|lua, (url, body, opts): (String, Value, Option<Table>)| {
|
||||
log::debug!("[plugin] http.post: {}", url);
|
||||
|
||||
let timeout_secs = opts
|
||||
.as_ref()
|
||||
.and_then(|o| o.get::<u64>("timeout").ok())
|
||||
.unwrap_or(30);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to create HTTP client: {}", e)))?;
|
||||
|
||||
let mut request = client.post(&url);
|
||||
|
||||
// Add custom headers if provided
|
||||
if let Some(ref opts) = opts
|
||||
&& let Ok(headers) = opts.get::<Table>("headers") {
|
||||
for pair in headers.pairs::<String, String>() {
|
||||
let (key, value) = pair?;
|
||||
request = request.header(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
// Set body based on type
|
||||
request = match body {
|
||||
Value::String(s) => request.body(s.to_str()?.to_string()),
|
||||
Value::Table(t) => {
|
||||
// Assume JSON if body is a table
|
||||
let json_str = table_to_json(&t)?;
|
||||
request
|
||||
.header("Content-Type", "application/json")
|
||||
.body(json_str)
|
||||
}
|
||||
Value::Nil => request,
|
||||
_ => {
|
||||
return Err(mlua::Error::external(
|
||||
"POST body must be a string or table",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let headers = extract_headers(&response);
|
||||
let body = response
|
||||
.text()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to read response body: {}", e)))?;
|
||||
|
||||
let result = lua.create_table()?;
|
||||
result.set("status", status)?;
|
||||
result.set("body", body)?;
|
||||
result.set("ok", (200..300).contains(&status))?;
|
||||
|
||||
let headers_table = lua.create_table()?;
|
||||
for (key, value) in headers {
|
||||
headers_table.set(key, value)?;
|
||||
}
|
||||
result.set("headers", headers_table)?;
|
||||
|
||||
Ok(result)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.http.get_json(url, opts?) -> parsed JSON as table
|
||||
// Convenience function that parses JSON response
|
||||
http_table.set(
|
||||
"get_json",
|
||||
lua.create_function(|lua, (url, opts): (String, Option<Table>)| {
|
||||
log::debug!("[plugin] http.get_json: {}", url);
|
||||
|
||||
let timeout_secs = opts
|
||||
.as_ref()
|
||||
.and_then(|o| o.get::<u64>("timeout").ok())
|
||||
.unwrap_or(30);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to create HTTP client: {}", e)))?;
|
||||
|
||||
let mut request = client.get(&url);
|
||||
request = request.header("Accept", "application/json");
|
||||
|
||||
// Add custom headers if provided
|
||||
if let Some(ref opts) = opts
|
||||
&& let Ok(headers) = opts.get::<Table>("headers") {
|
||||
for pair in headers.pairs::<String, String>() {
|
||||
let (key, value) = pair?;
|
||||
request = request.header(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(mlua::Error::external(format!(
|
||||
"HTTP request failed with status {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to read response body: {}", e)))?;
|
||||
|
||||
// Parse JSON and convert to Lua table
|
||||
let json_value: serde_json::Value = serde_json::from_str(&body)
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to parse JSON: {}", e)))?;
|
||||
|
||||
json_to_lua(lua, &json_value)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("http", http_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract headers from response into a HashMap
|
||||
fn extract_headers(response: &reqwest::blocking::Response) -> HashMap<String, String> {
|
||||
response
|
||||
.headers()
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
v.to_str()
|
||||
.ok()
|
||||
.map(|v| (k.as_str().to_lowercase(), v.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert a Lua table to JSON string
|
||||
fn table_to_json(table: &Table) -> LuaResult<String> {
|
||||
let value = lua_to_json(table)?;
|
||||
serde_json::to_string(&value)
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to serialize to JSON: {}", e)))
|
||||
}
|
||||
|
||||
/// Convert Lua table to serde_json::Value
|
||||
fn lua_to_json(table: &Table) -> LuaResult<serde_json::Value> {
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
|
||||
// Check if it's an array (sequential integer keys starting from 1)
|
||||
let is_array = table
|
||||
.clone()
|
||||
.pairs::<i64, Value>()
|
||||
.enumerate()
|
||||
.all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false));
|
||||
|
||||
if is_array {
|
||||
let mut arr = Vec::new();
|
||||
for pair in table.clone().pairs::<i64, Value>() {
|
||||
let (_, v) = pair?;
|
||||
arr.push(lua_value_to_json(&v)?);
|
||||
}
|
||||
Ok(JsonValue::Array(arr))
|
||||
} else {
|
||||
let mut map = Map::new();
|
||||
for pair in table.clone().pairs::<String, Value>() {
|
||||
let (k, v) = pair?;
|
||||
map.insert(k, lua_value_to_json(&v)?);
|
||||
}
|
||||
Ok(JsonValue::Object(map))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a single Lua value to JSON
|
||||
fn lua_value_to_json(value: &Value) -> LuaResult<serde_json::Value> {
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
match value {
|
||||
Value::Nil => Ok(JsonValue::Null),
|
||||
Value::Boolean(b) => Ok(JsonValue::Bool(*b)),
|
||||
Value::Integer(i) => Ok(JsonValue::Number((*i).into())),
|
||||
Value::Number(n) => Ok(serde_json::Number::from_f64(*n)
|
||||
.map(JsonValue::Number)
|
||||
.unwrap_or(JsonValue::Null)),
|
||||
Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())),
|
||||
Value::Table(t) => lua_to_json(t),
|
||||
_ => Err(mlua::Error::external("Unsupported Lua type for JSON")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert serde_json::Value to Lua value
|
||||
fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<Value> {
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
match value {
|
||||
JsonValue::Null => Ok(Value::Nil),
|
||||
JsonValue::Bool(b) => Ok(Value::Boolean(*b)),
|
||||
JsonValue::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)
|
||||
}
|
||||
}
|
||||
JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)),
|
||||
JsonValue::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))
|
||||
}
|
||||
JsonValue::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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_lua() -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_http_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_conversion() {
|
||||
let lua = setup_lua();
|
||||
|
||||
// Test table to JSON
|
||||
let table = lua.create_table().unwrap();
|
||||
table.set("name", "test").unwrap();
|
||||
table.set("value", 42).unwrap();
|
||||
|
||||
let json = table_to_json(&table).unwrap();
|
||||
assert!(json.contains("name"));
|
||||
assert!(json.contains("test"));
|
||||
assert!(json.contains("42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_to_json() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let table = lua.create_table().unwrap();
|
||||
table.set(1, "first").unwrap();
|
||||
table.set(2, "second").unwrap();
|
||||
table.set(3, "third").unwrap();
|
||||
|
||||
let json = table_to_json(&table).unwrap();
|
||||
assert!(json.starts_with('['));
|
||||
assert!(json.contains("first"));
|
||||
}
|
||||
|
||||
// Note: Network tests are skipped in CI - they require internet access
|
||||
// Use `cargo test -- --ignored` to run them locally
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_http_get() {
|
||||
let lua = setup_lua();
|
||||
let chunk = lua.load(r#"return owlry.http.get("https://httpbin.org/get")"#);
|
||||
let result: Table = chunk.call(()).unwrap();
|
||||
|
||||
assert_eq!(result.get::<u16>("status").unwrap(), 200);
|
||||
assert!(result.get::<bool>("ok").unwrap());
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
//! Math calculation API for Lua plugins
|
||||
//!
|
||||
//! Provides safe math expression evaluation:
|
||||
//! - `owlry.math.calculate(expression)` - Evaluate a math expression
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table};
|
||||
|
||||
/// Register math APIs
|
||||
pub fn register_math_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let math_table = lua.create_table()?;
|
||||
|
||||
// owlry.math.calculate(expression) -> number or nil, error
|
||||
// Evaluates a mathematical expression safely
|
||||
// Returns (result, nil) on success or (nil, error_message) on failure
|
||||
math_table.set(
|
||||
"calculate",
|
||||
lua.create_function(|_lua, expr: String| -> LuaResult<(Option<f64>, Option<String>)> {
|
||||
match meval::eval_str(&expr) {
|
||||
Ok(result) => {
|
||||
if result.is_finite() {
|
||||
Ok((Some(result), None))
|
||||
} else {
|
||||
Ok((None, Some("Result is not a finite number".to_string())))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
Ok((None, Some(e.to_string())))
|
||||
}
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.math.calc(expression) -> number (throws on error)
|
||||
// Convenience function that throws instead of returning error
|
||||
math_table.set(
|
||||
"calc",
|
||||
lua.create_function(|_lua, expr: String| {
|
||||
meval::eval_str(&expr)
|
||||
.map_err(|e| mlua::Error::external(format!("Math error: {}", e)))
|
||||
.and_then(|r| {
|
||||
if r.is_finite() {
|
||||
Ok(r)
|
||||
} else {
|
||||
Err(mlua::Error::external("Result is not a finite number"))
|
||||
}
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.math.is_expression(str) -> boolean
|
||||
// Check if a string looks like a math expression
|
||||
math_table.set(
|
||||
"is_expression",
|
||||
lua.create_function(|_lua, expr: String| {
|
||||
let trimmed = expr.trim();
|
||||
|
||||
// Must have at least one digit
|
||||
if !trimmed.chars().any(|c| c.is_ascii_digit()) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Should only contain valid math characters
|
||||
let valid = trimmed.chars().all(|c| {
|
||||
c.is_ascii_digit()
|
||||
|| c.is_ascii_alphabetic()
|
||||
|| matches!(c, '+' | '-' | '*' | '/' | '^' | '(' | ')' | '.' | ' ' | '%')
|
||||
});
|
||||
|
||||
Ok(valid)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.math.format(number, decimals?) -> string
|
||||
// Format a number with optional decimal places
|
||||
math_table.set(
|
||||
"format",
|
||||
lua.create_function(|_lua, (num, decimals): (f64, Option<usize>)| {
|
||||
let decimals = decimals.unwrap_or(2);
|
||||
|
||||
// Check if it's effectively an integer
|
||||
if (num - num.round()).abs() < f64::EPSILON {
|
||||
Ok(format!("{}", num as i64))
|
||||
} else {
|
||||
Ok(format!("{:.prec$}", num, prec = decimals))
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("math", math_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_lua() -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_math_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_basic() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
local result, err = owlry.math.calculate("2 + 2")
|
||||
if err then error(err) end
|
||||
return result
|
||||
"#);
|
||||
let result: f64 = chunk.call(()).unwrap();
|
||||
assert!((result - 4.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_complex() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
local result, err = owlry.math.calculate("sqrt(16) + 2^3")
|
||||
if err then error(err) end
|
||||
return result
|
||||
"#);
|
||||
let result: f64 = chunk.call(()).unwrap();
|
||||
assert!((result - 12.0).abs() < f64::EPSILON); // sqrt(16) = 4, 2^3 = 8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_error() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
local result, err = owlry.math.calculate("invalid expression @@")
|
||||
if result then
|
||||
return false -- should not succeed
|
||||
else
|
||||
return true -- correctly failed
|
||||
end
|
||||
"#);
|
||||
let had_error: bool = chunk.call(()).unwrap();
|
||||
assert!(had_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calc_throws() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"return owlry.math.calc("3 * 4")"#);
|
||||
let result: f64 = chunk.call(()).unwrap();
|
||||
assert!((result - 12.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_expression() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"return owlry.math.is_expression("2 + 2")"#);
|
||||
let is_expr: bool = chunk.call(()).unwrap();
|
||||
assert!(is_expr);
|
||||
|
||||
let chunk = lua.load(r#"return owlry.math.is_expression("hello world")"#);
|
||||
let is_expr: bool = chunk.call(()).unwrap();
|
||||
assert!(!is_expr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"return owlry.math.format(3.14159, 2)"#);
|
||||
let formatted: String = chunk.call(()).unwrap();
|
||||
assert_eq!(formatted, "3.14");
|
||||
|
||||
let chunk = lua.load(r#"return owlry.math.format(42.0)"#);
|
||||
let formatted: String = chunk.call(()).unwrap();
|
||||
assert_eq!(formatted, "42");
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
//! Lua API implementations for plugins
|
||||
//!
|
||||
//! This module provides the `owlry` global table and its submodules
|
||||
//! that plugins can use to interact with owlry.
|
||||
|
||||
pub mod action;
|
||||
mod cache;
|
||||
pub mod hook;
|
||||
mod http;
|
||||
mod math;
|
||||
mod process;
|
||||
pub mod provider;
|
||||
pub mod theme;
|
||||
mod utils;
|
||||
|
||||
use mlua::{Lua, Result as LuaResult};
|
||||
|
||||
pub use action::ActionRegistration;
|
||||
pub use hook::HookEvent;
|
||||
pub use provider::ProviderRegistration;
|
||||
pub use theme::ThemeRegistration;
|
||||
|
||||
/// Register all owlry APIs in the Lua runtime
|
||||
///
|
||||
/// This creates the `owlry` global table with all available APIs:
|
||||
/// - `owlry.log.*` - Logging functions
|
||||
/// - `owlry.path.*` - XDG path helpers
|
||||
/// - `owlry.fs.*` - Filesystem operations
|
||||
/// - `owlry.json.*` - JSON encode/decode
|
||||
/// - `owlry.provider.*` - Provider registration
|
||||
/// - `owlry.process.*` - Process execution
|
||||
/// - `owlry.env.*` - Environment variables
|
||||
/// - `owlry.http.*` - HTTP client
|
||||
/// - `owlry.cache.*` - In-memory caching
|
||||
/// - `owlry.math.*` - Math expression evaluation
|
||||
/// - `owlry.hook.*` - Event hooks
|
||||
/// - `owlry.action.*` - Custom actions
|
||||
/// - `owlry.theme.*` - Theme registration
|
||||
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)?;
|
||||
|
||||
// Register extended APIs (Phase 3)
|
||||
process::register_process_api(lua, &owlry)?;
|
||||
process::register_env_api(lua, &owlry)?;
|
||||
http::register_http_api(lua, &owlry)?;
|
||||
cache::register_cache_api(lua, &owlry)?;
|
||||
math::register_math_api(lua, &owlry)?;
|
||||
|
||||
// Register Phase 4 APIs (hooks, actions, themes)
|
||||
hook::register_hook_api(lua, &owlry, plugin_id)?;
|
||||
action::register_action_api(lua, &owlry, plugin_id)?;
|
||||
theme::register_theme_api(lua, &owlry, plugin_id, plugin_dir)?;
|
||||
|
||||
// Set owlry as global
|
||||
globals.set("owlry", owlry)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get provider registrations from the Lua runtime
|
||||
///
|
||||
/// Returns all providers that were registered via `owlry.provider.register()`
|
||||
pub fn get_provider_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
|
||||
provider::get_registrations(lua)
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
//! Process and environment APIs for Lua plugins
|
||||
//!
|
||||
//! Provides:
|
||||
//! - `owlry.process.run(cmd)` - Run a shell command and return output
|
||||
//! - `owlry.process.exists(cmd)` - Check if a command exists in PATH
|
||||
//! - `owlry.env.get(name)` - Get an environment variable
|
||||
//! - `owlry.env.set(name, value)` - Set an environment variable (for plugin scope)
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table};
|
||||
use std::process::Command;
|
||||
|
||||
/// Register process-related APIs
|
||||
pub fn register_process_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let process_table = lua.create_table()?;
|
||||
|
||||
// owlry.process.run(cmd) -> { stdout, stderr, exit_code, success }
|
||||
// Runs a shell command and returns the result
|
||||
process_table.set(
|
||||
"run",
|
||||
lua.create_function(|lua, cmd: String| {
|
||||
log::debug!("[plugin] process.run: {}", cmd);
|
||||
|
||||
let output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.output()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?;
|
||||
|
||||
let result = lua.create_table()?;
|
||||
result.set("stdout", String::from_utf8_lossy(&output.stdout).to_string())?;
|
||||
result.set("stderr", String::from_utf8_lossy(&output.stderr).to_string())?;
|
||||
result.set("exit_code", output.status.code().unwrap_or(-1))?;
|
||||
result.set("success", output.status.success())?;
|
||||
|
||||
Ok(result)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.process.run_lines(cmd) -> table of lines
|
||||
// Convenience function that runs a command and returns stdout split into lines
|
||||
process_table.set(
|
||||
"run_lines",
|
||||
lua.create_function(|lua, cmd: String| {
|
||||
log::debug!("[plugin] process.run_lines: {}", cmd);
|
||||
|
||||
let output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.output()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(mlua::Error::external(format!(
|
||||
"Command failed with exit code {}: {}",
|
||||
output.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
|
||||
let result = lua.create_table()?;
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
result.set(i + 1, *line)?;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.process.exists(cmd) -> boolean
|
||||
// Checks if a command exists in PATH
|
||||
process_table.set(
|
||||
"exists",
|
||||
lua.create_function(|_lua, cmd: String| {
|
||||
let exists = Command::new("which")
|
||||
.arg(&cmd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(exists)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("process", process_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register environment variable APIs
|
||||
pub fn register_env_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let env_table = lua.create_table()?;
|
||||
|
||||
// owlry.env.get(name) -> string or nil
|
||||
env_table.set(
|
||||
"get",
|
||||
lua.create_function(|_lua, name: String| {
|
||||
Ok(std::env::var(&name).ok())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.env.get_or(name, default) -> string
|
||||
env_table.set(
|
||||
"get_or",
|
||||
lua.create_function(|_lua, (name, default): (String, String)| {
|
||||
Ok(std::env::var(&name).unwrap_or(default))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.env.home() -> string
|
||||
// Convenience function to get home directory
|
||||
env_table.set(
|
||||
"home",
|
||||
lua.create_function(|_lua, ()| {
|
||||
Ok(dirs::home_dir().map(|p| p.to_string_lossy().to_string()))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("env", env_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_lua() -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_process_api(&lua, &owlry).unwrap();
|
||||
register_env_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_run() {
|
||||
let lua = setup_lua();
|
||||
let chunk = lua.load(r#"return owlry.process.run("echo hello")"#);
|
||||
let result: Table = chunk.call(()).unwrap();
|
||||
|
||||
assert_eq!(result.get::<bool>("success").unwrap(), true);
|
||||
assert_eq!(result.get::<i32>("exit_code").unwrap(), 0);
|
||||
assert!(result.get::<String>("stdout").unwrap().contains("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_run_lines() {
|
||||
let lua = setup_lua();
|
||||
let chunk = lua.load(r#"return owlry.process.run_lines("echo -e 'line1\nline2\nline3'")"#);
|
||||
let result: Table = chunk.call(()).unwrap();
|
||||
|
||||
assert_eq!(result.get::<String>(1).unwrap(), "line1");
|
||||
assert_eq!(result.get::<String>(2).unwrap(), "line2");
|
||||
assert_eq!(result.get::<String>(3).unwrap(), "line3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_exists() {
|
||||
let lua = setup_lua();
|
||||
|
||||
// 'sh' should always exist
|
||||
let chunk = lua.load(r#"return owlry.process.exists("sh")"#);
|
||||
let exists: bool = chunk.call(()).unwrap();
|
||||
assert!(exists);
|
||||
|
||||
// Made-up command should not exist
|
||||
let chunk = lua.load(r#"return owlry.process.exists("this_command_definitely_does_not_exist_12345")"#);
|
||||
let not_exists: bool = chunk.call(()).unwrap();
|
||||
assert!(!not_exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_get() {
|
||||
let lua = setup_lua();
|
||||
|
||||
// HOME should be set on any Unix system
|
||||
let chunk = lua.load(r#"return owlry.env.get("HOME")"#);
|
||||
let home: Option<String> = chunk.call(()).unwrap();
|
||||
assert!(home.is_some());
|
||||
|
||||
// Non-existent variable should return nil
|
||||
let chunk = lua.load(r#"return owlry.env.get("THIS_VAR_DOES_NOT_EXIST_12345")"#);
|
||||
let missing: Option<String> = chunk.call(()).unwrap();
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_get_or() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"return owlry.env.get_or("THIS_VAR_DOES_NOT_EXIST_12345", "default_value")"#);
|
||||
let result: String = chunk.call(()).unwrap();
|
||||
assert_eq!(result, "default_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_home() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"return owlry.env.home()"#);
|
||||
let home: Option<String> = chunk.call(()).unwrap();
|
||||
assert!(home.is_some());
|
||||
assert!(home.unwrap().starts_with('/'));
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
//! Provider registration API for Lua plugins
|
||||
//!
|
||||
//! Allows plugins to register providers via `owlry.provider.register()`
|
||||
|
||||
use mlua::{Function, Lua, Result as LuaResult, Table};
|
||||
|
||||
/// Provider registration data extracted from Lua
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Some fields are for future use
|
||||
pub struct ProviderRegistration {
|
||||
/// Provider name (used for filtering/identification)
|
||||
pub name: String,
|
||||
/// Human-readable display name
|
||||
pub display_name: String,
|
||||
/// Provider type ID (for badge/filtering)
|
||||
pub type_id: String,
|
||||
/// Default icon name
|
||||
pub default_icon: String,
|
||||
/// Whether this is a static provider (refresh once) or dynamic (query-based)
|
||||
pub is_static: bool,
|
||||
/// Prefix to trigger this provider (e.g., ":" for commands)
|
||||
pub prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// Register owlry.provider.* API
|
||||
pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let provider_table = lua.create_table()?;
|
||||
|
||||
// Initialize registry for storing provider registrations
|
||||
let registrations: Table = lua.create_table()?;
|
||||
lua.set_named_registry_value("provider_registrations", registrations)?;
|
||||
|
||||
// owlry.provider.register(config) - Register a new provider
|
||||
provider_table.set(
|
||||
"register",
|
||||
lua.create_function(|lua, config: Table| {
|
||||
// Extract required fields
|
||||
let name: String = config
|
||||
.get("name")
|
||||
.map_err(|_| mlua::Error::external("provider.register: 'name' is required"))?;
|
||||
|
||||
let _display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone());
|
||||
|
||||
let type_id: String = config
|
||||
.get("type_id")
|
||||
.unwrap_or_else(|_| name.replace('-', "_"));
|
||||
|
||||
let _default_icon: String = config
|
||||
.get("default_icon")
|
||||
.unwrap_or_else(|_| "application-x-executable".to_string());
|
||||
|
||||
let _prefix: Option<String> = config.get("prefix").ok();
|
||||
|
||||
// Check for refresh function (static provider) or query function (dynamic)
|
||||
let has_refresh = config.get::<Function>("refresh").is_ok();
|
||||
let has_query = config.get::<Function>("query").is_ok();
|
||||
|
||||
if !has_refresh && !has_query {
|
||||
return Err(mlua::Error::external(
|
||||
"provider.register: either 'refresh' or 'query' function is required",
|
||||
));
|
||||
}
|
||||
|
||||
let is_static = has_refresh;
|
||||
|
||||
log::info!(
|
||||
"[plugin] Registered provider '{}' (type: {}, static: {})",
|
||||
name,
|
||||
type_id,
|
||||
is_static
|
||||
);
|
||||
|
||||
// Store the config in registry for later retrieval
|
||||
let registrations: Table = lua.named_registry_value("provider_registrations")?;
|
||||
registrations.set(name.clone(), config)?;
|
||||
|
||||
Ok(name)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("provider", provider_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all provider registrations from the Lua runtime
|
||||
pub fn get_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
|
||||
let registrations: Table = lua.named_registry_value("provider_registrations")?;
|
||||
let mut result = Vec::new();
|
||||
|
||||
for pair in registrations.pairs::<String, Table>() {
|
||||
let (name, config) = pair?;
|
||||
|
||||
let display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone());
|
||||
let type_id: String = config
|
||||
.get("type_id")
|
||||
.unwrap_or_else(|_| name.replace('-', "_"));
|
||||
let default_icon: String = config
|
||||
.get("default_icon")
|
||||
.unwrap_or_else(|_| "application-x-executable".to_string());
|
||||
let prefix: Option<String> = config.get("prefix").ok();
|
||||
let is_static = config.get::<Function>("refresh").is_ok();
|
||||
|
||||
result.push(ProviderRegistration {
|
||||
name,
|
||||
display_name,
|
||||
type_id,
|
||||
default_icon,
|
||||
is_static,
|
||||
prefix,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Call a provider's refresh function and extract items
|
||||
pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>> {
|
||||
let registrations: Table = lua.named_registry_value("provider_registrations")?;
|
||||
let config: Table = registrations.get(provider_name)?;
|
||||
let refresh: Function = config.get("refresh")?;
|
||||
|
||||
let items: Table = refresh.call(())?;
|
||||
extract_items(&items)
|
||||
}
|
||||
|
||||
/// Call a provider's query function with a query string
|
||||
#[allow(dead_code)] // Will be used for dynamic query providers
|
||||
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
|
||||
let registrations: Table = lua.named_registry_value("provider_registrations")?;
|
||||
let config: Table = registrations.get(provider_name)?;
|
||||
let query_fn: Function = config.get("query")?;
|
||||
|
||||
let items: Table = query_fn.call(query.to_string())?;
|
||||
extract_items(&items)
|
||||
}
|
||||
|
||||
/// Item data from a plugin provider
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // data field is for future action handlers
|
||||
pub struct PluginItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub command: Option<String>,
|
||||
pub terminal: bool,
|
||||
pub tags: Vec<String>,
|
||||
/// Custom data passed to action handlers
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract items from a Lua table returned by refresh/query
|
||||
fn extract_items(items: &Table) -> LuaResult<Vec<PluginItem>> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for pair in items.clone().pairs::<i64, Table>() {
|
||||
let (_, item) = pair?;
|
||||
|
||||
let id: String = item.get("id")?;
|
||||
let name: String = item.get("name")?;
|
||||
let description: Option<String> = item.get("description").ok();
|
||||
let icon: Option<String> = item.get("icon").ok();
|
||||
let command: Option<String> = item.get("command").ok();
|
||||
let terminal: bool = item.get("terminal").unwrap_or(false);
|
||||
let data: Option<String> = item.get("data").ok();
|
||||
|
||||
// Extract tags array
|
||||
let tags: Vec<String> = if let Ok(tags_table) = item.get::<Table>("tags") {
|
||||
tags_table
|
||||
.pairs::<i64, String>()
|
||||
.filter_map(|r| r.ok())
|
||||
.map(|(_, v)| v)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
result.push(PluginItem {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
command,
|
||||
terminal,
|
||||
tags,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_lua() -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_provider_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_static_provider() {
|
||||
let lua = create_test_lua();
|
||||
|
||||
let script = r#"
|
||||
owlry.provider.register({
|
||||
name = "test-provider",
|
||||
display_name = "Test Provider",
|
||||
type_id = "test",
|
||||
default_icon = "test-icon",
|
||||
refresh = function()
|
||||
return {
|
||||
{ id = "1", name = "Item 1", description = "First item" },
|
||||
{ id = "2", name = "Item 2", command = "echo hello" },
|
||||
}
|
||||
end
|
||||
})
|
||||
"#;
|
||||
lua.load(script).call::<()>(()).unwrap();
|
||||
|
||||
let registrations = get_registrations(&lua).unwrap();
|
||||
assert_eq!(registrations.len(), 1);
|
||||
assert_eq!(registrations[0].name, "test-provider");
|
||||
assert_eq!(registrations[0].display_name, "Test Provider");
|
||||
assert!(registrations[0].is_static);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_dynamic_provider() {
|
||||
let lua = create_test_lua();
|
||||
|
||||
let script = r#"
|
||||
owlry.provider.register({
|
||||
name = "search",
|
||||
prefix = "?",
|
||||
query = function(q)
|
||||
return {
|
||||
{ id = "result", name = "Result for: " .. q }
|
||||
}
|
||||
end
|
||||
})
|
||||
"#;
|
||||
lua.load(script).call::<()>(()).unwrap();
|
||||
|
||||
let registrations = get_registrations(&lua).unwrap();
|
||||
assert_eq!(registrations.len(), 1);
|
||||
assert!(!registrations[0].is_static);
|
||||
assert_eq!(registrations[0].prefix, Some("?".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_call_refresh() {
|
||||
let lua = create_test_lua();
|
||||
|
||||
let script = r#"
|
||||
owlry.provider.register({
|
||||
name = "items",
|
||||
refresh = function()
|
||||
return {
|
||||
{ id = "a", name = "Alpha", tags = {"one", "two"} },
|
||||
{ id = "b", name = "Beta", terminal = true },
|
||||
}
|
||||
end
|
||||
})
|
||||
"#;
|
||||
lua.load(script).call::<()>(()).unwrap();
|
||||
|
||||
let items = call_refresh(&lua, "items").unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].id, "a");
|
||||
assert_eq!(items[0].name, "Alpha");
|
||||
assert_eq!(items[0].tags, vec!["one", "two"]);
|
||||
assert!(!items[0].terminal);
|
||||
assert_eq!(items[1].id, "b");
|
||||
assert!(items[1].terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_call_query() {
|
||||
let lua = create_test_lua();
|
||||
|
||||
let script = r#"
|
||||
owlry.provider.register({
|
||||
name = "search",
|
||||
query = function(q)
|
||||
return {
|
||||
{ id = "1", name = "Found: " .. q }
|
||||
}
|
||||
end
|
||||
})
|
||||
"#;
|
||||
lua.load(script).call::<()>(()).unwrap();
|
||||
|
||||
let items = call_query(&lua, "search", "hello").unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].name, "Found: hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_missing_function() {
|
||||
let lua = create_test_lua();
|
||||
|
||||
let script = r#"
|
||||
owlry.provider.register({
|
||||
name = "broken",
|
||||
})
|
||||
"#;
|
||||
let result = lua.load(script).call::<()>(());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
//! Theme API for Lua plugins
|
||||
//!
|
||||
//! Allows plugins to contribute CSS themes:
|
||||
//! - `owlry.theme.register(config)` - Register a theme
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table, Value};
|
||||
use std::path::Path;
|
||||
|
||||
/// Theme registration data
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Will be used by theme loading
|
||||
pub struct ThemeRegistration {
|
||||
/// Theme name (used in config)
|
||||
pub name: String,
|
||||
/// Human-readable display name
|
||||
pub display_name: String,
|
||||
/// CSS content
|
||||
pub css: String,
|
||||
/// Plugin that registered this theme
|
||||
pub plugin_id: String,
|
||||
}
|
||||
|
||||
/// Register theme APIs
|
||||
pub fn register_theme_api(lua: &Lua, owlry: &Table, plugin_id: &str, plugin_dir: &Path) -> LuaResult<()> {
|
||||
let theme_table = lua.create_table()?;
|
||||
let plugin_id_owned = plugin_id.to_string();
|
||||
let plugin_dir_owned = plugin_dir.to_path_buf();
|
||||
|
||||
// Initialize theme storage in Lua registry
|
||||
if lua.named_registry_value::<Value>("themes")?.is_nil() {
|
||||
let themes: Table = lua.create_table()?;
|
||||
lua.set_named_registry_value("themes", themes)?;
|
||||
}
|
||||
|
||||
// owlry.theme.register(config) -> string (theme_name)
|
||||
// config = {
|
||||
// name = "dark-owl",
|
||||
// display_name = "Dark Owl", -- optional, defaults to name
|
||||
// css = "...", -- CSS string
|
||||
// -- OR
|
||||
// css_file = "theme.css" -- path relative to plugin dir
|
||||
// }
|
||||
let plugin_id_for_register = plugin_id_owned.clone();
|
||||
let plugin_dir_for_register = plugin_dir_owned.clone();
|
||||
theme_table.set(
|
||||
"register",
|
||||
lua.create_function(move |lua, config: Table| {
|
||||
// Extract required fields
|
||||
let name: String = config
|
||||
.get("name")
|
||||
.map_err(|_| mlua::Error::external("theme.register: 'name' is required"))?;
|
||||
|
||||
let display_name: String = config
|
||||
.get("display_name")
|
||||
.unwrap_or_else(|_| name.clone());
|
||||
|
||||
// Get CSS either directly or from file
|
||||
let css: String = if let Ok(css_str) = config.get::<String>("css") {
|
||||
css_str
|
||||
} else if let Ok(css_file) = config.get::<String>("css_file") {
|
||||
let css_path = plugin_dir_for_register.join(&css_file);
|
||||
std::fs::read_to_string(&css_path).map_err(|e| {
|
||||
mlua::Error::external(format!(
|
||||
"Failed to read CSS file '{}': {}",
|
||||
css_path.display(),
|
||||
e
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
return Err(mlua::Error::external(
|
||||
"theme.register: either 'css' or 'css_file' is required",
|
||||
));
|
||||
};
|
||||
|
||||
// Store theme in registry
|
||||
let themes: Table = lua.named_registry_value("themes")?;
|
||||
|
||||
let theme_entry = lua.create_table()?;
|
||||
theme_entry.set("name", name.clone())?;
|
||||
theme_entry.set("display_name", display_name.clone())?;
|
||||
theme_entry.set("css", css)?;
|
||||
theme_entry.set("plugin_id", plugin_id_for_register.clone())?;
|
||||
|
||||
themes.set(name.clone(), theme_entry)?;
|
||||
|
||||
log::info!(
|
||||
"[plugin:{}] Registered theme '{}'",
|
||||
plugin_id_for_register,
|
||||
name
|
||||
);
|
||||
|
||||
Ok(name)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.theme.unregister(name) -> boolean
|
||||
theme_table.set(
|
||||
"unregister",
|
||||
lua.create_function(|lua, name: String| {
|
||||
let themes: Table = lua.named_registry_value("themes")?;
|
||||
|
||||
if themes.contains_key(name.clone())? {
|
||||
themes.set(name, Value::Nil)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.theme.list() -> table of theme names
|
||||
theme_table.set(
|
||||
"list",
|
||||
lua.create_function(|lua, ()| {
|
||||
let themes: Table = match lua.named_registry_value("themes") {
|
||||
Ok(t) => t,
|
||||
Err(_) => return lua.create_table(),
|
||||
};
|
||||
|
||||
let result = lua.create_table()?;
|
||||
let mut i = 1;
|
||||
|
||||
for pair in themes.pairs::<String, Table>() {
|
||||
let (name, _) = pair?;
|
||||
result.set(i, name)?;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("theme", theme_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all registered themes from a Lua runtime
|
||||
#[allow(dead_code)] // Will be used by theme system
|
||||
pub fn get_themes(lua: &Lua) -> LuaResult<Vec<ThemeRegistration>> {
|
||||
let themes: Table = match lua.named_registry_value("themes") {
|
||||
Ok(t) => t,
|
||||
Err(_) => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for pair in themes.pairs::<String, Table>() {
|
||||
let (_, entry) = pair?;
|
||||
|
||||
let name: String = entry.get("name")?;
|
||||
let display_name: String = entry.get("display_name")?;
|
||||
let css: String = entry.get("css")?;
|
||||
let plugin_id: String = entry.get("plugin_id")?;
|
||||
|
||||
result.push(ThemeRegistration {
|
||||
name,
|
||||
display_name,
|
||||
css,
|
||||
plugin_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get a specific theme's CSS by name
|
||||
#[allow(dead_code)] // Will be used by theme loading
|
||||
pub fn get_theme_css(lua: &Lua, name: &str) -> LuaResult<Option<String>> {
|
||||
let themes: Table = match lua.named_registry_value("themes") {
|
||||
Ok(t) => t,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
if let Ok(entry) = themes.get::<Table>(name) {
|
||||
let css: String = entry.get("css")?;
|
||||
Ok(Some(css))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_lua(plugin_id: &str, plugin_dir: &Path) -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_theme_api(&lua, &owlry, plugin_id, plugin_dir).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_registration_inline() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let lua = setup_lua("test-plugin", temp.path());
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
return owlry.theme.register({
|
||||
name = "my-theme",
|
||||
display_name = "My Theme",
|
||||
css = ".owlry-window { background: #333; }"
|
||||
})
|
||||
"#);
|
||||
let name: String = chunk.call(()).unwrap();
|
||||
assert_eq!(name, "my-theme");
|
||||
|
||||
let themes = get_themes(&lua).unwrap();
|
||||
assert_eq!(themes.len(), 1);
|
||||
assert_eq!(themes[0].display_name, "My Theme");
|
||||
assert!(themes[0].css.contains("background: #333"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_registration_file() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let css_content = ".owlry-window { background: #444; }";
|
||||
std::fs::write(temp.path().join("theme.css"), css_content).unwrap();
|
||||
|
||||
let lua = setup_lua("test-plugin", temp.path());
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
return owlry.theme.register({
|
||||
name = "file-theme",
|
||||
css_file = "theme.css"
|
||||
})
|
||||
"#);
|
||||
let name: String = chunk.call(()).unwrap();
|
||||
assert_eq!(name, "file-theme");
|
||||
|
||||
let css = get_theme_css(&lua, "file-theme").unwrap();
|
||||
assert!(css.is_some());
|
||||
assert!(css.unwrap().contains("background: #444"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_list() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let lua = setup_lua("test-plugin", temp.path());
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
owlry.theme.register({ name = "theme1", css = "a{}" })
|
||||
owlry.theme.register({ name = "theme2", css = "b{}" })
|
||||
return owlry.theme.list()
|
||||
"#);
|
||||
let list: Table = chunk.call(()).unwrap();
|
||||
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for pair in list.pairs::<i64, String>() {
|
||||
let (_, name) = pair.unwrap();
|
||||
names.push(name);
|
||||
}
|
||||
assert_eq!(names.len(), 2);
|
||||
assert!(names.contains(&"theme1".to_string()));
|
||||
assert!(names.contains(&"theme2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_unregister() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let lua = setup_lua("test-plugin", temp.path());
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
owlry.theme.register({ name = "temp-theme", css = "c{}" })
|
||||
return owlry.theme.unregister("temp-theme")
|
||||
"#);
|
||||
let unregistered: bool = chunk.call(()).unwrap();
|
||||
assert!(unregistered);
|
||||
|
||||
let themes = get_themes(&lua).unwrap();
|
||||
assert_eq!(themes.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,567 +0,0 @@
|
||||
//! Utility APIs: log, path, fs, json
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Register owlry.log.* API
|
||||
///
|
||||
/// Provides: debug, info, warn, error
|
||||
pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let log_table = lua.create_table()?;
|
||||
|
||||
log_table.set(
|
||||
"debug",
|
||||
lua.create_function(|_, msg: String| {
|
||||
log::debug!("[plugin] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
log_table.set(
|
||||
"info",
|
||||
lua.create_function(|_, msg: String| {
|
||||
log::info!("[plugin] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
log_table.set(
|
||||
"warn",
|
||||
lua.create_function(|_, msg: String| {
|
||||
log::warn!("[plugin] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
log_table.set(
|
||||
"error",
|
||||
lua.create_function(|_, msg: String| {
|
||||
log::error!("[plugin] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("log", log_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register owlry.path.* API
|
||||
///
|
||||
/// Provides XDG directory helpers: config, data, cache, home, plugin_dir
|
||||
pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> {
|
||||
let path_table = lua.create_table()?;
|
||||
let plugin_dir_str = plugin_dir.to_string_lossy().to_string();
|
||||
|
||||
// owlry.path.config() -> ~/.config/owlry
|
||||
path_table.set(
|
||||
"config",
|
||||
lua.create_function(|_, ()| {
|
||||
let path = dirs::config_dir()
|
||||
.map(|p| p.join("owlry"))
|
||||
.unwrap_or_default();
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.data() -> ~/.local/share/owlry
|
||||
path_table.set(
|
||||
"data",
|
||||
lua.create_function(|_, ()| {
|
||||
let path = dirs::data_dir()
|
||||
.map(|p| p.join("owlry"))
|
||||
.unwrap_or_default();
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.cache() -> ~/.cache/owlry
|
||||
path_table.set(
|
||||
"cache",
|
||||
lua.create_function(|_, ()| {
|
||||
let path = dirs::cache_dir()
|
||||
.map(|p| p.join("owlry"))
|
||||
.unwrap_or_default();
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.home() -> ~
|
||||
path_table.set(
|
||||
"home",
|
||||
lua.create_function(|_, ()| {
|
||||
let path = dirs::home_dir().unwrap_or_default();
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.join(base, ...) -> joined path
|
||||
path_table.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.exists(path) -> bool
|
||||
path_table.set(
|
||||
"exists",
|
||||
lua.create_function(|_, path: String| Ok(Path::new(&path).exists()))?,
|
||||
)?;
|
||||
|
||||
// owlry.path.is_file(path) -> bool
|
||||
path_table.set(
|
||||
"is_file",
|
||||
lua.create_function(|_, path: String| Ok(Path::new(&path).is_file()))?,
|
||||
)?;
|
||||
|
||||
// owlry.path.is_dir(path) -> bool
|
||||
path_table.set(
|
||||
"is_dir",
|
||||
lua.create_function(|_, path: String| Ok(Path::new(&path).is_dir()))?,
|
||||
)?;
|
||||
|
||||
// owlry.path.expand(path) -> expanded path (handles ~)
|
||||
path_table.set(
|
||||
"expand",
|
||||
lua.create_function(|_, path: String| {
|
||||
let expanded = if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(rest).to_string_lossy().to_string()
|
||||
} else {
|
||||
path
|
||||
}
|
||||
} else if path == "~" {
|
||||
dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or(path)
|
||||
} else {
|
||||
path
|
||||
};
|
||||
Ok(expanded)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.plugin_dir() -> this plugin's directory
|
||||
path_table.set(
|
||||
"plugin_dir",
|
||||
lua.create_function(move |_, ()| Ok(plugin_dir_str.clone()))?,
|
||||
)?;
|
||||
|
||||
owlry.set("path", path_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register owlry.fs.* API
|
||||
///
|
||||
/// Provides filesystem operations within the plugin's directory
|
||||
pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> {
|
||||
let fs_table = lua.create_table()?;
|
||||
let plugin_dir_str = plugin_dir.to_string_lossy().to_string();
|
||||
|
||||
// Store plugin directory in registry for access in closures
|
||||
lua.set_named_registry_value("plugin_dir", plugin_dir_str.clone())?;
|
||||
|
||||
// owlry.fs.read(path) -> string or nil, error
|
||||
fs_table.set(
|
||||
"read",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
|
||||
match std::fs::read_to_string(&full_path) {
|
||||
Ok(content) => Ok((Some(content), Value::Nil)),
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.write(path, content) -> bool, error
|
||||
fs_table.set(
|
||||
"write",
|
||||
lua.create_function(|lua, (path, content): (String, String)| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = full_path.parent()
|
||||
&& !parent.exists()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent) {
|
||||
return Ok((false, Value::String(lua.create_string(e.to_string())?)));
|
||||
}
|
||||
|
||||
match std::fs::write(&full_path, content) {
|
||||
Ok(()) => Ok((true, Value::Nil)),
|
||||
Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.list(path) -> array of filenames or nil, error
|
||||
fs_table.set(
|
||||
"list",
|
||||
lua.create_function(|lua, path: Option<String>| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let dir_path = path
|
||||
.map(|p| resolve_plugin_path(&plugin_dir, &p))
|
||||
.unwrap_or_else(|| PathBuf::from(&plugin_dir));
|
||||
|
||||
match std::fs::read_dir(&dir_path) {
|
||||
Ok(entries) => {
|
||||
let names: Vec<String> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.collect();
|
||||
let table = lua.create_sequence_from(names)?;
|
||||
Ok((Some(table), Value::Nil))
|
||||
}
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.exists(path) -> bool
|
||||
fs_table.set(
|
||||
"exists",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
Ok(full_path.exists())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.mkdir(path) -> bool, error
|
||||
fs_table.set(
|
||||
"mkdir",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
|
||||
match std::fs::create_dir_all(&full_path) {
|
||||
Ok(()) => Ok((true, Value::Nil)),
|
||||
Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.remove(path) -> bool, error
|
||||
fs_table.set(
|
||||
"remove",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
|
||||
let result = if full_path.is_dir() {
|
||||
std::fs::remove_dir_all(&full_path)
|
||||
} else {
|
||||
std::fs::remove_file(&full_path)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => Ok((true, Value::Nil)),
|
||||
Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.is_file(path) -> bool
|
||||
fs_table.set(
|
||||
"is_file",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
Ok(full_path.is_file())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.is_dir(path) -> bool
|
||||
fs_table.set(
|
||||
"is_dir",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
Ok(full_path.is_dir())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.is_executable(path) -> bool
|
||||
#[cfg(unix)]
|
||||
fs_table.set(
|
||||
"is_executable",
|
||||
lua.create_function(|lua, path: String| {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
let is_exec = full_path.metadata()
|
||||
.map(|m| m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false);
|
||||
Ok(is_exec)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.plugin_dir() -> plugin directory path
|
||||
let dir_clone = plugin_dir_str.clone();
|
||||
fs_table.set(
|
||||
"plugin_dir",
|
||||
lua.create_function(move |_, ()| Ok(dir_clone.clone()))?,
|
||||
)?;
|
||||
|
||||
owlry.set("fs", fs_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a path relative to the plugin directory
|
||||
///
|
||||
/// If the path is absolute, returns it as-is (for paths within allowed directories).
|
||||
/// If relative, joins with plugin directory.
|
||||
fn resolve_plugin_path(plugin_dir: &str, path: &str) -> PathBuf {
|
||||
let path = Path::new(path);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
Path::new(plugin_dir).join(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register owlry.json.* API
|
||||
///
|
||||
/// Provides JSON encoding/decoding
|
||||
pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let json_table = lua.create_table()?;
|
||||
|
||||
// owlry.json.encode(value) -> string or nil, error
|
||||
json_table.set(
|
||||
"encode",
|
||||
lua.create_function(|lua, value: Value| {
|
||||
match lua_to_json(&value) {
|
||||
Ok(json) => match serde_json::to_string(&json) {
|
||||
Ok(s) => Ok((Some(s), Value::Nil)),
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
|
||||
},
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(&e)?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.json.encode_pretty(value) -> string or nil, error
|
||||
json_table.set(
|
||||
"encode_pretty",
|
||||
lua.create_function(|lua, value: Value| {
|
||||
match lua_to_json(&value) {
|
||||
Ok(json) => match serde_json::to_string_pretty(&json) {
|
||||
Ok(s) => Ok((Some(s), Value::Nil)),
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
|
||||
},
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(&e)?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.json.decode(string) -> value or nil, error
|
||||
json_table.set(
|
||||
"decode",
|
||||
lua.create_function(|lua, s: String| {
|
||||
match serde_json::from_str::<serde_json::Value>(&s) {
|
||||
Ok(json) => match json_to_lua(lua, &json) {
|
||||
Ok(value) => Ok((Some(value), Value::Nil)),
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
|
||||
},
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("json", json_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert Lua value to JSON
|
||||
fn lua_to_json(value: &Value) -> Result<serde_json::Value, String> {
|
||||
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) => serde_json::Number::from_f64(*n)
|
||||
.map(serde_json::Value::Number)
|
||||
.ok_or_else(|| "Invalid number".to_string()),
|
||||
Value::String(s) => Ok(serde_json::Value::String(
|
||||
s.to_str().map_err(|e| e.to_string())?.to_string()
|
||||
)),
|
||||
Value::Table(t) => {
|
||||
// Check if it's an array (sequential integer keys starting from 1)
|
||||
let len = t.raw_len();
|
||||
let is_array = len > 0
|
||||
&& (1..=len).all(|i| t.raw_get::<Value>(i).is_ok_and(|v| !matches!(v, Value::Nil)));
|
||||
|
||||
if is_array {
|
||||
let arr: Result<Vec<serde_json::Value>, String> = (1..=len)
|
||||
.map(|i| {
|
||||
let v: Value = t.raw_get(i).map_err(|e| e.to_string())?;
|
||||
lua_to_json(&v)
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::Value::Array(arr?))
|
||||
} else {
|
||||
let mut map = serde_json::Map::new();
|
||||
for pair in t.clone().pairs::<Value, Value>() {
|
||||
let (k, v) = pair.map_err(|e| e.to_string())?;
|
||||
let key = match k {
|
||||
Value::String(s) => s.to_str().map_err(|e| e.to_string())?.to_string(),
|
||||
Value::Integer(i) => i.to_string(),
|
||||
_ => return Err("JSON object keys must be strings".to_string()),
|
||||
};
|
||||
map.insert(key, lua_to_json(&v)?);
|
||||
}
|
||||
Ok(serde_json::Value::Object(map))
|
||||
}
|
||||
}
|
||||
_ => Err(format!("Cannot convert {:?} to JSON", value)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert JSON to Lua value
|
||||
fn json_to_lua(lua: &Lua, json: &serde_json::Value) -> LuaResult<Value> {
|
||||
match json {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_lua() -> (Lua, TempDir) {
|
||||
let lua = Lua::new();
|
||||
let temp = TempDir::new().unwrap();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_log_api(&lua, &owlry).unwrap();
|
||||
register_path_api(&lua, &owlry, temp.path()).unwrap();
|
||||
register_fs_api(&lua, &owlry, temp.path()).unwrap();
|
||||
register_json_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
(lua, temp)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_api() {
|
||||
let (lua, _temp) = create_test_lua();
|
||||
// Just verify it doesn't panic - using call instead of the e-word
|
||||
lua.load("owlry.log.info('test message')").call::<()>(()).unwrap();
|
||||
lua.load("owlry.log.debug('debug')").call::<()>(()).unwrap();
|
||||
lua.load("owlry.log.warn('warning')").call::<()>(()).unwrap();
|
||||
lua.load("owlry.log.error('error')").call::<()>(()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_api() {
|
||||
let (lua, _temp) = create_test_lua();
|
||||
|
||||
let home: String = lua
|
||||
.load("return owlry.path.home()")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(!home.is_empty());
|
||||
|
||||
let joined: String = lua
|
||||
.load("return owlry.path.join('a', 'b', 'c')")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(joined.contains("a") && joined.contains("b") && joined.contains("c"));
|
||||
|
||||
let expanded: String = lua
|
||||
.load("return owlry.path.expand('~/test')")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(!expanded.starts_with("~"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_api() {
|
||||
let (lua, temp) = create_test_lua();
|
||||
|
||||
// Test write and read
|
||||
lua.load("owlry.fs.write('test.txt', 'hello world')")
|
||||
.call::<()>(())
|
||||
.unwrap();
|
||||
|
||||
assert!(temp.path().join("test.txt").exists());
|
||||
|
||||
let content: String = lua
|
||||
.load("return owlry.fs.read('test.txt')")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert_eq!(content, "hello world");
|
||||
|
||||
// Test exists
|
||||
let exists: bool = lua
|
||||
.load("return owlry.fs.exists('test.txt')")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(exists);
|
||||
|
||||
// Test list
|
||||
let script = r#"
|
||||
local files = owlry.fs.list()
|
||||
return #files
|
||||
"#;
|
||||
let count: i32 = lua.load(script).call(()).unwrap();
|
||||
assert!(count >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_api() {
|
||||
let (lua, _temp) = create_test_lua();
|
||||
|
||||
// Test encode
|
||||
let encoded: String = lua
|
||||
.load(r#"return owlry.json.encode({name = "test", value = 42})"#)
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(encoded.contains("test") && encoded.contains("42"));
|
||||
|
||||
// Test decode
|
||||
let script = r#"
|
||||
local data = owlry.json.decode('{"name":"hello","num":123}')
|
||||
return data.name, data.num
|
||||
"#;
|
||||
let (name, num): (String, i32) = lua.load(script).call(()).unwrap();
|
||||
assert_eq!(name, "hello");
|
||||
assert_eq!(num, 123);
|
||||
|
||||
// Test array encoding
|
||||
let encoded: String = lua
|
||||
.load(r#"return owlry.json.encode({1, 2, 3})"#)
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert_eq!(encoded, "[1,2,3]");
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
//! Plugin system error types
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur in the plugin system
|
||||
#[derive(Error, Debug)]
|
||||
#[allow(dead_code)] // Some variants are for future use
|
||||
pub enum PluginError {
|
||||
#[error("Plugin '{0}' not found")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Invalid plugin manifest in '{plugin}': {message}")]
|
||||
InvalidManifest { plugin: String, message: String },
|
||||
|
||||
#[error("Plugin '{plugin}' requires owlry {required}, but current version is {current}")]
|
||||
VersionMismatch {
|
||||
plugin: String,
|
||||
required: String,
|
||||
current: String,
|
||||
},
|
||||
|
||||
#[error("Lua error in plugin '{plugin}': {message}")]
|
||||
LuaError { plugin: String, message: String },
|
||||
|
||||
#[error("Plugin '{plugin}' timed out after {timeout_ms}ms")]
|
||||
Timeout { plugin: String, timeout_ms: u64 },
|
||||
|
||||
#[error("Plugin '{plugin}' attempted forbidden operation: {operation}")]
|
||||
SandboxViolation { plugin: String, operation: String },
|
||||
|
||||
#[error("Plugin '{0}' is already loaded")]
|
||||
AlreadyLoaded(String),
|
||||
|
||||
#[error("Plugin '{0}' is disabled")]
|
||||
Disabled(String),
|
||||
|
||||
#[error("Failed to load native plugin: {0}")]
|
||||
LoadError(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("TOML parsing error: {0}")]
|
||||
TomlParse(#[from] toml::de::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Result type for plugin operations
|
||||
pub type PluginResult<T> = Result<T, PluginError>;
|
||||
@@ -1,205 +0,0 @@
|
||||
//! Lua plugin loading and initialization
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
use super::api;
|
||||
use super::error::{PluginError, PluginResult};
|
||||
use super::manifest::PluginManifest;
|
||||
use super::runtime::{create_lua_runtime, load_file, SandboxConfig};
|
||||
|
||||
/// A loaded plugin instance
|
||||
#[derive(Debug)]
|
||||
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 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
|
||||
}
|
||||
|
||||
/// Get the plugin name
|
||||
#[allow(dead_code)]
|
||||
pub fn name(&self) -> &str {
|
||||
&self.manifest.plugin.name
|
||||
}
|
||||
|
||||
/// Initialize the Lua runtime and load the entry point
|
||||
pub fn initialize(&mut self) -> PluginResult<()> {
|
||||
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| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
// Register owlry APIs before loading entry point
|
||||
api::register_apis(&lua, &self.path, self.id()).map_err(|e| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: 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(PluginError::InvalidManifest {
|
||||
plugin: self.id().to_string(),
|
||||
message: format!("Entry point '{}' not found", self.manifest.plugin.entry),
|
||||
});
|
||||
}
|
||||
|
||||
load_file(&lua, &entry_path).map_err(|e| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
self.lua = Some(lua);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get provider registrations from this plugin
|
||||
pub fn get_provider_registrations(&self) -> PluginResult<Vec<super::ProviderRegistration>> {
|
||||
let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: "Plugin not initialized".to_string(),
|
||||
})?;
|
||||
|
||||
api::get_provider_registrations(lua).map_err(|e| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Call a provider's refresh function
|
||||
pub fn call_provider_refresh(&self, provider_name: &str) -> PluginResult<Vec<super::PluginItem>> {
|
||||
let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: "Plugin not initialized".to_string(),
|
||||
})?;
|
||||
|
||||
api::provider::call_refresh(lua, provider_name).map_err(|e| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Call a provider's query function
|
||||
#[allow(dead_code)] // Will be used for dynamic query providers
|
||||
pub fn call_provider_query(&self, provider_name: &str, query: &str) -> PluginResult<Vec<super::PluginItem>> {
|
||||
let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: "Plugin not initialized".to_string(),
|
||||
})?;
|
||||
|
||||
api::provider::call_query(lua, provider_name, query).map_err(|e| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a reference to the Lua runtime (if initialized)
|
||||
#[allow(dead_code)]
|
||||
pub fn lua(&self) -> Option<&Lua> {
|
||||
self.lua.as_ref()
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the Lua runtime (if initialized)
|
||||
#[allow(dead_code)]
|
||||
pub fn lua_mut(&mut self) -> Option<&mut Lua> {
|
||||
self.lua.as_mut()
|
||||
}
|
||||
}
|
||||
|
||||
// Note: discover_plugins and check_compatibility are in manifest.rs
|
||||
// to avoid Lua dependency for plugin discovery.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::manifest::{check_compatibility, discover_plugins};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_plugin(dir: &Path, id: &str, name: &str) {
|
||||
let plugin_dir = dir.join(id);
|
||||
fs::create_dir_all(&plugin_dir).unwrap();
|
||||
|
||||
let manifest = format!(
|
||||
r#"
|
||||
[plugin]
|
||||
id = "{}"
|
||||
name = "{}"
|
||||
version = "1.0.0"
|
||||
"#,
|
||||
id, name
|
||||
);
|
||||
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", "Test Plugin");
|
||||
create_test_plugin(plugins_dir, "another-plugin", "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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_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!(check_compatibility(&manifest, "0.3.5").is_ok());
|
||||
assert!(check_compatibility(&manifest, "0.2.0").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
//! Plugin manifest (plugin.toml) parsing
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::error::{PluginError, PluginResult};
|
||||
|
||||
/// 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,
|
||||
/// CLI commands this plugin provides
|
||||
#[serde(default)]
|
||||
pub commands: Vec<PluginCommand>,
|
||||
}
|
||||
|
||||
/// A CLI command provided by a plugin
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginCommand {
|
||||
/// Command name (e.g., "add", "list", "sync")
|
||||
pub name: String,
|
||||
/// Short description shown in help
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Usage pattern (e.g., "<url> [name]")
|
||||
#[serde(default)]
|
||||
pub usage: String,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Discovery (no Lua dependency)
|
||||
// ============================================================================
|
||||
|
||||
/// Discover all plugins in a directory
|
||||
///
|
||||
/// Returns a map of plugin ID -> (manifest, path)
|
||||
pub fn discover_plugins(plugins_dir: &Path) -> PluginResult<HashMap<String, (PluginManifest, PathBuf)>> {
|
||||
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)?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let manifest_path = path.join("plugin.toml");
|
||||
if !manifest_path.exists() {
|
||||
log::debug!("Skipping {}: no plugin.toml", path.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
match PluginManifest::load(&manifest_path) {
|
||||
Ok(manifest) => {
|
||||
let id = manifest.plugin.id.clone();
|
||||
if plugins.contains_key(&id) {
|
||||
log::warn!("Duplicate plugin ID '{}', skipping {}", id, path.display());
|
||||
continue;
|
||||
}
|
||||
log::info!("Discovered plugin: {} v{}", manifest.plugin.name, manifest.plugin.version);
|
||||
plugins.insert(id, (manifest, path));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load plugin at {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
/// Check if a plugin is compatible with the given owlry version
|
||||
#[allow(dead_code)]
|
||||
pub fn check_compatibility(manifest: &PluginManifest, owlry_version: &str) -> PluginResult<()> {
|
||||
if !manifest.is_compatible_with(owlry_version) {
|
||||
return Err(PluginError::VersionMismatch {
|
||||
plugin: manifest.plugin.id.clone(),
|
||||
required: manifest.plugin.owlry_version.clone(),
|
||||
current: owlry_version.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PluginManifest Implementation
|
||||
// ============================================================================
|
||||
|
||||
impl PluginManifest {
|
||||
/// Load a plugin manifest from a plugin.toml file
|
||||
pub fn load(path: &Path) -> PluginResult<Self> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let manifest: PluginManifest = toml::from_str(&content)?;
|
||||
manifest.validate()?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Load from a plugin directory (looks for plugin.toml inside)
|
||||
#[allow(dead_code)]
|
||||
pub fn load_from_dir(plugin_dir: &Path) -> PluginResult<Self> {
|
||||
let manifest_path = plugin_dir.join("plugin.toml");
|
||||
if !manifest_path.exists() {
|
||||
return Err(PluginError::InvalidManifest {
|
||||
plugin: plugin_dir.display().to_string(),
|
||||
message: "plugin.toml not found".to_string(),
|
||||
});
|
||||
}
|
||||
Self::load(&manifest_path)
|
||||
}
|
||||
|
||||
/// Validate the manifest
|
||||
fn validate(&self) -> PluginResult<()> {
|
||||
// Validate plugin ID format
|
||||
if self.plugin.id.is_empty() {
|
||||
return Err(PluginError::InvalidManifest {
|
||||
plugin: self.plugin.id.clone(),
|
||||
message: "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(PluginError::InvalidManifest {
|
||||
plugin: self.plugin.id.clone(),
|
||||
message: "Plugin ID must be lowercase alphanumeric with hyphens".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate version format
|
||||
if semver::Version::parse(&self.plugin.version).is_err() {
|
||||
return Err(PluginError::InvalidManifest {
|
||||
plugin: self.plugin.id.clone(),
|
||||
message: format!("Invalid version format: {}", self.plugin.version),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate owlry_version constraint
|
||||
if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() {
|
||||
return Err(PluginError::InvalidManifest {
|
||||
plugin: self.plugin.id.clone(),
|
||||
message: format!("Invalid owlry_version constraint: {}", self.plugin.owlry_version),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if this plugin is compatible with the given owlry version
|
||||
#[allow(dead_code)]
|
||||
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_parse_full_manifest() {
|
||||
let toml_str = r#"
|
||||
[plugin]
|
||||
id = "my-provider"
|
||||
name = "My Provider"
|
||||
version = "1.2.3"
|
||||
description = "A test provider"
|
||||
author = "Test Author"
|
||||
license = "MIT"
|
||||
owlry_version = ">=0.4.0"
|
||||
entry = "main.lua"
|
||||
|
||||
[provides]
|
||||
providers = ["my-provider"]
|
||||
actions = true
|
||||
themes = ["dark"]
|
||||
hooks = true
|
||||
|
||||
[permissions]
|
||||
network = true
|
||||
filesystem = ["~/.config/myapp"]
|
||||
run_commands = ["myapp"]
|
||||
environment = ["MY_API_KEY"]
|
||||
|
||||
[settings]
|
||||
max_results = 20
|
||||
api_url = "https://api.example.com"
|
||||
"#;
|
||||
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(manifest.plugin.id, "my-provider");
|
||||
assert!(manifest.provides.actions);
|
||||
assert!(manifest.permissions.network);
|
||||
assert_eq!(manifest.permissions.run_commands, vec!["myapp"]);
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
//! Owlry Plugin System
|
||||
//!
|
||||
//! This module provides plugin support for extending owlry's functionality.
|
||||
//! Plugins can register providers, actions, themes, and hooks.
|
||||
//!
|
||||
//! # Plugin Types
|
||||
//!
|
||||
//! - **Native plugins** (.so): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/`
|
||||
//! - **Lua plugins**: Script-based plugins from `~/.config/owlry/plugins/` (requires `lua` feature)
|
||||
//!
|
||||
//! # Plugin Structure (Lua)
|
||||
//!
|
||||
//! Each Lua plugin lives in its own directory under `~/.config/owlry/plugins/`:
|
||||
//!
|
||||
//! ```text
|
||||
//! ~/.config/owlry/plugins/
|
||||
//! my-plugin/
|
||||
//! plugin.toml # Plugin manifest
|
||||
//! init.lua # Entry point
|
||||
//! lib/ # Optional modules
|
||||
//! ```
|
||||
|
||||
// Always available
|
||||
pub mod commands;
|
||||
pub mod error;
|
||||
pub mod manifest;
|
||||
pub mod native_loader;
|
||||
pub mod registry;
|
||||
pub mod runtime_loader;
|
||||
|
||||
// Lua-specific modules (require mlua)
|
||||
#[cfg(feature = "lua")]
|
||||
pub mod api;
|
||||
#[cfg(feature = "lua")]
|
||||
pub mod loader;
|
||||
#[cfg(feature = "lua")]
|
||||
pub mod runtime;
|
||||
|
||||
// Re-export commonly used types
|
||||
#[cfg(feature = "lua")]
|
||||
pub use api::provider::{PluginItem, ProviderRegistration};
|
||||
#[cfg(feature = "lua")]
|
||||
#[allow(unused_imports)]
|
||||
pub use api::{ActionRegistration, HookEvent, ThemeRegistration};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use error::{PluginError, PluginResult};
|
||||
|
||||
#[cfg(feature = "lua")]
|
||||
pub use loader::LoadedPlugin;
|
||||
|
||||
// Used by plugins/commands.rs for plugin CLI commands
|
||||
#[allow(unused_imports)]
|
||||
pub use manifest::{check_compatibility, discover_plugins, PluginManifest};
|
||||
|
||||
// ============================================================================
|
||||
// Lua Plugin Manager (only available with lua feature)
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(feature = "lua")]
|
||||
mod lua_manager {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use manifest::{discover_plugins, check_compatibility};
|
||||
|
||||
/// Plugin manager coordinates loading, initialization, and lifecycle of Lua plugins
|
||||
pub struct PluginManager {
|
||||
/// Directory where plugins are stored
|
||||
plugins_dir: PathBuf,
|
||||
/// Current owlry version for compatibility checks
|
||||
owlry_version: String,
|
||||
/// Loaded plugins by ID (Rc<RefCell<>> allows sharing with LuaProviders)
|
||||
plugins: HashMap<String, Rc<RefCell<LoadedPlugin>>>,
|
||||
/// Plugin IDs that are explicitly disabled
|
||||
disabled: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginManager {
|
||||
/// Create a new plugin manager
|
||||
pub fn new(plugins_dir: PathBuf, owlry_version: &str) -> Self {
|
||||
Self {
|
||||
plugins_dir,
|
||||
owlry_version: owlry_version.to_string(),
|
||||
plugins: HashMap::new(),
|
||||
disabled: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the list of disabled plugin IDs
|
||||
pub fn set_disabled(&mut self, disabled: Vec<String>) {
|
||||
self.disabled = disabled;
|
||||
}
|
||||
|
||||
/// Discover and load all plugins from the plugins directory
|
||||
pub fn discover(&mut self) -> PluginResult<usize> {
|
||||
log::info!("Discovering plugins in {}", self.plugins_dir.display());
|
||||
|
||||
let discovered = discover_plugins(&self.plugins_dir)?;
|
||||
let mut loaded_count = 0;
|
||||
|
||||
for (id, (manifest, path)) in discovered {
|
||||
// Skip disabled plugins
|
||||
if self.disabled.contains(&id) {
|
||||
log::info!("Plugin '{}' is disabled, skipping", id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check version compatibility
|
||||
if let Err(e) = check_compatibility(&manifest, &self.owlry_version) {
|
||||
log::warn!("Plugin '{}' is not compatible: {}", id, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
let plugin = LoadedPlugin::new(manifest, path);
|
||||
self.plugins.insert(id, Rc::new(RefCell::new(plugin)));
|
||||
loaded_count += 1;
|
||||
}
|
||||
|
||||
log::info!("Discovered {} compatible plugins", loaded_count);
|
||||
Ok(loaded_count)
|
||||
}
|
||||
|
||||
/// Initialize all discovered plugins (load their Lua code)
|
||||
pub fn initialize_all(&mut self) -> Vec<PluginError> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, plugin_rc) in &self.plugins {
|
||||
let mut plugin = plugin_rc.borrow_mut();
|
||||
if !plugin.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
log::debug!("Initializing plugin: {}", id);
|
||||
if let Err(e) = plugin.initialize() {
|
||||
log::error!("Failed to initialize plugin '{}': {}", id, e);
|
||||
errors.push(e);
|
||||
plugin.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
/// Get a loaded plugin by ID (returns Rc for shared ownership)
|
||||
#[allow(dead_code)]
|
||||
pub fn get(&self, id: &str) -> Option<Rc<RefCell<LoadedPlugin>>> {
|
||||
self.plugins.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Get all loaded plugins
|
||||
#[allow(dead_code)]
|
||||
pub fn plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
|
||||
self.plugins.values().cloned()
|
||||
}
|
||||
|
||||
/// Get all enabled plugins
|
||||
pub fn enabled_plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
|
||||
self.plugins.values().filter(|p| p.borrow().enabled).cloned()
|
||||
}
|
||||
|
||||
/// Get the number of loaded plugins
|
||||
#[allow(dead_code)]
|
||||
pub fn plugin_count(&self) -> usize {
|
||||
self.plugins.len()
|
||||
}
|
||||
|
||||
/// Get the number of enabled plugins
|
||||
#[allow(dead_code)]
|
||||
pub fn enabled_count(&self) -> usize {
|
||||
self.plugins.values().filter(|p| p.borrow().enabled).count()
|
||||
}
|
||||
|
||||
/// Enable a plugin by ID
|
||||
#[allow(dead_code)]
|
||||
pub fn enable(&mut self, id: &str) -> PluginResult<()> {
|
||||
let plugin_rc = self.plugins.get(id).ok_or_else(|| PluginError::NotFound(id.to_string()))?;
|
||||
let mut plugin = plugin_rc.borrow_mut();
|
||||
|
||||
if !plugin.enabled {
|
||||
plugin.enabled = true;
|
||||
// Initialize if not already done
|
||||
plugin.initialize()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disable a plugin by ID
|
||||
#[allow(dead_code)]
|
||||
pub fn disable(&mut self, id: &str) -> PluginResult<()> {
|
||||
let plugin_rc = self.plugins.get(id).ok_or_else(|| PluginError::NotFound(id.to_string()))?;
|
||||
plugin_rc.borrow_mut().enabled = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get plugin IDs that provide a specific feature
|
||||
#[allow(dead_code)]
|
||||
pub fn providers_for(&self, provider_name: &str) -> Vec<String> {
|
||||
self.enabled_plugins()
|
||||
.filter(|p| p.borrow().manifest.provides.providers.contains(&provider_name.to_string()))
|
||||
.map(|p| p.borrow().id().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if any plugin provides actions
|
||||
#[allow(dead_code)]
|
||||
pub fn has_action_plugins(&self) -> bool {
|
||||
self.enabled_plugins().any(|p| p.borrow().manifest.provides.actions)
|
||||
}
|
||||
|
||||
/// Check if any plugin provides hooks
|
||||
#[allow(dead_code)]
|
||||
pub fn has_hook_plugins(&self) -> bool {
|
||||
self.enabled_plugins().any(|p| p.borrow().manifest.provides.hooks)
|
||||
}
|
||||
|
||||
/// Get all theme names provided by plugins
|
||||
#[allow(dead_code)]
|
||||
pub fn theme_names(&self) -> Vec<String> {
|
||||
self.enabled_plugins()
|
||||
.flat_map(|p| p.borrow().manifest.provides.themes.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Create providers from all enabled plugins
|
||||
///
|
||||
/// This must be called after `initialize_all()`. Returns a vec of Provider trait
|
||||
/// objects that can be added to the ProviderManager.
|
||||
pub fn create_providers(&self) -> Vec<Box<dyn crate::providers::Provider>> {
|
||||
use crate::providers::lua_provider::create_providers_from_plugin;
|
||||
|
||||
let mut providers = Vec::new();
|
||||
|
||||
for plugin_rc in self.enabled_plugins() {
|
||||
let plugin_providers = create_providers_from_plugin(plugin_rc);
|
||||
providers.extend(plugin_providers);
|
||||
}
|
||||
|
||||
providers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "lua")]
|
||||
pub use lua_manager::PluginManager;
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(all(test, feature = "lua"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_plugin(dir: &std::path::Path, id: &str, version: &str, owlry_req: &str) {
|
||||
let plugin_dir = dir.join(id);
|
||||
fs::create_dir_all(&plugin_dir).unwrap();
|
||||
|
||||
let manifest = format!(
|
||||
r#"
|
||||
[plugin]
|
||||
id = "{}"
|
||||
name = "Test {}"
|
||||
version = "{}"
|
||||
owlry_version = "{}"
|
||||
|
||||
[provides]
|
||||
providers = ["{}"]
|
||||
"#,
|
||||
id, id, version, owlry_req, id
|
||||
);
|
||||
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
|
||||
fs::write(plugin_dir.join("init.lua"), "-- test plugin").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_manager_discover() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0");
|
||||
create_test_plugin(temp.path(), "plugin-b", "2.0.0", ">=0.3.0");
|
||||
|
||||
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
|
||||
let count = manager.discover().unwrap();
|
||||
|
||||
assert_eq!(count, 2);
|
||||
assert!(manager.get("plugin-a").is_some());
|
||||
assert!(manager.get("plugin-b").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_manager_disabled() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0");
|
||||
create_test_plugin(temp.path(), "plugin-b", "1.0.0", ">=0.3.0");
|
||||
|
||||
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
|
||||
manager.set_disabled(vec!["plugin-b".to_string()]);
|
||||
let count = manager.discover().unwrap();
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert!(manager.get("plugin-a").is_some());
|
||||
assert!(manager.get("plugin-b").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_manager_version_compat() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
create_test_plugin(temp.path(), "old-plugin", "1.0.0", ">=0.5.0"); // Requires future version
|
||||
create_test_plugin(temp.path(), "new-plugin", "1.0.0", ">=0.3.0");
|
||||
|
||||
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
|
||||
let count = manager.discover().unwrap();
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert!(manager.get("old-plugin").is_none()); // Incompatible
|
||||
assert!(manager.get("new-plugin").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_providers_for() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
create_test_plugin(temp.path(), "my-provider", "1.0.0", ">=0.3.0");
|
||||
|
||||
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
|
||||
manager.discover().unwrap();
|
||||
|
||||
let providers = manager.providers_for("my-provider");
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(providers[0], "my-provider");
|
||||
}
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
//! Native Plugin Loader
|
||||
//!
|
||||
//! Loads pre-compiled Rust plugins (.so files) from `/usr/lib/owlry/plugins/`.
|
||||
//! These plugins use the ABI-stable interface defined in `owlry-plugin-api`.
|
||||
//!
|
||||
//! Note: This module is infrastructure for the plugin architecture. Full integration
|
||||
//! with ProviderManager is pending Phase 5 (AUR Packaging) when native plugins
|
||||
//! will actually be deployed.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Once};
|
||||
|
||||
use libloading::Library;
|
||||
use log::{debug, error, info, warn};
|
||||
use owlry_plugin_api::{
|
||||
HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
RStr, API_VERSION,
|
||||
};
|
||||
|
||||
use crate::notify;
|
||||
|
||||
// ============================================================================
|
||||
// Host API Implementation
|
||||
// ============================================================================
|
||||
|
||||
/// Host notification handler
|
||||
extern "C" fn host_notify(summary: RStr<'_>, body: RStr<'_>, icon: RStr<'_>, urgency: NotifyUrgency) {
|
||||
let icon_str = icon.as_str();
|
||||
let icon_opt = if icon_str.is_empty() { None } else { Some(icon_str) };
|
||||
|
||||
let notify_urgency = match urgency {
|
||||
NotifyUrgency::Low => notify::NotifyUrgency::Low,
|
||||
NotifyUrgency::Normal => notify::NotifyUrgency::Normal,
|
||||
NotifyUrgency::Critical => notify::NotifyUrgency::Critical,
|
||||
};
|
||||
|
||||
notify::notify_with_options(summary.as_str(), body.as_str(), icon_opt, notify_urgency);
|
||||
}
|
||||
|
||||
/// Host log info handler
|
||||
extern "C" fn host_log_info(message: RStr<'_>) {
|
||||
info!("[plugin] {}", message.as_str());
|
||||
}
|
||||
|
||||
/// Host log warning handler
|
||||
extern "C" fn host_log_warn(message: RStr<'_>) {
|
||||
warn!("[plugin] {}", message.as_str());
|
||||
}
|
||||
|
||||
/// Host log error handler
|
||||
extern "C" fn host_log_error(message: RStr<'_>) {
|
||||
error!("[plugin] {}", message.as_str());
|
||||
}
|
||||
|
||||
/// Static host API instance
|
||||
static HOST_API: HostAPI = HostAPI {
|
||||
notify: host_notify,
|
||||
log_info: host_log_info,
|
||||
log_warn: host_log_warn,
|
||||
log_error: host_log_error,
|
||||
};
|
||||
|
||||
/// Initialize the host API (called once before loading plugins)
|
||||
static HOST_API_INIT: Once = Once::new();
|
||||
|
||||
fn ensure_host_api_initialized() {
|
||||
HOST_API_INIT.call_once(|| {
|
||||
// SAFETY: We only call this once, before any plugins are loaded
|
||||
unsafe {
|
||||
owlry_plugin_api::init_host_api(&HOST_API);
|
||||
}
|
||||
debug!("Host API initialized for plugins");
|
||||
});
|
||||
}
|
||||
|
||||
use super::error::{PluginError, PluginResult};
|
||||
|
||||
/// Default directory for system-installed native plugins
|
||||
pub const SYSTEM_PLUGINS_DIR: &str = "/usr/lib/owlry/plugins";
|
||||
|
||||
/// A loaded native plugin with its library handle and vtable
|
||||
pub struct NativePlugin {
|
||||
/// Plugin metadata
|
||||
pub info: PluginInfo,
|
||||
/// List of providers this plugin offers
|
||||
pub providers: Vec<ProviderInfo>,
|
||||
/// The vtable for calling plugin functions
|
||||
vtable: &'static PluginVTable,
|
||||
/// The loaded library (must be kept alive)
|
||||
_library: Library,
|
||||
}
|
||||
|
||||
impl NativePlugin {
|
||||
/// Get the plugin ID
|
||||
pub fn id(&self) -> &str {
|
||||
self.info.id.as_str()
|
||||
}
|
||||
|
||||
/// Get the plugin name
|
||||
pub fn name(&self) -> &str {
|
||||
self.info.name.as_str()
|
||||
}
|
||||
|
||||
/// Initialize a provider by ID
|
||||
pub fn init_provider(&self, provider_id: &str) -> ProviderHandle {
|
||||
(self.vtable.provider_init)(provider_id.into())
|
||||
}
|
||||
|
||||
/// Refresh a static provider
|
||||
pub fn refresh_provider(&self, handle: ProviderHandle) -> Vec<owlry_plugin_api::PluginItem> {
|
||||
(self.vtable.provider_refresh)(handle).into_iter().collect()
|
||||
}
|
||||
|
||||
/// Query a dynamic provider
|
||||
pub fn query_provider(
|
||||
&self,
|
||||
handle: ProviderHandle,
|
||||
query: &str,
|
||||
) -> Vec<owlry_plugin_api::PluginItem> {
|
||||
(self.vtable.provider_query)(handle, query.into()).into_iter().collect()
|
||||
}
|
||||
|
||||
/// Drop a provider handle
|
||||
pub fn drop_provider(&self, handle: ProviderHandle) {
|
||||
(self.vtable.provider_drop)(handle)
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: NativePlugin is safe to send between threads because:
|
||||
// - `info` and `providers` are plain data (RString, RVec from abi_stable are Send+Sync)
|
||||
// - `vtable` is a &'static reference to immutable function pointers
|
||||
// - `_library` (libloading::Library) is Send+Sync
|
||||
unsafe impl Send for NativePlugin {}
|
||||
unsafe impl Sync for NativePlugin {}
|
||||
|
||||
/// Manages native plugin discovery and loading
|
||||
pub struct NativePluginLoader {
|
||||
/// Directory to scan for plugins
|
||||
plugins_dir: PathBuf,
|
||||
/// Loaded plugins by ID (Arc for shared ownership with providers)
|
||||
plugins: HashMap<String, Arc<NativePlugin>>,
|
||||
/// Plugin IDs that are disabled
|
||||
disabled: Vec<String>,
|
||||
}
|
||||
|
||||
impl NativePluginLoader {
|
||||
/// Create a new loader with the default system plugins directory
|
||||
pub fn new() -> Self {
|
||||
Self::with_dir(PathBuf::from(SYSTEM_PLUGINS_DIR))
|
||||
}
|
||||
|
||||
/// Create a new loader with a custom plugins directory
|
||||
pub fn with_dir(plugins_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
plugins_dir,
|
||||
plugins: HashMap::new(),
|
||||
disabled: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the list of disabled plugin IDs
|
||||
pub fn set_disabled(&mut self, disabled: Vec<String>) {
|
||||
self.disabled = disabled;
|
||||
}
|
||||
|
||||
/// Check if the plugins directory exists
|
||||
pub fn plugins_dir_exists(&self) -> bool {
|
||||
self.plugins_dir.exists()
|
||||
}
|
||||
|
||||
/// Discover and load all native plugins
|
||||
pub fn discover(&mut self) -> PluginResult<usize> {
|
||||
// Initialize host API before loading any plugins
|
||||
ensure_host_api_initialized();
|
||||
|
||||
if !self.plugins_dir.exists() {
|
||||
debug!(
|
||||
"Native plugins directory does not exist: {}",
|
||||
self.plugins_dir.display()
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Discovering native plugins in {}",
|
||||
self.plugins_dir.display()
|
||||
);
|
||||
|
||||
let entries = std::fs::read_dir(&self.plugins_dir).map_err(|e| {
|
||||
PluginError::LoadError(format!(
|
||||
"Failed to read plugins directory {}: {}",
|
||||
self.plugins_dir.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut loaded_count = 0;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
// Only process .so files
|
||||
if path.extension() != Some(OsStr::new("so")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.load_plugin(&path) {
|
||||
Ok(plugin) => {
|
||||
let id = plugin.id().to_string();
|
||||
|
||||
// Check if disabled
|
||||
if self.disabled.contains(&id) {
|
||||
info!("Native plugin '{}' is disabled, skipping", id);
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Loaded native plugin '{}' v{} with {} providers",
|
||||
plugin.name(),
|
||||
plugin.info.version.as_str(),
|
||||
plugin.providers.len()
|
||||
);
|
||||
|
||||
self.plugins.insert(id, Arc::new(plugin));
|
||||
loaded_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to load plugin {:?}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Loaded {} native plugins", loaded_count);
|
||||
Ok(loaded_count)
|
||||
}
|
||||
|
||||
/// Load a single plugin from a .so file
|
||||
fn load_plugin(&self, path: &Path) -> PluginResult<NativePlugin> {
|
||||
debug!("Loading native plugin from {:?}", path);
|
||||
|
||||
// Load the library
|
||||
// SAFETY: We trust plugins in /usr/lib/owlry/plugins/ as they were
|
||||
// installed by the package manager
|
||||
let library = unsafe { Library::new(path) }.map_err(|e| {
|
||||
PluginError::LoadError(format!("Failed to load library {:?}: {}", path, e))
|
||||
})?;
|
||||
|
||||
// Get the vtable function
|
||||
let vtable: &'static PluginVTable = unsafe {
|
||||
let func: libloading::Symbol<extern "C" fn() -> &'static PluginVTable> =
|
||||
library.get(b"owlry_plugin_vtable").map_err(|e| {
|
||||
PluginError::LoadError(format!(
|
||||
"Plugin {:?} missing owlry_plugin_vtable symbol: {}",
|
||||
path, e
|
||||
))
|
||||
})?;
|
||||
func()
|
||||
};
|
||||
|
||||
// Get plugin info
|
||||
let info = (vtable.info)();
|
||||
|
||||
// Check API version compatibility
|
||||
if info.api_version != API_VERSION {
|
||||
return Err(PluginError::LoadError(format!(
|
||||
"Plugin '{}' has API version {} but owlry requires version {}",
|
||||
info.id.as_str(),
|
||||
info.api_version,
|
||||
API_VERSION
|
||||
)));
|
||||
}
|
||||
|
||||
// Get provider list
|
||||
let providers: Vec<ProviderInfo> = (vtable.providers)().into_iter().collect();
|
||||
|
||||
Ok(NativePlugin {
|
||||
info,
|
||||
providers,
|
||||
vtable,
|
||||
_library: library,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a loaded plugin by ID
|
||||
pub fn get(&self, id: &str) -> Option<Arc<NativePlugin>> {
|
||||
self.plugins.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Get all loaded plugins as Arc references
|
||||
pub fn plugins(&self) -> impl Iterator<Item = Arc<NativePlugin>> + '_ {
|
||||
self.plugins.values().cloned()
|
||||
}
|
||||
|
||||
/// Get all loaded plugins as a Vec (for passing to create_providers)
|
||||
pub fn into_plugins(self) -> Vec<Arc<NativePlugin>> {
|
||||
self.plugins.into_values().collect()
|
||||
}
|
||||
|
||||
/// Get the number of loaded plugins
|
||||
pub fn plugin_count(&self) -> usize {
|
||||
self.plugins.len()
|
||||
}
|
||||
|
||||
/// Create providers from all loaded native plugins
|
||||
///
|
||||
/// Returns a vec of (plugin_id, provider_info, handle) tuples that can be
|
||||
/// used to create NativeProvider instances.
|
||||
pub fn create_provider_handles(&self) -> Vec<(String, ProviderInfo, ProviderHandle)> {
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for plugin in self.plugins.values() {
|
||||
for provider_info in &plugin.providers {
|
||||
let handle = plugin.init_provider(provider_info.id.as_str());
|
||||
handles.push((plugin.id().to_string(), provider_info.clone(), handle));
|
||||
}
|
||||
}
|
||||
|
||||
handles
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NativePluginLoader {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Active provider instance from a native plugin
|
||||
pub struct NativeProviderInstance {
|
||||
/// Plugin ID this provider belongs to
|
||||
pub plugin_id: String,
|
||||
/// Provider metadata
|
||||
pub info: ProviderInfo,
|
||||
/// Handle to the provider state
|
||||
pub handle: ProviderHandle,
|
||||
/// Cached items for static providers
|
||||
pub cached_items: Vec<owlry_plugin_api::PluginItem>,
|
||||
}
|
||||
|
||||
impl NativeProviderInstance {
|
||||
/// Create a new provider instance
|
||||
pub fn new(plugin_id: String, info: ProviderInfo, handle: ProviderHandle) -> Self {
|
||||
Self {
|
||||
plugin_id,
|
||||
info,
|
||||
handle,
|
||||
cached_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a static provider
|
||||
pub fn is_static(&self) -> bool {
|
||||
self.info.provider_type == ProviderKind::Static
|
||||
}
|
||||
|
||||
/// Check if this is a dynamic provider
|
||||
pub fn is_dynamic(&self) -> bool {
|
||||
self.info.provider_type == ProviderKind::Dynamic
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_loader_nonexistent_dir() {
|
||||
let mut loader = NativePluginLoader::with_dir(PathBuf::from("/nonexistent/path"));
|
||||
let count = loader.discover().unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loader_empty_dir() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let mut loader = NativePluginLoader::with_dir(temp.path().to_path_buf());
|
||||
let count = loader.discover().unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_plugins() {
|
||||
let mut loader = NativePluginLoader::new();
|
||||
loader.set_disabled(vec!["test-plugin".to_string()]);
|
||||
assert!(loader.disabled.contains(&"test-plugin".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
//! Plugin registry client for discovering and installing remote plugins
|
||||
//!
|
||||
//! The registry is a git repository containing an `index.toml` file with
|
||||
//! plugin metadata. Plugins are installed by cloning their source repositories.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// Default registry URL (can be overridden in config)
|
||||
pub const DEFAULT_REGISTRY_URL: &str =
|
||||
"https://raw.githubusercontent.com/owlry/plugin-registry/main/index.toml";
|
||||
|
||||
/// Cache duration for registry index (1 hour)
|
||||
const CACHE_DURATION: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Registry index containing all available plugins
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegistryIndex {
|
||||
/// Registry metadata
|
||||
#[serde(default)]
|
||||
pub registry: RegistryMeta,
|
||||
/// Available plugins
|
||||
#[serde(default)]
|
||||
pub plugins: Vec<RegistryPlugin>,
|
||||
}
|
||||
|
||||
/// Registry metadata
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RegistryMeta {
|
||||
/// Registry name
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// Registry description
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Registry maintainer URL
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Plugin entry in the registry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegistryPlugin {
|
||||
/// Unique plugin identifier
|
||||
pub id: String,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Latest version
|
||||
pub version: String,
|
||||
/// Short description
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Plugin author
|
||||
#[serde(default)]
|
||||
pub author: String,
|
||||
/// Git repository URL for installation
|
||||
pub repository: String,
|
||||
/// Search tags
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Minimum owlry version required
|
||||
#[serde(default)]
|
||||
pub owlry_version: String,
|
||||
/// License identifier
|
||||
#[serde(default)]
|
||||
pub license: String,
|
||||
}
|
||||
|
||||
/// Registry client for fetching and searching plugins
|
||||
pub struct RegistryClient {
|
||||
/// Registry URL (index.toml location)
|
||||
registry_url: String,
|
||||
/// Local cache directory
|
||||
cache_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RegistryClient {
|
||||
/// Create a new registry client with the given URL
|
||||
pub fn new(registry_url: &str) -> Self {
|
||||
let cache_dir = paths::owlry_cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp/owlry"))
|
||||
.join("registry");
|
||||
|
||||
Self {
|
||||
registry_url: registry_url.to_string(),
|
||||
cache_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a client with the default registry URL
|
||||
pub fn default_registry() -> Self {
|
||||
Self::new(DEFAULT_REGISTRY_URL)
|
||||
}
|
||||
|
||||
/// Get the path to the cached index file
|
||||
fn cache_path(&self) -> PathBuf {
|
||||
self.cache_dir.join("index.toml")
|
||||
}
|
||||
|
||||
/// Check if the cache is valid (exists and not expired)
|
||||
fn is_cache_valid(&self) -> bool {
|
||||
let cache_path = self.cache_path();
|
||||
if !cache_path.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(metadata) = fs::metadata(&cache_path)
|
||||
&& let Ok(modified) = metadata.modified()
|
||||
&& let Ok(elapsed) = SystemTime::now().duration_since(modified) {
|
||||
return elapsed < CACHE_DURATION;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Fetch the registry index (from cache or network)
|
||||
pub fn fetch_index(&self, force_refresh: bool) -> Result<RegistryIndex, String> {
|
||||
// Use cache if valid and not forcing refresh
|
||||
if !force_refresh && self.is_cache_valid()
|
||||
&& let Ok(content) = fs::read_to_string(self.cache_path())
|
||||
&& let Ok(index) = toml::from_str(&content) {
|
||||
return Ok(index);
|
||||
}
|
||||
|
||||
// Fetch from network
|
||||
self.fetch_from_network()
|
||||
}
|
||||
|
||||
/// Fetch the index from the network and cache it
|
||||
fn fetch_from_network(&self) -> Result<RegistryIndex, String> {
|
||||
// Use curl for fetching (available on most systems)
|
||||
let output = std::process::Command::new("curl")
|
||||
.args([
|
||||
"-fsSL",
|
||||
"--max-time",
|
||||
"30",
|
||||
&self.registry_url,
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run curl: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to fetch registry: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse the index
|
||||
let index: RegistryIndex = toml::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse registry index: {}", e))?;
|
||||
|
||||
// Cache the result
|
||||
if let Err(e) = self.cache_index(&content) {
|
||||
eprintln!("Warning: Failed to cache registry index: {}", e);
|
||||
}
|
||||
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Cache the index content to disk
|
||||
fn cache_index(&self, content: &str) -> Result<(), String> {
|
||||
fs::create_dir_all(&self.cache_dir)
|
||||
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
|
||||
|
||||
fs::write(self.cache_path(), content)
|
||||
.map_err(|e| format!("Failed to write cache file: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search for plugins matching a query
|
||||
pub fn search(&self, query: &str, force_refresh: bool) -> Result<Vec<RegistryPlugin>, String> {
|
||||
let index = self.fetch_index(force_refresh)?;
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let matches: Vec<_> = index
|
||||
.plugins
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
p.id.to_lowercase().contains(&query_lower)
|
||||
|| p.name.to_lowercase().contains(&query_lower)
|
||||
|| p.description.to_lowercase().contains(&query_lower)
|
||||
|| p.tags.iter().any(|t| t.to_lowercase().contains(&query_lower))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// Find a specific plugin by ID
|
||||
pub fn find(&self, id: &str, force_refresh: bool) -> Result<Option<RegistryPlugin>, String> {
|
||||
let index = self.fetch_index(force_refresh)?;
|
||||
|
||||
Ok(index.plugins.into_iter().find(|p| p.id == id))
|
||||
}
|
||||
|
||||
/// List all available plugins
|
||||
pub fn list_all(&self, force_refresh: bool) -> Result<Vec<RegistryPlugin>, String> {
|
||||
let index = self.fetch_index(force_refresh)?;
|
||||
Ok(index.plugins)
|
||||
}
|
||||
|
||||
/// Clear the cache
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_cache(&self) -> Result<(), String> {
|
||||
let cache_path = self.cache_path();
|
||||
if cache_path.exists() {
|
||||
fs::remove_file(&cache_path)
|
||||
.map_err(|e| format!("Failed to remove cache: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the repository URL for a plugin
|
||||
#[allow(dead_code)]
|
||||
pub fn get_install_url(&self, id: &str) -> Result<String, String> {
|
||||
match self.find(id, false)? {
|
||||
Some(plugin) => Ok(plugin.repository),
|
||||
None => Err(format!("Plugin '{}' not found in registry", id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a string looks like a URL (for distinguishing registry names from URLs)
|
||||
pub fn is_url(s: &str) -> bool {
|
||||
s.starts_with("http://")
|
||||
|| s.starts_with("https://")
|
||||
|| s.starts_with("git@")
|
||||
|| s.starts_with("git://")
|
||||
}
|
||||
|
||||
/// Check if a string looks like a local path
|
||||
pub fn is_path(s: &str) -> bool {
|
||||
s.starts_with('/')
|
||||
|| s.starts_with("./")
|
||||
|| s.starts_with("../")
|
||||
|| s.starts_with('~')
|
||||
|| Path::new(s).exists()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_registry_index() {
|
||||
let toml_str = r#"
|
||||
[registry]
|
||||
name = "Test Registry"
|
||||
description = "A test registry"
|
||||
|
||||
[[plugins]]
|
||||
id = "test-plugin"
|
||||
name = "Test Plugin"
|
||||
version = "1.0.0"
|
||||
description = "A test plugin"
|
||||
author = "Test Author"
|
||||
repository = "https://github.com/test/plugin"
|
||||
tags = ["test", "example"]
|
||||
owlry_version = ">=0.3.0"
|
||||
"#;
|
||||
|
||||
let index: RegistryIndex = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(index.registry.name, "Test Registry");
|
||||
assert_eq!(index.plugins.len(), 1);
|
||||
assert_eq!(index.plugins[0].id, "test-plugin");
|
||||
assert_eq!(index.plugins[0].tags, vec!["test", "example"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_url() {
|
||||
assert!(is_url("https://github.com/user/repo"));
|
||||
assert!(is_url("http://example.com"));
|
||||
assert!(is_url("git@github.com:user/repo.git"));
|
||||
assert!(!is_url("my-plugin"));
|
||||
assert!(!is_url("/path/to/plugin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_path() {
|
||||
assert!(is_path("/absolute/path"));
|
||||
assert!(is_path("./relative/path"));
|
||||
assert!(is_path("../parent/path"));
|
||||
assert!(is_path("~/home/path"));
|
||||
assert!(!is_path("my-plugin"));
|
||||
assert!(!is_path("https://example.com"));
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
//! Lua runtime setup and sandboxing
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, StdLib};
|
||||
|
||||
use super::manifest::PluginPermissions;
|
||||
|
||||
/// Configuration for the Lua sandbox
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Fields used for future permission enforcement
|
||||
pub struct SandboxConfig {
|
||||
/// Allow shell command running
|
||||
pub allow_commands: bool,
|
||||
/// Allow HTTP requests
|
||||
pub allow_network: bool,
|
||||
/// Allow filesystem access outside plugin directory
|
||||
pub allow_external_fs: bool,
|
||||
/// Maximum run time per call (ms)
|
||||
pub max_run_time_ms: u64,
|
||||
/// Memory limit (bytes, 0 = unlimited)
|
||||
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
|
||||
// ALL_SAFE excludes: debug, io, os (dangerous parts), package (loadlib), ffi
|
||||
// We then customize the os table to only allow safe functions
|
||||
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
|
||||
// We do NOT include: os.exit, os.remove, os.rename, os.setlocale, os.tmpname
|
||||
// and the shell-related 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)
|
||||
// We'll add it back via owlry.log
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
//! Dynamic runtime loader
|
||||
//!
|
||||
//! This module provides dynamic loading of script runtimes (Lua, Rune)
|
||||
//! when they're not compiled into the core binary.
|
||||
//!
|
||||
//! Runtimes are loaded from `/usr/lib/owlry/runtimes/`:
|
||||
//! - `liblua.so` - Lua runtime (from owlry-lua package)
|
||||
//! - `librune.so` - Rune runtime (from owlry-rune package)
|
||||
//!
|
||||
//! Note: This module is infrastructure for the runtime architecture. Full integration
|
||||
//! is pending Phase 5 (AUR Packaging) when runtime packages will be available.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use libloading::{Library, Symbol};
|
||||
use owlry_plugin_api::{PluginItem, RStr, RString, RVec};
|
||||
|
||||
use super::error::{PluginError, PluginResult};
|
||||
use crate::providers::{LaunchItem, Provider, ProviderType};
|
||||
|
||||
/// System directory for runtime libraries
|
||||
pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes";
|
||||
|
||||
/// Information about a loaded runtime
|
||||
#[repr(C)]
|
||||
#[derive(Debug)]
|
||||
pub struct RuntimeInfo {
|
||||
pub name: RString,
|
||||
pub version: RString,
|
||||
}
|
||||
|
||||
/// Information about a provider from a script runtime
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScriptProviderInfo {
|
||||
pub name: RString,
|
||||
pub display_name: RString,
|
||||
pub type_id: RString,
|
||||
pub default_icon: RString,
|
||||
pub is_static: bool,
|
||||
pub prefix: owlry_plugin_api::ROption<RString>,
|
||||
}
|
||||
|
||||
// Type alias for backwards compatibility
|
||||
pub type LuaProviderInfo = ScriptProviderInfo;
|
||||
|
||||
/// Handle to runtime-managed state
|
||||
#[repr(transparent)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RuntimeHandle(pub *mut ());
|
||||
|
||||
/// VTable for script runtime functions (used by both Lua and Rune)
|
||||
#[repr(C)]
|
||||
pub struct ScriptRuntimeVTable {
|
||||
pub info: extern "C" fn() -> RuntimeInfo,
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
|
||||
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<ScriptProviderInfo>,
|
||||
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),
|
||||
}
|
||||
|
||||
/// A loaded script runtime
|
||||
pub struct LoadedRuntime {
|
||||
/// Runtime name (for logging)
|
||||
name: &'static str,
|
||||
/// Keep library alive
|
||||
_library: Arc<Library>,
|
||||
/// Runtime vtable
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
/// Runtime handle (state)
|
||||
handle: RuntimeHandle,
|
||||
/// Provider information
|
||||
providers: Vec<ScriptProviderInfo>,
|
||||
}
|
||||
|
||||
impl LoadedRuntime {
|
||||
/// Load the Lua runtime from the system directory
|
||||
pub fn load_lua(plugins_dir: &Path) -> PluginResult<Self> {
|
||||
Self::load_from_path(
|
||||
"Lua",
|
||||
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"),
|
||||
b"owlry_lua_runtime_vtable",
|
||||
plugins_dir,
|
||||
)
|
||||
}
|
||||
|
||||
/// Load a runtime from a specific path
|
||||
fn load_from_path(
|
||||
name: &'static str,
|
||||
library_path: &Path,
|
||||
vtable_symbol: &[u8],
|
||||
plugins_dir: &Path,
|
||||
) -> PluginResult<Self> {
|
||||
if !library_path.exists() {
|
||||
return Err(PluginError::NotFound(library_path.display().to_string()));
|
||||
}
|
||||
|
||||
// SAFETY: We trust the runtime library to be correct
|
||||
let library = unsafe { Library::new(library_path) }.map_err(|e| {
|
||||
PluginError::LoadError(format!("{}: {}", library_path.display(), e))
|
||||
})?;
|
||||
|
||||
let library = Arc::new(library);
|
||||
|
||||
// Get the vtable
|
||||
let vtable: &'static ScriptRuntimeVTable = unsafe {
|
||||
let get_vtable: Symbol<extern "C" fn() -> &'static ScriptRuntimeVTable> =
|
||||
library.get(vtable_symbol).map_err(|e| {
|
||||
PluginError::LoadError(format!(
|
||||
"{}: Missing vtable symbol: {}",
|
||||
library_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
get_vtable()
|
||||
};
|
||||
|
||||
// Initialize the runtime
|
||||
let plugins_dir_str = plugins_dir.to_string_lossy();
|
||||
let handle = (vtable.init)(RStr::from_str(&plugins_dir_str));
|
||||
|
||||
// Get provider information
|
||||
let providers_rvec = (vtable.providers)(handle);
|
||||
let providers: Vec<ScriptProviderInfo> = providers_rvec.into_iter().collect();
|
||||
|
||||
log::info!(
|
||||
"Loaded {} runtime with {} provider(s)",
|
||||
name,
|
||||
providers.len()
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
_library: library,
|
||||
vtable,
|
||||
handle,
|
||||
providers,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all providers from this runtime
|
||||
pub fn providers(&self) -> &[ScriptProviderInfo] {
|
||||
&self.providers
|
||||
}
|
||||
|
||||
/// Create Provider trait objects for all providers in this runtime
|
||||
pub fn create_providers(&self) -> Vec<Box<dyn Provider>> {
|
||||
self.providers
|
||||
.iter()
|
||||
.map(|info| {
|
||||
let provider = RuntimeProvider::new(
|
||||
self.name,
|
||||
self.vtable,
|
||||
self.handle,
|
||||
info.clone(),
|
||||
);
|
||||
Box::new(provider) as Box<dyn Provider>
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LoadedRuntime {
|
||||
fn drop(&mut self) {
|
||||
(self.vtable.drop)(self.handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// A provider backed by a dynamically loaded runtime
|
||||
pub struct RuntimeProvider {
|
||||
/// Runtime name (for logging)
|
||||
#[allow(dead_code)]
|
||||
runtime_name: &'static str,
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
handle: RuntimeHandle,
|
||||
info: ScriptProviderInfo,
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl RuntimeProvider {
|
||||
fn new(
|
||||
runtime_name: &'static str,
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
handle: RuntimeHandle,
|
||||
info: ScriptProviderInfo,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime_name,
|
||||
vtable,
|
||||
handle,
|
||||
info,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_item(&self, item: PluginItem) -> LaunchItem {
|
||||
LaunchItem {
|
||||
id: item.id.to_string(),
|
||||
name: item.name.to_string(),
|
||||
description: item.description.into_option().map(|s| s.to_string()),
|
||||
icon: item.icon.into_option().map(|s| s.to_string()),
|
||||
provider: ProviderType::Plugin(self.info.type_id.to_string()),
|
||||
command: item.command.to_string(),
|
||||
terminal: item.terminal,
|
||||
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for RuntimeProvider {
|
||||
fn name(&self) -> &str {
|
||||
self.info.name.as_str()
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin(self.info.type_id.to_string())
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
if !self.info.is_static {
|
||||
return;
|
||||
}
|
||||
|
||||
let name_rstr = RStr::from_str(self.info.name.as_str());
|
||||
let items_rvec = (self.vtable.refresh)(self.handle, name_rstr);
|
||||
self.items = items_rvec.into_iter().map(|i| self.convert_item(i)).collect();
|
||||
|
||||
log::debug!(
|
||||
"[RuntimeProvider] '{}' refreshed with {} items",
|
||||
self.info.name,
|
||||
self.items.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
// RuntimeProvider needs to be Send for the Provider trait
|
||||
unsafe impl Send for RuntimeProvider {}
|
||||
|
||||
/// Check if the Lua runtime is available
|
||||
pub fn lua_runtime_available() -> bool {
|
||||
PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so").exists()
|
||||
}
|
||||
|
||||
/// Check if the Rune runtime is available
|
||||
pub fn rune_runtime_available() -> bool {
|
||||
PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so").exists()
|
||||
}
|
||||
|
||||
impl LoadedRuntime {
|
||||
/// Load the Rune runtime from the system directory
|
||||
pub fn load_rune(plugins_dir: &Path) -> PluginResult<Self> {
|
||||
Self::load_from_path(
|
||||
"Rune",
|
||||
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"),
|
||||
b"owlry_rune_runtime_vtable",
|
||||
plugins_dir,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_lua_runtime_check_doesnt_panic() {
|
||||
// Just verify the function runs without panicking
|
||||
// Result depends on whether runtime is installed
|
||||
let _available = lua_runtime_available();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rune_runtime_check_doesnt_panic() {
|
||||
// Just verify the function runs without panicking
|
||||
// Result depends on whether runtime is installed
|
||||
let _available = rune_runtime_available();
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use crate::paths;
|
||||
use freedesktop_desktop_entry::{DesktopEntry, Iter};
|
||||
use log::{debug, warn};
|
||||
|
||||
/// Clean desktop file field codes from command string.
|
||||
/// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes
|
||||
/// while preserving quoted arguments and %% (literal percent).
|
||||
/// See: https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html
|
||||
fn clean_desktop_exec_field(cmd: &str) -> String {
|
||||
let mut result = String::with_capacity(cmd.len());
|
||||
let mut chars = cmd.chars().peekable();
|
||||
let mut in_single_quote = false;
|
||||
let mut in_double_quote = false;
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\'' if !in_double_quote => {
|
||||
in_single_quote = !in_single_quote;
|
||||
result.push(c);
|
||||
}
|
||||
'"' if !in_single_quote => {
|
||||
in_double_quote = !in_double_quote;
|
||||
result.push(c);
|
||||
}
|
||||
'%' if !in_single_quote => {
|
||||
// Check the next character for field code
|
||||
if let Some(&next) = chars.peek() {
|
||||
match next {
|
||||
// Standard field codes to remove (with following space if present)
|
||||
'f' | 'F' | 'u' | 'U' | 'd' | 'D' | 'n' | 'N' | 'i' | 'c' | 'k' | 'v'
|
||||
| 'm' => {
|
||||
chars.next(); // consume the field code letter
|
||||
// Skip trailing whitespace after the field code
|
||||
while chars.peek() == Some(&' ') {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
// %% is escaped percent, output single %
|
||||
'%' => {
|
||||
chars.next();
|
||||
result.push('%');
|
||||
}
|
||||
// Unknown % sequence, keep as-is
|
||||
_ => {
|
||||
result.push('%');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// % at end of string, keep it
|
||||
result.push('%');
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any double spaces that may have resulted from removing field codes
|
||||
let mut cleaned = result.trim().to_string();
|
||||
while cleaned.contains(" ") {
|
||||
cleaned = cleaned.replace(" ", " ");
|
||||
}
|
||||
|
||||
cleaned
|
||||
}
|
||||
|
||||
pub struct ApplicationProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl ApplicationProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
fn get_application_dirs() -> Vec<std::path::PathBuf> {
|
||||
paths::system_data_dirs()
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for ApplicationProvider {
|
||||
fn name(&self) -> &str {
|
||||
"Applications"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Application
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
let dirs = Self::get_application_dirs();
|
||||
debug!("Scanning application directories: {:?}", dirs);
|
||||
|
||||
// Empty locale list for default locale
|
||||
let locales: &[&str] = &[];
|
||||
|
||||
// Get current desktop environment(s) for OnlyShowIn/NotShowIn filtering
|
||||
// XDG_CURRENT_DESKTOP can be colon-separated (e.g., "ubuntu:GNOME")
|
||||
let current_desktops: Vec<String> = std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.unwrap_or_default()
|
||||
.split(':')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
for path in Iter::new(dirs.into_iter()) {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("Failed to read {:?}: {}", path, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let desktop_entry = match DesktopEntry::from_str(&path, &content, Some(locales)) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse {:?}: {}", path, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Skip entries marked as hidden or no-display
|
||||
if desktop_entry.no_display() || desktop_entry.hidden() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only include Application type entries
|
||||
if desktop_entry.type_() != Some("Application") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply OnlyShowIn/NotShowIn filters only if we know the current desktop
|
||||
// If XDG_CURRENT_DESKTOP is not set, show all apps (don't filter)
|
||||
if !current_desktops.is_empty() {
|
||||
// OnlyShowIn: if set, current desktop must be in the list
|
||||
if desktop_entry.only_show_in().is_some_and(|only| {
|
||||
!current_desktops.iter().any(|de| only.contains(&de.as_str()))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// NotShowIn: if current desktop is in the list, skip
|
||||
if desktop_entry.not_show_in().is_some_and(|not| {
|
||||
current_desktops.iter().any(|de| not.contains(&de.as_str()))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let name = match desktop_entry.name(locales) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let run_cmd = match desktop_entry.exec() {
|
||||
Some(e) => clean_desktop_exec_field(e),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract categories and keywords as tags (lowercase for consistency)
|
||||
let mut tags: Vec<String> = desktop_entry
|
||||
.categories()
|
||||
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Add keywords for searchability (e.g., Nautilus has Name=Files but Keywords contains "nautilus")
|
||||
if let Some(keywords) = desktop_entry.keywords(locales) {
|
||||
tags.extend(keywords.into_iter().map(|s| s.to_lowercase()));
|
||||
}
|
||||
|
||||
let item = LaunchItem {
|
||||
id: path.to_string_lossy().to_string(),
|
||||
name,
|
||||
description: desktop_entry.comment(locales).map(|s| s.to_string()),
|
||||
icon: desktop_entry.icon().map(|s| s.to_string()),
|
||||
provider: ProviderType::Application,
|
||||
command: run_cmd,
|
||||
terminal: desktop_entry.terminal(),
|
||||
tags,
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
}
|
||||
|
||||
debug!("Found {} applications", self.items.len());
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"XDG_CURRENT_DESKTOP={:?}, scanned dirs count={}",
|
||||
current_desktops,
|
||||
Self::get_application_dirs().len()
|
||||
);
|
||||
|
||||
// Sort alphabetically by name
|
||||
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_simple() {
|
||||
assert_eq!(clean_desktop_exec_field("firefox"), "firefox");
|
||||
assert_eq!(clean_desktop_exec_field("firefox %u"), "firefox");
|
||||
assert_eq!(clean_desktop_exec_field("code %F"), "code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_multiple_placeholders() {
|
||||
assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app");
|
||||
assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_preserves_quotes() {
|
||||
// Double quotes preserve spacing but field codes are still processed
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field(r#"bash -c "echo hello""#),
|
||||
r#"bash -c "echo hello""#
|
||||
);
|
||||
// Field codes in double quotes are stripped (per FreeDesktop spec: undefined behavior,
|
||||
// but practical implementations strip them)
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field(r#"bash -c "test %u value""#),
|
||||
r#"bash -c "test value""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_escaped_percent() {
|
||||
assert_eq!(clean_desktop_exec_field("echo 100%%"), "echo 100%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_single_quotes() {
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field("bash -c 'echo %u'"),
|
||||
"bash -c 'echo %u'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_preserves_env() {
|
||||
// env VAR=value pattern should be preserved
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field("env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity %F"),
|
||||
"env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity"
|
||||
);
|
||||
// Multiple env vars
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field("env FOO=bar BAZ=qux myapp %u"),
|
||||
"env FOO=bar BAZ=qux myapp"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use log::debug;
|
||||
use std::collections::HashSet;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct CommandProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl CommandProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
fn get_path_dirs() -> Vec<PathBuf> {
|
||||
std::env::var("PATH")
|
||||
.unwrap_or_default()
|
||||
.split(':')
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.exists())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_executable(path: &std::path::Path) -> bool {
|
||||
if let Ok(metadata) = path.metadata() {
|
||||
let permissions = metadata.permissions();
|
||||
permissions.mode() & 0o111 != 0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for CommandProvider {
|
||||
fn name(&self) -> &str {
|
||||
"Commands"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Command
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
let dirs = Self::get_path_dirs();
|
||||
let mut seen_names: HashSet<String> = HashSet::new();
|
||||
|
||||
debug!("Scanning PATH directories for commands");
|
||||
|
||||
for dir in dirs {
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for entry in entries.filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
|
||||
// Skip directories and non-executable files
|
||||
if path.is_dir() || !Self::is_executable(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = match path.file_name() {
|
||||
Some(n) => n.to_string_lossy().to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Skip duplicates (first one in PATH wins)
|
||||
if seen_names.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
seen_names.insert(name.clone());
|
||||
|
||||
// Skip hidden files
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let item = LaunchItem {
|
||||
id: path.to_string_lossy().to_string(),
|
||||
name: name.clone(),
|
||||
description: Some(format!("Run {}", path.display())),
|
||||
icon: Some("utilities-terminal".to_string()),
|
||||
provider: ProviderType::Command,
|
||||
command: name,
|
||||
terminal: false,
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Found {} commands in PATH", self.items.len());
|
||||
|
||||
// Sort alphabetically
|
||||
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use owlry_core::providers::{LaunchItem, Provider, ProviderType};
|
||||
use log::debug;
|
||||
use std::io::{self, BufRead};
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
//! LuaProvider - Bridge between Lua plugins and the Provider trait
|
||||
//!
|
||||
//! This module provides a `LuaProvider` struct that implements the `Provider` trait
|
||||
//! by delegating to a Lua plugin's registered provider functions.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::plugins::{LoadedPlugin, PluginItem, ProviderRegistration};
|
||||
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
|
||||
/// A provider backed by a Lua plugin
|
||||
///
|
||||
/// This struct implements the `Provider` trait by calling into a Lua plugin's
|
||||
/// `refresh` or `query` functions.
|
||||
pub struct LuaProvider {
|
||||
/// Provider registration info
|
||||
registration: ProviderRegistration,
|
||||
/// Reference to the loaded plugin (shared with other providers from same plugin)
|
||||
plugin: Rc<RefCell<LoadedPlugin>>,
|
||||
/// Cached items from last refresh
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl LuaProvider {
|
||||
/// Create a new LuaProvider
|
||||
pub fn new(registration: ProviderRegistration, plugin: Rc<RefCell<LoadedPlugin>>) -> Self {
|
||||
Self {
|
||||
registration,
|
||||
plugin,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a PluginItem to a LaunchItem
|
||||
fn convert_item(&self, item: PluginItem) -> LaunchItem {
|
||||
LaunchItem {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
icon: item.icon,
|
||||
provider: ProviderType::Plugin(self.registration.type_id.clone()),
|
||||
command: item.command.unwrap_or_default(),
|
||||
terminal: item.terminal,
|
||||
tags: item.tags,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for LuaProvider {
|
||||
fn name(&self) -> &str {
|
||||
&self.registration.name
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin(self.registration.type_id.clone())
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
// Only refresh static providers
|
||||
if !self.registration.is_static {
|
||||
return;
|
||||
}
|
||||
|
||||
let plugin = self.plugin.borrow();
|
||||
match plugin.call_provider_refresh(&self.registration.name) {
|
||||
Ok(items) => {
|
||||
self.items = items.into_iter().map(|i| self.convert_item(i)).collect();
|
||||
log::debug!(
|
||||
"[LuaProvider] '{}' refreshed with {} items",
|
||||
self.registration.name,
|
||||
self.items.len()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[LuaProvider] Failed to refresh '{}': {}",
|
||||
self.registration.name,
|
||||
e
|
||||
);
|
||||
self.items.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
// LuaProvider needs to be Send for the Provider trait
|
||||
// Since we're using Rc<RefCell<>>, we need to be careful about thread safety
|
||||
// For now, owlry is single-threaded, so this is safe
|
||||
unsafe impl Send for LuaProvider {}
|
||||
|
||||
/// Create LuaProviders from all registered providers in a plugin
|
||||
pub fn create_providers_from_plugin(
|
||||
plugin: Rc<RefCell<LoadedPlugin>>,
|
||||
) -> Vec<Box<dyn Provider>> {
|
||||
let registrations = {
|
||||
let p = plugin.borrow();
|
||||
match p.get_provider_registrations() {
|
||||
Ok(regs) => regs,
|
||||
Err(e) => {
|
||||
log::error!("[LuaProvider] Failed to get registrations: {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
registrations
|
||||
.into_iter()
|
||||
.map(|reg| {
|
||||
let provider = LuaProvider::new(reg, plugin.clone());
|
||||
Box::new(provider) as Box<dyn Provider>
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Note: Full integration tests require a complete plugin setup
|
||||
// These tests verify the basic structure
|
||||
|
||||
#[test]
|
||||
fn test_provider_type() {
|
||||
let reg = ProviderRegistration {
|
||||
name: "test".to_string(),
|
||||
display_name: "Test".to_string(),
|
||||
type_id: "test_provider".to_string(),
|
||||
default_icon: "test-icon".to_string(),
|
||||
is_static: true,
|
||||
prefix: None,
|
||||
};
|
||||
|
||||
// We can't easily create a mock LoadedPlugin, so just test the type
|
||||
assert_eq!(reg.type_id, "test_provider");
|
||||
}
|
||||
}
|
||||
@@ -1,616 +1,2 @@
|
||||
// Core providers (no plugin equivalents)
|
||||
mod application;
|
||||
mod command;
|
||||
mod dmenu;
|
||||
|
||||
// Native plugin bridge
|
||||
pub mod native_provider;
|
||||
|
||||
// Lua plugin bridge (optional)
|
||||
#[cfg(feature = "lua")]
|
||||
pub mod lua_provider;
|
||||
|
||||
// Re-exports for core providers
|
||||
pub use application::ApplicationProvider;
|
||||
pub use command::CommandProvider;
|
||||
pub mod dmenu;
|
||||
pub use dmenu::DmenuProvider;
|
||||
|
||||
// Re-export native provider for plugin loading
|
||||
pub use native_provider::NativeProvider;
|
||||
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use log::info;
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
|
||||
use crate::data::FrecencyStore;
|
||||
|
||||
/// Represents a single searchable/launchable item
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchItem {
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub provider: ProviderType,
|
||||
pub command: String,
|
||||
pub terminal: bool,
|
||||
/// Tags/categories for filtering (e.g., from .desktop Categories)
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Provider type identifier for filtering and badge display
|
||||
///
|
||||
/// Core types are built-in providers. All native plugins use Plugin(type_id).
|
||||
/// This keeps the core app free of plugin-specific knowledge.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ProviderType {
|
||||
/// Built-in: Desktop applications from XDG directories
|
||||
Application,
|
||||
/// Built-in: Shell commands from PATH
|
||||
Command,
|
||||
/// Built-in: Pipe-based input (dmenu compatibility)
|
||||
Dmenu,
|
||||
/// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji")
|
||||
Plugin(String),
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ProviderType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
// Core built-in providers
|
||||
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
|
||||
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
|
||||
"dmenu" => Ok(ProviderType::Dmenu),
|
||||
// Everything else is a plugin
|
||||
other => Ok(ProviderType::Plugin(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProviderType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ProviderType::Application => write!(f, "app"),
|
||||
ProviderType::Command => write!(f, "cmd"),
|
||||
ProviderType::Dmenu => write!(f, "dmenu"),
|
||||
ProviderType::Plugin(type_id) => write!(f, "{}", type_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for all search providers
|
||||
pub trait Provider: Send {
|
||||
#[allow(dead_code)]
|
||||
fn name(&self) -> &str;
|
||||
fn provider_type(&self) -> ProviderType;
|
||||
fn refresh(&mut self);
|
||||
fn items(&self) -> &[LaunchItem];
|
||||
}
|
||||
|
||||
/// Manages all providers and handles searching
|
||||
pub struct ProviderManager {
|
||||
/// Core static providers (apps, commands, dmenu)
|
||||
providers: Vec<Box<dyn Provider>>,
|
||||
/// Static native plugin providers (need query() for submenu support)
|
||||
static_native_providers: Vec<NativeProvider>,
|
||||
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
|
||||
/// These are queried per-keystroke, not cached
|
||||
dynamic_providers: Vec<NativeProvider>,
|
||||
/// Widget providers from native plugins (weather, media, pomodoro)
|
||||
/// These appear at the top of results
|
||||
widget_providers: Vec<NativeProvider>,
|
||||
/// Fuzzy matcher for search
|
||||
matcher: SkimMatcherV2,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
/// Create a new ProviderManager with native plugins
|
||||
///
|
||||
/// Native plugins are loaded from /usr/lib/owlry/plugins/ and categorized based on
|
||||
/// their declared ProviderKind and ProviderPosition:
|
||||
/// - Static providers with Normal position (added to providers vec)
|
||||
/// - Dynamic providers (queried per-keystroke, declared via ProviderKind::Dynamic)
|
||||
/// - Widget providers (shown at top, declared via ProviderPosition::Widget)
|
||||
pub fn with_native_plugins(native_providers: Vec<NativeProvider>) -> Self {
|
||||
let mut manager = Self {
|
||||
providers: Vec::new(),
|
||||
static_native_providers: Vec::new(),
|
||||
dynamic_providers: Vec::new(),
|
||||
widget_providers: Vec::new(),
|
||||
matcher: SkimMatcherV2::default(),
|
||||
};
|
||||
|
||||
// Check if running in dmenu mode (stdin has data)
|
||||
let dmenu_mode = DmenuProvider::has_stdin_data();
|
||||
|
||||
if dmenu_mode {
|
||||
// In dmenu mode, only use dmenu provider
|
||||
let mut dmenu = DmenuProvider::new();
|
||||
dmenu.enable();
|
||||
manager.providers.push(Box::new(dmenu));
|
||||
} else {
|
||||
// Core providers (no plugin equivalents)
|
||||
manager.providers.push(Box::new(ApplicationProvider::new()));
|
||||
manager.providers.push(Box::new(CommandProvider::new()));
|
||||
|
||||
// Categorize native plugins based on their declared ProviderKind and ProviderPosition
|
||||
for provider in native_providers {
|
||||
let type_id = provider.type_id();
|
||||
|
||||
if provider.is_dynamic() {
|
||||
// Dynamic providers declare ProviderKind::Dynamic
|
||||
info!("Registered dynamic provider: {} ({})", provider.name(), type_id);
|
||||
manager.dynamic_providers.push(provider);
|
||||
} else if provider.is_widget() {
|
||||
// Widgets declare ProviderPosition::Widget
|
||||
info!("Registered widget provider: {} ({})", provider.name(), type_id);
|
||||
manager.widget_providers.push(provider);
|
||||
} else {
|
||||
// Static native providers (keep as NativeProvider for query/submenu support)
|
||||
info!("Registered static provider: {} ({})", provider.name(), type_id);
|
||||
manager.static_native_providers.push(provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial refresh
|
||||
manager.refresh_all();
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_dmenu_mode(&self) -> bool {
|
||||
self.providers
|
||||
.iter()
|
||||
.any(|p| p.provider_type() == ProviderType::Dmenu)
|
||||
}
|
||||
|
||||
pub fn refresh_all(&mut self) {
|
||||
// Refresh core providers (apps, commands)
|
||||
for provider in &mut self.providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Provider '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh static native providers (clipboard, emoji, ssh, etc.)
|
||||
for provider in &mut self.static_native_providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Static provider '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
|
||||
// Widget providers are refreshed separately to avoid blocking startup
|
||||
// Call refresh_widgets() after window is shown
|
||||
|
||||
// Dynamic providers don't need refresh (they query on demand)
|
||||
}
|
||||
|
||||
/// Refresh widget providers (weather, media, pomodoro)
|
||||
/// Call this separately from refresh_all() to avoid blocking startup
|
||||
/// since widgets may make network requests or spawn processes
|
||||
pub fn refresh_widgets(&mut self) {
|
||||
for provider in &mut self.widget_providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Widget '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a native provider by type ID
|
||||
/// Searches in all native provider lists (static, dynamic, widget)
|
||||
pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> {
|
||||
// Check static native providers first (clipboard, emoji, ssh, systemd, etc.)
|
||||
if let Some(p) = self.static_native_providers.iter().find(|p| p.type_id() == type_id) {
|
||||
return Some(p);
|
||||
}
|
||||
// Check widget providers (pomodoro, weather, media)
|
||||
if let Some(p) = self.widget_providers.iter().find(|p| p.type_id() == type_id) {
|
||||
return Some(p);
|
||||
}
|
||||
// Then dynamic providers (calc, websearch, filesearch)
|
||||
self.dynamic_providers.iter().find(|p| p.type_id() == type_id)
|
||||
}
|
||||
|
||||
/// Execute a plugin action command
|
||||
/// Command format: PLUGIN_ID:action_data (e.g., "POMODORO:start", "SYSTEMD:unit:restart")
|
||||
/// Returns true if the command was handled by a plugin
|
||||
pub fn execute_plugin_action(&self, command: &str) -> bool {
|
||||
// Parse command format: PLUGIN_ID:action_data
|
||||
if let Some(colon_pos) = command.find(':') {
|
||||
let plugin_id = &command[..colon_pos];
|
||||
let action = command; // Pass full command to plugin
|
||||
|
||||
// Find provider by type ID (case-insensitive for convenience)
|
||||
let type_id = plugin_id.to_lowercase();
|
||||
|
||||
if let Some(provider) = self.find_native_provider(&type_id) {
|
||||
provider.execute_action(action);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Add a dynamic provider (e.g., from a Lua plugin)
|
||||
#[allow(dead_code)]
|
||||
pub fn add_provider(&mut self, provider: Box<dyn Provider>) {
|
||||
info!("Added plugin provider: {}", provider.name());
|
||||
self.providers.push(provider);
|
||||
}
|
||||
|
||||
/// Add multiple providers at once (for batch plugin loading)
|
||||
#[allow(dead_code)]
|
||||
pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) {
|
||||
for provider in providers {
|
||||
self.add_provider(provider);
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate over all static provider items (core + native static plugins)
|
||||
fn all_static_items(&self) -> impl Iterator<Item = &LaunchItem> {
|
||||
self.providers
|
||||
.iter()
|
||||
.flat_map(|p| p.items().iter())
|
||||
.chain(self.static_native_providers.iter().flat_map(|p| p.items().iter()))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
|
||||
if query.is_empty() {
|
||||
// Return recent/popular items when query is empty
|
||||
return self.all_static_items()
|
||||
.take(max_results)
|
||||
.map(|item| (item.clone(), 0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = self.all_static_items()
|
||||
.filter_map(|item| {
|
||||
// Match against name and description
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
let score = match (name_score, desc_score) {
|
||||
(Some(n), Some(d)) => Some(n.max(d)),
|
||||
(Some(n), None) => Some(n),
|
||||
(None, Some(d)) => Some(d / 2), // Lower weight for description matches
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
score.map(|s| (item.clone(), s))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by score (descending)
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
|
||||
/// Search with provider filtering
|
||||
pub fn search_filtered(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &crate::filter::ProviderFilter,
|
||||
) -> Vec<(LaunchItem, i64)> {
|
||||
// Collect items from core providers
|
||||
let core_items = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
// Collect items from static native providers
|
||||
let native_items = self
|
||||
.static_native_providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
if query.is_empty() {
|
||||
return core_items
|
||||
.chain(native_items)
|
||||
.take(max_results)
|
||||
.map(|item| (item, 0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = core_items
|
||||
.chain(native_items)
|
||||
.filter_map(|item| {
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
let score = match (name_score, desc_score) {
|
||||
(Some(n), Some(d)) => Some(n.max(d)),
|
||||
(Some(n), None) => Some(n),
|
||||
(None, Some(d)) => Some(d / 2),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
score.map(|s| (item, s))
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
|
||||
/// Search with frecency boosting, dynamic providers, and tag filtering
|
||||
pub fn search_with_frecency(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &crate::filter::ProviderFilter,
|
||||
frecency: &FrecencyStore,
|
||||
frecency_weight: f64,
|
||||
tag_filter: Option<&str>,
|
||||
) -> Vec<(LaunchItem, i64)> {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Search] query={:?}, max={}, frecency_weight={}", query, max_results, frecency_weight);
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
|
||||
|
||||
// Add widget items first (highest priority) - only when:
|
||||
// 1. No specific filter prefix is active
|
||||
// 2. Query is empty (user hasn't started searching)
|
||||
// This keeps widgets visible on launch but hides them during active search
|
||||
// Widgets are always visible regardless of filter settings (they declare position via API)
|
||||
if filter.active_prefix().is_none() && query.is_empty() {
|
||||
// Widget priority comes from plugin-declared priority field
|
||||
for provider in &self.widget_providers {
|
||||
let base_score = provider.priority() as i64;
|
||||
for (idx, item) in provider.items().iter().enumerate() {
|
||||
results.push((item.clone(), base_score - idx as i64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query dynamic providers (calculator, websearch, filesearch)
|
||||
// Only query if:
|
||||
// 1. Their specific filter is active (e.g., :file prefix or Files tab selected), OR
|
||||
// 2. No specific single-mode filter is active (showing all providers)
|
||||
if !query.is_empty() {
|
||||
for provider in &self.dynamic_providers {
|
||||
// Skip if this provider type is explicitly filtered out
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
let dynamic_results = provider.query(query);
|
||||
// Priority comes from plugin-declared priority field
|
||||
let base_score = provider.priority() as i64;
|
||||
for (idx, item) in dynamic_results.into_iter().enumerate() {
|
||||
results.push((item, base_score - idx as i64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty query (after checking special providers) - return frecency-sorted items
|
||||
if query.is_empty() {
|
||||
// Collect items from core providers
|
||||
let core_items = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
// Collect items from static native providers
|
||||
let native_items = self
|
||||
.static_native_providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
let items: Vec<(LaunchItem, i64)> = core_items
|
||||
.chain(native_items)
|
||||
.filter(|item| {
|
||||
// Apply tag filter if present
|
||||
if let Some(tag) = tag_filter {
|
||||
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.map(|item| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
|
||||
(item, boosted)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Combine widgets (already in results) with frecency items
|
||||
results.extend(items);
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Regular search with frecency boost and tag matching
|
||||
// Helper closure for scoring items
|
||||
let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> {
|
||||
// Apply tag filter if present
|
||||
if let Some(tag) = tag_filter
|
||||
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
// Also match against tags (lower weight)
|
||||
let tag_score = item
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|t| self.matcher.fuzzy_match(t, query))
|
||||
.max()
|
||||
.map(|s| s / 3); // Lower weight for tag matches
|
||||
|
||||
let base_score = match (name_score, desc_score, tag_score) {
|
||||
(Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)),
|
||||
(Some(n), Some(d), None) => Some(n.max(d)),
|
||||
(Some(n), None, Some(t)) => Some(n.max(t)),
|
||||
(Some(n), None, None) => Some(n),
|
||||
(None, Some(d), Some(t)) => Some((d / 2).max(t)),
|
||||
(None, Some(d), None) => Some(d / 2),
|
||||
(None, None, Some(t)) => Some(t),
|
||||
(None, None, None) => None,
|
||||
};
|
||||
|
||||
base_score.map(|s| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
|
||||
(item.clone(), s + frecency_boost)
|
||||
})
|
||||
};
|
||||
|
||||
// Search core providers
|
||||
for provider in &self.providers {
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
for item in provider.items() {
|
||||
if let Some(scored) = score_item(item) {
|
||||
results.push(scored);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search static native providers
|
||||
for provider in &self.static_native_providers {
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
for item in provider.items() {
|
||||
if let Some(scored) = score_item(item) {
|
||||
results.push(scored);
|
||||
}
|
||||
}
|
||||
}
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
{
|
||||
debug!("[Search] Returning {} results", results.len());
|
||||
for (i, (item, score)) in results.iter().take(5).enumerate() {
|
||||
debug!("[Search] #{}: {} (score={}, provider={:?})", i + 1, item.name, score, item.provider);
|
||||
}
|
||||
if results.len() > 5 {
|
||||
debug!("[Search] ... and {} more", results.len() - 5);
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Get all available provider types (for UI tabs)
|
||||
#[allow(dead_code)]
|
||||
pub fn available_providers(&self) -> Vec<ProviderType> {
|
||||
self.providers
|
||||
.iter()
|
||||
.map(|p| p.provider_type())
|
||||
.chain(self.static_native_providers.iter().map(|p| p.provider_type()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get a widget item by type_id (e.g., "pomodoro", "weather", "media")
|
||||
/// Returns the first item from the widget provider, if any
|
||||
pub fn get_widget_item(&self, type_id: &str) -> Option<LaunchItem> {
|
||||
self.widget_providers
|
||||
.iter()
|
||||
.find(|p| p.type_id() == type_id)
|
||||
.and_then(|p| p.items().first().cloned())
|
||||
}
|
||||
|
||||
/// Get all loaded widget provider type_ids
|
||||
/// Returns an iterator over the type_ids of currently loaded widget providers
|
||||
pub fn widget_type_ids(&self) -> impl Iterator<Item = &str> {
|
||||
self.widget_providers.iter().map(|p| p.type_id())
|
||||
}
|
||||
|
||||
/// Query a plugin for submenu actions
|
||||
///
|
||||
/// This is used when a user selects a SUBMENU:plugin_id:data item.
|
||||
/// The plugin is queried with "?SUBMENU:data" and returns action items.
|
||||
///
|
||||
/// Returns (display_name, actions) where display_name is the item name
|
||||
/// and actions are the submenu items returned by the plugin.
|
||||
pub fn query_submenu_actions(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
data: &str,
|
||||
display_name: &str,
|
||||
) -> Option<(String, Vec<LaunchItem>)> {
|
||||
// Build the submenu query
|
||||
let submenu_query = format!("?SUBMENU:{}", data);
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Submenu] Querying plugin '{}' with: {}",
|
||||
plugin_id, submenu_query
|
||||
);
|
||||
|
||||
// Search in static native providers (clipboard, emoji, ssh, systemd, etc.)
|
||||
for provider in &self.static_native_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in dynamic providers
|
||||
for provider in &self.dynamic_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in widget providers
|
||||
for provider in &self.widget_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Submenu] No submenu actions found for plugin '{}'", plugin_id);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
//! Native Plugin Provider Bridge
|
||||
//!
|
||||
//! This module provides a bridge between native plugins (compiled .so files)
|
||||
//! and the core Provider trait used by ProviderManager.
|
||||
//!
|
||||
//! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files
|
||||
//! and provide search providers via an ABI-stable interface.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use log::debug;
|
||||
use owlry_plugin_api::{PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition};
|
||||
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use crate::plugins::native_loader::NativePlugin;
|
||||
|
||||
/// A provider backed by a native plugin
|
||||
///
|
||||
/// This wraps a native plugin's provider and implements the core Provider trait,
|
||||
/// allowing native plugins to be used seamlessly with the existing ProviderManager.
|
||||
pub struct NativeProvider {
|
||||
/// The native plugin (shared reference since multiple providers may use same plugin)
|
||||
plugin: Arc<NativePlugin>,
|
||||
/// Provider metadata
|
||||
info: ProviderInfo,
|
||||
/// Handle to the provider state in the plugin
|
||||
handle: ProviderHandle,
|
||||
/// Cached items (for static providers)
|
||||
items: RwLock<Vec<LaunchItem>>,
|
||||
}
|
||||
|
||||
impl NativeProvider {
|
||||
/// Create a new native provider
|
||||
pub fn new(plugin: Arc<NativePlugin>, info: ProviderInfo) -> Self {
|
||||
let handle = plugin.init_provider(info.id.as_str());
|
||||
|
||||
Self {
|
||||
plugin,
|
||||
info,
|
||||
handle,
|
||||
items: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the ProviderType for this native provider
|
||||
/// All native plugins return Plugin(type_id) - the core has no hardcoded plugin types
|
||||
fn get_provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin(self.info.type_id.to_string())
|
||||
}
|
||||
|
||||
/// Convert a plugin API item to a core LaunchItem
|
||||
fn convert_item(&self, item: ApiPluginItem) -> LaunchItem {
|
||||
LaunchItem {
|
||||
id: item.id.to_string(),
|
||||
name: item.name.to_string(),
|
||||
description: item.description.as_ref().map(|s| s.to_string()).into(),
|
||||
icon: item.icon.as_ref().map(|s| s.to_string()).into(),
|
||||
provider: self.get_provider_type(),
|
||||
command: item.command.to_string(),
|
||||
terminal: item.terminal,
|
||||
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query the provider
|
||||
///
|
||||
/// For dynamic providers, this is called per-keystroke.
|
||||
/// For static providers, returns cached items unless query is a special command
|
||||
/// (submenu queries `?SUBMENU:` or action commands `!ACTION:`).
|
||||
pub fn query(&self, query: &str) -> Vec<LaunchItem> {
|
||||
// Special queries (submenu, actions) should always be forwarded to the plugin
|
||||
let is_special_query = query.starts_with("?SUBMENU:") || query.starts_with("!");
|
||||
|
||||
if self.info.provider_type != ProviderKind::Dynamic && !is_special_query {
|
||||
return self.items.read().unwrap().clone();
|
||||
}
|
||||
|
||||
let api_items = self.plugin.query_provider(self.handle, query);
|
||||
api_items.into_iter().map(|item| self.convert_item(item)).collect()
|
||||
}
|
||||
|
||||
/// Check if this provider has a prefix that matches the query
|
||||
#[allow(dead_code)]
|
||||
pub fn matches_prefix(&self, query: &str) -> bool {
|
||||
match self.info.prefix.as_ref().into_option() {
|
||||
Some(prefix) => query.starts_with(prefix.as_str()),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the prefix for this provider (if any)
|
||||
#[allow(dead_code)]
|
||||
pub fn prefix(&self) -> Option<&str> {
|
||||
self.info.prefix.as_ref().map(|s| s.as_str()).into()
|
||||
}
|
||||
|
||||
/// Check if this is a dynamic provider
|
||||
#[allow(dead_code)]
|
||||
pub fn is_dynamic(&self) -> bool {
|
||||
self.info.provider_type == ProviderKind::Dynamic
|
||||
}
|
||||
|
||||
/// Get the provider type ID (e.g., "calc", "clipboard", "weather")
|
||||
pub fn type_id(&self) -> &str {
|
||||
self.info.type_id.as_str()
|
||||
}
|
||||
|
||||
/// Check if this is a widget provider (appears at top of results)
|
||||
pub fn is_widget(&self) -> bool {
|
||||
self.info.position == ProviderPosition::Widget
|
||||
}
|
||||
|
||||
/// Get the provider's priority for result ordering
|
||||
/// Higher values appear first in results
|
||||
pub fn priority(&self) -> i32 {
|
||||
self.info.priority
|
||||
}
|
||||
|
||||
/// Execute an action command on the provider
|
||||
/// Uses query with "!" prefix to trigger action handling in the plugin
|
||||
pub fn execute_action(&self, action: &str) {
|
||||
let action_query = format!("!{}", action);
|
||||
self.plugin.query_provider(self.handle, &action_query);
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for NativeProvider {
|
||||
fn name(&self) -> &str {
|
||||
self.info.name.as_str()
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
self.get_provider_type()
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
// Only refresh static providers
|
||||
if self.info.provider_type != ProviderKind::Static {
|
||||
return;
|
||||
}
|
||||
|
||||
debug!("Refreshing native provider '{}'", self.info.name.as_str());
|
||||
|
||||
let api_items = self.plugin.refresh_provider(self.handle);
|
||||
let items: Vec<LaunchItem> = api_items
|
||||
.into_iter()
|
||||
.map(|item| self.convert_item(item))
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
"Native provider '{}' loaded {} items",
|
||||
self.info.name.as_str(),
|
||||
items.len()
|
||||
);
|
||||
|
||||
*self.items.write().unwrap() = items;
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
// This is tricky with RwLock - we need to return a reference but can't
|
||||
// hold the lock across the return. We use a raw pointer approach.
|
||||
//
|
||||
// SAFETY: The items Vec is only modified during refresh() which takes
|
||||
// &mut self, so no concurrent modification can occur while this
|
||||
// reference is live.
|
||||
unsafe {
|
||||
let guard = self.items.read().unwrap();
|
||||
let ptr = guard.as_ptr();
|
||||
let len = guard.len();
|
||||
std::slice::from_raw_parts(ptr, len)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeProvider {
|
||||
fn drop(&mut self) {
|
||||
// Clean up the provider handle
|
||||
self.plugin.drop_provider(self.handle);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Note: Full testing requires actual .so plugins, which we'll test
|
||||
// via integration tests. Unit tests here focus on the conversion logic.
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_conversion() {
|
||||
// Test that type_id is correctly converted to ProviderType::Plugin
|
||||
let type_id = "calculator";
|
||||
let provider_type = ProviderType::Plugin(type_id.to_string());
|
||||
|
||||
assert_eq!(format!("{}", provider_type), "calculator");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::config::AppearanceConfig;
|
||||
use owlry_core::config::AppearanceConfig;
|
||||
|
||||
/// Generate CSS with :root variables from config settings
|
||||
pub fn generate_variables_css(config: &AppearanceConfig) -> String {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::config::Config;
|
||||
use crate::data::FrecencyStore;
|
||||
use crate::filter::ProviderFilter;
|
||||
use crate::providers::{LaunchItem, ProviderManager, ProviderType};
|
||||
use owlry_core::config::Config;
|
||||
use owlry_core::data::FrecencyStore;
|
||||
use owlry_core::filter::ProviderFilter;
|
||||
use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType};
|
||||
use crate::ui::submenu;
|
||||
use crate::ui::ResultRow;
|
||||
use gtk4::gdk::Key;
|
||||
@@ -408,7 +408,7 @@ impl MainWindow {
|
||||
}
|
||||
|
||||
/// Build dynamic hints based on enabled providers
|
||||
fn build_hints(config: &crate::config::ProvidersConfig) -> String {
|
||||
fn build_hints(config: &owlry_core::config::ProvidersConfig) -> String {
|
||||
let mut parts: Vec<String> = vec![
|
||||
"Tab: cycle".to_string(),
|
||||
"↑↓: nav".to_string(),
|
||||
@@ -1337,7 +1337,7 @@ impl MainWindow {
|
||||
if let Err(e) = result {
|
||||
let msg = format!("Failed to launch '{}': {}", item.name, e);
|
||||
log::error!("{}", msg);
|
||||
crate::notify::notify("Launch failed", &msg);
|
||||
owlry_core::notify::notify("Launch failed", &msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1355,7 +1355,7 @@ impl MainWindow {
|
||||
if !Path::new(desktop_path).exists() {
|
||||
let msg = format!("Desktop file not found: {}", desktop_path);
|
||||
log::error!("{}", msg);
|
||||
crate::notify::notify("Launch failed", &msg);
|
||||
owlry_core::notify::notify("Launch failed", &msg);
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg));
|
||||
}
|
||||
|
||||
@@ -1372,7 +1372,7 @@ impl MainWindow {
|
||||
if !uwsm_available {
|
||||
let msg = "uwsm is enabled in config but not installed";
|
||||
log::error!("{}", msg);
|
||||
crate::notify::notify("Launch failed", msg);
|
||||
owlry_core::notify::notify("Launch failed", msg);
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::providers::LaunchItem;
|
||||
use owlry_core::providers::LaunchItem;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget};
|
||||
|
||||
@@ -81,11 +81,11 @@ impl ResultRow {
|
||||
} else {
|
||||
// Default icon based on provider type (only core types, plugins should provide icons)
|
||||
let default_icon = match &item.provider {
|
||||
crate::providers::ProviderType::Application => "application-x-executable-symbolic",
|
||||
crate::providers::ProviderType::Command => "utilities-terminal-symbolic",
|
||||
crate::providers::ProviderType::Dmenu => "view-list-symbolic",
|
||||
owlry_core::providers::ProviderType::Application => "application-x-executable-symbolic",
|
||||
owlry_core::providers::ProviderType::Command => "utilities-terminal-symbolic",
|
||||
owlry_core::providers::ProviderType::Dmenu => "view-list-symbolic",
|
||||
// Plugins should provide their own icon; fallback to generic addon icon
|
||||
crate::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic",
|
||||
owlry_core::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic",
|
||||
};
|
||||
let img = Image::from_icon_name(default_icon);
|
||||
img.set_pixel_size(32);
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::providers::LaunchItem;
|
||||
use owlry_core::providers::LaunchItem;
|
||||
|
||||
/// Parse a submenu command and extract plugin_id and data
|
||||
/// Returns (plugin_id, data) if command matches SUBMENU: format
|
||||
@@ -66,7 +66,7 @@ pub fn is_submenu_item(item: &LaunchItem) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::providers::ProviderType;
|
||||
use owlry_core::providers::ProviderType;
|
||||
|
||||
#[test]
|
||||
fn test_parse_submenu_command() {
|
||||
|
||||
Reference in New Issue
Block a user