Compare commits

..

24 Commits

Author SHA1 Message Date
ffe04f3c54 docs: add per-crate tagging convention to CLAUDE.md 2026-03-26 18:57:09 +01:00
5c0e63f94c chore(owlry-rune): bump version to 1.1.0 2026-03-26 18:51:20 +01:00
5441011d6b chore(owlry-lua): bump version to 1.1.0 2026-03-26 18:51:20 +01:00
317572634f chore(owlry): bump version to 1.0.1 2026-03-26 18:51:12 +01:00
449dc010db chore(owlry-core): bump version to 1.1.0 2026-03-26 18:51:04 +01:00
7273cd3ba7 chore(owlry-plugin-api): bump version to 1.0.1 2026-03-26 18:50:58 +01:00
f8388a4327 docs: update CLAUDE.md with script runtime loading, hot-reload, dynamic prefixes 2026-03-26 18:47:05 +01:00
fa671ebd77 feat: dynamic prefix fallback for user plugin prefixes (e.g. :hs for hyprshutdown) 2026-03-26 18:40:23 +01:00
d63c7d170b fix: build Rune provider registrations from manifest [[providers]] declarations 2026-03-26 18:37:33 +01:00
5f14ed2b3b fix: register Rune Item type under owlry crate path 2026-03-26 18:35:41 +01:00
83f551dd7f fix: use rune::function attribute for Item constructor and builder methods 2026-03-26 18:30:39 +01:00
9b1eada1ee fix: set accept_all when no CLI mode restriction so user plugins appear in default filter 2026-03-26 18:27:50 +01:00
677e6d7fa9 fix: send accept_all filter as None in IPC so runtime plugins appear in results 2026-03-26 18:21:32 +01:00
f0741f4128 fix: store Lua provider callbacks for refresh, fix Rune Item::new parameter types 2026-03-26 18:12:07 +01:00
7da8f3c249 fix: align Lua ProviderInfo ABI, implement Rune Item type and refresh/query 2026-03-26 18:07:46 +01:00
38dda8c44c fix: watcher startup grace period, defensive runtime drop on reload 2026-03-26 17:59:32 +01:00
ab2d3cfe55 feat: add filesystem watcher for automatic user plugin hot-reload
Watch ~/.config/owlry/plugins/ for changes using notify-debouncer-mini
(500ms debounce) and trigger a full runtime reload on file modifications.
Respects OWLRY_SKIP_RUNTIMES=1 to skip watcher in tests.
2026-03-26 17:48:15 +01:00
e2939e266c feat: wire script runtime loading into daemon ProviderManager 2026-03-26 17:44:33 +01:00
651166a9f3 feat: change default entry points to main.lua/main.rn, add entry_point alias 2026-03-26 17:33:54 +01:00
a2eb7d1b0d fix: align runtime ABI — shrink Lua RuntimeInfo, pass owlry_version to init
- Shrink Lua RuntimeInfo from 5 fields to 2 (name, version), matching
  core and Rune. The mismatch caused SIGSEGV across the ABI boundary.
- Add owlry_version parameter to vtable init in all three crates
  (core, Lua, Rune) so runtimes receive the version at init time
  instead of hardcoding it.
- Remove unused Lua constants (RUNTIME_ID, RUNTIME_NAME, etc.) and
  LUA_RUNTIME_API_VERSION.
- Update plugin_commands.rs call sites to pass CARGO_PKG_VERSION.
2026-03-26 17:31:23 +01:00
8073d27df2 docs: update LuaProvider safety comment for RwLock architecture 2026-03-26 16:51:55 +01:00
3349350bf6 fix: robustness — RwLock for concurrent reads, log malformed JSON requests
Replace Mutex with RwLock for ProviderManager and FrecencyStore in the
IPC server. Most request types (Query, Providers, Submenu, PluginAction)
only need read access and can now proceed concurrently. Only Launch
(frecency write) and Refresh (provider write) acquire exclusive locks.

Also adds a warn!() log for malformed JSON requests before sending the
error response, improving observability for debugging client issues.

Provider trait now requires Send + Sync to satisfy RwLock's Sync bound
on the inner type. RuntimeProvider and LuaProvider gain the
corresponding unsafe impl Sync.
2026-03-26 16:39:10 +01:00
3aaeafde8b fix: security — socket perms 0600, signal handler logging, client read timeout 2026-03-26 16:32:06 +01:00
7ce6de17aa fix: soundness — OnceLock for HOST_API, IPC size limits, mutex poisoning recovery 2026-03-26 16:29:47 +01:00
26 changed files with 727 additions and 163 deletions

View File

@@ -166,6 +166,23 @@ just bump 0.5.1
# Create and push release tag
git push && just tag
# Tagging convention: every crate gets its own tag
# Format: {crate-name}-v{version}
# Examples:
# owlry-v1.0.1
# owlry-core-v1.1.0
# owlry-lua-v1.1.0
# owlry-rune-v1.1.0
# plugin-api-v1.0.1
#
# The plugins repo uses the same convention:
# owlry-plugin-bookmarks-v1.0.1
# owlry-plugin-calculator-v1.0.1
# etc.
#
# IMPORTANT: After bumping versions, tag EVERY changed crate individually.
# The plugin-api tag is referenced by owlry-plugins Cargo.toml as a git dependency.
# AUR package management
just aur-update # Update core UI PKGBUILD
just aur-update-pkg NAME # Update specific package (owlry-core, owlry-lua, etc.)
@@ -299,10 +316,18 @@ CoreClient ──── IPC ────→ ProviderManager ProviderFi
All other providers are native plugins in the separate `owlry-plugins` repo (`somegit.dev/Owlibou/owlry-plugins`).
**User plugins** (script-based, in `~/.config/owlry/plugins/`):
- **Lua plugins**: Loaded by `owlry-lua` runtime from `/usr/lib/owlry/runtimes/liblua.so`
- **Rune plugins**: Loaded by `owlry-rune` runtime from `/usr/lib/owlry/runtimes/librune.so`
- User plugins are **hot-reloaded** automatically when files change (no daemon restart needed)
- Custom prefixes (e.g., `:hs`) are resolved dynamically for user plugins
`ProviderManager` (in `owlry-core`) orchestrates providers and handles:
- Fuzzy matching via `SkimMatcherV2`
- Frecency score boosting
- Native plugin loading from `/usr/lib/owlry/plugins/`
- Script runtime loading from `/usr/lib/owlry/runtimes/` for user plugins
- Filesystem watching for automatic user plugin hot-reload
**Submenu System**: Plugins can return items with `SUBMENU:plugin_id:data` commands. When selected, the plugin is queried with `?SUBMENU:data` to get action items (e.g., systemd service actions).
@@ -340,6 +365,7 @@ Plugins are compiled as `.so` (cdylib) and loaded by the daemon at startup.
- Profile-based mode selection (`--profile dev`)
- Provider toggling (Ctrl+1/2/3)
- Prefix parsing (`:app`, `:cmd`, `:sys`, etc.)
- Dynamic prefix fallback for user plugins (any `:word` prefix maps to `Plugin(word)`)
Query parsing extracts prefix and forwards clean query to providers.
@@ -395,6 +421,8 @@ Plugins live in a separate repository: `somegit.dev/Owlibou/owlry-plugins`
- **Rc<RefCell<T>>** used throughout for GTK signal handlers needing mutable state
- **Feature flag `dev-logging`**: Wraps debug!() calls in `#[cfg(feature = "dev-logging")]`
- **Feature flag `lua`**: Enables built-in Lua runtime (off by default); enable to embed Lua in core binary
- **Script runtimes**: External `.so` runtimes loaded from `/usr/lib/owlry/runtimes/` — Lua and Rune user plugins loaded from `~/.config/owlry/plugins/`
- **Hot-reload**: Filesystem watcher (`notify` crate) monitors user plugins dir and reloads runtimes on file changes
- **dmenu mode**: Runs locally without daemon. Use `-m dmenu` with piped stdin
- **Frecency**: Time-decayed frequency scoring stored in `~/.local/share/owlry/frecency.json`
- **ABI stability**: Plugin interface uses `abi_stable` crate for safe Rust dynamic linking

178
Cargo.lock generated
View File

@@ -337,6 +337,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -400,7 +406,7 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"cairo-sys-rs",
"glib 0.21.5",
"libc",
@@ -699,7 +705,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
@@ -851,6 +857,17 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -909,6 +926,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -1231,7 +1257,7 @@ version = "0.20.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1252,7 +1278,7 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1425,7 +1451,7 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d422cce9367945916b7a5083eedf67b0a5380d326af1943a0b5cef9afb6e48"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"gdk4",
"glib 0.21.5",
"glib-sys 0.21.5",
@@ -1777,6 +1803,35 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inotify"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -1899,6 +1954,26 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1943,7 +2018,10 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [
"bitflags 2.11.0",
"libc",
"plain",
"redox_syscall 0.7.3",
]
[[package]]
@@ -2102,6 +2180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@@ -2186,7 +2265,7 @@ version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases",
"libc",
@@ -2198,6 +2277,37 @@ version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce"
[[package]]
name = "notify"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
dependencies = [
"bitflags 2.11.0",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.52.0",
]
[[package]]
name = "notify-debouncer-mini"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaa5a66d07ed97dce782be94dcf5ab4d1b457f4243f7566c7557f15cabc8c799"
dependencies = [
"log",
"notify",
"notify-types",
"tempfile",
]
[[package]]
name = "notify-rust"
version = "4.12.0"
@@ -2212,6 +2322,15 @@ dependencies = [
"zbus",
]
[[package]]
name = "notify-types"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
dependencies = [
"instant",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2335,7 +2454,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"dispatch2",
"objc2",
]
@@ -2352,7 +2471,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
@@ -2417,7 +2536,7 @@ dependencies = [
[[package]]
name = "owlry"
version = "1.0.0"
version = "1.0.1"
dependencies = [
"chrono",
"clap",
@@ -2437,7 +2556,7 @@ dependencies = [
[[package]]
name = "owlry-core"
version = "1.0.0"
version = "1.1.0"
dependencies = [
"chrono",
"ctrlc",
@@ -2449,6 +2568,8 @@ dependencies = [
"log",
"meval",
"mlua",
"notify",
"notify-debouncer-mini",
"notify-rust",
"owlry-plugin-api",
"reqwest 0.13.2",
@@ -2462,7 +2583,7 @@ dependencies = [
[[package]]
name = "owlry-lua"
version = "1.0.0"
version = "1.1.0"
dependencies = [
"abi_stable",
"chrono",
@@ -2480,7 +2601,7 @@ dependencies = [
[[package]]
name = "owlry-plugin-api"
version = "1.0.0"
version = "1.0.1"
dependencies = [
"abi_stable",
"serde",
@@ -2488,7 +2609,7 @@ dependencies = [
[[package]]
name = "owlry-rune"
version = "1.0.0"
version = "1.1.0"
dependencies = [
"chrono",
"dirs",
@@ -2553,7 +2674,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link 0.2.1",
]
@@ -2619,6 +2740,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "polling"
version = "3.11.0"
@@ -2821,7 +2948,16 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.11.0",
]
[[package]]
name = "redox_syscall"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
@@ -3096,7 +3232,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys",
@@ -3227,7 +3363,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -3475,7 +3611,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -3803,7 +3939,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"async-compression",
"bitflags",
"bitflags 2.11.0",
"bytes",
"futures-core",
"futures-util",
@@ -4161,7 +4297,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
@@ -4786,7 +4922,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"bitflags 2.11.0",
"indexmap",
"log",
"serde",

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-core"
version = "1.0.0"
version = "1.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -36,6 +36,10 @@ dirs = "5"
# Error handling
thiserror = "2"
# Filesystem watching (plugin hot-reload)
notify = "7"
notify-debouncer-mini = "0.5"
# Signal handling
ctrlc = { version = "3", features = ["termination"] }

View File

@@ -32,6 +32,8 @@ impl ProviderFilter {
cli_providers: Option<Vec<ProviderType>>,
config_providers: &ProvidersConfig,
) -> Self {
let accept_all = cli_mode.is_none() && cli_providers.is_none();
let enabled = if let Some(mode) = cli_mode {
// --mode overrides everything: single provider
HashSet::from([mode])
@@ -90,7 +92,7 @@ impl ProviderFilter {
let filter = Self {
enabled,
active_prefix: None,
accept_all: false,
accept_all,
};
#[cfg(feature = "dev-logging")]
@@ -184,6 +186,11 @@ impl ProviderFilter {
self.accept_all || self.enabled.contains(&provider)
}
/// Whether this filter accepts all provider types
pub fn is_accept_all(&self) -> bool {
self.accept_all
}
/// Get current active prefix if any
#[allow(dead_code)]
pub fn active_prefix(&self) -> Option<ProviderType> {
@@ -353,6 +360,28 @@ impl ProviderFilter {
}
}
// Dynamic plugin prefix fallback: ":word " or ":word" where word is unknown
// Maps to Plugin(word) so user plugins with custom prefixes work
if let Some(rest) = trimmed.strip_prefix(':') {
if let Some(space_idx) = rest.find(' ') {
let prefix_word = &rest[..space_idx];
if !prefix_word.is_empty() && prefix_word.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return ParsedQuery {
prefix: Some(ProviderType::Plugin(prefix_word.to_string())),
tag_filter: None,
query: rest[space_idx + 1..].to_string(),
};
}
} else if !rest.is_empty() && rest.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
// Partial prefix (no space yet)
return ParsedQuery {
prefix: Some(ProviderType::Plugin(rest.to_string())),
tag_filter: None,
query: String::new(),
};
}
}
let result = ParsedQuery {
prefix: None,
tag_filter: None,

View File

@@ -1,4 +1,4 @@
use log::info;
use log::{info, warn};
use owlry_core::paths;
use owlry_core::server::Server;
@@ -25,11 +25,12 @@ fn main() {
// Graceful shutdown on SIGTERM/SIGINT
let sock_cleanup = sock.clone();
ctrlc::set_handler(move || {
if let Err(e) = ctrlc::set_handler(move || {
let _ = std::fs::remove_file(&sock_cleanup);
std::process::exit(0);
})
.ok();
}) {
warn!("Failed to set signal handler: {}", e);
}
if let Err(e) = server.run() {
eprintln!("Server error: {e}");

View File

@@ -26,6 +26,7 @@ pub mod manifest;
pub mod native_loader;
pub mod registry;
pub mod runtime_loader;
pub mod watcher;
// Lua-specific modules (require mlua)
#[cfg(feature = "lua")]

View File

@@ -10,8 +10,6 @@
//! Note: This module is infrastructure for the runtime architecture. Full integration
//! is pending Phase 5 (AUR Packaging) when runtime packages will be available.
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -56,7 +54,7 @@ pub struct RuntimeHandle(pub *mut ());
#[repr(C)]
pub struct ScriptRuntimeVTable {
pub info: extern "C" fn() -> RuntimeInfo,
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<ScriptProviderInfo>,
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
pub query: extern "C" fn(
@@ -83,12 +81,13 @@ pub struct LoadedRuntime {
impl LoadedRuntime {
/// Load the Lua runtime from the system directory
pub fn load_lua(plugins_dir: &Path) -> PluginResult<Self> {
pub fn load_lua(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
Self::load_from_path(
"Lua",
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"),
b"owlry_lua_runtime_vtable",
plugins_dir,
owlry_version,
)
}
@@ -98,6 +97,7 @@ impl LoadedRuntime {
library_path: &Path,
vtable_symbol: &[u8],
plugins_dir: &Path,
owlry_version: &str,
) -> PluginResult<Self> {
if !library_path.exists() {
return Err(PluginError::NotFound(library_path.display().to_string()));
@@ -124,7 +124,7 @@ impl LoadedRuntime {
// Initialize the runtime
let plugins_dir_str = plugins_dir.to_string_lossy();
let handle = (vtable.init)(RStr::from_str(&plugins_dir_str));
let handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version));
// Get provider information
let providers_rvec = (vtable.providers)(handle);
@@ -169,6 +169,14 @@ impl Drop for LoadedRuntime {
}
}
// 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 {}
/// A provider backed by a dynamically loaded runtime
pub struct RuntimeProvider {
/// Runtime name (for logging)
@@ -243,8 +251,12 @@ impl Provider for RuntimeProvider {
}
}
// RuntimeProvider needs to be Send for the Provider trait
// 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 {}
/// Check if the Lua runtime is available
pub fn lua_runtime_available() -> bool {
@@ -262,12 +274,13 @@ pub fn rune_runtime_available() -> bool {
impl LoadedRuntime {
/// Load the Rune runtime from the system directory
pub fn load_rune(plugins_dir: &Path) -> PluginResult<Self> {
pub fn load_rune(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
Self::load_from_path(
"Rune",
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"),
b"owlry_rune_runtime_vtable",
plugins_dir,
owlry_version,
)
}
}

View File

@@ -0,0 +1,104 @@
//! Filesystem watcher for user plugin hot-reload
//!
//! Watches `~/.config/owlry/plugins/` for changes and triggers
//! runtime reload when plugin files are modified.
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
use log::{info, warn};
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use crate::providers::ProviderManager;
/// Start watching the user plugins directory for changes.
///
/// Spawns a background thread that monitors the directory and triggers
/// a full runtime reload on any file change. Returns immediately.
///
/// Respects `OWLRY_SKIP_RUNTIMES=1` — returns early if set.
pub fn start_watching(pm: Arc<RwLock<ProviderManager>>) {
if std::env::var("OWLRY_SKIP_RUNTIMES").is_ok() {
info!("OWLRY_SKIP_RUNTIMES set, skipping file watcher");
return;
}
let plugins_dir = match crate::paths::plugins_dir() {
Some(d) => d,
None => {
info!("No plugins directory configured, skipping file watcher");
return;
}
};
if !plugins_dir.exists()
&& std::fs::create_dir_all(&plugins_dir).is_err()
{
warn!(
"Failed to create plugins directory: {}",
plugins_dir.display()
);
return;
}
info!(
"Plugin file watcher started for {}",
plugins_dir.display()
);
thread::spawn(move || {
if let Err(e) = watch_loop(&plugins_dir, &pm) {
warn!("Plugin watcher stopped: {}", e);
}
});
}
fn watch_loop(
plugins_dir: &PathBuf,
pm: &Arc<RwLock<ProviderManager>>,
) -> Result<(), Box<dyn std::error::Error>> {
let (tx, rx) = std::sync::mpsc::channel();
let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?;
debouncer
.watcher()
.watch(plugins_dir.as_ref(), notify::RecursiveMode::Recursive)?;
info!("Watching {} for plugin changes", plugins_dir.display());
// Skip events during initial startup grace period (watcher setup triggers events)
let startup = std::time::Instant::now();
let grace_period = Duration::from_secs(2);
loop {
match rx.recv() {
Ok(Ok(events)) => {
if startup.elapsed() < grace_period {
continue;
}
let has_relevant_change = events.iter().any(|e| {
matches!(
e.kind,
DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous
)
});
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();
}
}
Ok(Err(error)) => {
warn!("File watcher error: {}", error);
}
Err(e) => {
return Err(Box::new(e));
}
}
}
}

View File

@@ -89,10 +89,12 @@ impl Provider for LuaProvider {
}
}
// LuaProvider needs to be Send for the Provider trait
// Since we're using Rc<RefCell<>>, we need to be careful about thread safety
// For now, owlry is single-threaded, so this is safe
// 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 {}
/// Create LuaProviders from all registered providers in a plugin
pub fn create_providers_from_plugin(plugin: Rc<RefCell<LoadedPlugin>>) -> Vec<Box<dyn Provider>> {

View File

@@ -25,6 +25,7 @@ use log::debug;
use crate::config::Config;
use crate::data::FrecencyStore;
use crate::plugins::runtime_loader::LoadedRuntime;
/// Metadata descriptor for an available provider (used by IPC/daemon API)
#[derive(Debug, Clone)]
@@ -94,7 +95,7 @@ impl std::fmt::Display for ProviderType {
}
/// Trait for all search providers
pub trait Provider: Send {
pub trait Provider: Send + Sync {
#[allow(dead_code)]
fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType;
@@ -116,6 +117,10 @@ pub struct ProviderManager {
widget_providers: Vec<NativeProvider>,
/// Fuzzy matcher for search
matcher: SkimMatcherV2,
/// Loaded script runtimes (Lua, Rune) — must stay alive to keep Library handles
runtimes: Vec<LoadedRuntime>,
/// Type IDs of providers from script runtimes (for hot-reload removal)
runtime_type_ids: std::collections::HashSet<String>,
}
impl ProviderManager {
@@ -134,6 +139,8 @@ impl ProviderManager {
dynamic_providers: Vec::new(),
widget_providers: Vec::new(),
matcher: SkimMatcherV2::default(),
runtimes: Vec::new(),
runtime_type_ids: std::collections::HashSet::new(),
};
// Categorize native plugins based on their declared ProviderKind and ProviderPosition
@@ -180,7 +187,7 @@ impl ProviderManager {
use std::sync::Arc;
// Create core providers
let core_providers: Vec<Box<dyn Provider>> = vec![
let mut core_providers: Vec<Box<dyn Provider>> = vec![
Box::new(ApplicationProvider::new()),
Box::new(CommandProvider::new()),
];
@@ -220,7 +227,122 @@ impl ProviderManager {
}
};
Self::new(core_providers, native_providers)
// Load script runtimes (Lua, Rune) for user plugins
let mut runtime_providers: Vec<Box<dyn Provider>> = Vec::new();
let mut runtimes: Vec<LoadedRuntime> = Vec::new();
let mut runtime_type_ids = std::collections::HashSet::new();
let owlry_version = env!("CARGO_PKG_VERSION");
let skip_runtimes = std::env::var("OWLRY_SKIP_RUNTIMES").is_ok();
if !skip_runtimes
&& let Some(plugins_dir) = crate::paths::plugins_dir()
{
// Try Lua runtime
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Loaded Lua runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
runtime_type_ids.insert(type_id);
runtime_providers.push(provider);
}
runtimes.push(rt);
}
Err(e) => {
info!("Lua runtime not available: {}", e);
}
}
// Try Rune runtime
match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Loaded Rune runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
runtime_type_ids.insert(type_id);
runtime_providers.push(provider);
}
runtimes.push(rt);
}
Err(e) => {
info!("Rune runtime not available: {}", e);
}
}
} // skip_runtimes
// Merge runtime providers into core providers
for provider in runtime_providers {
info!("Registered runtime provider: {}", provider.name());
core_providers.push(provider);
}
let mut manager = Self::new(core_providers, native_providers);
manager.runtimes = runtimes;
manager.runtime_type_ids = runtime_type_ids;
manager
}
/// Reload all script runtime providers (called by filesystem watcher)
pub fn reload_runtimes(&mut self) {
use crate::plugins::runtime_loader::LoadedRuntime;
// Remove old runtime providers from the core providers list
self.providers.retain(|p| {
let type_str = format!("{}", p.provider_type());
!self.runtime_type_ids.contains(&type_str)
});
// Drop old runtimes (catch panics from runtime cleanup)
let old_runtimes = std::mem::take(&mut self.runtimes);
drop(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
drop(old_runtimes);
})));
self.runtime_type_ids.clear();
let owlry_version = env!("CARGO_PKG_VERSION");
let plugins_dir = match crate::paths::plugins_dir() {
Some(d) => d,
None => return,
};
// Reload Lua runtime
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Reloaded Lua runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
self.runtime_type_ids.insert(type_id);
self.providers.push(provider);
}
self.runtimes.push(rt);
}
Err(e) => {
info!("Lua runtime not available on reload: {}", e);
}
}
// Reload Rune runtime
match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Reloaded Rune runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
self.runtime_type_ids.insert(type_id);
self.providers.push(provider);
}
self.runtimes.push(rt);
}
Err(e) => {
info!("Rune runtime not available on reload: {}", e);
}
}
// Refresh the newly added providers
for provider in &mut self.providers {
provider.refresh();
}
info!("Runtime reload complete");
}
#[allow(dead_code)]

View File

@@ -1,9 +1,14 @@
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::{Arc, Mutex};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use std::thread;
/// Maximum allowed size for a single IPC request line (1 MiB).
const MAX_REQUEST_SIZE: usize = 1_048_576;
use log::{error, info, warn};
use crate::config::Config;
@@ -17,8 +22,8 @@ use crate::providers::{LaunchItem, ProviderManager};
pub struct Server {
listener: UnixListener,
socket_path: PathBuf,
provider_manager: Arc<Mutex<ProviderManager>>,
frecency: Arc<Mutex<FrecencyStore>>,
provider_manager: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
}
@@ -34,6 +39,7 @@ impl Server {
}
let listener = UnixListener::bind(socket_path)?;
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();
@@ -43,14 +49,17 @@ impl Server {
Ok(Self {
listener,
socket_path: socket_path.to_path_buf(),
provider_manager: Arc::new(Mutex::new(provider_manager)),
frecency: Arc::new(Mutex::new(frecency)),
provider_manager: Arc::new(RwLock::new(provider_manager)),
frecency: Arc::new(RwLock::new(frecency)),
config: Arc::new(config),
})
}
/// Accept connections in a loop, spawning a thread per client.
pub fn run(&self) -> io::Result<()> {
// Start filesystem watcher for user plugin hot-reload
crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager));
info!("Server entering accept loop");
for stream in self.listener.incoming() {
match stream {
@@ -90,15 +99,33 @@ impl Server {
/// dispatch each, and write the JSON response back.
fn handle_client(
stream: UnixStream,
pm: Arc<Mutex<ProviderManager>>,
frecency: Arc<Mutex<FrecencyStore>>,
pm: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
) -> io::Result<()> {
let reader = BufReader::new(stream.try_clone()?);
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
let mut reader = BufReader::new(stream.try_clone()?);
let mut writer = stream;
for line in reader.lines() {
let line = line?;
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 trimmed = line.trim();
if trimmed.is_empty() {
continue;
@@ -107,6 +134,7 @@ impl Server {
let request: Request = match serde_json::from_str(trimmed) {
Ok(req) => req,
Err(e) => {
warn!("Malformed request from client: {}", e);
let resp = Response::Error {
message: format!("invalid request JSON: {}", e),
};
@@ -126,8 +154,8 @@ impl Server {
/// the response.
fn handle_request(
request: &Request,
pm: &Arc<Mutex<ProviderManager>>,
frecency: &Arc<Mutex<FrecencyStore>>,
pm: &Arc<RwLock<ProviderManager>>,
frecency: &Arc<RwLock<FrecencyStore>>,
config: &Arc<Config>,
) -> Response {
match request {
@@ -139,8 +167,8 @@ impl Server {
let max = config.general.max_results;
let weight = config.providers.frecency_weight;
let pm_guard = pm.lock().unwrap();
let frecency_guard = frecency.lock().unwrap();
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 results = pm_guard.search_with_frecency(
text,
max,
@@ -162,13 +190,13 @@ impl Server {
item_id,
provider: _,
} => {
let mut frecency_guard = frecency.lock().unwrap();
let mut frecency_guard = frecency.write().unwrap_or_else(|e| e.into_inner());
frecency_guard.record_launch(item_id);
Response::Ack
}
Request::Providers => {
let pm_guard = pm.lock().unwrap();
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
let descs = pm_guard.available_providers();
Response::Providers {
list: descs.into_iter().map(descriptor_to_desc).collect(),
@@ -176,7 +204,7 @@ impl Server {
}
Request::Refresh { provider } => {
let mut pm_guard = pm.lock().unwrap();
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
pm_guard.refresh_provider(provider);
Response::Ack
}
@@ -187,7 +215,7 @@ impl Server {
}
Request::Submenu { plugin_id, data } => {
let pm_guard = pm.lock().unwrap();
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) {
Some((_name, actions)) => Response::SubmenuItems {
items: actions
@@ -202,7 +230,7 @@ impl Server {
}
Request::PluginAction { command } => {
let pm_guard = pm.lock().unwrap();
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
if pm_guard.execute_plugin_action(command) {
Response::Ack
} else {

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-lua"
version = "1.0.0"
version = "1.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -22,7 +22,7 @@ pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
}
/// Implementation of owlry.provider.register()
fn register_provider(_lua: &Lua, config: Table) -> LuaResult<()> {
fn register_provider(lua: &Lua, config: Table) -> LuaResult<()> {
let name: String = config.get("name")?;
let display_name: String = config
.get::<Option<String>>("display_name")?
@@ -47,6 +47,21 @@ fn register_provider(_lua: &Lua, config: Table) -> LuaResult<()> {
let is_dynamic = has_query;
// Store the config table in owlry.provider._registrations[name]
// so call_refresh/call_query can find the callback functions later
let globals = lua.globals();
let owlry: Table = globals.get("owlry")?;
let provider: Table = owlry.get("provider")?;
let registrations: Table = match provider.get::<Value>("_registrations")? {
Value::Table(t) => t,
_ => {
let t = lua.create_table()?;
provider.set("_registrations", t.clone())?;
t
}
};
registrations.set(name.as_str(), config)?;
REGISTRATIONS.with(|regs| {
regs.borrow_mut().push(ProviderRegistration {
name,

View File

@@ -27,28 +27,19 @@ mod manifest;
mod runtime;
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{PluginItem, ProviderKind};
use owlry_plugin_api::PluginItem;
use std::collections::HashMap;
use std::path::PathBuf;
use loader::LoadedPlugin;
// Runtime metadata
const RUNTIME_ID: &str = "lua";
const RUNTIME_NAME: &str = "Lua Runtime";
const RUNTIME_VERSION: &str = env!("CARGO_PKG_VERSION");
const RUNTIME_DESCRIPTION: &str = "Lua 5.4 runtime for user plugins";
/// API version for compatibility checking
pub const LUA_RUNTIME_API_VERSION: u32 = 1;
/// Runtime vtable - exported interface for the core to use
#[repr(C)]
pub struct LuaRuntimeVTable {
/// Get runtime info
pub info: extern "C" fn() -> RuntimeInfo,
/// Initialize the runtime with plugins directory
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
/// Get provider infos from all loaded plugins
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<LuaProviderInfo>,
/// Refresh a provider's items
@@ -66,11 +57,8 @@ pub struct LuaRuntimeVTable {
/// Runtime info returned by the runtime
#[repr(C)]
pub struct RuntimeInfo {
pub id: RString,
pub name: RString,
pub version: RString,
pub description: RString,
pub api_version: u32,
}
/// Opaque handle to the runtime state
@@ -106,24 +94,22 @@ impl RuntimeHandle {
}
/// Provider info from a Lua plugin
///
/// Must match ScriptProviderInfo layout in owlry-core/src/plugins/runtime_loader.rs
#[repr(C)]
pub struct LuaProviderInfo {
/// Full provider ID: "plugin_id:provider_name"
pub id: RString,
/// Plugin ID this provider belongs to
pub plugin_id: RString,
/// Provider name within the plugin
pub provider_name: RString,
/// Provider name (used as vtable refresh/query key: "plugin_id:provider_name")
pub name: RString,
/// Display name
pub display_name: RString,
/// Optional prefix trigger
pub prefix: ROption<RString>,
/// Icon name
pub icon: RString,
/// Provider type (static/dynamic)
pub provider_type: ProviderKind,
/// Type ID for filtering
pub type_id: RString,
/// Icon name
pub default_icon: RString,
/// Whether this is a static provider (true) or dynamic (false)
pub is_static: bool,
/// Optional prefix trigger
pub prefix: ROption<RString>,
}
/// Internal runtime state
@@ -187,21 +173,14 @@ impl LuaRuntimeState {
if let Ok(registrations) = plugin.get_provider_registrations() {
for reg in registrations {
let full_id = format!("{}:{}", plugin_id, reg.name);
let provider_type = if reg.is_dynamic {
ProviderKind::Dynamic
} else {
ProviderKind::Static
};
providers.push(LuaProviderInfo {
id: RString::from(full_id),
plugin_id: RString::from(plugin_id.as_str()),
provider_name: RString::from(reg.name.as_str()),
name: RString::from(full_id),
display_name: RString::from(reg.display_name.as_str()),
prefix: reg.prefix.map(RString::from).into(),
icon: RString::from(reg.default_icon.as_str()),
provider_type,
type_id: RString::from(reg.type_id.as_str()),
default_icon: RString::from(reg.default_icon.as_str()),
is_static: !reg.is_dynamic,
prefix: reg.prefix.map(RString::from).into(),
});
}
}
@@ -259,22 +238,15 @@ impl LuaRuntimeState {
extern "C" fn runtime_info() -> RuntimeInfo {
RuntimeInfo {
id: RString::from(RUNTIME_ID),
name: RString::from(RUNTIME_NAME),
version: RString::from(RUNTIME_VERSION),
description: RString::from(RUNTIME_DESCRIPTION),
api_version: LUA_RUNTIME_API_VERSION,
name: RString::from("Lua"),
version: RString::from(env!("CARGO_PKG_VERSION")),
}
}
extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle {
extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
let plugins_dir = PathBuf::from(plugins_dir.as_str());
let mut state = Box::new(LuaRuntimeState::new(plugins_dir));
// TODO: Get owlry version from core somehow
// For now, use a reasonable default
state.discover_and_load("0.3.0");
state.discover_and_load(owlry_version.as_str());
RuntimeHandle::from_box(state)
}
@@ -346,8 +318,8 @@ mod tests {
#[test]
fn test_runtime_info() {
let info = runtime_info();
assert_eq!(info.id.as_str(), "lua");
assert_eq!(info.api_version, LUA_RUNTIME_API_VERSION);
assert_eq!(info.name.as_str(), "Lua");
assert!(!info.version.as_str().is_empty());
}
#[test]

View File

@@ -200,7 +200,7 @@ version = "1.0.0"
id, id
);
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
fs::write(plugin_dir.join("init.lua"), "-- empty plugin").unwrap();
fs::write(plugin_dir.join("main.lua"), "-- empty plugin").unwrap();
}
#[test]

View File

@@ -41,7 +41,7 @@ pub struct PluginInfo {
#[serde(default = "default_owlry_version")]
pub owlry_version: String,
/// Entry point file (relative to plugin directory)
#[serde(default = "default_entry")]
#[serde(default = "default_entry", alias = "entry_point")]
pub entry: String,
}
@@ -50,7 +50,7 @@ fn default_owlry_version() -> String {
}
fn default_entry() -> String {
"init.lua".to_string()
"main.lua".to_string()
}
/// What the plugin provides
@@ -160,7 +160,7 @@ version = "1.0.0"
assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.name, "Test Plugin");
assert_eq!(manifest.plugin.version, "1.0.0");
assert_eq!(manifest.plugin.entry, "init.lua");
assert_eq!(manifest.plugin.entry, "main.lua");
}
#[test]

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-plugin-api"
version = "1.0.0"
version = "1.0.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -297,26 +297,24 @@ pub struct HostAPI {
pub log_error: extern "C" fn(message: RStr<'_>),
}
use std::sync::OnceLock;
// Global host API pointer - set by the host when loading plugins
static mut HOST_API: Option<&'static HostAPI> = None;
static HOST_API: OnceLock<&'static HostAPI> = OnceLock::new();
/// Initialize the host API (called by the host)
///
/// # Safety
/// Must only be called once by the host before any plugins use the API
pub unsafe fn init_host_api(api: &'static HostAPI) {
// SAFETY: Caller guarantees this is called once before any plugins use the API
unsafe {
HOST_API = Some(api);
}
let _ = HOST_API.set(api);
}
/// Get the host API
///
/// Returns None if the host hasn't initialized the API yet
pub fn host_api() -> Option<&'static HostAPI> {
// SAFETY: We only read the pointer, and it's set once at startup
unsafe { HOST_API }
HOST_API.get().copied()
}
// ============================================================================

View File

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

View File

@@ -2,7 +2,7 @@
//!
//! This module provides the `owlry` module that Rune plugins can use.
use rune::{ContextError, Module};
use rune::{Any, ContextError, Module};
use std::sync::Mutex;
use owlry_plugin_api::{PluginItem, RString};
@@ -20,9 +20,9 @@ pub struct ProviderRegistration {
/// An item returned by a provider
///
/// Used for converting Rune plugin items to FFI format.
#[derive(Debug, Clone)]
#[allow(dead_code)]
/// Exposed to Rune scripts as `owlry::Item`.
#[derive(Debug, Clone, Any)]
#[rune(item = ::owlry)]
pub struct Item {
pub id: String,
pub name: String,
@@ -34,8 +34,42 @@ pub struct Item {
}
impl Item {
/// Constructor exposed to Rune via #[rune::function]
#[rune::function(path = Self::new)]
pub fn rune_new(id: String, name: String, command: String) -> Self {
Self {
id,
name,
command,
description: None,
icon: None,
terminal: false,
keywords: Vec::new(),
}
}
/// Set description (builder pattern for Rune)
#[rune::function]
fn description(mut self, desc: String) -> Self {
self.description = Some(desc);
self
}
/// Set icon (builder pattern for Rune)
#[rune::function]
fn icon(mut self, icon: String) -> Self {
self.icon = Some(icon);
self
}
/// Set keywords (builder pattern for Rune)
#[rune::function]
fn keywords(mut self, keywords: Vec<String>) -> Self {
self.keywords = keywords;
self
}
/// Convert to PluginItem for FFI
#[allow(dead_code)]
pub fn to_plugin_item(&self) -> PluginItem {
let mut item = PluginItem::new(
RString::from(self.id.as_str()),
@@ -62,7 +96,14 @@ pub static REGISTRATIONS: Mutex<Vec<ProviderRegistration>> = Mutex::new(Vec::new
pub fn module() -> Result<Module, ContextError> {
let mut module = Module::with_crate("owlry")?;
// Register logging functions using builder pattern
// Register Item type with constructor and builder methods
module.ty::<Item>()?;
module.function_meta(Item::rune_new)?;
module.function_meta(Item::description)?;
module.function_meta(Item::icon)?;
module.function_meta(Item::keywords)?;
// Register logging functions
module.function("log_info", log_info).build()?;
module.function("log_debug", log_debug).build()?;
module.function("log_warn", log_warn).build()?;

View File

@@ -72,7 +72,7 @@ struct RuntimeState {
#[repr(C)]
pub struct RuneRuntimeVTable {
pub info: extern "C" fn() -> RuntimeInfo,
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<RuneProviderInfo>,
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
pub query: extern "C" fn(
@@ -94,8 +94,9 @@ extern "C" fn runtime_info() -> RuntimeInfo {
}
}
extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle {
extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
let _ = env_logger::try_init();
let _version = owlry_version.as_str();
let plugins_dir = PathBuf::from(plugins_dir.as_str());
log::info!(
@@ -250,7 +251,7 @@ mod tests {
let plugins_dir = temp.path().to_string_lossy();
// Initialize runtime
let handle = runtime_init(RStr::from_str(&plugins_dir));
let handle = runtime_init(RStr::from_str(&plugins_dir), RStr::from_str("1.0.0"));
assert!(!handle.0.is_null());
// Get providers (should be empty with no plugins)

View File

@@ -59,8 +59,20 @@ impl LoadedPlugin {
}
}
// Collect registrations
let registrations = api::get_registrations();
// Collect registrations — from runtime API or from manifest [[providers]]
let mut registrations = api::get_registrations();
if registrations.is_empty() && !manifest.providers.is_empty() {
for decl in &manifest.providers {
registrations.push(ProviderRegistration {
name: decl.id.clone(),
display_name: decl.name.clone(),
type_id: decl.type_id.clone().unwrap_or_else(|| decl.id.clone()),
default_icon: decl.icon.clone().unwrap_or_else(|| "application-x-addon".to_string()),
is_static: decl.provider_type != "dynamic",
prefix: decl.prefix.clone(),
});
}
}
log::info!(
"Loaded Rune plugin '{}' with {} provider(s)",
@@ -92,16 +104,37 @@ impl LoadedPlugin {
self.registrations.iter().any(|r| r.name == name)
}
/// Refresh a static provider (stub for now)
/// Refresh a static provider by calling the Rune `refresh()` function
pub fn refresh_provider(&mut self, _name: &str) -> Result<Vec<PluginItem>, String> {
// TODO: Implement provider refresh by calling Rune function
Ok(Vec::new())
let mut vm = create_vm(&self.context, self.unit.clone())
.map_err(|e| format!("Failed to create VM: {}", e))?;
let output = vm
.call(rune::Hash::type_hash(["refresh"]), ())
.map_err(|e| format!("refresh() call failed: {}", e))?;
let items: Vec<crate::api::Item> = rune::from_value(output)
.map_err(|e| format!("Failed to parse refresh() result: {}", e))?;
Ok(items.iter().map(|i| i.to_plugin_item()).collect())
}
/// Query a dynamic provider (stub for now)
pub fn query_provider(&mut self, _name: &str, _query: &str) -> Result<Vec<PluginItem>, String> {
// TODO: Implement provider query by calling Rune function
Ok(Vec::new())
/// Query a dynamic provider by calling the Rune `query(q)` function
pub fn query_provider(&mut self, _name: &str, query: &str) -> Result<Vec<PluginItem>, String> {
let mut vm = create_vm(&self.context, self.unit.clone())
.map_err(|e| format!("Failed to create VM: {}", e))?;
let output = vm
.call(
rune::Hash::type_hash(["query"]),
(query.to_string(),),
)
.map_err(|e| format!("query() call failed: {}", e))?;
let items: Vec<crate::api::Item> = rune::from_value(output)
.map_err(|e| format!("Failed to parse query() result: {}", e))?;
Ok(items.iter().map(|i| i.to_plugin_item()).collect())
}
}

View File

@@ -11,6 +11,28 @@ pub struct PluginManifest {
pub provides: PluginProvides,
#[serde(default)]
pub permissions: PluginPermissions,
/// Provider declarations from [[providers]] sections
#[serde(default)]
pub providers: Vec<ProviderDecl>,
}
/// A provider declared in [[providers]] section of plugin.toml
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderDecl {
pub id: String,
pub name: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default = "default_provider_type", rename = "type")]
pub provider_type: String,
#[serde(default)]
pub type_id: Option<String>,
}
fn default_provider_type() -> String {
"static".to_string()
}
/// Core plugin information
@@ -25,7 +47,7 @@ pub struct PluginInfo {
pub author: String,
#[serde(default = "default_owlry_version")]
pub owlry_version: String,
#[serde(default = "default_entry")]
#[serde(default = "default_entry", alias = "entry_point")]
pub entry: String,
}
@@ -34,7 +56,7 @@ fn default_owlry_version() -> String {
}
fn default_entry() -> String {
"init.rn".to_string()
"main.rn".to_string()
}
/// What the plugin provides
@@ -128,7 +150,7 @@ version = "1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.entry, "init.rn");
assert_eq!(manifest.plugin.entry, "main.rn");
}
#[test]

View File

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

View File

@@ -39,13 +39,18 @@ impl SearchBackend {
) -> Vec<LaunchItem> {
match self {
SearchBackend::Daemon(client) => {
// When accept_all, send None so daemon doesn't restrict to a specific set
// (otherwise dynamically loaded plugin types would be filtered out)
let modes_param = if filter.is_accept_all() {
None
} else {
let modes: Vec<String> = filter
.enabled_providers()
.iter()
.map(|p| p.to_string())
.collect();
let modes_param = if modes.is_empty() { None } else { Some(modes) };
if modes.is_empty() { None } else { Some(modes) }
};
match client.query(query, modes_param) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
@@ -105,13 +110,18 @@ impl SearchBackend {
query.to_string()
};
// When accept_all, send None so daemon doesn't restrict to a specific set
// (otherwise dynamically loaded plugin types would be filtered out)
let modes_param = if filter.is_accept_all() {
None
} else {
let modes: Vec<String> = filter
.enabled_providers()
.iter()
.map(|p| p.to_string())
.collect();
let modes_param = if modes.is_empty() { None } else { Some(modes) };
if modes.is_empty() { None } else { Some(modes) }
};
match client.query(&effective_query, modes_param) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),

View File

@@ -1114,10 +1114,14 @@ fn execute_plugin_command(
// Load the appropriate runtime
let loaded_runtime = match runtime {
PluginRuntime::Lua => LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path))
.map_err(|e| format!("Failed to load Lua runtime: {}", e))?,
PluginRuntime::Lua => {
let owlry_version = env!("CARGO_PKG_VERSION");
LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path), owlry_version)
.map_err(|e| format!("Failed to load Lua runtime: {}", e))?
}
PluginRuntime::Rune => {
LoadedRuntime::load_rune(plugin_path.parent().unwrap_or(plugin_path))
let owlry_version = env!("CARGO_PKG_VERSION");
LoadedRuntime::load_rune(plugin_path.parent().unwrap_or(plugin_path), owlry_version)
.map_err(|e| format!("Failed to load Rune runtime: {}", e))?
}
};