Compare commits
2 Commits
owlry-core
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7275fcab35 | |||
| 4d7e913657 |
55
Cargo.lock
generated
55
Cargo.lock
generated
@@ -409,12 +409,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -555,17 +549,6 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"nix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -603,8 +586,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -807,6 +788,16 @@ dependencies = [
|
||||
"xdg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
@@ -2033,18 +2024,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "1.2.4"
|
||||
@@ -2372,10 +2351,10 @@ name = "owlry-core"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"ctrlc",
|
||||
"dirs",
|
||||
"env_logger",
|
||||
"freedesktop-desktop-entry",
|
||||
"fs2",
|
||||
"fuzzy-matcher",
|
||||
"libloading 0.8.9",
|
||||
"log",
|
||||
@@ -2389,10 +2368,10 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"signal-hook",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.8.23",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3058,6 +3037,16 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
|
||||
@@ -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
10
aur/owlry-core/.gitignore
vendored
Normal 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/
|
||||
@@ -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"
|
||||
|
||||
@@ -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
10
aur/owlry-lua/.gitignore
vendored
Normal 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/
|
||||
@@ -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
@@ -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
10
aur/owlry-rune/.gitignore
vendored
Normal 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/
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
10
aur/owlry/.gitignore
vendored
Normal 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/
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
|
||||
use std::mem::ManuallyDrop;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use libloading::{Library, Symbol};
|
||||
use owlry_plugin_api::{PluginItem, RStr, RString, RVec};
|
||||
|
||||
use super::error::{PluginError, PluginResult};
|
||||
use crate::providers::{LaunchItem, Provider, ProviderType};
|
||||
use crate::providers::{ItemSource, LaunchItem, Provider, ProviderType};
|
||||
|
||||
/// System directory for runtime libraries
|
||||
pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes";
|
||||
@@ -51,6 +51,11 @@ pub type LuaProviderInfo = ScriptProviderInfo;
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RuntimeHandle(pub *mut ());
|
||||
|
||||
// SAFETY: The underlying runtime state (Lua VM, Rune VM) is Send — mlua enables
|
||||
// the "send" feature and Rune wraps its state in Mutex internally. Access is always
|
||||
// serialized through Arc<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 {
|
||||
@@ -77,8 +82,10 @@ pub struct LoadedRuntime {
|
||||
_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>,
|
||||
}
|
||||
@@ -128,10 +135,14 @@ impl LoadedRuntime {
|
||||
|
||||
// Initialize the runtime
|
||||
let plugins_dir_str = plugins_dir.to_string_lossy();
|
||||
let handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version));
|
||||
let raw_handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version));
|
||||
let handle = Arc::new(Mutex::new(raw_handle));
|
||||
|
||||
// Get provider information
|
||||
let providers_rvec = (vtable.providers)(handle);
|
||||
// Get provider information — lock to serialize the vtable call
|
||||
let providers_rvec = {
|
||||
let h = handle.lock().unwrap();
|
||||
(vtable.providers)(*h)
|
||||
};
|
||||
let providers: Vec<ScriptProviderInfo> = providers_rvec.into_iter().collect();
|
||||
|
||||
log::info!(
|
||||
@@ -159,8 +170,12 @@ impl LoadedRuntime {
|
||||
self.providers
|
||||
.iter()
|
||||
.map(|info| {
|
||||
let provider =
|
||||
RuntimeProvider::new(self.name, self.vtable, self.handle, info.clone());
|
||||
let provider = RuntimeProvider::new(
|
||||
self.name,
|
||||
self.vtable,
|
||||
Arc::clone(&self.handle),
|
||||
info.clone(),
|
||||
);
|
||||
Box::new(provider) as Box<dyn Provider>
|
||||
})
|
||||
.collect()
|
||||
@@ -169,19 +184,16 @@ impl LoadedRuntime {
|
||||
|
||||
impl Drop for LoadedRuntime {
|
||||
fn drop(&mut self) {
|
||||
(self.vtable.drop)(self.handle);
|
||||
let h = self.handle.lock().unwrap();
|
||||
(self.vtable.drop)(*h);
|
||||
// Do NOT drop _library: ManuallyDrop ensures dlclose() is never called.
|
||||
// See field comment for rationale.
|
||||
}
|
||||
}
|
||||
|
||||
// LoadedRuntime needs to be Send + Sync because ProviderManager is shared across
|
||||
// threads via Arc<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 {
|
||||
@@ -189,7 +201,9 @@ pub struct RuntimeProvider {
|
||||
#[allow(dead_code)]
|
||||
runtime_name: &'static str,
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
handle: RuntimeHandle,
|
||||
/// Shared with the owning LoadedRuntime and sibling RuntimeProviders.
|
||||
/// Mutex serializes concurrent vtable calls on the same runtime handle.
|
||||
handle: Arc<Mutex<RuntimeHandle>>,
|
||||
info: ScriptProviderInfo,
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
@@ -198,7 +212,7 @@ impl RuntimeProvider {
|
||||
fn new(
|
||||
runtime_name: &'static str,
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
handle: RuntimeHandle,
|
||||
handle: Arc<Mutex<RuntimeHandle>>,
|
||||
info: ScriptProviderInfo,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -220,6 +234,7 @@ impl RuntimeProvider {
|
||||
command: item.command.to_string(),
|
||||
terminal: item.terminal,
|
||||
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
|
||||
source: ItemSource::ScriptPlugin,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,7 +254,10 @@ impl Provider for RuntimeProvider {
|
||||
}
|
||||
|
||||
let name_rstr = RStr::from_str(self.info.name.as_str());
|
||||
let items_rvec = (self.vtable.refresh)(self.handle, name_rstr);
|
||||
let items_rvec = {
|
||||
let h = self.handle.lock().unwrap();
|
||||
(self.vtable.refresh)(*h, name_rstr)
|
||||
};
|
||||
self.items = items_rvec
|
||||
.into_iter()
|
||||
.map(|i| self.convert_item(i))
|
||||
@@ -257,12 +275,10 @@ impl Provider for RuntimeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// RuntimeProvider needs to be Send + Sync for the Provider trait.
|
||||
// Safety: RuntimeHandle is an opaque FFI handle accessed only through
|
||||
// extern "C" vtable functions. The same safety argument that justifies
|
||||
// Send applies to Sync — all access is mediated by the vtable.
|
||||
unsafe impl Send for RuntimeProvider {}
|
||||
unsafe impl Sync for RuntimeProvider {}
|
||||
// RuntimeProvider is Send + Sync because:
|
||||
// - Arc<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 {
|
||||
|
||||
@@ -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)) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod main_window;
|
||||
pub mod provider_meta;
|
||||
mod result_row;
|
||||
pub mod submenu;
|
||||
|
||||
|
||||
101
crates/owlry/src/ui/provider_meta.rs
Normal file
101
crates/owlry/src/ui/provider_meta.rs
Normal 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",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user