Compare commits

..

10 Commits

Author SHA1 Message Date
7275fcab35 fix: implement all 24 FIX_PLAN issues across 6 phases
Phase 1 — Critical Safety:
- #11: bounded IPC reads via read_bounded_line (server + client)
- #13: sound Send+Sync via Arc<Mutex<RuntimeHandle>>; remove unsafe impl Sync
- #10: ItemSource enum (Core/NativePlugin/ScriptPlugin) on LaunchItem;
  script plugin allowlist guard in launch_item()

Phase 2 — Config System Overhaul:
- #6: remove dead enabled_plugins field
- #1: replace #[serde(flatten)] with explicit Config::plugin_config
- #4: Server.config Arc<RwLock<Config>>; ConfigProvider shares same Arc
- #2/#3: atomic config save (temp+rename); TOCTOU fixed — write lock held
  across mutation and save() in config_editor
- #23: fs2 lock_exclusive() on .lock sidecar file in Config::save()
- #16: SIGHUP handler reloads config; ExecReload in systemd service

Phase 3 — Plugin Architecture:
- #7: HostAPI v4 with get_config_string/int/bool; PLUGIN_CONFIG OnceLock
  in native_loader, set_shared_config() called from Server::bind()
- #5: PluginEntry + Request::PluginList + Response::PluginList; plugin_registry
  in ProviderManager tracks active and suppressed native plugins;
  cmd_list_installed shows both script and native plugins
- #9: suppressed native plugin log level info! → warn!
- #8: ProviderType doc glossary; plugins/mod.rs terminology table

Phase 4 — Data Integrity:
- #12: all into_inner() in server.rs + providers/mod.rs → explicit Response::Error;
  watcher exits on poisoned lock
- #14: FrecencyStore::prune() (180-day age + 5000-entry cap) called on load
- #17: empty command guard in launch_item(); warn in lua_provider
- #24: 5-min periodic frecency save thread; SIGTERM/SIGINT saves frecency
  before exit (replaces ctrlc handler)

Phase 5 — UI & UX:
- #19: provider_meta.rs ProviderMeta + meta_for(); three match blocks collapsed
- #18: desktop file dedup via seen_basenames HashSet in ApplicationProvider
- #20: search_filtered gains tag_filter param; non-frecency path now filters
- #15: widget refresh 5s→10s; skip when user is typing

Phase 6 — Hardening:
- #22: catch_unwind removed from reload_runtimes(); direct drop()
- #21: AtomicUsize + RAII ConnectionGuard; MAX_CONNECTIONS = 16

Deps: add fs2 = "0.4"; remove ctrlc and toml_edit from owlry-core
2026-04-08 16:43:52 +02:00
4d7e913657 chore(aur): update all packages to latest versions 2026-04-06 02:42:09 +02:00
f8d011447e chore(owlry): bump version to 1.0.8 2026-04-06 02:39:19 +02:00
9163b1ea6c chore(owlry-rune): bump version to 1.1.4 2026-04-06 02:38:47 +02:00
6586f5d6c2 fix(plugins): close remaining gaps in new plugin format support
- Fix cmd_runtimes() help text: init.lua/init.rn → main.lua/main.rn
- Add .lua extension validation to owlry-lua manifest (mirrors Rune)
- Replace eprintln! with log::warn!/log::debug! in owlry-lua loader
- Add log = "0.4" dependency to owlry-lua
- Add tests: [[providers]] deserialization in owlry-core manifest,
  manifest provider fallback in owlry-lua and owlry-rune loaders,
  non-runtime plugin filtering in both runtimes
2026-04-06 02:38:42 +02:00
a6e94deb3c fix(runtime): prevent dlclose() to avoid SIGSEGV on runtime teardown
Wrap LoadedRuntime._library in ManuallyDrop so dlclose() is never called.
dlclose() unmaps the library code; thread-local destructors inside liblua.so
then SIGSEGV when they try to run against the unmapped addresses.

Also filter out non-.lua plugins in the Lua runtime's discover_plugins()
so liblua.so does not attempt to load Rune plugins.
2026-04-06 02:26:12 +02:00
de74cac67d chore(owlry-lua): bump version to 1.1.3 2026-04-06 02:22:08 +02:00
2f396306fd chore(owlry-core): bump version to 1.3.4 2026-04-06 02:22:07 +02:00
133d5264ea feat(plugins): update plugin format to new entry_point + [[providers]] style
- owlry-core/manifest: add entry_point alias for entry field, add ProviderSpec
  struct for [[providers]] array, change default entry to main.lua
- owlry-lua/manifest: add ProviderDecl struct and providers: Vec<ProviderDecl>
  for [[providers]] support
- owlry-lua/loader: fall back to manifest [[providers]] when script has no API
  registrations; fall back to global refresh() for manifest-declared providers
- owlry-lua/api: expose call_global_refresh() that calls the top-level Lua
  refresh() function directly
- owlry/plugin_commands: update create templates to emit new format:
  entry_point instead of entry, [[providers]] instead of [provides],
  main.rn/main.lua instead of init.rn/init.lua, Rune uses Item::new() builder
  pattern, Lua uses standalone refresh() function
- cmd_validate: accept [[providers]] declarations as a valid provides source
2026-04-06 02:22:03 +02:00
a16c3a0523 fix(just): skip meta packages without PKGBUILD in aur-publish-all 2026-04-06 02:11:32 +02:00
58 changed files with 1690 additions and 588 deletions

64
Cargo.lock generated
View File

@@ -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"
@@ -2348,7 +2327,7 @@ dependencies = [
[[package]]
name = "owlry"
version = "1.0.7"
version = "1.0.8"
dependencies = [
"chrono",
"clap",
@@ -2369,13 +2348,13 @@ dependencies = [
[[package]]
name = "owlry-core"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"chrono",
"ctrlc",
"dirs",
"env_logger",
"freedesktop-desktop-entry",
"fs2",
"fuzzy-matcher",
"libloading 0.8.9",
"log",
@@ -2389,19 +2368,20 @@ dependencies = [
"semver",
"serde",
"serde_json",
"signal-hook",
"tempfile",
"thiserror 2.0.18",
"toml 0.8.23",
"toml_edit 0.22.27",
]
[[package]]
name = "owlry-lua"
version = "1.1.2"
version = "1.1.3"
dependencies = [
"abi_stable",
"chrono",
"dirs",
"log",
"meval",
"mlua",
"owlry-plugin-api",
@@ -2423,7 +2403,7 @@ dependencies = [
[[package]]
name = "owlry-rune"
version = "1.1.3"
version = "1.1.4"
dependencies = [
"chrono",
"dirs",
@@ -3057,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"

View File

@@ -1,6 +1,6 @@
pkgbase = owlry-core
pkgdesc = Core daemon for the Owlry application launcher — manages plugins, providers, and search
pkgver = 1.3.2
pkgver = 1.3.4
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
@@ -8,7 +8,7 @@ pkgbase = owlry-core
makedepends = cargo
depends = gcc-libs
depends = openssl
source = owlry-core-1.3.2.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v1.3.2.tar.gz
b2sums = 36a1e31cadcfdbe70c0a10c13eddbcea7ae21b7dcfb0aa10a75f44a82a377d6598c4237228457c13260ca4b4b88f12d416541ad7698cf28076124b1a4d3dbbc6
source = owlry-core-1.3.4.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v1.3.4.tar.gz
b2sums = 648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f
pkgname = owlry-core

10
aur/owlry-core/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
*.pkg.tar.zst
*.pkg.tar.zst-namcap.log
*-namcap.log
*-build.log
*-check.log
*-package.log
*-prepare.log
*.tar.gz
src/
pkg/

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-core
pkgver=1.3.2
pkgver=1.3.4
pkgrel=1
pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search'
arch=('x86_64')
@@ -9,7 +9,7 @@ license=('GPL-3.0-or-later')
depends=('gcc-libs' 'openssl')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v$pkgver.tar.gz")
b2sums=('36a1e31cadcfdbe70c0a10c13eddbcea7ae21b7dcfb0aa10a75f44a82a377d6598c4237228457c13260ca4b4b88f12d416541ad7698cf28076124b1a4d3dbbc6')
b2sums=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
prepare() {
cd "owlry"

View File

@@ -1,6 +1,6 @@
pkgbase = owlry-lua
pkgdesc = Lua scripting runtime for Owlry — enables user-created Lua plugins
pkgver = 1.1.2
pkgver = 1.1.3
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
@@ -8,7 +8,7 @@ pkgbase = owlry-lua
makedepends = cargo
depends = owlry-core
depends = openssl
source = owlry-lua-1.1.2.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v1.1.2.tar.gz
b2sums = 42e6221e6e07c629ece1493e7f5feb1b2cb2e77632d1d7779dfbe544bd89a17d77d1839d63e50d71d4f0e0322ca8a1cc39b872101039019bdf08d9bcaeda7603
source = owlry-lua-1.1.3.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v1.1.3.tar.gz
b2sums = 648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f
pkgname = owlry-lua

10
aur/owlry-lua/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
*.pkg.tar.zst
*.pkg.tar.zst-namcap.log
*-namcap.log
*-build.log
*-check.log
*-package.log
*-prepare.log
*.tar.gz
src/
pkg/

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-lua
pkgver=1.1.2
pkgver=1.1.3
pkgrel=1
pkgdesc="Lua scripting runtime for Owlry — enables user-created Lua plugins"
arch=('x86_64')
@@ -9,7 +9,7 @@ license=('GPL-3.0-or-later')
depends=('owlry-core' 'openssl')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v$pkgver.tar.gz")
b2sums=('42e6221e6e07c629ece1493e7f5feb1b2cb2e77632d1d7779dfbe544bd89a17d77d1839d63e50d71d4f0e0322ca8a1cc39b872101039019bdf08d9bcaeda7603')
b2sums=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
_cratename=owlry-lua

Submodule aur/owlry-meta-essentials updated: 4a09cfb73c...ed91b61709

Submodule aur/owlry-meta-full updated: 8f85087731...2115aa08f8

Submodule aur/owlry-meta-tools updated: 28c78b7953...bc821ff47f

Submodule aur/owlry-meta-widgets updated: aa4c2cd217...8ba6dd318c

View File

@@ -1,6 +1,6 @@
pkgbase = owlry-rune
pkgdesc = Rune scripting runtime for Owlry — enables user-created Rune plugins
pkgver = 1.1.3
pkgver = 1.1.4
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
@@ -8,7 +8,7 @@ pkgbase = owlry-rune
makedepends = cargo
depends = owlry-core
depends = openssl
source = owlry-rune-1.1.3.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.3.tar.gz
b2sums = 3dd1db5783b0e0c814f8e6064fd5d8cb736da5bd4759d648f48ea69a476451f64d27ecd0894378f3e41e7cc85557b7d9dcb0adf80114c32e561d4688aae6bb53
source = owlry-rune-1.1.4.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.4.tar.gz
b2sums = 648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f
pkgname = owlry-rune

10
aur/owlry-rune/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
*.pkg.tar.zst
*.pkg.tar.zst-namcap.log
*-namcap.log
*-build.log
*-check.log
*-package.log
*-prepare.log
*.tar.gz
src/
pkg/

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-rune
pkgver=1.1.3
pkgver=1.1.4
pkgrel=1
pkgdesc="Rune scripting runtime for Owlry — enables user-created Rune plugins"
arch=('x86_64')
@@ -9,7 +9,7 @@ license=('GPL-3.0-or-later')
depends=('owlry-core' 'openssl')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v$pkgver.tar.gz")
b2sums=('3dd1db5783b0e0c814f8e6064fd5d8cb736da5bd4759d648f48ea69a476451f64d27ecd0894378f3e41e7cc85557b7d9dcb0adf80114c32e561d4688aae6bb53')
b2sums=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
_cratename=owlry-rune

View File

@@ -1,6 +1,6 @@
pkgbase = owlry
pkgdesc = Lightweight Wayland application launcher with plugin support
pkgver = 1.0.6
pkgver = 1.0.8
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
@@ -28,7 +28,7 @@ pkgbase = owlry
optdepends = owlry-plugin-pomodoro: pomodoro timer widget
optdepends = owlry-lua: Lua runtime for user plugins
optdepends = owlry-rune: Rune runtime for user plugins
source = owlry-1.0.6.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.6.tar.gz
b2sums = 8967562bda33820b282350eaad17e8194699926b721eabe978fb0b70af2a75e399866c6bfa7abb449141701bad618df56079c7e81358708b1852b1070b0b7c05
source = owlry-1.0.8.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.8.tar.gz
b2sums = 648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f
pkgname = owlry

10
aur/owlry/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
*.pkg.tar.zst
*.pkg.tar.zst-namcap.log
*-namcap.log
*-build.log
*-check.log
*-package.log
*-prepare.log
*.tar.gz
src/
pkg/

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry
pkgver=1.0.6
pkgver=1.0.8
pkgrel=1
pkgdesc="Lightweight Wayland application launcher with plugin support"
arch=('x86_64')
@@ -29,7 +29,7 @@ optdepends=(
'owlry-rune: Rune runtime for user plugins'
)
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz")
b2sums=('8967562bda33820b282350eaad17e8194699926b721eabe978fb0b70af2a75e399866c6bfa7abb449141701bad618df56079c7e81358708b1852b1070b0b7c05')
b2sums=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
prepare() {
cd "owlry"

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-core"
version = "1.3.3"
version = "1.3.4"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -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"

View File

@@ -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<String, ProfileConfig>,
/// Per-plugin configuration tables.
/// Defined as `[plugin_config.<plugin_name>]` in config.toml.
#[serde(default)]
pub plugin_config: HashMap<String, toml::Value>,
}
#[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<String>,
/// List of plugin IDs to explicitly disable
#[serde(default)]
pub disabled_plugins: Vec<String>,
@@ -233,11 +233,6 @@ pub struct PluginsConfig {
#[serde(default)]
pub registry_url: Option<String>,
/// Per-plugin configuration tables
/// Accessed via `[plugins.<plugin_name>]` sections in config.toml
/// Each plugin can define its own config schema
#[serde(flatten)]
pub plugin_configs: HashMap<String, toml::Value>,
}
/// Sandbox settings for plugin security
@@ -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.<name>]`
#[allow(dead_code)]
pub fn get_plugin_config(&self, plugin_name: &str) -> Option<&toml::Value> {
self.plugin_configs.get(plugin_name)
}
/// Get a string value from a plugin's config
#[allow(dead_code)]
pub fn get_plugin_string(&self, plugin_name: &str, key: &str) -> Option<&str> {
self.plugin_configs.get(plugin_name)?.get(key)?.as_str()
}
/// Get an integer value from a plugin's config
#[allow(dead_code)]
pub fn get_plugin_int(&self, plugin_name: &str, key: &str) -> Option<i64> {
self.plugin_configs.get(plugin_name)?.get(key)?.as_integer()
}
/// Get a boolean value from a plugin's config
#[allow(dead_code)]
pub fn get_plugin_bool(&self, plugin_name: &str, key: &str) -> Option<bool> {
self.plugin_configs.get(plugin_name)?.get(key)?.as_bool()
}
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
@@ -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<i64> {
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<bool> {
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.<name>] entries to [plugin_config.<name>].
// 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::<toml::Value>(&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::<toml_edit::DocumentMut>() {
Ok(mut doc) => {
if let Ok(new_doc) = new_content.parse::<toml_edit::DocumentMut>() {
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(())
}

View File

@@ -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<String> =
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;
}
}

View File

@@ -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<ResultItem> },
Providers { list: Vec<ProviderDesc> },
SubmenuItems { items: Vec<ResultItem> },
PluginList { entries: Vec<PluginEntry> },
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<String>,
}
#[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<String>,
/// 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)]

View File

@@ -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}");

View File

@@ -10,6 +10,10 @@ use super::error::{PluginError, PluginResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
/// Provider declarations from [[providers]] sections (new-style)
#[serde(default)]
pub providers: Vec<ProviderSpec>,
/// Legacy provides block (old-style)
#[serde(default)]
pub provides: PluginProvides,
#[serde(default)]
@@ -43,7 +47,7 @@ pub struct PluginInfo {
#[serde(default = "default_owlry_version")]
pub owlry_version: String,
/// Entry point file (relative to plugin directory)
#[serde(default = "default_entry")]
#[serde(default = "default_entry", alias = "entry_point")]
pub entry: String,
}
@@ -52,7 +56,27 @@ fn default_owlry_version() -> String {
}
fn default_entry() -> String {
"init.lua".to_string()
"main.lua".to_string()
}
/// A provider declared in a [[providers]] section
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderSpec {
pub id: String,
pub name: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub icon: Option<String>,
/// "static" (default) or "dynamic"
#[serde(default = "default_provider_type", rename = "type")]
pub provider_type: String,
#[serde(default)]
pub type_id: Option<String>,
}
fn default_provider_type() -> String {
"static".to_string()
}
/// What the plugin provides
@@ -278,7 +302,7 @@ version = "1.0.0"
assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.name, "Test Plugin");
assert_eq!(manifest.plugin.version, "1.0.0");
assert_eq!(manifest.plugin.entry, "init.lua");
assert_eq!(manifest.plugin.entry, "main.lua");
}
#[test]
@@ -317,6 +341,70 @@ api_url = "https://api.example.com"
assert_eq!(manifest.permissions.run_commands, vec!["myapp"]);
}
#[test]
fn test_parse_new_format_with_providers_array() {
let toml_str = r#"
[plugin]
id = "my-plugin"
name = "My Plugin"
version = "0.1.0"
description = "Test"
entry_point = "main.rn"
[[providers]]
id = "my-plugin"
name = "My Plugin"
type = "static"
type_id = "myplugin"
icon = "system-run"
prefix = ":mp"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.entry, "main.rn");
assert_eq!(manifest.providers.len(), 1);
let p = &manifest.providers[0];
assert_eq!(p.id, "my-plugin");
assert_eq!(p.name, "My Plugin");
assert_eq!(p.provider_type, "static");
assert_eq!(p.type_id.as_deref(), Some("myplugin"));
assert_eq!(p.icon.as_deref(), Some("system-run"));
assert_eq!(p.prefix.as_deref(), Some(":mp"));
}
#[test]
fn test_parse_new_format_entry_point_alias() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
entry_point = "main.lua"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.entry, "main.lua");
}
#[test]
fn test_provider_spec_defaults() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
[[providers]]
id = "test"
name = "Test"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.providers.len(), 1);
let p = &manifest.providers[0];
assert_eq!(p.provider_type, "static"); // default
assert!(p.prefix.is_none());
assert!(p.icon.is_none());
assert!(p.type_id.is_none());
}
#[test]
fn test_version_compatibility() {
let toml_str = r#"

View File

@@ -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<RefCell<>> allows sharing with LuaProviders)
plugins: HashMap<String, Rc<RefCell<LoadedPlugin>>>,
/// Loaded plugins by ID. Arc<Mutex<>> allows sharing with LuaProviders across threads.
plugins: HashMap<String, Arc<Mutex<LoadedPlugin>>>,
/// Plugin IDs that are explicitly disabled
disabled: Vec<String>,
}
@@ -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<Rc<RefCell<LoadedPlugin>>> {
pub fn get(&self, id: &str) -> Option<Arc<Mutex<LoadedPlugin>>> {
self.plugins.get(id).cloned()
}
/// Get all loaded plugins
#[allow(dead_code)]
pub fn plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
pub fn plugins(&self) -> impl Iterator<Item = Arc<Mutex<LoadedPlugin>>> + '_ {
self.plugins.values().cloned()
}
/// Get all enabled plugins
pub fn enabled_plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
pub fn enabled_plugins(&self) -> impl Iterator<Item = Arc<Mutex<LoadedPlugin>>> + '_ {
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<String> {
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<String> {
self.enabled_plugins()
.flat_map(|p| p.borrow().manifest.provides.themes.clone())
.flat_map(|p| p.lock().unwrap().manifest.provides.themes.clone())
.collect()
}

View File

@@ -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<Arc<RwLock<Config>>> = 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<RwLock<Config>>) {
let _ = PLUGIN_CONFIG.set(config);
}
extern "C" fn host_get_config_string(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<RString> {
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<i64> {
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<bool> {
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)

View File

@@ -10,14 +10,15 @@
//! Note: This module is infrastructure for the runtime architecture. Full integration
//! is pending Phase 5 (AUR Packaging) when runtime packages will be available.
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";
@@ -50,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<Mutex<RuntimeHandle>>, 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 {
@@ -69,12 +75,17 @@ pub struct ScriptRuntimeVTable {
pub struct LoadedRuntime {
/// Runtime name (for logging)
name: &'static str,
/// Keep library alive
_library: Arc<Library>,
/// Keep library alive — wrapped in ManuallyDrop so we never call dlclose().
/// dlclose() unmaps the library code; any thread-local destructors inside the
/// library then SIGSEGV when they try to run against the unmapped addresses.
/// Runtime libraries live for the process lifetime, so leaking the handle is safe.
_library: ManuallyDrop<Arc<Library>>,
/// 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<Mutex<RuntimeHandle>>,
/// Provider information
providers: Vec<ScriptProviderInfo>,
}
@@ -124,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<ScriptProviderInfo> = providers_rvec.into_iter().collect();
log::info!(
@@ -138,7 +153,7 @@ impl LoadedRuntime {
Ok(Self {
name,
_library: library,
_library: ManuallyDrop::new(library),
vtable,
handle,
providers,
@@ -155,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<dyn Provider>
})
.collect()
@@ -165,17 +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<RwLock<ProviderManager>>.
// 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<Mutex<RuntimeHandle>> 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 {
@@ -183,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<Mutex<RuntimeHandle>>,
info: ScriptProviderInfo,
items: Vec<LaunchItem>,
}
@@ -192,7 +212,7 @@ impl RuntimeProvider {
fn new(
runtime_name: &'static str,
vtable: &'static ScriptRuntimeVTable,
handle: RuntimeHandle,
handle: Arc<Mutex<RuntimeHandle>>,
info: ScriptProviderInfo,
) -> Self {
Self {
@@ -214,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,
}
}
}
@@ -233,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))
@@ -251,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<Mutex<RuntimeHandle>> 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 {

View File

@@ -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)) => {

View File

@@ -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<String> = 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);

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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,
}
}

View File

@@ -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()
}

View File

@@ -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<RefCell<LoadedPlugin>>,
/// Reference to the loaded plugin (shared with other providers from same plugin).
/// Mutex serializes concurrent refresh calls; Arc allows sharing across threads.
plugin: Arc<Mutex<LoadedPlugin>>,
/// Cached items from last refresh
items: Vec<LaunchItem>,
}
impl LuaProvider {
/// Create a new LuaProvider
pub fn new(registration: ProviderRegistration, plugin: Rc<RefCell<LoadedPlugin>>) -> Self {
pub fn new(registration: ProviderRegistration, plugin: Arc<Mutex<LoadedPlugin>>) -> 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<RefCell<>> is !Send and !Sync, but the ProviderManager RwLock ensures
// Rc<RefCell<>> is only accessed during refresh() (write lock = exclusive access).
// Read-only operations (items(), search) only touch self.items (Vec<LaunchItem>).
unsafe impl Send for LuaProvider {}
unsafe impl Sync for LuaProvider {}
// LuaProvider is Send + Sync because:
// - Arc<Mutex<LoadedPlugin>> 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<RefCell<LoadedPlugin>>) -> Vec<Box<dyn Provider>> {
pub fn create_providers_from_plugin(plugin: Arc<Mutex<LoadedPlugin>>) -> Vec<Box<dyn Provider>> {
let registrations = {
let p = plugin.borrow();
let p = plugin.lock().unwrap();
match p.get_provider_registrations() {
Ok(regs) => regs,
Err(e) => {

View File

@@ -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<Self, Self::Err> {
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<String>,
/// 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<LoadedRuntime>,
/// Type IDs of providers from script runtimes (for hot-reload removal)
runtime_type_ids: std::collections::HashSet<String>,
/// 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<crate::ipc::PluginEntry>,
}
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<RwLock<Config>>) -> Self {
use crate::plugins::native_loader::NativePluginLoader;
use std::sync::Arc;
// Create core providers
let mut core_providers: Vec<Box<dyn Provider>> = 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<Box<dyn DynamicProvider>> = 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<RwLock<Config>>
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<crate::ipc::PluginEntry> = Vec::new();
let native_providers: Vec<NativeProvider> = 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<crate::ipc::PluginEntry> = 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,
}
}

View File

@@ -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,
}
}

View File

@@ -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();

View File

@@ -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<Self> {
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<UnixStream>, max: usize) -> io::Result<Option<String>> {
let mut buf: Vec<u8> = 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<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
config: Arc<RwLock<Config>>,
}
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<RwLock<Config>>.
{
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<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
config: Arc<RwLock<Config>>,
) -> 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<RwLock<ProviderManager>>,
frecency: &Arc<RwLock<FrecencyStore>>,
config: &Arc<Config>,
config: &Arc<RwLock<Config>>,
) -> 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<UnixStream>, we test it indirectly
// through an in-memory byte slice via a helper.
fn bounded_line_from_bytes(data: &[u8], max: usize) -> io::Result<Option<String>> {
// 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()));
}
}

View File

@@ -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"));

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-lua"
version = "1.1.2"
version = "1.1.3"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -30,6 +30,9 @@ serde_json = "1.0"
# Version compatibility
semver = "1"
# Logging
log = "0.4"
# HTTP client for plugins
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "blocking", "json"] }

View File

@@ -50,3 +50,8 @@ pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
provider::call_query(lua, provider_name, query)
}
/// Call the global `refresh()` function (for manifest-declared providers)
pub fn call_global_refresh(lua: &Lua) -> LuaResult<Vec<PluginItem>> {
provider::call_global_refresh(lua)
}

View File

@@ -76,6 +76,15 @@ fn register_provider(lua: &Lua, config: Table) -> LuaResult<()> {
Ok(())
}
/// Call the top-level `refresh()` global function (for manifest-declared providers)
pub fn call_global_refresh(lua: &Lua) -> LuaResult<Vec<PluginItem>> {
let globals = lua.globals();
match globals.get::<Function>("refresh") {
Ok(refresh_fn) => parse_items_result(refresh_fn.call(())?),
Err(_) => Ok(Vec::new()),
}
}
/// Get all registered providers
pub fn get_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
// Suppress unused warning

View File

@@ -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<Mutex<RuntimeHandle>> in the runtime loader.
unsafe impl Send for RuntimeHandle {}
unsafe impl Sync for RuntimeHandle {}
impl RuntimeHandle {
/// Create a null handle (reserved for error cases)

View File

@@ -96,8 +96,28 @@ impl LoadedPlugin {
.as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?;
api::get_provider_registrations(lua)
.map_err(|e| format!("Failed to get registrations: {}", e))
let mut regs = api::get_provider_registrations(lua)
.map_err(|e| format!("Failed to get registrations: {}", e))?;
// Fall back to manifest [[providers]] declarations when the script
// doesn't call owlry.provider.register() (new-style plugins)
if regs.is_empty() {
for decl in &self.manifest.providers {
regs.push(ProviderRegistration {
name: decl.id.clone(),
display_name: decl.name.clone(),
type_id: decl.type_id.clone().unwrap_or_else(|| decl.id.clone()),
default_icon: decl
.icon
.clone()
.unwrap_or_else(|| "application-x-addon".to_string()),
prefix: decl.prefix.clone(),
is_dynamic: decl.provider_type == "dynamic",
});
}
}
Ok(regs)
}
/// Call a provider's refresh function
@@ -107,7 +127,17 @@ impl LoadedPlugin {
.as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?;
api::call_refresh(lua, provider_name).map_err(|e| format!("Refresh failed: {}", e))
let items = api::call_refresh(lua, provider_name)
.map_err(|e| format!("Refresh failed: {}", e))?;
// If the API path returned nothing, try calling the global refresh()
// function directly (new-style plugins with manifest [[providers]])
if items.is_empty() {
return api::call_global_refresh(lua)
.map_err(|e| format!("Refresh failed: {}", e));
}
Ok(items)
}
/// Call a provider's query function
@@ -156,9 +186,18 @@ pub fn discover_plugins(
match PluginManifest::load(&manifest_path) {
Ok(manifest) => {
// Skip plugins whose entry point is not a Lua file
if !manifest.plugin.entry.ends_with(".lua") {
log::debug!(
"owlry-lua: Skipping non-Lua plugin at {} (entry: {})",
path.display(),
manifest.plugin.entry
);
continue;
}
let id = manifest.plugin.id.clone();
if plugins.contains_key(&id) {
eprintln!(
log::warn!(
"owlry-lua: Duplicate plugin ID '{}', skipping {}",
id,
path.display()
@@ -168,7 +207,7 @@ pub fn discover_plugins(
plugins.insert(id, (manifest, path));
}
Err(e) => {
eprintln!(
log::warn!(
"owlry-lua: Failed to load plugin at {}: {}",
path.display(),
e
@@ -229,4 +268,79 @@ version = "1.0.0"
let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap();
assert!(plugins.is_empty());
}
#[test]
fn test_discover_skips_non_lua_plugins() {
let temp = TempDir::new().unwrap();
let plugins_dir = temp.path();
// Rune plugin — should be skipped by the Lua runtime
let rune_dir = plugins_dir.join("rune-plugin");
fs::create_dir_all(&rune_dir).unwrap();
fs::write(
rune_dir.join("plugin.toml"),
r#"
[plugin]
id = "rune-plugin"
name = "Rune Plugin"
version = "1.0.0"
entry_point = "main.rn"
[[providers]]
id = "rune-plugin"
name = "Rune Plugin"
"#,
)
.unwrap();
fs::write(rune_dir.join("main.rn"), "pub fn refresh() { [] }").unwrap();
// Lua plugin — should be discovered
create_test_plugin(plugins_dir, "lua-plugin");
let plugins = discover_plugins(plugins_dir).unwrap();
assert_eq!(plugins.len(), 1);
assert!(plugins.contains_key("lua-plugin"));
assert!(!plugins.contains_key("rune-plugin"));
}
#[test]
fn test_manifest_provider_fallback() {
let temp = TempDir::new().unwrap();
let plugin_dir = temp.path().join("test-plugin");
fs::create_dir_all(&plugin_dir).unwrap();
fs::write(
plugin_dir.join("plugin.toml"),
r#"
[plugin]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
entry_point = "main.lua"
[[providers]]
id = "test-plugin"
name = "Test Plugin"
type = "static"
type_id = "testplugin"
icon = "system-run"
prefix = ":tp"
"#,
)
.unwrap();
// Script that does NOT call owlry.provider.register()
fs::write(plugin_dir.join("main.lua"), "function refresh() return {} end").unwrap();
let manifest =
crate::manifest::PluginManifest::load(&plugin_dir.join("plugin.toml")).unwrap();
let mut plugin = LoadedPlugin::new(manifest, plugin_dir);
plugin.initialize().unwrap();
let regs = plugin.get_provider_registrations().unwrap();
assert_eq!(regs.len(), 1, "should fall back to [[providers]] declaration");
assert_eq!(regs[0].name, "test-plugin");
assert_eq!(regs[0].type_id, "testplugin");
assert_eq!(regs[0].prefix.as_deref(), Some(":tp"));
assert!(!regs[0].is_dynamic);
}
}

View File

@@ -8,6 +8,10 @@ use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
/// Provider declarations from [[providers]] sections (new-style)
#[serde(default)]
pub providers: Vec<ProviderDecl>,
/// Legacy provides block (old-style)
#[serde(default)]
pub provides: PluginProvides,
#[serde(default)]
@@ -16,6 +20,26 @@ pub struct PluginManifest {
pub settings: HashMap<String, toml::Value>,
}
/// A provider declared in a [[providers]] section
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderDecl {
pub id: String,
pub name: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub icon: Option<String>,
/// "static" (default) or "dynamic"
#[serde(default = "default_provider_type", rename = "type")]
pub provider_type: String,
#[serde(default)]
pub type_id: Option<String>,
}
fn default_provider_type() -> String {
"static".to_string()
}
/// Core plugin information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
@@ -127,6 +151,11 @@ impl PluginManifest {
));
}
// Lua plugins must have a .lua entry point
if !self.plugin.entry.ends_with(".lua") {
return Err("Entry point must be a .lua file for Lua plugins".to_string());
}
Ok(())
}

View File

@@ -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<RString>,
/// Read an integer value from this plugin's config section.
pub get_config_int: extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<i64>,
/// Read a boolean value from this plugin's config section.
pub get_config_bool: extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<bool>,
}
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<String> {
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<i64> {
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<bool> {
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:

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-rune"
version = "1.1.3"
version = "1.1.4"
edition = "2024"
rust-version = "1.90"
description = "Rune scripting runtime for owlry plugins"

View File

@@ -203,6 +203,7 @@ pub fn discover_rune_plugins(plugins_dir: &Path) -> Result<HashMap<String, Loade
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
@@ -211,4 +212,81 @@ mod tests {
let plugins = discover_rune_plugins(temp.path()).unwrap();
assert!(plugins.is_empty());
}
#[test]
fn test_discover_skips_non_rune_plugins() {
let temp = TempDir::new().unwrap();
let plugins_dir = temp.path();
// Lua plugin — should be skipped by the Rune runtime
let lua_dir = plugins_dir.join("lua-plugin");
fs::create_dir_all(&lua_dir).unwrap();
fs::write(
lua_dir.join("plugin.toml"),
r#"
[plugin]
id = "lua-plugin"
name = "Lua Plugin"
version = "1.0.0"
entry_point = "main.lua"
[[providers]]
id = "lua-plugin"
name = "Lua Plugin"
"#,
)
.unwrap();
fs::write(lua_dir.join("main.lua"), "function refresh() return {} end").unwrap();
let plugins = discover_rune_plugins(plugins_dir).unwrap();
assert!(plugins.is_empty(), "Lua plugin should be skipped by Rune runtime");
}
#[test]
fn test_manifest_provider_fallback() {
let temp = TempDir::new().unwrap();
let plugin_dir = temp.path().join("test-plugin");
fs::create_dir_all(&plugin_dir).unwrap();
fs::write(
plugin_dir.join("plugin.toml"),
r#"
[plugin]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
entry_point = "main.rn"
[[providers]]
id = "test-plugin"
name = "Test Plugin"
type = "static"
type_id = "testplugin"
icon = "system-run"
prefix = ":tp"
"#,
)
.unwrap();
// Script that exports refresh() but doesn't call register_provider()
fs::write(
plugin_dir.join("main.rn"),
r#"use owlry::Item;
pub fn refresh() {
[]
}
"#,
)
.unwrap();
let manifest =
crate::manifest::PluginManifest::load(&plugin_dir.join("plugin.toml")).unwrap();
let plugin = LoadedPlugin::new(manifest, plugin_dir).unwrap();
let regs = plugin.provider_registrations();
assert_eq!(regs.len(), 1, "should fall back to [[providers]] declaration");
assert_eq!(regs[0].name, "test-plugin");
assert_eq!(regs[0].type_id, "testplugin");
assert_eq!(regs[0].prefix.as_deref(), Some(":tp"));
assert!(regs[0].is_static);
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry"
version = "1.0.7"
version = "1.0.8"
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"

View File

@@ -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,
}
}

View File

@@ -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<UnixStream>, max: usize) -> io::Result<Option<String>> {
let mut buf: Vec<u8> = 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<Vec<PluginEntry>> {
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<Vec<ResultItem>> {
self.send(&Request::Submenu {
@@ -186,14 +238,15 @@ impl CoreClient {
}
fn receive(&mut self) -> io::Result<Response> {
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(),
}],
};

View File

@@ -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(())
@@ -366,6 +415,14 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
"runtime": runtime.to_string(),
"runtime_available": runtime_available,
"path": plugin_path.display().to_string(),
"providers": manifest.providers.iter().map(|p| serde_json::json!({
"id": p.id,
"name": p.name,
"type": p.provider_type,
"type_id": p.type_id,
"prefix": p.prefix,
"icon": p.icon,
})).collect::<Vec<_>>(),
"provides": {
"providers": manifest.provides.providers,
"actions": manifest.provides.actions,
@@ -406,9 +463,13 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
);
println!("Path: {}", plugin_path.display());
println!();
println!("Provides:");
println!("Providers:");
for p in &manifest.providers {
let prefix = p.prefix.as_deref().map(|s| format!(" ({})", s)).unwrap_or_default();
println!(" {} [{}]{}", p.name, p.provider_type, prefix);
}
if !manifest.provides.providers.is_empty() {
println!(" Providers: {}", manifest.provides.providers.join(", "));
println!(" {}", manifest.provides.providers.join(", "));
}
if manifest.provides.actions {
println!(" Actions: yes");
@@ -754,10 +815,16 @@ fn cmd_create(
let desc = description.unwrap_or("A custom owlry plugin");
let (entry_file, entry_ext) = match runtime {
PluginRuntime::Lua => ("init.lua", "lua"),
PluginRuntime::Rune => ("init.rn", "rn"),
PluginRuntime::Lua => ("main.lua", "lua"),
PluginRuntime::Rune => ("main.rn", "rn"),
};
// Derive a short type_id from the plugin name (strip common prefixes)
let type_id = name
.strip_prefix("owlry-")
.unwrap_or(name)
.replace('-', "_");
// Create plugin.toml
let manifest = format!(
r#"[plugin]
@@ -765,25 +832,21 @@ id = "{name}"
name = "{display}"
version = "0.1.0"
description = "{desc}"
author = ""
owlry_version = ">=0.3.0"
entry = "{entry_file}"
entry_point = "{entry_file}"
[provides]
providers = ["{name}"]
actions = false
themes = []
hooks = false
[permissions]
network = false
filesystem = []
run_commands = []
[[providers]]
id = "{name}"
name = "{display}"
type = "static"
type_id = "{type_id}"
icon = "application-x-addon"
# prefix = ":{type_id}"
"#,
name = name,
display = display,
desc = desc,
entry_file = entry_file,
type_id = type_id,
);
fs::write(plugin_dir.join("plugin.toml"), manifest)
@@ -792,91 +855,51 @@ run_commands = []
// Create entry point template based on runtime
match runtime {
PluginRuntime::Lua => {
let init_lua = format!(
let main_lua = format!(
r#"-- {display} Plugin for Owlry
-- {desc}
-- Register the provider
owlry.provider.register({{
name = "{name}",
display_name = "{display}",
type_id = "{name}",
default_icon = "application-x-executable",
refresh = function()
-- Return a list of items
return {{
{{
id = "{name}:example",
name = "Example Item",
description = "This is an example item from {display}",
icon = "dialog-information",
command = "echo 'Hello from {name}!'",
terminal = false,
tags = {{}}
}}
}}
end
}})
owlry.log.info("{display} plugin loaded")
function refresh()
return {{
{{
id = "{name}:example",
name = "Example Item",
description = "This is an example item from {display}",
icon = "dialog-information",
command = "echo 'Hello from {name}!'",
tags = {{}},
}},
}}
end
"#,
name = name,
display = display,
desc = desc,
);
fs::write(plugin_dir.join(entry_file), init_lua)
fs::write(plugin_dir.join(entry_file), main_lua)
.map_err(|e| format!("Failed to write {}: {}", entry_file, e))?;
}
PluginRuntime::Rune => {
// Note: Rune uses #{{ for object literals, so we build manually
let init_rn = format!(
r#"//! {display} Plugin for Owlry
//! {desc}
let main_rn = format!(
r#"use owlry::Item;
/// Plugin item structure
struct Item {{{{
id: String,
name: String,
description: String,
icon: String,
command: String,
terminal: bool,
tags: Vec<String>,
}}}}
pub fn refresh() {{
let items = [];
/// Provider registration
pub fn register(owlry) {{{{
owlry.provider.register(#{{{{
name: "{name}",
display_name: "{display}",
type_id: "{name}",
default_icon: "application-x-executable",
items.push(
Item::new("{name}:example", "Example Item", "echo 'Hello from {name}!'")
.description("This is an example item from {display}")
.icon("dialog-information")
.keywords(["example"]),
);
refresh: || {{{{
// Return a list of items
[
Item {{{{
id: "{name}:example",
name: "Example Item",
description: "This is an example item from {display}",
icon: "dialog-information",
command: "echo 'Hello from {name}!'",
terminal: false,
tags: [],
}}}},
]
}}}},
}}}});
owlry.log.info("{display} plugin loaded");
}}}}
items
}}
"#,
name = name,
display = display,
desc = desc,
);
fs::write(plugin_dir.join(entry_file), init_rn)
fs::write(plugin_dir.join(entry_file), main_rn)
.map_err(|e| format!("Failed to write {}: {}", entry_file, e))?;
}
}
@@ -955,13 +978,14 @@ fn cmd_validate(path: Option<&str>) -> CommandResult {
));
}
// Check for empty provides
if manifest.provides.providers.is_empty()
&& !manifest.provides.actions
&& manifest.provides.themes.is_empty()
&& !manifest.provides.hooks
{
warnings.push("Plugin does not provide any features".to_string());
// Check for empty provides (accept either [[providers]] or [provides])
let has_providers = !manifest.providers.is_empty()
|| !manifest.provides.providers.is_empty()
|| manifest.provides.actions
|| !manifest.provides.themes.is_empty()
|| manifest.provides.hooks;
if !has_providers {
warnings.push("Plugin does not declare any providers".to_string());
}
println!(" Plugin ID: {}", manifest.plugin.id);
@@ -1013,11 +1037,11 @@ fn cmd_runtimes() -> CommandResult {
if lua_available {
println!(" ✓ Lua - Installed");
println!(" Package: owlry-lua");
println!(" Entry point: init.lua");
println!(" Entry point: main.lua");
} else {
println!(" ✗ Lua - Not installed");
println!(" Install: yay -S owlry-lua");
println!(" Entry point: init.lua");
println!(" Entry point: main.lua");
}
println!();
@@ -1026,11 +1050,11 @@ fn cmd_runtimes() -> CommandResult {
if rune_available {
println!(" ✓ Rune - Installed");
println!(" Package: owlry-rune");
println!(" Entry point: init.rn");
println!(" Entry point: main.rn");
} else {
println!(" ✗ Rune - Not installed");
println!(" Install: yay -S owlry-rune");
println!(" Entry point: init.rn");
println!(" Entry point: main.rn");
}
println!();

View File

@@ -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);

View File

@@ -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");

View File

@@ -1,4 +1,5 @@
mod main_window;
pub mod provider_meta;
mod result_row;
pub mod submenu;

View File

@@ -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",
},
},
}
}

View File

@@ -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));
}

View File

@@ -252,6 +252,7 @@ aur-publish-all:
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -d "$dir/.git" ] || continue
[ -f "$dir/PKGBUILD" ] || continue
echo "=== $pkg ==="
just aur-publish-pkg "$pkg"
echo ""

View File

@@ -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