diff --git a/Cargo.lock b/Cargo.lock index 3abeb03..dd5b445 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -409,12 +409,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.44" @@ -555,17 +549,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "ctrlc" -version = "3.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" -dependencies = [ - "dispatch2", - "nix", - "windows-sys 0.61.2", -] - [[package]] name = "deranged" version = "0.5.8" @@ -603,8 +586,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.0", - "block2", - "libc", "objc2", ] @@ -807,6 +788,16 @@ dependencies = [ "xdg", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2033,18 +2024,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "nix" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nom" version = "1.2.4" @@ -2372,10 +2351,10 @@ name = "owlry-core" version = "1.3.4" dependencies = [ "chrono", - "ctrlc", "dirs", "env_logger", "freedesktop-desktop-entry", + "fs2", "fuzzy-matcher", "libloading 0.8.9", "log", @@ -2389,10 +2368,10 @@ dependencies = [ "semver", "serde", "serde_json", + "signal-hook", "tempfile", "thiserror 2.0.18", "toml 0.8.23", - "toml_edit 0.22.27", ] [[package]] @@ -3058,6 +3037,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" diff --git a/crates/owlry-core/Cargo.toml b/crates/owlry-core/Cargo.toml index 3896295..a11d932 100644 --- a/crates/owlry-core/Cargo.toml +++ b/crates/owlry-core/Cargo.toml @@ -30,7 +30,7 @@ semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" -toml_edit = "0.22" +fs2 = "0.4" chrono = { version = "0.4", features = ["serde"] } dirs = "5" @@ -42,7 +42,7 @@ notify = "7" notify-debouncer-mini = "0.5" # Signal handling -ctrlc = { version = "3", features = ["termination"] } +signal-hook = "0.3" # Logging & notifications log = "0.4" diff --git a/crates/owlry-core/src/config/mod.rs b/crates/owlry-core/src/config/mod.rs index 43a5888..28aff4d 100644 --- a/crates/owlry-core/src/config/mod.rs +++ b/crates/owlry-core/src/config/mod.rs @@ -1,8 +1,8 @@ +use fs2::FileExt; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; -use toml_edit::{DocumentMut, Item}; use crate::paths; @@ -33,6 +33,10 @@ pub struct Config { pub plugins: PluginsConfig, #[serde(default)] pub profiles: HashMap, + /// Per-plugin configuration tables. + /// Defined as `[plugin_config.]` in config.toml. + #[serde(default)] + pub plugin_config: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -216,10 +220,6 @@ pub struct PluginsConfig { #[serde(default = "default_true")] pub enabled: bool, - /// List of plugin IDs to enable (empty = all discovered plugins) - #[serde(default)] - pub enabled_plugins: Vec, - /// List of plugin IDs to explicitly disable #[serde(default)] pub disabled_plugins: Vec, @@ -233,11 +233,6 @@ pub struct PluginsConfig { #[serde(default)] pub registry_url: Option, - /// Per-plugin configuration tables - /// Accessed via `[plugins.]` sections in config.toml - /// Each plugin can define its own config schema - #[serde(flatten)] - pub plugin_configs: HashMap, } /// Sandbox settings for plugin security @@ -264,43 +259,13 @@ 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.]` - #[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 { - 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 { - self.plugin_configs.get(plugin_name)?.get(key)?.as_bool() - } -} - impl Default for SandboxConfig { fn default() -> Self { Self { @@ -456,36 +421,20 @@ fn command_exists(cmd: &str) -> bool { // Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default -/// Merge `new` into `existing`, updating values while preserving comments and unknown keys. -/// -/// Tables are recursed into so that section-level comments survive. For leaf values -/// (scalars, arrays) the item is replaced but the surrounding table structure — and -/// any keys in `existing` that are absent from `new` — are left untouched. -fn merge_toml_doc(existing: &mut DocumentMut, new: &DocumentMut) { - for (key, new_item) in new.iter() { - match existing.get_mut(key) { - Some(existing_item) => merge_item(existing_item, new_item), - None => { - existing.insert(key, new_item.clone()); - } +/// Extract leading comment lines (lines beginning with `#`) from a TOML file's content. +/// Stops at the first non-comment, non-empty line. +fn extract_header_comments(content: &str) -> String { + let mut header = String::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') || trimmed.is_empty() { + header.push_str(line); + header.push('\n'); + } else { + break; } } -} - -fn merge_item(existing: &mut Item, new: &Item) { - match (existing.as_table_mut(), new.as_table()) { - (Some(e), Some(n)) => { - for (key, new_child) in n.iter() { - match e.get_mut(key) { - Some(existing_child) => merge_item(existing_child, new_child), - None => { - e.insert(key, new_child.clone()); - } - } - } - } - _ => *existing = new.clone(), - } + header } impl Config { @@ -493,6 +442,30 @@ impl Config { paths::config_file() } + /// Get configuration table for a plugin by name. + #[allow(dead_code)] + pub fn get_plugin_config(&self, plugin_name: &str) -> Option<&toml::Value> { + self.plugin_config.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_config.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 { + self.plugin_config.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 { + self.plugin_config.get(plugin_name)?.get(key)?.as_bool() + } + pub fn load_or_default() -> Self { Self::load().unwrap_or_else(|e| { warn!("Failed to load config: {}, using defaults", e); @@ -508,8 +481,27 @@ impl Config { Self::default() } else { let content = std::fs::read_to_string(&path)?; - let config: Config = toml::from_str(&content)?; + let mut config: Config = toml::from_str(&content)?; info!("Loaded config from {:?}", path); + // Migrate legacy [plugins.] entries to [plugin_config.]. + // Known PluginsConfig fields are excluded from migration. + const KNOWN_PLUGINS_KEYS: &[&str] = + &["enabled", "disabled_plugins", "sandbox", "registry_url"]; + if let Ok(raw) = toml::from_str::(&content) + && let Some(plugins_table) = raw.get("plugins").and_then(|v| v.as_table()) + { + for (key, value) in plugins_table { + if !KNOWN_PLUGINS_KEYS.contains(&key.as_str()) + && !config.plugin_config.contains_key(key) + { + warn!( + "Config: [plugins.{}] is deprecated; move to [plugin_config.{}]", + key, key + ); + config.plugin_config.insert(key.clone(), value.clone()); + } + } + } config }; @@ -539,30 +531,41 @@ impl Config { paths::ensure_parent_dir(&path)?; - let new_content = toml::to_string_pretty(self)?; + // Acquire an exclusive advisory lock via a sibling lock file. + // Concurrent writers (e.g. two `owlry plugin enable` invocations) will + // block here until the first one finishes, preventing interleaved writes. + let lock_path = path.with_extension("toml.lock"); + let lock_file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) // lock files are never written to; don't clobber existing + .open(&lock_path)?; + lock_file.lock_exclusive()?; - // If a config file already exists, merge into it to preserve comments and - // any keys the user has added that are not part of the Config struct. - let content = if path.exists() { + // Preserve any leading comment block (e.g. user docs / generated header). + let header = if path.exists() { let existing = std::fs::read_to_string(&path)?; - match existing.parse::() { - Ok(mut doc) => { - if let Ok(new_doc) = new_content.parse::() { - merge_toml_doc(&mut doc, &new_doc); - } - doc.to_string() - } - Err(_) => { - // Existing file is malformed — fall back to full rewrite. - warn!("Existing config is malformed; overwriting with current settings"); - new_content - } - } + extract_header_comments(&existing) } else { - new_content + String::new() }; - std::fs::write(&path, content)?; + let body = toml::to_string_pretty(self)?; + let content = if header.is_empty() { + body + } else { + format!("{}\n{}", header.trim_end(), body) + }; + + // Atomic write: write to a sibling temp file, then rename over the target. + // rename(2) is atomic on POSIX — readers always see either the old or new file. + let tmp_path = path.with_extension("toml.tmp"); + std::fs::write(&tmp_path, &content)?; + std::fs::rename(&tmp_path, &path)?; + + // Lock is released when lock_file is dropped here. + drop(lock_file); + info!("Saved config to {:?}", path); Ok(()) } diff --git a/crates/owlry-core/src/data/frecency.rs b/crates/owlry-core/src/data/frecency.rs index 13238d2..584a3e1 100644 --- a/crates/owlry-core/src/data/frecency.rs +++ b/crates/owlry-core/src/data/frecency.rs @@ -29,6 +29,10 @@ impl Default for FrecencyData { } } +const MAX_ENTRIES: usize = 5000; +const PRUNE_AGE_DAYS: i64 = 180; +const MIN_LAUNCHES_TO_KEEP: u32 = 3; + /// Frecency store for tracking and boosting recently/frequently used items pub struct FrecencyStore { data: FrecencyData, @@ -44,10 +48,49 @@ impl FrecencyStore { info!("Frecency store loaded with {} entries", data.entries.len()); - Self { + let mut store = Self { data, path, dirty: false, + }; + store.prune(); + store + } + + /// Remove stale low-usage entries and enforce the hard cap. + /// + /// Entries older than `PRUNE_AGE_DAYS` with fewer than `MIN_LAUNCHES_TO_KEEP` + /// launches are removed. After age-based pruning, entries are sorted by score + /// (descending) and the list is truncated to `MAX_ENTRIES`. + fn prune(&mut self) { + let now = Utc::now(); + let cutoff = now - chrono::Duration::days(PRUNE_AGE_DAYS); + + let before = self.data.entries.len(); + self.data.entries.retain(|_, e| { + e.last_launch > cutoff || e.launch_count >= MIN_LAUNCHES_TO_KEEP + }); + + if self.data.entries.len() > MAX_ENTRIES { + // Sort by score descending and keep the top MAX_ENTRIES + let mut scored: Vec<(String, f64)> = self + .data + .entries + .iter() + .map(|(k, e)| { + (k.clone(), Self::calculate_frecency_at(e.launch_count, e.last_launch, now)) + }) + .collect(); + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + let keep: std::collections::HashSet = + scored.into_iter().take(MAX_ENTRIES).map(|(k, _)| k).collect(); + self.data.entries.retain(|k, _| keep.contains(k)); + } + + let removed = before - self.data.entries.len(); + if removed > 0 { + info!("Frecency: pruned {} stale entries ({} remaining)", removed, self.data.entries.len()); + self.dirty = true; } } diff --git a/crates/owlry-core/src/ipc.rs b/crates/owlry-core/src/ipc.rs index 9ac3d16..340d2e6 100644 --- a/crates/owlry-core/src/ipc.rs +++ b/crates/owlry-core/src/ipc.rs @@ -24,6 +24,8 @@ pub enum Request { PluginAction { command: String, }, + /// Query the daemon's plugin registry (native plugins + suppressed entries). + PluginList, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -32,10 +34,30 @@ pub enum Response { Results { items: Vec }, Providers { list: Vec }, SubmenuItems { items: Vec }, + PluginList { entries: Vec }, Ack, Error { message: String }, } +/// Registry entry for a loaded or suppressed plugin (native plugins only). +/// Script plugins are tracked separately via filesystem discovery. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PluginEntry { + pub id: String, + pub name: String, + pub version: String, + /// Plugin runtime type: "native", "builtin" + pub runtime: String, + /// Load status: "active" or "suppressed" + pub status: String, + /// Human-readable detail for non-active status (e.g. suppression reason) + #[serde(default, skip_serializing_if = "String::is_empty")] + pub status_detail: String, + /// Provider type IDs registered by this plugin + #[serde(default)] + pub providers: Vec, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ResultItem { pub id: String, @@ -50,6 +72,14 @@ pub struct ResultItem { pub terminal: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, + /// Item trust level: "core", "native_plugin", or "script_plugin". + /// Defaults to "core" when absent (backwards-compatible with old daemons). + #[serde(default = "default_source")] + pub source: String, +} + +fn default_source() -> String { + "core".to_string() } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/owlry-core/src/main.rs b/crates/owlry-core/src/main.rs index 4e40bad..8931098 100644 --- a/crates/owlry-core/src/main.rs +++ b/crates/owlry-core/src/main.rs @@ -1,4 +1,4 @@ -use log::{info, warn}; +use log::info; use owlry_core::paths; use owlry_core::server::Server; @@ -23,14 +23,8 @@ fn main() { } }; - // Graceful shutdown on SIGTERM/SIGINT - let sock_cleanup = sock.clone(); - if let Err(e) = ctrlc::set_handler(move || { - let _ = std::fs::remove_file(&sock_cleanup); - std::process::exit(0); - }) { - warn!("Failed to set signal handler: {}", e); - } + // SIGTERM/SIGINT are handled inside Server::run() via signal-hook, + // which saves frecency before exiting. if let Err(e) = server.run() { eprintln!("Server error: {e}"); diff --git a/crates/owlry-core/src/plugins/mod.rs b/crates/owlry-core/src/plugins/mod.rs index b7fa684..7dcd8d6 100644 --- a/crates/owlry-core/src/plugins/mod.rs +++ b/crates/owlry-core/src/plugins/mod.rs @@ -1,24 +1,38 @@ //! Owlry Plugin System //! -//! This module provides plugin support for extending owlry's functionality. -//! Plugins can register providers, actions, themes, and hooks. +//! This module loads and manages *plugins* — external code that extends owlry +//! with additional *plugin providers* beyond the built-in ones. +//! +//! # Terminology +//! +//! | Term | Meaning | +//! |------|---------| +//! | **Provider** | Abstract source of [`LaunchItem`]s (the core interface) | +//! | **Built-in provider** | Provider compiled into owlry-core (Application, Command) | +//! | **Plugin** | External code (native `.so` or script) loaded at runtime | +//! | **Plugin provider** | A provider registered by a plugin via its `type_id` | +//! | **Native plugin** | Pre-compiled Rust `.so` from `/usr/lib/owlry/plugins/` | +//! | **Script plugin** | Lua or Rune plugin from `~/.config/owlry/plugins/` | //! //! # 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) +//! - **Native plugins** (`.so`): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/` +//! - **Script plugins**: Lua or Rune scripts from `~/.config/owlry/plugins/` +//! (requires the corresponding runtime: `owlry-lua` or `owlry-rune`) //! -//! # Plugin Structure (Lua) +//! # Script Plugin Structure //! -//! Each Lua plugin lives in its own directory under `~/.config/owlry/plugins/`: +//! Each script plugin lives in its own directory under `~/.config/owlry/plugins/`: //! //! ```text //! ~/.config/owlry/plugins/ //! my-plugin/ //! plugin.toml # Plugin manifest -//! init.lua # Entry point +//! init.lua # Entry point (Lua) or init.rn (Rune) //! lib/ # Optional modules //! ``` +//! +//! [`LaunchItem`]: crate::providers::LaunchItem // Always available pub mod error; @@ -60,10 +74,9 @@ pub use manifest::{PluginManifest, check_compatibility, discover_plugins}; #[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 std::sync::{Arc, Mutex}; use manifest::{check_compatibility, discover_plugins}; @@ -73,8 +86,8 @@ mod lua_manager { plugins_dir: PathBuf, /// Current owlry version for compatibility checks owlry_version: String, - /// Loaded plugins by ID (Rc> allows sharing with LuaProviders) - plugins: HashMap>>, + /// Loaded plugins by ID. Arc> allows sharing with LuaProviders across threads. + plugins: HashMap>>, /// Plugin IDs that are explicitly disabled disabled: Vec, } @@ -116,7 +129,7 @@ mod lua_manager { } let plugin = LoadedPlugin::new(manifest, path); - self.plugins.insert(id, Rc::new(RefCell::new(plugin))); + self.plugins.insert(id, Arc::new(Mutex::new(plugin))); loaded_count += 1; } @@ -129,7 +142,7 @@ mod lua_manager { let mut errors = Vec::new(); for (id, plugin_rc) in &self.plugins { - let mut plugin = plugin_rc.borrow_mut(); + let mut plugin = plugin_rc.lock().unwrap(); if !plugin.enabled { continue; } @@ -145,23 +158,23 @@ mod lua_manager { errors } - /// Get a loaded plugin by ID (returns Rc for shared ownership) + /// Get a loaded plugin by ID #[allow(dead_code)] - pub fn get(&self, id: &str) -> Option>> { + pub fn get(&self, id: &str) -> Option>> { self.plugins.get(id).cloned() } /// Get all loaded plugins #[allow(dead_code)] - pub fn plugins(&self) -> impl Iterator>> + '_ { + pub fn plugins(&self) -> impl Iterator>> + '_ { self.plugins.values().cloned() } /// Get all enabled plugins - pub fn enabled_plugins(&self) -> impl Iterator>> + '_ { + pub fn enabled_plugins(&self) -> impl Iterator>> + '_ { self.plugins .values() - .filter(|p| p.borrow().enabled) + .filter(|p| p.lock().unwrap().enabled) .cloned() } @@ -174,7 +187,7 @@ mod lua_manager { /// Get the number of enabled plugins #[allow(dead_code)] pub fn enabled_count(&self) -> usize { - self.plugins.values().filter(|p| p.borrow().enabled).count() + self.plugins.values().filter(|p| p.lock().unwrap().enabled).count() } /// Enable a plugin by ID @@ -184,7 +197,7 @@ mod lua_manager { .plugins .get(id) .ok_or_else(|| PluginError::NotFound(id.to_string()))?; - let mut plugin = plugin_rc.borrow_mut(); + let mut plugin = plugin_rc.lock().unwrap(); if !plugin.enabled { plugin.enabled = true; @@ -202,7 +215,7 @@ mod lua_manager { .plugins .get(id) .ok_or_else(|| PluginError::NotFound(id.to_string()))?; - plugin_rc.borrow_mut().enabled = false; + plugin_rc.lock().unwrap().enabled = false; Ok(()) } @@ -211,13 +224,14 @@ mod lua_manager { pub fn providers_for(&self, provider_name: &str) -> Vec { self.enabled_plugins() .filter(|p| { - p.borrow() + p.lock() + .unwrap() .manifest .provides .providers .contains(&provider_name.to_string()) }) - .map(|p| p.borrow().id().to_string()) + .map(|p| p.lock().unwrap().id().to_string()) .collect() } @@ -225,21 +239,21 @@ mod lua_manager { #[allow(dead_code)] pub fn has_action_plugins(&self) -> bool { self.enabled_plugins() - .any(|p| p.borrow().manifest.provides.actions) + .any(|p| p.lock().unwrap().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) + .any(|p| p.lock().unwrap().manifest.provides.hooks) } /// Get all theme names provided by plugins #[allow(dead_code)] pub fn theme_names(&self) -> Vec { self.enabled_plugins() - .flat_map(|p| p.borrow().manifest.provides.themes.clone()) + .flat_map(|p| p.lock().unwrap().manifest.provides.themes.clone()) .collect() } diff --git a/crates/owlry-core/src/plugins/native_loader.rs b/crates/owlry-core/src/plugins/native_loader.rs index 372bc98..f9431bd 100644 --- a/crates/owlry-core/src/plugins/native_loader.rs +++ b/crates/owlry-core/src/plugins/native_loader.rs @@ -12,17 +12,70 @@ use std::collections::HashMap; use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Once}; +use std::sync::{Arc, Once, OnceLock, RwLock}; use libloading::Library; use log::{debug, error, info, warn}; use owlry_plugin_api::{ API_VERSION, HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo, - ProviderKind, RStr, + ProviderKind, ROption, RStr, RString, }; +use crate::config::Config; use crate::notify; +// ============================================================================ +// Plugin config access +// ============================================================================ + +/// Shared config reference, set by the host before any plugins are loaded. +static PLUGIN_CONFIG: OnceLock>> = OnceLock::new(); + +/// Share the config with the native plugin loader so plugins can read their +/// own config sections. Must be called before `NativePluginLoader::discover()`. +pub fn set_shared_config(config: Arc>) { + let _ = PLUGIN_CONFIG.set(config); +} + +extern "C" fn host_get_config_string(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption { + let Some(cfg_arc) = PLUGIN_CONFIG.get() else { + return ROption::RNone; + }; + let Ok(cfg) = cfg_arc.read() else { + return ROption::RNone; + }; + match cfg.get_plugin_string(plugin_id.as_str(), key.as_str()) { + Some(v) => ROption::RSome(RString::from(v)), + None => ROption::RNone, + } +} + +extern "C" fn host_get_config_int(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption { + let Some(cfg_arc) = PLUGIN_CONFIG.get() else { + return ROption::RNone; + }; + let Ok(cfg) = cfg_arc.read() else { + return ROption::RNone; + }; + match cfg.get_plugin_int(plugin_id.as_str(), key.as_str()) { + Some(v) => ROption::RSome(v), + None => ROption::RNone, + } +} + +extern "C" fn host_get_config_bool(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption { + let Some(cfg_arc) = PLUGIN_CONFIG.get() else { + return ROption::RNone; + }; + let Ok(cfg) = cfg_arc.read() else { + return ROption::RNone; + }; + match cfg.get_plugin_bool(plugin_id.as_str(), key.as_str()) { + Some(v) => ROption::RSome(v), + None => ROption::RNone, + } +} + // ============================================================================ // Host API Implementation // ============================================================================ @@ -71,6 +124,9 @@ static HOST_API: HostAPI = HostAPI { log_info: host_log_info, log_warn: host_log_warn, log_error: host_log_error, + get_config_string: host_get_config_string, + get_config_int: host_get_config_int, + get_config_bool: host_get_config_bool, }; /// Initialize the host API (called once before loading plugins) diff --git a/crates/owlry-core/src/plugins/runtime_loader.rs b/crates/owlry-core/src/plugins/runtime_loader.rs index 4206742..84b7ded 100644 --- a/crates/owlry-core/src/plugins/runtime_loader.rs +++ b/crates/owlry-core/src/plugins/runtime_loader.rs @@ -12,13 +12,13 @@ use std::mem::ManuallyDrop; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use libloading::{Library, Symbol}; use owlry_plugin_api::{PluginItem, RStr, RString, RVec}; use super::error::{PluginError, PluginResult}; -use crate::providers::{LaunchItem, Provider, ProviderType}; +use crate::providers::{ItemSource, LaunchItem, Provider, ProviderType}; /// System directory for runtime libraries pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes"; @@ -51,6 +51,11 @@ pub type LuaProviderInfo = ScriptProviderInfo; #[derive(Clone, Copy)] pub struct RuntimeHandle(pub *mut ()); +// SAFETY: The underlying runtime state (Lua VM, Rune VM) is Send — mlua enables +// the "send" feature and Rune wraps its state in Mutex internally. Access is always +// serialized through Arc>, so there are no data races. +unsafe impl Send for RuntimeHandle {} + /// VTable for script runtime functions (used by both Lua and Rune) #[repr(C)] pub struct ScriptRuntimeVTable { @@ -77,8 +82,10 @@ pub struct LoadedRuntime { _library: ManuallyDrop>, /// Runtime vtable vtable: &'static ScriptRuntimeVTable, - /// Runtime handle (state) - handle: RuntimeHandle, + /// Runtime handle shared with all RuntimeProvider instances for this runtime. + /// Mutex serializes concurrent vtable calls. Arc shares ownership so all + /// RuntimeProviders can call into the runtime through the same handle. + handle: Arc>, /// Provider information providers: Vec, } @@ -128,10 +135,14 @@ impl LoadedRuntime { // Initialize the runtime let plugins_dir_str = plugins_dir.to_string_lossy(); - let handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version)); + let raw_handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version)); + let handle = Arc::new(Mutex::new(raw_handle)); - // Get provider information - let providers_rvec = (vtable.providers)(handle); + // Get provider information — lock to serialize the vtable call + let providers_rvec = { + let h = handle.lock().unwrap(); + (vtable.providers)(*h) + }; let providers: Vec = providers_rvec.into_iter().collect(); log::info!( @@ -159,8 +170,12 @@ impl LoadedRuntime { self.providers .iter() .map(|info| { - let provider = - RuntimeProvider::new(self.name, self.vtable, self.handle, info.clone()); + let provider = RuntimeProvider::new( + self.name, + self.vtable, + Arc::clone(&self.handle), + info.clone(), + ); Box::new(provider) as Box }) .collect() @@ -169,19 +184,16 @@ impl LoadedRuntime { impl Drop for LoadedRuntime { fn drop(&mut self) { - (self.vtable.drop)(self.handle); + let h = self.handle.lock().unwrap(); + (self.vtable.drop)(*h); // Do NOT drop _library: ManuallyDrop ensures dlclose() is never called. // See field comment for rationale. } } - -// LoadedRuntime needs to be Send + Sync because ProviderManager is shared across -// threads via Arc>. -// Safety: RuntimeHandle is an opaque FFI handle accessed only through extern "C" -// vtable functions. The same safety argument that applies to RuntimeProvider applies -// here — all access is mediated by the vtable, and the runtime itself serializes access. -unsafe impl Send for LoadedRuntime {} -unsafe impl Sync for LoadedRuntime {} +// LoadedRuntime is Send + Sync because: +// - Arc> is Send + Sync (RuntimeHandle: Send via unsafe impl above) +// - All other fields are 'static references or Send types +// No unsafe impl needed — this is derived automatically. /// A provider backed by a dynamically loaded runtime pub struct RuntimeProvider { @@ -189,7 +201,9 @@ pub struct RuntimeProvider { #[allow(dead_code)] runtime_name: &'static str, vtable: &'static ScriptRuntimeVTable, - handle: RuntimeHandle, + /// Shared with the owning LoadedRuntime and sibling RuntimeProviders. + /// Mutex serializes concurrent vtable calls on the same runtime handle. + handle: Arc>, info: ScriptProviderInfo, items: Vec, } @@ -198,7 +212,7 @@ impl RuntimeProvider { fn new( runtime_name: &'static str, vtable: &'static ScriptRuntimeVTable, - handle: RuntimeHandle, + handle: Arc>, info: ScriptProviderInfo, ) -> Self { Self { @@ -220,6 +234,7 @@ impl RuntimeProvider { command: item.command.to_string(), terminal: item.terminal, tags: item.keywords.iter().map(|s| s.to_string()).collect(), + source: ItemSource::ScriptPlugin, } } } @@ -239,7 +254,10 @@ impl Provider for RuntimeProvider { } let name_rstr = RStr::from_str(self.info.name.as_str()); - let items_rvec = (self.vtable.refresh)(self.handle, name_rstr); + let items_rvec = { + let h = self.handle.lock().unwrap(); + (self.vtable.refresh)(*h, name_rstr) + }; self.items = items_rvec .into_iter() .map(|i| self.convert_item(i)) @@ -257,12 +275,10 @@ impl Provider for RuntimeProvider { } } -// RuntimeProvider needs to be Send + Sync for the Provider trait. -// Safety: RuntimeHandle is an opaque FFI handle accessed only through -// extern "C" vtable functions. The same safety argument that justifies -// Send applies to Sync — all access is mediated by the vtable. -unsafe impl Send for RuntimeProvider {} -unsafe impl Sync for RuntimeProvider {} +// RuntimeProvider is Send + Sync because: +// - Arc> is Send + Sync (RuntimeHandle: Send via unsafe impl above) +// - vtable is &'static (Send + Sync), info and items are Send +// No unsafe impl needed — this is derived automatically. /// Check if the Lua runtime is available pub fn lua_runtime_available() -> bool { diff --git a/crates/owlry-core/src/plugins/watcher.rs b/crates/owlry-core/src/plugins/watcher.rs index f3bb346..af828c0 100644 --- a/crates/owlry-core/src/plugins/watcher.rs +++ b/crates/owlry-core/src/plugins/watcher.rs @@ -89,8 +89,13 @@ fn watch_loop( if has_relevant_change { info!("Plugin file change detected, reloading runtimes..."); - let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner()); - pm_guard.reload_runtimes(); + match pm.write() { + Ok(mut pm_guard) => pm_guard.reload_runtimes(), + Err(_) => { + log::error!("Plugin watcher: provider lock poisoned; stopping watcher"); + return Err(Box::from("provider lock poisoned")); + } + } } } Ok(Err(error)) => { diff --git a/crates/owlry-core/src/providers/application.rs b/crates/owlry-core/src/providers/application.rs index 24cdd47..e00061d 100644 --- a/crates/owlry-core/src/providers/application.rs +++ b/crates/owlry-core/src/providers/application.rs @@ -1,4 +1,6 @@ -use super::{LaunchItem, Provider, ProviderType}; +use std::collections::HashSet; + +use super::{ItemSource, LaunchItem, Provider, ProviderType}; use crate::paths; use freedesktop_desktop_entry::{DesktopEntry, Iter}; use log::{debug, warn}; @@ -118,7 +120,21 @@ impl Provider for ApplicationProvider { .map(|s| s.to_string()) .collect(); + // Track seen .desktop file basenames to skip duplicates. + // XDG dirs are iterated user-first per spec, so the first occurrence wins. + let mut seen_basenames: HashSet = HashSet::new(); + for path in Iter::new(dirs.into_iter()) { + // Skip if we've already loaded a .desktop with this basename from a higher-priority dir. + if path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|basename| !seen_basenames.insert(basename.to_string())) + { + debug!("Skipping duplicate desktop entry: {:?}", path); + continue; + } + let content = match std::fs::read_to_string(&path) { Ok(c) => c, Err(e) => { @@ -196,6 +212,7 @@ impl Provider for ApplicationProvider { command: run_cmd, terminal: desktop_entry.terminal(), tags, + source: ItemSource::Core, }; self.items.push(item); diff --git a/crates/owlry-core/src/providers/calculator.rs b/crates/owlry-core/src/providers/calculator.rs index 5f80375..0a45627 100644 --- a/crates/owlry-core/src/providers/calculator.rs +++ b/crates/owlry-core/src/providers/calculator.rs @@ -1,4 +1,4 @@ -use super::{DynamicProvider, LaunchItem, ProviderType}; +use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType}; /// Built-in calculator provider. Evaluates mathematical expressions via `meval`. /// @@ -42,6 +42,7 @@ impl DynamicProvider for CalculatorProvider { command: copy_cmd, terminal: false, tags: vec!["math".into(), "calculator".into()], + source: ItemSource::Core, }] } Err(_) => Vec::new(), diff --git a/crates/owlry-core/src/providers/command.rs b/crates/owlry-core/src/providers/command.rs index 6fa15fa..3227521 100644 --- a/crates/owlry-core/src/providers/command.rs +++ b/crates/owlry-core/src/providers/command.rs @@ -1,4 +1,4 @@ -use super::{LaunchItem, Provider, ProviderType}; +use super::{ItemSource, LaunchItem, Provider, ProviderType}; use log::debug; use std::collections::HashSet; use std::os::unix::fs::PermissionsExt; @@ -89,6 +89,7 @@ impl Provider for CommandProvider { command: name, terminal: false, tags: Vec::new(), + source: ItemSource::Core, }; self.items.push(item); diff --git a/crates/owlry-core/src/providers/config_editor.rs b/crates/owlry-core/src/providers/config_editor.rs index aa777e2..ae6fd5e 100644 --- a/crates/owlry-core/src/providers/config_editor.rs +++ b/crates/owlry-core/src/providers/config_editor.rs @@ -2,7 +2,7 @@ use std::sync::{Arc, RwLock}; use log::warn; -use super::{DynamicProvider, LaunchItem, ProviderType}; +use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType}; use crate::config::Config; const ICON: &str = "preferences-system-symbolic"; @@ -46,22 +46,33 @@ impl ConfigProvider { } /// Execute a `CONFIG:*` action command. Returns `true` if handled. + /// + /// Acquires the write lock once and holds it across both the mutation and + /// the subsequent save, eliminating the TOCTOU window that would exist if + /// the sub-handlers each acquired the lock independently. fn handle_config_action(&self, command: &str) -> bool { let Some(rest) = command.strip_prefix("CONFIG:") else { return false; }; + let mut cfg = match self.config.write() { + Ok(c) => c, + Err(_) => return false, + }; + let result = if let Some(path) = rest.strip_prefix("toggle:") { - self.handle_toggle(path) + Self::toggle_config(&mut cfg, path) } else if let Some(kv) = rest.strip_prefix("set:") { - self.handle_set(kv) + Self::set_config(&mut cfg, kv) } else if let Some(profile_cmd) = rest.strip_prefix("profile:") { - self.handle_profile(profile_cmd) + Self::profile_config(&mut cfg, profile_cmd) } else { false }; - if result && let Ok(cfg) = self.config.read() && let Err(e) = cfg.save() { + if result + && let Err(e) = cfg.save() + { warn!("Failed to save config: {}", e); } @@ -70,12 +81,7 @@ impl ConfigProvider { // ── Toggle handler ────────────────────────────────────────────────── - fn handle_toggle(&self, path: &str) -> bool { - let mut cfg = match self.config.write() { - Ok(c) => c, - Err(_) => return false, - }; - + fn toggle_config(cfg: &mut Config, path: &str) -> bool { match path { "providers.applications" => { cfg.providers.applications = !cfg.providers.applications; @@ -115,14 +121,10 @@ impl ConfigProvider { // ── Set handler ───────────────────────────────────────────────────── - fn handle_set(&self, kv: &str) -> bool { + fn set_config(cfg: &mut Config, kv: &str) -> bool { let Some((path, value)) = kv.split_once(':') else { return false; }; - let mut cfg = match self.config.write() { - Ok(c) => c, - Err(_) => return false, - }; match path { "appearance.theme" => { @@ -183,12 +185,8 @@ impl ConfigProvider { // ── Profile handler ───────────────────────────────────────────────── - fn handle_profile(&self, cmd: &str) -> bool { + fn profile_config(cfg: &mut Config, cmd: &str) -> bool { if let Some(name) = cmd.strip_prefix("create:") { - let mut cfg = match self.config.write() { - Ok(c) => c, - Err(_) => return false, - }; if !name.is_empty() && !cfg.profiles.contains_key(name) { cfg.profiles.insert( name.to_string(), @@ -199,10 +197,6 @@ impl ConfigProvider { false } } else if let Some(name) = cmd.strip_prefix("delete:") { - let mut cfg = match self.config.write() { - Ok(c) => c, - Err(_) => return false, - }; cfg.profiles.remove(name).is_some() } else if let Some(rest) = cmd.strip_prefix("mode:") { // format: profile_name:toggle:mode_name @@ -210,10 +204,6 @@ impl ConfigProvider { if parts.len() == 3 && parts[1] == "toggle" { let profile_name = parts[0]; let mode_name = parts[2]; - let mut cfg = match self.config.write() { - Ok(c) => c, - Err(_) => return false, - }; if let Some(profile) = cfg.profiles.get_mut(profile_name) { if let Some(pos) = profile.modes.iter().position(|m| m == mode_name) { profile.modes.remove(pos); @@ -272,6 +262,7 @@ impl ConfigProvider { command: format!("CONFIG:toggle:providers.{}", field), terminal: false, tags: vec!["config".into(), "settings".into()], + source: ItemSource::Core, } }) .collect() @@ -326,6 +317,7 @@ impl ConfigProvider { command: format!("CONFIG:set:appearance.theme:{}", theme_name), terminal: false, tags: vec!["config".into(), "settings".into()], + source: ItemSource::Core, } }) .collect() @@ -356,6 +348,7 @@ impl ConfigProvider { command: format!("CONFIG:set:providers.search_engine:{}", engine), terminal: false, tags: vec!["config".into(), "settings".into()], + source: ItemSource::Core, } }) .collect() @@ -382,6 +375,7 @@ impl ConfigProvider { command: "CONFIG:toggle:providers.frecency".into(), terminal: false, tags: vec!["config".into(), "settings".into()], + source: ItemSource::Core, }); // If numeric input, offer a set-weight action @@ -396,6 +390,7 @@ impl ConfigProvider { command: format!("CONFIG:set:providers.frecency_weight:{}", clamped), terminal: false, tags: vec!["config".into(), "settings".into()], + source: ItemSource::Core, }); } @@ -422,6 +417,7 @@ impl ConfigProvider { command: String::new(), terminal: false, tags: vec!["config".into(), "settings".into()], + source: ItemSource::Core, }); // If numeric input, offer a set action @@ -438,6 +434,7 @@ impl ConfigProvider { command: format!("CONFIG:set:{}:{}", config_path, input), terminal: false, tags: vec!["config".into(), "settings".into()], + source: ItemSource::Core, }); } } @@ -481,6 +478,7 @@ impl ConfigProvider { command: String::new(), terminal: false, tags: vec!["config".into(), "settings".into()], + source: ItemSource::Core, }); } @@ -506,6 +504,7 @@ impl ConfigProvider { command: format!("CONFIG:profile:create:{}", name), terminal: false, tags: vec!["config".into(), "settings".into()], + source: ItemSource::Core, }] } @@ -534,6 +533,7 @@ impl ConfigProvider { command: format!("CONFIG:profile:delete:{}", profile_name), terminal: false, tags: vec!["config".into(), "settings".into()], + source: ItemSource::Core, }, ] } @@ -570,6 +570,7 @@ impl ConfigProvider { ), terminal: false, tags: vec!["config".into(), "settings".into()], + source: ItemSource::Core, } }) .collect() @@ -697,6 +698,7 @@ fn nav_item(id: &str, name: &str, description: &str) -> LaunchItem { command: String::new(), terminal: false, tags: vec!["config".into(), "settings".into()], + source: ItemSource::Core, } } diff --git a/crates/owlry-core/src/providers/converter/mod.rs b/crates/owlry-core/src/providers/converter/mod.rs index 5afceba..2c36e95 100644 --- a/crates/owlry-core/src/providers/converter/mod.rs +++ b/crates/owlry-core/src/providers/converter/mod.rs @@ -2,7 +2,7 @@ mod currency; mod parser; mod units; -use super::{DynamicProvider, LaunchItem, ProviderType}; +use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType}; const PROVIDER_TYPE_ID: &str = "conv"; const PROVIDER_ICON: &str = "edit-find-replace-symbolic"; @@ -69,6 +69,7 @@ impl DynamicProvider for ConverterProvider { ), terminal: false, tags: vec!["converter".into(), "units".into()], + source: ItemSource::Core, }) .collect() } diff --git a/crates/owlry-core/src/providers/lua_provider.rs b/crates/owlry-core/src/providers/lua_provider.rs index 7ed4c9c..067a85e 100644 --- a/crates/owlry-core/src/providers/lua_provider.rs +++ b/crates/owlry-core/src/providers/lua_provider.rs @@ -3,12 +3,11 @@ //! 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 std::sync::{Arc, Mutex}; use crate::plugins::{LoadedPlugin, PluginItem, ProviderRegistration}; -use super::{LaunchItem, Provider, ProviderType}; +use super::{ItemSource, LaunchItem, Provider, ProviderType}; /// A provider backed by a Lua plugin /// @@ -17,15 +16,16 @@ use super::{LaunchItem, Provider, ProviderType}; pub struct LuaProvider { /// Provider registration info registration: ProviderRegistration, - /// Reference to the loaded plugin (shared with other providers from same plugin) - plugin: Rc>, + /// Reference to the loaded plugin (shared with other providers from same plugin). + /// Mutex serializes concurrent refresh calls; Arc allows sharing across threads. + plugin: Arc>, /// Cached items from last refresh items: Vec, } impl LuaProvider { /// Create a new LuaProvider - pub fn new(registration: ProviderRegistration, plugin: Rc>) -> Self { + pub fn new(registration: ProviderRegistration, plugin: Arc>) -> Self { Self { registration, plugin, @@ -35,6 +35,9 @@ impl LuaProvider { /// Convert a PluginItem to a LaunchItem fn convert_item(&self, item: PluginItem) -> LaunchItem { + if item.command.is_none() { + log::warn!("Plugin item '{}' has no command", item.name); + } LaunchItem { id: item.id, name: item.name, @@ -44,6 +47,7 @@ impl LuaProvider { command: item.command.unwrap_or_default(), terminal: item.terminal, tags: item.tags, + source: ItemSource::ScriptPlugin, } } } @@ -63,7 +67,7 @@ impl Provider for LuaProvider { return; } - let plugin = self.plugin.borrow(); + let plugin = self.plugin.lock().unwrap(); match plugin.call_provider_refresh(&self.registration.name) { Ok(items) => { self.items = items.into_iter().map(|i| self.convert_item(i)).collect(); @@ -89,17 +93,15 @@ impl Provider for LuaProvider { } } -// LuaProvider needs to be Send + Sync for the Provider trait. -// Rc> is !Send and !Sync, but the ProviderManager RwLock ensures -// Rc> is only accessed during refresh() (write lock = exclusive access). -// Read-only operations (items(), search) only touch self.items (Vec). -unsafe impl Send for LuaProvider {} -unsafe impl Sync for LuaProvider {} +// LuaProvider is Send + Sync because: +// - Arc> is Send + Sync (LoadedPlugin: Send with mlua "send" feature) +// - All other fields are Send + Sync +// No unsafe impl needed. /// Create LuaProviders from all registered providers in a plugin -pub fn create_providers_from_plugin(plugin: Rc>) -> Vec> { +pub fn create_providers_from_plugin(plugin: Arc>) -> Vec> { let registrations = { - let p = plugin.borrow(); + let p = plugin.lock().unwrap(); match p.get_provider_registrations() { Ok(regs) => regs, Err(e) => { diff --git a/crates/owlry-core/src/providers/mod.rs b/crates/owlry-core/src/providers/mod.rs index 5b2f7ff..432e1d1 100644 --- a/crates/owlry-core/src/providers/mod.rs +++ b/crates/owlry-core/src/providers/mod.rs @@ -23,11 +23,13 @@ pub use native_provider::NativeProvider; use chrono::Utc; use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; -use log::info; +use log::{info, warn}; #[cfg(feature = "dev-logging")] use log::debug; +use std::sync::{Arc, RwLock}; + use crate::config::Config; use crate::data::FrecencyStore; use crate::plugins::runtime_loader::LoadedRuntime; @@ -42,6 +44,38 @@ pub struct ProviderDescriptor { pub position: String, } +/// Trust level of a [`LaunchItem`]'s command, used to gate `sh -c` execution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ItemSource { + /// Built-in provider compiled into owlry-core (trusted). + Core, + /// Native plugin (.so from /usr/lib/owlry/plugins/) — trusted at install time. + NativePlugin, + /// Script plugin (Lua/Rune from ~/.config/owlry/plugins/) — user-installed, untrusted. + ScriptPlugin, +} + +impl ItemSource { + pub fn as_str(&self) -> &'static str { + match self { + ItemSource::Core => "core", + ItemSource::NativePlugin => "native_plugin", + ItemSource::ScriptPlugin => "script_plugin", + } + } +} + +impl std::str::FromStr for ItemSource { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "native_plugin" => Ok(ItemSource::NativePlugin), + "script_plugin" => Ok(ItemSource::ScriptPlugin), + _ => Ok(ItemSource::Core), + } + } +} + /// Represents a single searchable/launchable item #[derive(Debug, Clone)] pub struct LaunchItem { @@ -55,21 +89,29 @@ pub struct LaunchItem { pub terminal: bool, /// Tags/categories for filtering (e.g., from .desktop Categories) pub tags: Vec, + /// Trust level — gates `sh -c` execution for script plugin items. + pub source: ItemSource, } -/// Provider type identifier for filtering and badge display +/// 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. +/// **Glossary:** +/// - *Provider*: An abstract source of [`LaunchItem`]s (interface). +/// - *Built-in provider*: A provider compiled into owlry-core (Application, Command). +/// - *Plugin*: External code (native `.so` or Lua/Rune script) loaded at runtime. +/// - *Plugin provider*: A provider registered by a plugin, identified by its `type_id`. +/// +/// All plugin-provided types use `Plugin(type_id)`. The core has no hardcoded +/// knowledge of individual plugin types — this keeps the core app extensible. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum ProviderType { - /// Built-in: Desktop applications from XDG directories + /// Built-in provider: desktop applications from XDG data directories. Application, - /// Built-in: Shell commands from PATH + /// Built-in provider: shell commands from `$PATH`. Command, - /// Built-in: Pipe-based input (dmenu compatibility) + /// Built-in provider: pipe-based input for dmenu compatibility (client-local only). Dmenu, - /// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji") + /// Plugin provider with its declared `type_id` (e.g. `"calc"`, `"weather"`, `"emoji"`). Plugin(String), } @@ -145,6 +187,9 @@ pub struct ProviderManager { runtimes: Vec, /// Type IDs of providers from script runtimes (for hot-reload removal) runtime_type_ids: std::collections::HashSet, + /// Registry of native plugins that were loaded or suppressed at startup. + /// Used by `Request::PluginList` to report plugin status to the CLI. + pub plugin_registry: Vec, } impl ProviderManager { @@ -166,6 +211,7 @@ impl ProviderManager { matcher: SkimMatcherV2::default(), runtimes: Vec::new(), runtime_type_ids: std::collections::HashSet::new(), + plugin_registry: Vec::new(), }; // Categorize native plugins based on their declared ProviderKind and ProviderPosition @@ -207,9 +253,8 @@ impl ProviderManager { /// Loads native plugins, creates core providers (Application + Command), /// categorizes everything, and performs initial refresh. Used by the daemon /// which doesn't have the UI-driven setup path from `app.rs`. - pub fn new_with_config(config: &Config) -> Self { + pub fn new_with_config(config: Arc>) -> Self { use crate::plugins::native_loader::NativePluginLoader; - use std::sync::Arc; // Create core providers let mut core_providers: Vec> = vec![ @@ -217,9 +262,23 @@ impl ProviderManager { Box::new(CommandProvider::new()), ]; + // Take a read lock once for configuration reads during setup. + let (disabled_plugins, calc_enabled, conv_enabled, sys_enabled) = match config.read() { + Ok(cfg) => ( + cfg.plugins.disabled_plugins.clone(), + cfg.providers.calculator, + cfg.providers.converter, + cfg.providers.system, + ), + Err(_) => { + warn!("Config lock poisoned during provider init; using defaults"); + (Vec::new(), true, true, true) + } + }; + // Load native plugins let mut loader = NativePluginLoader::new(); - loader.set_disabled(config.plugins.disabled_plugins.clone()); + loader.set_disabled(disabled_plugins); let native_providers = match loader.discover() { Ok(count) => { @@ -304,23 +363,22 @@ impl ProviderManager { // Built-in dynamic providers let mut builtin_dynamic: Vec> = Vec::new(); - if config.providers.calculator { + if calc_enabled { builtin_dynamic.push(Box::new(calculator::CalculatorProvider)); info!("Registered built-in calculator provider"); } - if config.providers.converter { + if conv_enabled { builtin_dynamic.push(Box::new(converter::ConverterProvider::new())); info!("Registered built-in converter provider"); } - // Config editor — always enabled - let config_arc = std::sync::Arc::new(std::sync::RwLock::new(config.clone())); - builtin_dynamic.push(Box::new(config_editor::ConfigProvider::new(config_arc))); + // Config editor — always enabled; shares the same Arc> + builtin_dynamic.push(Box::new(config_editor::ConfigProvider::new(Arc::clone(&config)))); info!("Registered built-in config editor provider"); // Built-in static providers - if config.providers.system { + if sys_enabled { core_providers.push(Box::new(system::SystemProvider::new())); info!("Registered built-in system provider"); } @@ -345,15 +403,28 @@ impl ProviderManager { ids }; + let mut suppressed_registry: Vec = Vec::new(); let native_providers: Vec = native_providers .into_iter() .filter(|provider| { let type_id = provider.type_id(); if builtin_ids.contains(type_id) { - info!( - "Skipping native plugin '{}' — built-in provider exists", + log::warn!( + "Native plugin '{}' suppressed — a built-in provider with the same type ID exists", type_id ); + suppressed_registry.push(crate::ipc::PluginEntry { + id: provider.plugin_id().to_string(), + name: provider.plugin_name().to_string(), + version: provider.plugin_version().to_string(), + runtime: "native".to_string(), + status: "suppressed".to_string(), + status_detail: format!( + "built-in provider '{}' takes precedence", + type_id + ), + providers: vec![type_id.to_string()], + }); false } else { true @@ -361,10 +432,28 @@ impl ProviderManager { }) .collect(); + // Capture active native plugin entries before ownership moves into Self::new(). + let active_registry: Vec = native_providers + .iter() + .map(|p| crate::ipc::PluginEntry { + id: p.plugin_id().to_string(), + name: p.plugin_name().to_string(), + version: p.plugin_version().to_string(), + runtime: "native".to_string(), + status: "active".to_string(), + status_detail: String::new(), + providers: vec![p.type_id().to_string()], + }) + .collect(); + let mut manager = Self::new(core_providers, native_providers); manager.builtin_dynamic = builtin_dynamic; manager.runtimes = runtimes; manager.runtime_type_ids = runtime_type_ids; + + manager.plugin_registry = active_registry; + manager.plugin_registry.extend(suppressed_registry); + manager } @@ -378,11 +467,11 @@ impl ProviderManager { !self.runtime_type_ids.contains(&type_str) }); - // Drop old runtimes (catch panics from runtime cleanup) + // Drop old runtimes. Panics here will poison the ProviderManager RwLock, + // which is caught and reported by the watcher thread (see plugins/watcher.rs). + info!("Dropping old runtimes before reload"); let old_runtimes = std::mem::take(&mut self.runtimes); - drop(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - drop(old_runtimes); - }))); + drop(old_runtimes); self.runtime_type_ids.clear(); let owlry_version = env!("CARGO_PKG_VERSION"); @@ -600,6 +689,7 @@ impl ProviderManager { query: &str, max_results: usize, filter: &crate::filter::ProviderFilter, + tag_filter: Option<&str>, ) -> Vec<(LaunchItem, i64)> { // Collect items from core providers let core_items = self @@ -615,16 +705,15 @@ impl ProviderManager { .filter(|p| filter.is_active(p.provider_type())) .flat_map(|p| p.items().iter().cloned()); + let all_items = core_items.chain(native_items).filter(|item| { + tag_filter.is_none_or(|t| item.tags.iter().any(|it| it == t)) + }); + if query.is_empty() { - return core_items - .chain(native_items) - .take(max_results) - .map(|item| (item, 0)) - .collect(); + return all_items.take(max_results).map(|item| (item, 0)).collect(); } - let mut results: Vec<(LaunchItem, i64)> = core_items - .chain(native_items) + let mut results: Vec<(LaunchItem, i64)> = all_items .filter_map(|item| { let name_score = self.matcher.fuzzy_match(&item.name, query); let desc_score = item @@ -1146,6 +1235,7 @@ mod tests { command: format!("run-{}", id), terminal: false, tags: Vec::new(), + source: ItemSource::Core, } } diff --git a/crates/owlry-core/src/providers/native_provider.rs b/crates/owlry-core/src/providers/native_provider.rs index a144930..3d6061c 100644 --- a/crates/owlry-core/src/providers/native_provider.rs +++ b/crates/owlry-core/src/providers/native_provider.rs @@ -13,7 +13,7 @@ use owlry_plugin_api::{ PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition, }; -use super::{LaunchItem, Provider, ProviderType}; +use super::{ItemSource, LaunchItem, Provider, ProviderType}; use crate::plugins::native_loader::NativePlugin; /// A provider backed by a native plugin @@ -50,6 +50,21 @@ impl NativeProvider { ProviderType::Plugin(self.info.type_id.to_string()) } + /// The ID of the plugin that owns this provider. + pub fn plugin_id(&self) -> &str { + self.plugin.id() + } + + /// The human-readable name of the plugin that owns this provider. + pub fn plugin_name(&self) -> &str { + self.plugin.name() + } + + /// The version string of the plugin that owns this provider. + pub fn plugin_version(&self) -> &str { + self.plugin.info.version.as_str() + } + /// Convert a plugin API item to a core LaunchItem fn convert_item(&self, item: ApiPluginItem) -> LaunchItem { LaunchItem { @@ -61,6 +76,7 @@ impl NativeProvider { command: item.command.to_string(), terminal: item.terminal, tags: item.keywords.iter().map(|s| s.to_string()).collect(), + source: ItemSource::NativePlugin, } } diff --git a/crates/owlry-core/src/providers/system.rs b/crates/owlry-core/src/providers/system.rs index 6c78a3a..a7d3c6f 100644 --- a/crates/owlry-core/src/providers/system.rs +++ b/crates/owlry-core/src/providers/system.rs @@ -1,4 +1,4 @@ -use super::{LaunchItem, Provider, ProviderType}; +use super::{ItemSource, LaunchItem, Provider, ProviderType}; /// Built-in system provider. Returns a fixed set of power and session management actions. /// @@ -72,6 +72,7 @@ impl SystemProvider { command: command.to_string(), terminal: false, tags: vec!["system".into()], + source: ItemSource::Core, }) .collect(); diff --git a/crates/owlry-core/src/server.rs b/crates/owlry-core/src/server.rs index 8bef4f2..06bfb24 100644 --- a/crates/owlry-core/src/server.rs +++ b/crates/owlry-core/src/server.rs @@ -2,6 +2,7 @@ use std::io::{self, BufRead, BufReader, Write}; use std::os::unix::fs::PermissionsExt; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, RwLock}; use std::time::Duration; use std::thread; @@ -9,6 +10,73 @@ use std::thread; /// Maximum allowed size for a single IPC request line (1 MiB). const MAX_REQUEST_SIZE: usize = 1_048_576; +/// Maximum number of concurrently active client connections. +const MAX_CONNECTIONS: usize = 16; + +/// Tracks active connection count across all handler threads. +static ACTIVE_CONNECTIONS: AtomicUsize = AtomicUsize::new(0); + +/// RAII guard that increments the connection counter on creation and decrements on drop. +struct ConnectionGuard; + +impl ConnectionGuard { + /// Try to acquire a connection slot. Returns `None` if at capacity. + fn try_acquire() -> Option { + let prev = ACTIVE_CONNECTIONS.fetch_add(1, Ordering::SeqCst); + if prev >= MAX_CONNECTIONS { + ACTIVE_CONNECTIONS.fetch_sub(1, Ordering::SeqCst); + None + } else { + Some(ConnectionGuard) + } + } +} + +impl Drop for ConnectionGuard { + fn drop(&mut self) { + ACTIVE_CONNECTIONS.fetch_sub(1, Ordering::SeqCst); + } +} + +/// Read a newline-terminated line from `reader` without allocating beyond `max` bytes. +/// +/// Unlike `BufRead::read_line`, this checks the size limit incrementally against +/// the internal buffer rather than after the full allocation. Returns `Ok(None)` +/// on clean EOF, `Err(InvalidData)` when `max` is exceeded before finding `\n`. +fn read_bounded_line(reader: &mut BufReader, max: usize) -> io::Result> { + let mut buf: Vec = Vec::with_capacity(4096); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return if buf.is_empty() { + Ok(None) + } else { + Ok(Some(String::from_utf8_lossy(&buf).into_owned())) + }; + } + if let Some(pos) = available.iter().position(|&b| b == b'\n') { + if buf.len() + pos > max { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("request too large (exceeded {} bytes)", max), + )); + } + buf.extend_from_slice(&available[..pos]); + reader.consume(pos + 1); + return Ok(Some(String::from_utf8_lossy(&buf).into_owned())); + } + let len = available.len(); + if buf.len() + len > max { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("request too large (exceeded {} bytes)", max), + )); + } + buf.extend_from_slice(available); + reader.consume(len); + } +} + use log::{error, info, warn}; use crate::config::Config; @@ -24,7 +92,7 @@ pub struct Server { socket_path: PathBuf, provider_manager: Arc>, frecency: Arc>, - config: Arc, + config: Arc>, } impl Server { @@ -42,8 +110,10 @@ impl Server { std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?; info!("IPC server listening on {:?}", socket_path); - let config = Config::load_or_default(); - let provider_manager = ProviderManager::new_with_config(&config); + let config = Arc::new(RwLock::new(Config::load_or_default())); + // Share config with native plugin loader so plugins can read their own config sections. + crate::plugins::native_loader::set_shared_config(Arc::clone(&config)); + let provider_manager = ProviderManager::new_with_config(Arc::clone(&config)); let frecency = FrecencyStore::new(); Ok(Self { @@ -51,7 +121,7 @@ impl Server { socket_path: socket_path.to_path_buf(), provider_manager: Arc::new(RwLock::new(provider_manager)), frecency: Arc::new(RwLock::new(frecency)), - config: Arc::new(config), + config, }) } @@ -60,18 +130,106 @@ impl Server { // Start filesystem watcher for user plugin hot-reload crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager)); + // SIGHUP handler: reload config from disk into the shared Arc>. + { + use signal_hook::consts::SIGHUP; + use signal_hook::iterator::Signals; + let config = Arc::clone(&self.config); + let mut signals = Signals::new([SIGHUP]) + .map_err(io::Error::other)?; + thread::spawn(move || { + for _sig in signals.forever() { + match Config::load() { + Ok(new_cfg) => { + match config.write() { + Ok(mut cfg) => { + *cfg = new_cfg; + info!("Config reloaded via SIGHUP"); + } + Err(_) => { + warn!("SIGHUP: config lock poisoned; reload skipped"); + } + } + } + Err(e) => { + warn!("SIGHUP: failed to reload config: {}", e); + } + } + } + }); + } + + // SIGTERM/SIGINT handler: save frecency before exiting. + // Replaces the ctrlc handler in main.rs so all signal management lives here. + { + use signal_hook::consts::{SIGINT, SIGTERM}; + use signal_hook::iterator::Signals; + let frecency = Arc::clone(&self.frecency); + let socket_path = self.socket_path.clone(); + let mut signals = Signals::new([SIGTERM, SIGINT]) + .map_err(io::Error::other)?; + thread::spawn(move || { + // Block until we receive SIGTERM or SIGINT, then save and exit. + let _ = signals.forever().next(); + match frecency.write() { + Ok(mut f) => { + if let Err(e) = f.save() { + warn!("Shutdown: frecency save failed: {}", e); + } else { + info!("Shutdown: frecency saved"); + } + } + Err(_) => { + warn!("Shutdown: frecency lock poisoned; skipping save"); + } + } + let _ = std::fs::remove_file(&socket_path); + std::process::exit(0); + }); + } + + // Periodic frecency auto-save every 5 minutes. + { + let frecency = Arc::clone(&self.frecency); + thread::spawn(move || loop { + thread::sleep(Duration::from_secs(300)); + match frecency.write() { + Ok(mut f) => { + if let Err(e) = f.save() { + warn!("Periodic frecency save failed: {}", e); + } + } + Err(_) => { + warn!("Periodic frecency save: lock poisoned; skipping"); + } + } + }); + } + info!("Server entering accept loop"); for stream in self.listener.incoming() { match stream { - Ok(stream) => { - let pm = Arc::clone(&self.provider_manager); - let frecency = Arc::clone(&self.frecency); - let config = Arc::clone(&self.config); - thread::spawn(move || { - if let Err(e) = Self::handle_client(stream, pm, frecency, config) { - warn!("Client handler error: {}", e); + Ok(mut stream) => { + match ConnectionGuard::try_acquire() { + Some(guard) => { + let pm = Arc::clone(&self.provider_manager); + let frecency = Arc::clone(&self.frecency); + let config = Arc::clone(&self.config); + thread::spawn(move || { + let _guard = guard; // released on thread exit + if let Err(e) = Self::handle_client(stream, pm, frecency, config) { + warn!("Client handler error: {}", e); + } + }); } - }); + None => { + warn!("Connection limit reached ({} max); rejecting client", MAX_CONNECTIONS); + let resp = Response::Error { + message: format!("server busy: max {} concurrent connections", MAX_CONNECTIONS), + }; + let _ = write_response(&mut stream, &resp); + } + } } Err(e) => { error!("Failed to accept connection: {}", e); @@ -101,30 +259,25 @@ impl Server { stream: UnixStream, pm: Arc>, frecency: Arc>, - config: Arc, + config: Arc>, ) -> io::Result<()> { stream.set_read_timeout(Some(Duration::from_secs(30)))?; let mut reader = BufReader::new(stream.try_clone()?); let mut writer = stream; loop { - let mut line = String::new(); - let bytes_read = reader.read_line(&mut line)?; - if bytes_read == 0 { - break; - } - - if line.len() > MAX_REQUEST_SIZE { - let resp = Response::Error { - message: format!( - "request too large ({} bytes, max {})", - line.len(), - MAX_REQUEST_SIZE - ), - }; - write_response(&mut writer, &resp)?; - break; - } + let line = match read_bounded_line(&mut reader, MAX_REQUEST_SIZE) { + Ok(Some(l)) => l, + Ok(None) => break, + Err(e) if e.kind() == io::ErrorKind::InvalidData => { + let resp = Response::Error { + message: format!("request too large (max {} bytes)", MAX_REQUEST_SIZE), + }; + write_response(&mut writer, &resp)?; + break; + } + Err(e) => return Err(e), + }; let trimmed = line.trim(); if trimmed.is_empty() { @@ -156,7 +309,7 @@ impl Server { request: &Request, pm: &Arc>, frecency: &Arc>, - config: &Arc, + config: &Arc>, ) -> Response { match request { Request::Query { text, modes } => { @@ -164,11 +317,22 @@ impl Server { Some(m) => ProviderFilter::from_mode_strings(m), None => ProviderFilter::all(), }; - let max = config.general.max_results; - let weight = config.providers.frecency_weight; + let (max, weight) = { + let cfg = match config.read() { + Ok(g) => g, + Err(_) => return Response::Error { message: "internal error: config lock poisoned".into() }, + }; + (cfg.general.max_results, cfg.providers.frecency_weight) + }; - let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner()); - let frecency_guard = frecency.read().unwrap_or_else(|e| e.into_inner()); + let pm_guard = match pm.read() { + Ok(g) => g, + Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() }, + }; + let frecency_guard = match frecency.read() { + Ok(g) => g, + Err(_) => return Response::Error { message: "internal error: frecency lock poisoned".into() }, + }; let results = pm_guard.search_with_frecency( text, max, @@ -190,13 +354,19 @@ impl Server { item_id, provider: _, } => { - let mut frecency_guard = frecency.write().unwrap_or_else(|e| e.into_inner()); + let mut frecency_guard = match frecency.write() { + Ok(g) => g, + Err(_) => return Response::Error { message: "internal error: frecency lock poisoned".into() }, + }; frecency_guard.record_launch(item_id); Response::Ack } Request::Providers => { - let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner()); + let pm_guard = match pm.read() { + Ok(g) => g, + Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() }, + }; let descs = pm_guard.available_providers(); Response::Providers { list: descs.into_iter().map(descriptor_to_desc).collect(), @@ -204,7 +374,10 @@ impl Server { } Request::Refresh { provider } => { - let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner()); + let mut pm_guard = match pm.write() { + Ok(g) => g, + Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() }, + }; pm_guard.refresh_provider(provider); Response::Ack } @@ -215,7 +388,10 @@ impl Server { } Request::Submenu { plugin_id, data } => { - let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner()); + let pm_guard = match pm.read() { + Ok(g) => g, + Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() }, + }; match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) { Some((_name, actions)) => Response::SubmenuItems { items: actions @@ -230,7 +406,10 @@ impl Server { } Request::PluginAction { command } => { - let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner()); + let pm_guard = match pm.read() { + Ok(g) => g, + Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() }, + }; if pm_guard.execute_plugin_action(command) { Response::Ack } else { @@ -239,6 +418,16 @@ impl Server { } } } + + Request::PluginList => { + let pm_guard = match pm.read() { + Ok(g) => g, + Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() }, + }; + Response::PluginList { + entries: pm_guard.plugin_registry.clone(), + } + } } } } @@ -272,6 +461,7 @@ fn launch_item_to_result(item: LaunchItem, score: i64) -> ResultItem { command: Some(item.command), terminal: item.terminal, tags: item.tags, + source: item.source.as_str().to_string(), } } @@ -284,3 +474,57 @@ fn descriptor_to_desc(desc: crate::providers::ProviderDescriptor) -> ProviderDes position: desc.position, } } + +#[cfg(test)] +mod tests { + use super::*; + + // Wrap a Cursor in a BufReader backed by a UnixStream-like interface. + // Since read_bounded_line takes BufReader, we test it indirectly + // through an in-memory byte slice via a helper. + fn bounded_line_from_bytes(data: &[u8], max: usize) -> io::Result> { + // Use a pipe to simulate UnixStream I/O. + use std::os::unix::net::UnixStream; + let (mut write_end, read_end) = UnixStream::pair()?; + write_end.write_all(data)?; + drop(write_end); // Signal EOF to reader + let mut reader = BufReader::new(read_end); + read_bounded_line(&mut reader, max) + } + + #[test] + fn normal_line_within_limit() { + let result = bounded_line_from_bytes(b"hello world\n", 100).unwrap(); + assert_eq!(result, Some("hello world".to_string())); + } + + #[test] + fn line_at_exactly_max_succeeds() { + // "aaa...a\n" where content is exactly max bytes + let mut data = vec![b'a'; 100]; + data.push(b'\n'); + let result = bounded_line_from_bytes(&data, 100).unwrap(); + assert_eq!(result, Some("a".repeat(100))); + } + + #[test] + fn line_exceeding_max_errors() { + let mut data = vec![b'a'; 101]; + data.push(b'\n'); + let result = bounded_line_from_bytes(&data, 100); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn empty_input_returns_none() { + let result = bounded_line_from_bytes(b"", 100).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn no_trailing_newline_returns_content() { + let result = bounded_line_from_bytes(b"hello", 100).unwrap(); + assert_eq!(result, Some("hello".to_string())); + } +} diff --git a/crates/owlry-core/tests/ipc_test.rs b/crates/owlry-core/tests/ipc_test.rs index 8b0bf3d..6498309 100644 --- a/crates/owlry-core/tests/ipc_test.rs +++ b/crates/owlry-core/tests/ipc_test.rs @@ -47,6 +47,7 @@ fn test_results_response_roundtrip() { command: Some("firefox".into()), terminal: false, tags: vec![], + source: "core".into(), }], }; let json = serde_json::to_string(&resp).unwrap(); @@ -140,6 +141,7 @@ fn test_terminal_field_roundtrip() { command: Some("htop".into()), terminal: true, tags: vec![], + source: "cmd".into(), }; let json = serde_json::to_string(&item).unwrap(); assert!(json.contains("\"terminal\":true")); diff --git a/crates/owlry-lua/src/lib.rs b/crates/owlry-lua/src/lib.rs index 06d0c1b..f2ef492 100644 --- a/crates/owlry-lua/src/lib.rs +++ b/crates/owlry-lua/src/lib.rs @@ -68,8 +68,11 @@ pub struct RuntimeHandle { pub ptr: *mut (), } +// SAFETY: LuaRuntimeState (pointed to by RuntimeHandle) contains mlua::Lua, which is +// Send when the "send" feature is enabled (enabled in Cargo.toml). RuntimeHandle itself +// is Copy and has no interior mutability — Sync is NOT implemented because concurrent +// access is serialized by Arc> in the runtime loader. unsafe impl Send for RuntimeHandle {} -unsafe impl Sync for RuntimeHandle {} impl RuntimeHandle { /// Create a null handle (reserved for error cases) diff --git a/crates/owlry-plugin-api/src/lib.rs b/crates/owlry-plugin-api/src/lib.rs index 01ad883..48c201e 100644 --- a/crates/owlry-plugin-api/src/lib.rs +++ b/crates/owlry-plugin-api/src/lib.rs @@ -33,7 +33,8 @@ pub use abi_stable::std_types::{ROption, RStr, RString, RVec}; /// Current plugin API version - plugins must match this /// v2: Added ProviderPosition for widget support /// v3: Added priority field for plugin-declared result ordering -pub const API_VERSION: u32 = 3; +/// v4: Added get_config_string/int/bool to HostAPI for plugin config access +pub const API_VERSION: u32 = 4; /// Plugin metadata returned by the info function #[repr(C)] @@ -295,6 +296,18 @@ pub struct HostAPI { /// Log a message at error level pub log_error: extern "C" fn(message: RStr<'_>), + + /// Read a string value from this plugin's config section. + /// Parameters: plugin_id (the calling plugin's ID), key + /// Returns RSome(value) if set, RNone otherwise. + pub get_config_string: + extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption, + + /// Read an integer value from this plugin's config section. + pub get_config_int: extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption, + + /// Read a boolean value from this plugin's config section. + pub get_config_bool: extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption, } use std::sync::OnceLock; @@ -378,6 +391,30 @@ pub fn log_error(message: &str) { } } +/// Read a string value from this plugin's config section (convenience wrapper). +/// `plugin_id` must match the ID the plugin declares in its `PluginInfo`. +pub fn get_config_string(plugin_id: &str, key: &str) -> Option { + host_api().and_then(|api| { + (api.get_config_string)(RStr::from_str(plugin_id), RStr::from_str(key)) + .into_option() + .map(|s| s.into_string()) + }) +} + +/// Read an integer value from this plugin's config section (convenience wrapper). +pub fn get_config_int(plugin_id: &str, key: &str) -> Option { + host_api().and_then(|api| { + (api.get_config_int)(RStr::from_str(plugin_id), RStr::from_str(key)).into_option() + }) +} + +/// Read a boolean value from this plugin's config section (convenience wrapper). +pub fn get_config_bool(plugin_id: &str, key: &str) -> Option { + host_api().and_then(|api| { + (api.get_config_bool)(RStr::from_str(plugin_id), RStr::from_str(key)).into_option() + }) +} + /// Helper macro for defining plugin vtables /// /// Usage: diff --git a/crates/owlry/src/backend.rs b/crates/owlry/src/backend.rs index 8aa8975..a5fad79 100644 --- a/crates/owlry/src/backend.rs +++ b/crates/owlry/src/backend.rs @@ -9,7 +9,7 @@ use owlry_core::config::Config; use owlry_core::data::FrecencyStore; use owlry_core::filter::ProviderFilter; use owlry_core::ipc::ResultItem; -use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType}; +use owlry_core::providers::{ItemSource, LaunchItem, ProviderManager, ProviderType}; use std::sync::{Arc, Mutex}; /// Parameters needed to run a search query on a background thread. @@ -167,7 +167,7 @@ impl SearchBackend { .collect() } else { providers - .search_filtered(query, max_results, filter) + .search_filtered(query, max_results, filter, None) .into_iter() .map(|(item, _)| item) .collect() @@ -230,7 +230,7 @@ impl SearchBackend { .collect() } else { providers - .search_filtered(query, max_results, filter) + .search_filtered(query, max_results, filter, tag_filter) .into_iter() .map(|(item, _)| item) .collect() @@ -378,6 +378,7 @@ impl SearchBackend { /// Convert an IPC ResultItem to the internal LaunchItem type. fn result_to_launch_item(item: ResultItem) -> LaunchItem { let provider: ProviderType = item.provider.parse().unwrap_or(ProviderType::Application); + let source: ItemSource = item.source.parse().unwrap_or(ItemSource::Core); LaunchItem { id: item.id, name: item.title, @@ -395,5 +396,6 @@ fn result_to_launch_item(item: ResultItem) -> LaunchItem { command: item.command.unwrap_or_default(), terminal: item.terminal, tags: item.tags, + source, } } diff --git a/crates/owlry/src/client.rs b/crates/owlry/src/client.rs index e754d7e..e646a37 100644 --- a/crates/owlry/src/client.rs +++ b/crates/owlry/src/client.rs @@ -3,7 +3,46 @@ use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::time::Duration; -use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem}; +use owlry_core::ipc::{PluginEntry, ProviderDesc, Request, Response, ResultItem}; + +/// Maximum allowed size for a single IPC response line (4 MiB). +/// Larger than the request limit because responses carry result sets. +const MAX_RESPONSE_SIZE: usize = 4_194_304; + +/// Read a newline-terminated line from `reader` without allocating beyond `max` bytes. +fn read_bounded_line(reader: &mut BufReader, max: usize) -> io::Result> { + let mut buf: Vec = Vec::with_capacity(4096); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return if buf.is_empty() { + Ok(None) + } else { + Ok(Some(String::from_utf8_lossy(&buf).into_owned())) + }; + } + if let Some(pos) = available.iter().position(|&b| b == b'\n') { + if buf.len() + pos > max { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("response too large (exceeded {} bytes)", max), + )); + } + buf.extend_from_slice(&available[..pos]); + reader.consume(pos + 1); + return Ok(Some(String::from_utf8_lossy(&buf).into_owned())); + } + let len = available.len(); + if buf.len() + len > max { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("response too large (exceeded {} bytes)", max), + )); + } + buf.extend_from_slice(available); + reader.consume(len); + } +} /// IPC client that connects to the owlryd daemon Unix socket /// and provides typed methods for all IPC operations. @@ -157,6 +196,19 @@ impl CoreClient { } } + /// Query the daemon's native plugin registry (loaded + suppressed entries). + pub fn plugin_list(&mut self) -> io::Result> { + self.send(&Request::PluginList)?; + match self.receive()? { + Response::PluginList { entries } => Ok(entries), + Response::Error { message } => Err(io::Error::other(message)), + other => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected response to PluginList: {other:?}"), + )), + } + } + /// Query a plugin's submenu actions. pub fn submenu(&mut self, plugin_id: &str, data: &str) -> io::Result> { self.send(&Request::Submenu { @@ -186,14 +238,15 @@ impl CoreClient { } fn receive(&mut self) -> io::Result { - let mut line = String::new(); - self.reader.read_line(&mut line)?; - if line.is_empty() { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "daemon closed the connection", - )); - } + let line = match read_bounded_line(&mut self.reader, MAX_RESPONSE_SIZE)? { + Some(l) => l, + None => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "daemon closed the connection", + )) + } + }; serde_json::from_str(line.trim()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } } @@ -256,6 +309,7 @@ mod tests { command: Some("firefox".into()), terminal: false, tags: vec![], + source: "app".into(), }], }; @@ -327,6 +381,7 @@ mod tests { command: Some("systemctl --user start foo".into()), terminal: false, tags: vec![], + source: "native_plugin".into(), }], }; diff --git a/crates/owlry/src/plugin_commands.rs b/crates/owlry/src/plugin_commands.rs index 6f789bc..e4972f4 100644 --- a/crates/owlry/src/plugin_commands.rs +++ b/crates/owlry/src/plugin_commands.rs @@ -7,6 +7,7 @@ use std::io::{self, Write}; use std::path::{Path, PathBuf}; use crate::cli::{PluginCommand as CliPluginCommand, PluginRuntime}; +use crate::client::CoreClient; use owlry_core::config::Config; use owlry_core::paths; use owlry_core::plugins::manifest::{PluginManifest, discover_plugins}; @@ -135,48 +136,53 @@ fn cmd_list_installed( json_output: bool, ) -> CommandResult { let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; - - if !plugins_dir.exists() { - if json_output { - println!("[]"); - } else { - println!("No plugins installed."); - } - return Ok(()); - } - - let discovered = discover_plugins(&plugins_dir).map_err(|e| e.to_string())?; let config = Config::load().unwrap_or_default(); let disabled_list = &config.plugins.disabled_plugins; - let lua_available = lua_runtime_available(); let rune_available = rune_runtime_available(); - let mut plugins: Vec<_> = discovered - .iter() - .map(|(id, (manifest, _path))| { - let is_disabled = disabled_list.contains(id); - let runtime = detect_runtime(manifest); - (id.clone(), manifest.clone(), is_disabled, runtime) - }) - .collect(); + // ── Script plugins (from filesystem) ──────────────────────────────── + let mut script_plugins: Vec<_> = if plugins_dir.exists() { + let discovered = discover_plugins(&plugins_dir).map_err(|e| e.to_string())?; + discovered + .into_iter() + .map(|(id, (manifest, _path))| { + let is_disabled = disabled_list.contains(&id); + let runtime = detect_runtime(&manifest); + (id, manifest, is_disabled, runtime) + }) + .collect() + } else { + Vec::new() + }; - // Apply filters + // Apply filters to script plugins if only_enabled { - plugins.retain(|(_, _, is_disabled, _)| !*is_disabled); + script_plugins.retain(|(_, _, is_disabled, _)| !*is_disabled); } if only_disabled { - plugins.retain(|(_, _, is_disabled, _)| *is_disabled); + script_plugins.retain(|(_, _, is_disabled, _)| *is_disabled); } - if let Some(rt) = runtime_filter { - plugins.retain(|(_, _, _, runtime)| *runtime == rt); + if let Some(ref rt) = runtime_filter { + let rt_clone = *rt; + script_plugins.retain(|(_, _, _, runtime)| *runtime == rt_clone); } + script_plugins.sort_by(|a, b| a.0.cmp(&b.0)); - // Sort by ID - plugins.sort_by(|a, b| a.0.cmp(&b.0)); + // ── Native plugins (from daemon, if running) ───────────────────────── + // Skip native plugins if a runtime filter is active (they have no script runtime). + let native_entries = if runtime_filter.is_none() { + CoreClient::connect(&CoreClient::socket_path()) + .ok() + .and_then(|mut client| client.plugin_list().ok()) + .unwrap_or_default() + } else { + Vec::new() + }; + // ── Output ─────────────────────────────────────────────────────────── if json_output { - let json_list: Vec<_> = plugins + let mut json_list: Vec<_> = script_plugins .iter() .map(|(id, manifest, is_disabled, runtime)| { let runtime_available = match runtime { @@ -191,39 +197,82 @@ fn cmd_list_installed( "enabled": !is_disabled, "runtime": runtime.to_string(), "runtime_available": runtime_available, + "source": "script", }) }) .collect(); - println!("{}", serde_json::to_string_pretty(&json_list).unwrap()); - } else if plugins.is_empty() { - println!("No plugins found."); - } else { - println!("Installed plugins:\n"); - for (id, manifest, is_disabled, runtime) in &plugins { - let status = if *is_disabled { " (disabled)" } else { "" }; - let runtime_available = match runtime { - PluginRuntime::Lua => lua_available, - PluginRuntime::Rune => rune_available, - }; - let runtime_status = if !runtime_available { - format!(" [{} - NOT INSTALLED]", runtime) - } else { - format!(" [{}]", runtime) - }; - println!( - " {} v{}{}{}\n {}", - id, - manifest.plugin.version, - status, - runtime_status, - if manifest.plugin.description.is_empty() { - "No description" - } else { - &manifest.plugin.description - } - ); + for entry in &native_entries { + json_list.push(serde_json::json!({ + "id": entry.id, + "name": entry.name, + "version": entry.version, + "status": entry.status, + "status_detail": entry.status_detail, + "runtime": entry.runtime, + "providers": entry.providers, + "source": "native", + })); } - println!("\n{} plugin(s) installed.", plugins.len()); + println!("{}", serde_json::to_string_pretty(&json_list).unwrap()); + } else { + let total = script_plugins.len() + native_entries.len(); + if total == 0 { + println!("No plugins found."); + return Ok(()); + } + + if !script_plugins.is_empty() { + println!("Script plugins:\n"); + for (id, manifest, is_disabled, runtime) in &script_plugins { + let status = if *is_disabled { " (disabled)" } else { "" }; + let runtime_available = match runtime { + PluginRuntime::Lua => lua_available, + PluginRuntime::Rune => rune_available, + }; + let runtime_status = if !runtime_available { + format!(" [{} - NOT INSTALLED]", runtime) + } else { + format!(" [{}]", runtime) + }; + println!( + " {} v{}{}{}\n {}", + id, + manifest.plugin.version, + status, + runtime_status, + if manifest.plugin.description.is_empty() { + "No description" + } else { + &manifest.plugin.description + } + ); + } + } + + if !native_entries.is_empty() { + if !script_plugins.is_empty() { + println!(); + } + println!("Native plugins:\n"); + for entry in &native_entries { + let status_label = if entry.status == "suppressed" { + format!(" (suppressed: {})", entry.status_detail) + } else { + String::new() + }; + let providers_label = if entry.providers.is_empty() { + String::new() + } else { + format!(" [{}]", entry.providers.join(", ")) + }; + println!(" {} v{}{}{}", entry.id, entry.version, status_label, providers_label); + if !entry.name.is_empty() && entry.name != entry.id { + println!(" {}", entry.name); + } + } + } + + println!("\n{} plugin(s) total.", total); } Ok(()) diff --git a/crates/owlry/src/providers/dmenu.rs b/crates/owlry/src/providers/dmenu.rs index 99a9391..12eccf9 100644 --- a/crates/owlry/src/providers/dmenu.rs +++ b/crates/owlry/src/providers/dmenu.rs @@ -1,5 +1,5 @@ use log::debug; -use owlry_core::providers::{LaunchItem, Provider, ProviderType}; +use owlry_core::providers::{ItemSource, LaunchItem, Provider, ProviderType}; use std::io::{self, BufRead}; /// Provider for dmenu-style input from stdin @@ -102,6 +102,7 @@ impl Provider for DmenuProvider { command: line.to_string(), terminal: false, tags: Vec::new(), + source: ItemSource::Core, }; self.items.push(item); diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index bdf48aa..b7314cb 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -1,5 +1,6 @@ use crate::backend::SearchBackend; use crate::ui::ResultRow; +use crate::ui::provider_meta; use crate::ui::submenu; use gtk4::gdk::Key; use gtk4::prelude::*; @@ -10,7 +11,7 @@ use gtk4::{ use log::info; use owlry_core::config::Config; use owlry_core::filter::ProviderFilter; -use owlry_core::providers::{LaunchItem, ProviderType}; +use owlry_core::providers::{ItemSource, LaunchItem, ProviderType}; #[cfg(feature = "dev-logging")] use log::debug; @@ -248,7 +249,12 @@ impl MainWindow { // scroll position and selection. if !matches!(&*main_window.backend.borrow(), SearchBackend::Daemon(_)) { let backend_for_auto = main_window.backend.clone(); - gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || { + let debounce_for_auto = main_window.debounce_source.clone(); + gtk4::glib::timeout_add_local(std::time::Duration::from_secs(10), move || { + // Skip widget refresh while the user is actively typing. + if debounce_for_auto.borrow().is_some() { + return gtk4::glib::ControlFlow::Continue; + } backend_for_auto.borrow_mut().refresh_widgets(); gtk4::glib::ControlFlow::Continue }); @@ -315,80 +321,18 @@ impl MainWindow { /// Get display label for a provider tab /// Core types have fixed labels; plugins derive labels from type_id fn provider_tab_label(provider: &ProviderType) -> &'static str { - match provider { - ProviderType::Application => "Apps", - ProviderType::Command => "Cmds", - ProviderType::Dmenu => "Dmenu", - ProviderType::Plugin(type_id) => match type_id.as_str() { - "bookmarks" => "Bookmarks", - "calc" => "Calc", - "clipboard" => "Clip", - "emoji" => "Emoji", - "filesearch" => "Files", - "media" => "Media", - "pomodoro" => "Pomo", - "scripts" => "Scripts", - "ssh" => "SSH", - "system" => "System", - "uuctl" => "uuctl", - "weather" => "Weather", - "websearch" => "Web", - _ => "Plugin", - }, - } + provider_meta::meta_for(provider).tab_label } - /// Get CSS class for a provider - /// Core types have fixed CSS classes; plugins derive from type_id fn provider_css_class(provider: &ProviderType) -> &'static str { - match provider { - ProviderType::Application => "owlry-filter-app", - ProviderType::Command => "owlry-filter-cmd", - ProviderType::Dmenu => "owlry-filter-dmenu", - ProviderType::Plugin(type_id) => match type_id.as_str() { - "bookmarks" => "owlry-filter-bookmark", - "calc" => "owlry-filter-calc", - "clipboard" => "owlry-filter-clip", - "emoji" => "owlry-filter-emoji", - "filesearch" => "owlry-filter-file", - "media" => "owlry-filter-media", - "pomodoro" => "owlry-filter-pomodoro", - "scripts" => "owlry-filter-script", - "ssh" => "owlry-filter-ssh", - "system" => "owlry-filter-sys", - "uuctl" => "owlry-filter-uuctl", - "weather" => "owlry-filter-weather", - "websearch" => "owlry-filter-web", - _ => "owlry-filter-plugin", - }, - } + provider_meta::meta_for(provider).css_class } fn build_placeholder(filter: &ProviderFilter) -> String { let active: Vec<&str> = filter .enabled_providers() .iter() - .map(|p| match p { - ProviderType::Application => "applications", - ProviderType::Command => "commands", - ProviderType::Dmenu => "options", - ProviderType::Plugin(type_id) => match type_id.as_str() { - "bookmarks" => "bookmarks", - "calc" => "calculator", - "clipboard" => "clipboard", - "emoji" => "emoji", - "filesearch" => "files", - "media" => "media", - "pomodoro" => "pomodoro", - "scripts" => "scripts", - "ssh" => "SSH hosts", - "system" => "system", - "uuctl" => "uuctl units", - "weather" => "weather", - "websearch" => "web", - _ => "plugins", - }, - }) + .map(|p| provider_meta::meta_for(p).search_noun) .collect(); format!("Search {}...", active.join(", ")) @@ -1347,6 +1291,36 @@ impl MainWindow { item.terminal, item.provider, item.id ); + // Reject script plugin commands that don't match the known-safe allowlist. + // Script plugins (Lua/Rune user plugins) are untrusted code; only allow + // patterns that can't escalate privileges or exfiltrate data. + if item.source == ItemSource::ScriptPlugin { + let cmd = &item.command; + let allowed = cmd.is_empty() + || cmd.starts_with("xdg-open ") + || cmd.starts_with("wl-copy") + || cmd.starts_with("wl-paste") + || cmd.starts_with("SUBMENU:") + || cmd.starts_with('!'); + if !allowed { + let msg = format!( + "Blocked untrusted script plugin command from '{}': {}", + item.name, cmd + ); + log::warn!("{}", msg); + owlry_core::notify::notify("Command blocked", &msg); + return; + } + } + + // Reject items with no command — nothing to execute. + if item.command.is_empty() && !matches!(item.provider, ProviderType::Application) { + let msg = format!("Item '{}' has no command; cannot launch", item.name); + log::warn!("{}", msg); + owlry_core::notify::notify("Launch failed", &msg); + return; + } + // Check if this is a desktop application (has .desktop file as ID) let is_desktop_app = matches!(item.provider, ProviderType::Application) && item.id.ends_with(".desktop"); diff --git a/crates/owlry/src/ui/mod.rs b/crates/owlry/src/ui/mod.rs index 907f865..15cb74c 100644 --- a/crates/owlry/src/ui/mod.rs +++ b/crates/owlry/src/ui/mod.rs @@ -1,4 +1,5 @@ mod main_window; +pub mod provider_meta; mod result_row; pub mod submenu; diff --git a/crates/owlry/src/ui/provider_meta.rs b/crates/owlry/src/ui/provider_meta.rs new file mode 100644 index 0000000..0af2994 --- /dev/null +++ b/crates/owlry/src/ui/provider_meta.rs @@ -0,0 +1,101 @@ +use owlry_core::providers::ProviderType; + +/// Display metadata for a provider. +pub struct ProviderMeta { + pub tab_label: &'static str, + pub css_class: &'static str, + pub search_noun: &'static str, +} + +/// Return display metadata for a provider type. +pub fn meta_for(provider: &ProviderType) -> ProviderMeta { + match provider { + ProviderType::Application => ProviderMeta { + tab_label: "Apps", + css_class: "owlry-filter-app", + search_noun: "applications", + }, + ProviderType::Command => ProviderMeta { + tab_label: "Cmds", + css_class: "owlry-filter-cmd", + search_noun: "commands", + }, + ProviderType::Dmenu => ProviderMeta { + tab_label: "Dmenu", + css_class: "owlry-filter-dmenu", + search_noun: "options", + }, + ProviderType::Plugin(type_id) => match type_id.as_str() { + "bookmarks" => ProviderMeta { + tab_label: "Bookmarks", + css_class: "owlry-filter-bookmark", + search_noun: "bookmarks", + }, + "calc" => ProviderMeta { + tab_label: "Calc", + css_class: "owlry-filter-calc", + search_noun: "calculator", + }, + "clipboard" => ProviderMeta { + tab_label: "Clip", + css_class: "owlry-filter-clip", + search_noun: "clipboard", + }, + "emoji" => ProviderMeta { + tab_label: "Emoji", + css_class: "owlry-filter-emoji", + search_noun: "emoji", + }, + "filesearch" => ProviderMeta { + tab_label: "Files", + css_class: "owlry-filter-file", + search_noun: "files", + }, + "media" => ProviderMeta { + tab_label: "Media", + css_class: "owlry-filter-media", + search_noun: "media", + }, + "pomodoro" => ProviderMeta { + tab_label: "Pomo", + css_class: "owlry-filter-pomodoro", + search_noun: "pomodoro", + }, + "scripts" => ProviderMeta { + tab_label: "Scripts", + css_class: "owlry-filter-script", + search_noun: "scripts", + }, + "ssh" => ProviderMeta { + tab_label: "SSH", + css_class: "owlry-filter-ssh", + search_noun: "SSH hosts", + }, + "system" => ProviderMeta { + tab_label: "System", + css_class: "owlry-filter-sys", + search_noun: "system", + }, + "uuctl" => ProviderMeta { + tab_label: "uuctl", + css_class: "owlry-filter-uuctl", + search_noun: "uuctl units", + }, + "weather" => ProviderMeta { + tab_label: "Weather", + css_class: "owlry-filter-weather", + search_noun: "weather", + }, + "websearch" => ProviderMeta { + tab_label: "Web", + css_class: "owlry-filter-web", + search_noun: "web", + }, + _ => ProviderMeta { + tab_label: "Plugin", + css_class: "owlry-filter-plugin", + search_noun: "plugins", + }, + }, + } +} diff --git a/crates/owlry/src/ui/submenu.rs b/crates/owlry/src/ui/submenu.rs index b760733..3325c77 100644 --- a/crates/owlry/src/ui/submenu.rs +++ b/crates/owlry/src/ui/submenu.rs @@ -66,7 +66,7 @@ pub fn is_submenu_item(item: &LaunchItem) -> bool { #[cfg(test)] mod tests { use super::*; - use owlry_core::providers::ProviderType; + use owlry_core::providers::{ItemSource, ProviderType}; #[test] fn test_parse_submenu_command() { @@ -94,6 +94,7 @@ mod tests { command: "SUBMENU:plugin:data".to_string(), terminal: false, tags: vec![], + source: ItemSource::NativePlugin, }; assert!(is_submenu_item(&submenu_item)); @@ -106,6 +107,7 @@ mod tests { command: "some-command".to_string(), terminal: false, tags: vec![], + source: ItemSource::NativePlugin, }; assert!(!is_submenu_item(&normal_item)); } diff --git a/systemd/owlryd.service b/systemd/owlryd.service index 8ff5a95..12a6454 100644 --- a/systemd/owlryd.service +++ b/systemd/owlryd.service @@ -6,6 +6,7 @@ After=graphical-session.target [Service] Type=simple ExecStart=/usr/bin/owlryd +ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure RestartSec=3 Environment=RUST_LOG=warn