Compare commits

..

16 Commits

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

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

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

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

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

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

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

Also filter out non-.lua plugins in the Lua runtime's discover_plugins()
so liblua.so does not attempt to load Rune plugins.
2026-04-06 02:26:12 +02:00
de74cac67d chore(owlry-lua): bump version to 1.1.3 2026-04-06 02:22:08 +02:00
2f396306fd chore(owlry-core): bump version to 1.3.4 2026-04-06 02:22:07 +02:00
133d5264ea feat(plugins): update plugin format to new entry_point + [[providers]] style
- owlry-core/manifest: add entry_point alias for entry field, add ProviderSpec
  struct for [[providers]] array, change default entry to main.lua
- owlry-lua/manifest: add ProviderDecl struct and providers: Vec<ProviderDecl>
  for [[providers]] support
- owlry-lua/loader: fall back to manifest [[providers]] when script has no API
  registrations; fall back to global refresh() for manifest-declared providers
- owlry-lua/api: expose call_global_refresh() that calls the top-level Lua
  refresh() function directly
- owlry/plugin_commands: update create templates to emit new format:
  entry_point instead of entry, [[providers]] instead of [provides],
  main.rn/main.lua instead of init.rn/init.lua, Rune uses Item::new() builder
  pattern, Lua uses standalone refresh() function
- cmd_validate: accept [[providers]] declarations as a valid provides source
2026-04-06 02:22:03 +02:00
a16c3a0523 fix(just): skip meta packages without PKGBUILD in aur-publish-all 2026-04-06 02:11:32 +02:00
33b4f410e5 fix(scripts): fix duplicate inject deduplication in aur-local-test
dedup_inject_files() was called before its definition in the script's
execution order. Moved the call to the Main section where all functions
are already defined.
2026-04-06 02:04:24 +02:00
a7683f16bf docs: update config example and README to reflect provider field removal 2026-04-06 02:02:06 +02:00
178f81082a fix(owlry-core,owlry): preserve config on save, fix tab filtering, clean provider fields
- Config::save() now merges into existing file via toml_edit to preserve
  user comments and unknown keys instead of overwriting
- Remove 16 dead ProvidersConfig plugin-toggle fields (uuctl, ssh, clipboard,
  bookmarks, emoji, scripts, files, media, weather_*, pomodoro_*) that became
  unreachable after accept_all=true was introduced
- Wire general.tabs to ProviderFilter::new() to drive UI tab display
- Fix set_single_mode() and toggle() not clearing accept_all, making tab
  filtering a no-op in default launch mode
- Add restore_all_mode() for cycle-back-to-All path in tab cycling
- Reduce PROVIDER_TOGGLES to built-in providers only
2026-04-06 01:59:48 +02:00
7863de9971 chore(owlry): bump version to 1.0.7 2026-04-06 01:57:42 +02:00
dacc194d02 chore(owlry-core): bump version to 1.3.3 2026-04-06 01:57:39 +02:00
5871609c73 chore(aur): update owlry-rune to 1.1.3 2026-04-05 18:18:11 +02:00
63 changed files with 1861 additions and 840 deletions

63
Cargo.lock generated
View File

@@ -409,12 +409,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@@ -555,17 +549,6 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "ctrlc"
version = "3.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix",
"windows-sys 0.61.2",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -603,8 +586,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
]
@@ -807,6 +788,16 @@ dependencies = [
"xdg",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
@@ -2033,18 +2024,6 @@ dependencies = [
"tempfile",
]
[[package]]
name = "nix"
version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nom"
version = "1.2.4"
@@ -2348,7 +2327,7 @@ dependencies = [
[[package]]
name = "owlry"
version = "1.0.6"
version = "1.0.8"
dependencies = [
"chrono",
"clap",
@@ -2369,13 +2348,13 @@ dependencies = [
[[package]]
name = "owlry-core"
version = "1.3.2"
version = "1.3.4"
dependencies = [
"chrono",
"ctrlc",
"dirs",
"env_logger",
"freedesktop-desktop-entry",
"fs2",
"fuzzy-matcher",
"libloading 0.8.9",
"log",
@@ -2389,6 +2368,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"signal-hook",
"tempfile",
"thiserror 2.0.18",
"toml 0.8.23",
@@ -2396,11 +2376,12 @@ dependencies = [
[[package]]
name = "owlry-lua"
version = "1.1.2"
version = "1.1.3"
dependencies = [
"abi_stable",
"chrono",
"dirs",
"log",
"meval",
"mlua",
"owlry-plugin-api",
@@ -2422,7 +2403,7 @@ dependencies = [
[[package]]
name = "owlry-rune"
version = "1.1.3"
version = "1.1.4"
dependencies = [
"chrono",
"dirs",
@@ -3056,6 +3037,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"

View File

@@ -254,7 +254,7 @@ Type `:config` to browse and modify settings without editing files:
| Command | What it does |
|---------|-------------|
| `:config` | Show all setting categories |
| `:config providers` | Toggle providers on/off |
| `:config providers` | Toggle built-in providers on/off (calculator, converter, system, frecency) |
| `:config theme` | Select color theme |
| `:config engine` | Select web search engine |
| `:config frecency` | Toggle frecency, set weight |
@@ -265,6 +265,8 @@ Type `:config` to browse and modify settings without editing files:
Changes are saved to `config.toml` immediately. Some settings (theme, frecency) take effect on the next search. Others (font size, dimensions) require a restart.
> **Note:** `:config providers` only covers built-in providers. To enable or disable plugins, use `owlry plugin enable/disable <name>` or set `disabled_plugins` in `[plugins]`.
### Search Prefixes
| Prefix | Provider | Example |
@@ -332,33 +334,54 @@ Or configure from within the launcher: type `:config` to interactively change se
```toml
[general]
show_icons = true
max_results = 10
tabs = ["app", "cmd", "uuctl"]
# terminal_command = "kitty" # Auto-detected
# use_uwsm = false # Enable for systemd session integration
max_results = 100
tabs = ["app", "cmd", "uuctl"] # Provider tabs shown in the header bar
# terminal_command = "kitty" # Auto-detected; overrides $TERMINAL and xdg-terminal-exec
# use_uwsm = false # Enable for systemd session integration (uwsm app --)
[appearance]
width = 850
height = 650
font_size = 14
border_radius = 12
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc.
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc. (see Theming section)
[plugins]
disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
# Optional per-element color overrides — all fields are optional, unset inherits from theme
# [appearance.colors]
# background = "#1e1e2e"
# background_secondary = "#313244"
# border = "#45475a"
# text = "#cdd6f4"
# accent = "#cba6f7"
# badge_app = "#a6e3a1" # All badge_* keys: app, cmd, clip, ssh, emoji, file,
# badge_web = "#89dceb" # script, sys, uuctl, web, calc, bm, dmenu,
# badge_media = "#f38ba8" # media, weather, pomo
[providers]
applications = true # .desktop files
commands = true # PATH executables
calculator = true # Built-in math expressions
converter = true # Built-in unit/currency conversion
calculator = true # Built-in math expressions (= or calc trigger)
converter = true # Built-in unit/currency converter (> trigger)
system = true # Built-in shutdown/reboot/lock actions
frecency = true # Boost frequently used items
frecency_weight = 0.3 # 0.0-1.0
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
# Web search engine: google, duckduckgo, bing, startpage, brave, ecosia
# Web search engine: google, duckduckgo, bing, startpage, searxng, brave, ecosia
# Or a custom URL with a {query} placeholder: "https://example.com/search?q={query}"
search_engine = "duckduckgo"
[plugins]
# disabled_plugins = ["emoji", "pomodoro"] # Plugin IDs to disable
# enabled_plugins = [] # Empty = all discovered plugins are loaded
# registry_url = "https://..." # Custom plugin registry URL
# Sandboxing for Lua/Rune user plugins (~/.config/owlry/plugins/)
# [plugins.sandbox]
# allow_filesystem = false # Allow access outside plugin directory
# allow_network = false # Allow outbound network requests
# allow_commands = false # Allow shell command execution
# memory_limit = 67108864 # Lua memory cap in bytes (default: 64 MB)
# Profiles: named sets of modes
[profiles.dev]
modes = ["app", "cmd", "ssh"]
@@ -382,10 +405,17 @@ Add plugin IDs to the disabled list in your config:
```toml
[plugins]
disabled = ["emoji", "pomodoro"]
disabled_plugins = ["emoji", "pomodoro"]
```
Or toggle providers interactively: type `:config providers` in the launcher.
Or use the CLI:
```bash
owlry plugin disable emoji
owlry plugin enable emoji
```
> **Note:** `:config providers` in the launcher only manages built-in providers (calculator, converter, system). Use `disabled_plugins` or `owlry plugin disable` for plugins.
### Plugin Management CLI

View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

View File

@@ -1,6 +1,6 @@
pkgbase = owlry-rune
pkgdesc = Rune scripting runtime for Owlry — enables user-created Rune plugins
pkgver = 1.1.2
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.2.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.2.tar.gz
b2sums = 3e790b4aef289b650aa72371a0271636dc0785d25fb33f054f1ab817ace4ffd785561230399903ada2167d800558feb9a8e1147472432eebce6e3e4f33130fef
source = owlry-rune-1.1.4.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.4.tar.gz
b2sums = 648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f
pkgname = owlry-rune

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

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

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-rune
pkgver=1.1.2
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=('3e790b4aef289b650aa72371a0271636dc0785d25fb33f054f1ab817ace4ffd785561230399903ada2167d800558feb9a8e1147472432eebce6e3e4f33130fef')
b2sums=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
_cratename=owlry-rune

View File

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

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

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

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-core"
version = "1.3.2"
version = "1.3.4"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -30,6 +30,7 @@ semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
fs2 = "0.4"
chrono = { version = "0.4", features = ["serde"] }
dirs = "5"
@@ -41,7 +42,7 @@ notify = "7"
notify-debouncer-mini = "0.5"
# Signal handling
ctrlc = { version = "3", features = ["termination"] }
signal-hook = "0.3"
# Logging & notifications
log = "0.4"

View File

@@ -1,3 +1,4 @@
use fs2::FileExt;
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -32,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)]
@@ -157,82 +162,26 @@ pub struct ProvidersConfig {
pub applications: bool,
#[serde(default = "default_true")]
pub commands: bool,
#[serde(default = "default_true")]
pub uuctl: bool,
/// Enable calculator provider (= expression or calc expression)
/// Enable built-in calculator provider (= or calc trigger)
#[serde(default = "default_true")]
pub calculator: bool,
/// Enable converter provider (> expression or auto-detect)
/// Enable built-in unit/currency converter (> trigger)
#[serde(default = "default_true")]
pub converter: bool,
/// Enable built-in system actions (shutdown, reboot, lock, etc.)
#[serde(default = "default_true")]
pub system: bool,
/// Enable frecency-based result ranking
#[serde(default = "default_true")]
pub frecency: bool,
/// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost)
#[serde(default = "default_frecency_weight")]
pub frecency_weight: f64,
/// Enable web search provider (? query or web query)
#[serde(default = "default_true")]
pub websearch: bool,
/// Search engine for web search
/// Search engine for web search (used by owlry-plugin-websearch)
/// Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
/// Or custom URL with {query} placeholder
/// Or a custom URL with a {query} placeholder
#[serde(default = "default_search_engine")]
pub search_engine: String,
/// Enable system commands (shutdown, reboot, etc.)
#[serde(default = "default_true")]
pub system: bool,
/// Enable SSH connections from ~/.ssh/config
#[serde(default = "default_true")]
pub ssh: bool,
/// Enable clipboard history (requires cliphist)
#[serde(default = "default_true")]
pub clipboard: bool,
/// Enable browser bookmarks
#[serde(default = "default_true")]
pub bookmarks: bool,
/// Enable emoji picker
#[serde(default = "default_true")]
pub emoji: bool,
/// Enable custom scripts from ~/.config/owlry/scripts/
#[serde(default = "default_true")]
pub scripts: bool,
/// Enable file search (requires fd or locate)
#[serde(default = "default_true")]
pub files: bool,
// ─── Widget Providers ───────────────────────────────────────────────
/// Enable MPRIS media player widget
#[serde(default = "default_true")]
pub media: bool,
/// Enable weather widget
#[serde(default)]
pub weather: bool,
/// Weather provider: wttr.in (default), openweathermap, open-meteo
#[serde(default = "default_weather_provider")]
pub weather_provider: String,
/// API key for weather services that require it (e.g., OpenWeatherMap)
#[serde(default)]
pub weather_api_key: Option<String>,
/// Location for weather (city name or coordinates)
#[serde(default)]
pub weather_location: Option<String>,
/// Enable pomodoro timer widget
#[serde(default)]
pub pomodoro: bool,
/// Pomodoro work duration in minutes
#[serde(default = "default_pomodoro_work")]
pub pomodoro_work_mins: u32,
/// Pomodoro break duration in minutes
#[serde(default = "default_pomodoro_break")]
pub pomodoro_break_mins: u32,
}
impl Default for ProvidersConfig {
@@ -240,28 +189,12 @@ impl Default for ProvidersConfig {
Self {
applications: true,
commands: true,
uuctl: true,
calculator: true,
converter: true,
system: true,
frecency: true,
frecency_weight: 0.3,
websearch: true,
search_engine: "duckduckgo".to_string(),
system: true,
ssh: true,
clipboard: true,
bookmarks: true,
emoji: true,
scripts: true,
files: true,
media: true,
weather: false,
weather_provider: "wttr.in".to_string(),
weather_api_key: None,
weather_location: Some("Berlin".to_string()),
pomodoro: false,
pomodoro_work_mins: 25,
pomodoro_break_mins: 5,
}
}
}
@@ -287,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>,
@@ -304,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
@@ -335,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 {
@@ -399,18 +293,6 @@ fn default_frecency_weight() -> f64 {
0.3
}
fn default_weather_provider() -> String {
"wttr.in".to_string()
}
fn default_pomodoro_work() -> u32 {
25
}
fn default_pomodoro_break() -> u32 {
5
}
/// Detect the best available terminal emulator
/// Fallback chain:
/// 1. $TERMINAL env var (user's explicit preference)
@@ -539,11 +421,51 @@ fn command_exists(cmd: &str) -> bool {
// Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default
/// 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;
}
}
header
}
impl Config {
pub fn config_path() -> Option<PathBuf> {
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);
@@ -559,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
};
@@ -585,14 +526,46 @@ impl Config {
Ok(config)
}
#[allow(dead_code)]
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?;
paths::ensure_parent_dir(&path)?;
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)?;
// 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()?;
// Preserve any leading comment block (e.g. user docs / generated header).
let header = if path.exists() {
let existing = std::fs::read_to_string(&path)?;
extract_header_comments(&existing)
} else {
String::new()
};
let body = toml::to_string_pretty(self)?;
let content = if header.is_empty() {
body
} else {
format!("{}\n{}", header.trim_end(), body)
};
// Atomic write: write to a sibling temp file, then rename over the target.
// rename(2) is atomic on POSIX — readers always see either the old or new file.
let tmp_path = path.with_extension("toml.tmp");
std::fs::write(&tmp_path, &content)?;
std::fs::rename(&tmp_path, &path)?;
// Lock is released when lock_file is dropped here.
drop(lock_file);
info!("Saved config to {:?}", path);
Ok(())
}

View File

@@ -29,6 +29,10 @@ impl Default for FrecencyData {
}
}
const MAX_ENTRIES: usize = 5000;
const PRUNE_AGE_DAYS: i64 = 180;
const MIN_LAUNCHES_TO_KEEP: u32 = 3;
/// Frecency store for tracking and boosting recently/frequently used items
pub struct FrecencyStore {
data: FrecencyData,
@@ -44,10 +48,49 @@ impl FrecencyStore {
info!("Frecency store loaded with {} entries", data.entries.len());
Self {
let mut store = Self {
data,
path,
dirty: false,
};
store.prune();
store
}
/// Remove stale low-usage entries and enforce the hard cap.
///
/// Entries older than `PRUNE_AGE_DAYS` with fewer than `MIN_LAUNCHES_TO_KEEP`
/// launches are removed. After age-based pruning, entries are sorted by score
/// (descending) and the list is truncated to `MAX_ENTRIES`.
fn prune(&mut self) {
let now = Utc::now();
let cutoff = now - chrono::Duration::days(PRUNE_AGE_DAYS);
let before = self.data.entries.len();
self.data.entries.retain(|_, e| {
e.last_launch > cutoff || e.launch_count >= MIN_LAUNCHES_TO_KEEP
});
if self.data.entries.len() > MAX_ENTRIES {
// Sort by score descending and keep the top MAX_ENTRIES
let mut scored: Vec<(String, f64)> = self
.data
.entries
.iter()
.map(|(k, e)| {
(k.clone(), Self::calculate_frecency_at(e.launch_count, e.last_launch, now))
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let keep: std::collections::HashSet<String> =
scored.into_iter().take(MAX_ENTRIES).map(|(k, _)| k).collect();
self.data.entries.retain(|k, _| keep.contains(k));
}
let removed = before - self.data.entries.len();
if removed > 0 {
info!("Frecency: pruned {} stale entries ({} remaining)", removed, self.data.entries.len());
self.dirty = true;
}
}

View File

@@ -26,11 +26,17 @@ pub struct ParsedQuery {
}
impl ProviderFilter {
/// Create filter from CLI args and config
/// Create filter from CLI args and config.
///
/// `tabs` is `general.tabs` from config and drives which provider tabs are
/// shown in the UI when no explicit CLI mode is active. It has no effect on
/// query routing: when no CLI mode is set, `accept_all=true` causes
/// `is_active()` to return `true` for every provider regardless.
pub fn new(
cli_mode: Option<ProviderType>,
cli_providers: Option<Vec<ProviderType>>,
config_providers: &ProvidersConfig,
tabs: &[String],
) -> Self {
let accept_all = cli_mode.is_none() && cli_providers.is_none();
@@ -41,50 +47,23 @@ impl ProviderFilter {
// --providers overrides config
providers.into_iter().collect()
} else {
// Use config file settings, default to apps only
let mut set = HashSet::new();
// Core providers
if config_providers.applications {
set.insert(ProviderType::Application);
}
if config_providers.commands {
set.insert(ProviderType::Command);
}
// Plugin providers - use Plugin(type_id) for all
if config_providers.uuctl {
set.insert(ProviderType::Plugin("uuctl".to_string()));
}
if config_providers.system {
set.insert(ProviderType::Plugin("system".to_string()));
}
if config_providers.ssh {
set.insert(ProviderType::Plugin("ssh".to_string()));
}
if config_providers.clipboard {
set.insert(ProviderType::Plugin("clipboard".to_string()));
}
if config_providers.bookmarks {
set.insert(ProviderType::Plugin("bookmarks".to_string()));
}
if config_providers.emoji {
set.insert(ProviderType::Plugin("emoji".to_string()));
}
if config_providers.scripts {
set.insert(ProviderType::Plugin("scripts".to_string()));
}
// Dynamic providers
if config_providers.files {
set.insert(ProviderType::Plugin("filesearch".to_string()));
}
if config_providers.calculator {
set.insert(ProviderType::Plugin("calc".to_string()));
}
if config_providers.websearch {
set.insert(ProviderType::Plugin("websearch".to_string()));
}
// Default to apps if nothing enabled
// No CLI restriction: accept_all=true, so is_active() returns true for
// everything. Build the enabled set only for UI tab display, driven by
// general.tabs. Falls back to Application + Command if tabs is empty.
let mut set: HashSet<ProviderType> = tabs
.iter()
.map(|s| Self::mode_string_to_provider_type(s))
.collect();
if set.is_empty() {
set.insert(ProviderType::Application);
if config_providers.applications {
set.insert(ProviderType::Application);
}
if config_providers.commands {
set.insert(ProviderType::Command);
}
if set.is_empty() {
set.insert(ProviderType::Application);
}
}
set
};
@@ -114,7 +93,8 @@ impl ProviderFilter {
}
}
/// Toggle a provider on/off
/// Toggle a provider on/off. Clears accept_all so the enabled set is
/// actually used for routing — use restore_all_mode() to go back to All.
pub fn toggle(&mut self, provider: ProviderType) {
if self.enabled.contains(&provider) {
self.enabled.remove(&provider);
@@ -137,6 +117,7 @@ impl ProviderFilter {
provider_debug, self.enabled
);
}
self.accept_all = false;
}
/// Enable a specific provider
@@ -156,6 +137,12 @@ impl ProviderFilter {
pub fn set_single_mode(&mut self, provider: ProviderType) {
self.enabled.clear();
self.enabled.insert(provider);
self.accept_all = false;
}
/// Restore accept-all mode (used when cycling back to the "All" tab).
pub fn restore_all_mode(&mut self) {
self.accept_all = true;
}
/// Set prefix mode (from :app, :cmd, etc.)

View File

@@ -24,6 +24,8 @@ pub enum Request {
PluginAction {
command: String,
},
/// Query the daemon's plugin registry (native plugins + suppressed entries).
PluginList,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -32,10 +34,30 @@ pub enum Response {
Results { items: Vec<ResultItem> },
Providers { list: Vec<ProviderDesc> },
SubmenuItems { items: Vec<ResultItem> },
PluginList { entries: Vec<PluginEntry> },
Ack,
Error { message: String },
}
/// Registry entry for a loaded or suppressed plugin (native plugins only).
/// Script plugins are tracked separately via filesystem discovery.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginEntry {
pub id: String,
pub name: String,
pub version: String,
/// Plugin runtime type: "native", "builtin"
pub runtime: String,
/// Load status: "active" or "suppressed"
pub status: String,
/// Human-readable detail for non-active status (e.g. suppression reason)
#[serde(default, skip_serializing_if = "String::is_empty")]
pub status_detail: String,
/// Provider type IDs registered by this plugin
#[serde(default)]
pub providers: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResultItem {
pub id: String,
@@ -50,6 +72,14 @@ pub struct ResultItem {
pub terminal: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// Item trust level: "core", "native_plugin", or "script_plugin".
/// Defaults to "core" when absent (backwards-compatible with old daemons).
#[serde(default = "default_source")]
pub source: String,
}
fn default_source() -> String {
"core".to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View File

@@ -1,4 +1,4 @@
use log::{info, warn};
use log::info;
use owlry_core::paths;
use owlry_core::server::Server;
@@ -23,14 +23,8 @@ fn main() {
}
};
// Graceful shutdown on SIGTERM/SIGINT
let sock_cleanup = sock.clone();
if let Err(e) = ctrlc::set_handler(move || {
let _ = std::fs::remove_file(&sock_cleanup);
std::process::exit(0);
}) {
warn!("Failed to set signal handler: {}", e);
}
// SIGTERM/SIGINT are handled inside Server::run() via signal-hook,
// which saves frecency before exiting.
if let Err(e) = server.run() {
eprintln!("Server error: {e}");

View File

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

View File

@@ -1,24 +1,38 @@
//! Owlry Plugin System
//!
//! This module provides plugin support for extending owlry's functionality.
//! Plugins can register providers, actions, themes, and hooks.
//! This module loads and manages *plugins* — external code that extends owlry
//! with additional *plugin providers* beyond the built-in ones.
//!
//! # Terminology
//!
//! | Term | Meaning |
//! |------|---------|
//! | **Provider** | Abstract source of [`LaunchItem`]s (the core interface) |
//! | **Built-in provider** | Provider compiled into owlry-core (Application, Command) |
//! | **Plugin** | External code (native `.so` or script) loaded at runtime |
//! | **Plugin provider** | A provider registered by a plugin via its `type_id` |
//! | **Native plugin** | Pre-compiled Rust `.so` from `/usr/lib/owlry/plugins/` |
//! | **Script plugin** | Lua or Rune plugin from `~/.config/owlry/plugins/` |
//!
//! # Plugin Types
//!
//! - **Native plugins** (.so): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/`
//! - **Lua plugins**: Script-based plugins from `~/.config/owlry/plugins/` (requires `lua` feature)
//! - **Native plugins** (`.so`): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/`
//! - **Script plugins**: Lua or Rune scripts from `~/.config/owlry/plugins/`
//! (requires the corresponding runtime: `owlry-lua` or `owlry-rune`)
//!
//! # Plugin Structure (Lua)
//! # Script Plugin Structure
//!
//! Each Lua plugin lives in its own directory under `~/.config/owlry/plugins/`:
//! Each script plugin lives in its own directory under `~/.config/owlry/plugins/`:
//!
//! ```text
//! ~/.config/owlry/plugins/
//! my-plugin/
//! plugin.toml # Plugin manifest
//! init.lua # Entry point
//! init.lua # Entry point (Lua) or init.rn (Rune)
//! lib/ # Optional modules
//! ```
//!
//! [`LaunchItem`]: crate::providers::LaunchItem
// Always available
pub mod error;
@@ -60,10 +74,9 @@ pub use manifest::{PluginManifest, check_compatibility, discover_plugins};
#[cfg(feature = "lua")]
mod lua_manager {
use super::*;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use manifest::{check_compatibility, discover_plugins};
@@ -73,8 +86,8 @@ mod lua_manager {
plugins_dir: PathBuf,
/// Current owlry version for compatibility checks
owlry_version: String,
/// Loaded plugins by ID (Rc<RefCell<>> allows sharing with LuaProviders)
plugins: HashMap<String, Rc<RefCell<LoadedPlugin>>>,
/// Loaded plugins by ID. Arc<Mutex<>> allows sharing with LuaProviders across threads.
plugins: HashMap<String, Arc<Mutex<LoadedPlugin>>>,
/// Plugin IDs that are explicitly disabled
disabled: Vec<String>,
}
@@ -116,7 +129,7 @@ mod lua_manager {
}
let plugin = LoadedPlugin::new(manifest, path);
self.plugins.insert(id, Rc::new(RefCell::new(plugin)));
self.plugins.insert(id, Arc::new(Mutex::new(plugin)));
loaded_count += 1;
}
@@ -129,7 +142,7 @@ mod lua_manager {
let mut errors = Vec::new();
for (id, plugin_rc) in &self.plugins {
let mut plugin = plugin_rc.borrow_mut();
let mut plugin = plugin_rc.lock().unwrap();
if !plugin.enabled {
continue;
}
@@ -145,23 +158,23 @@ mod lua_manager {
errors
}
/// Get a loaded plugin by ID (returns Rc for shared ownership)
/// Get a loaded plugin by ID
#[allow(dead_code)]
pub fn get(&self, id: &str) -> Option<Rc<RefCell<LoadedPlugin>>> {
pub fn get(&self, id: &str) -> Option<Arc<Mutex<LoadedPlugin>>> {
self.plugins.get(id).cloned()
}
/// Get all loaded plugins
#[allow(dead_code)]
pub fn plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
pub fn plugins(&self) -> impl Iterator<Item = Arc<Mutex<LoadedPlugin>>> + '_ {
self.plugins.values().cloned()
}
/// Get all enabled plugins
pub fn enabled_plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
pub fn enabled_plugins(&self) -> impl Iterator<Item = Arc<Mutex<LoadedPlugin>>> + '_ {
self.plugins
.values()
.filter(|p| p.borrow().enabled)
.filter(|p| p.lock().unwrap().enabled)
.cloned()
}
@@ -174,7 +187,7 @@ mod lua_manager {
/// Get the number of enabled plugins
#[allow(dead_code)]
pub fn enabled_count(&self) -> usize {
self.plugins.values().filter(|p| p.borrow().enabled).count()
self.plugins.values().filter(|p| p.lock().unwrap().enabled).count()
}
/// Enable a plugin by ID
@@ -184,7 +197,7 @@ mod lua_manager {
.plugins
.get(id)
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
let mut plugin = plugin_rc.borrow_mut();
let mut plugin = plugin_rc.lock().unwrap();
if !plugin.enabled {
plugin.enabled = true;
@@ -202,7 +215,7 @@ mod lua_manager {
.plugins
.get(id)
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
plugin_rc.borrow_mut().enabled = false;
plugin_rc.lock().unwrap().enabled = false;
Ok(())
}
@@ -211,13 +224,14 @@ mod lua_manager {
pub fn providers_for(&self, provider_name: &str) -> Vec<String> {
self.enabled_plugins()
.filter(|p| {
p.borrow()
p.lock()
.unwrap()
.manifest
.provides
.providers
.contains(&provider_name.to_string())
})
.map(|p| p.borrow().id().to_string())
.map(|p| p.lock().unwrap().id().to_string())
.collect()
}
@@ -225,21 +239,21 @@ mod lua_manager {
#[allow(dead_code)]
pub fn has_action_plugins(&self) -> bool {
self.enabled_plugins()
.any(|p| p.borrow().manifest.provides.actions)
.any(|p| p.lock().unwrap().manifest.provides.actions)
}
/// Check if any plugin provides hooks
#[allow(dead_code)]
pub fn has_hook_plugins(&self) -> bool {
self.enabled_plugins()
.any(|p| p.borrow().manifest.provides.hooks)
.any(|p| p.lock().unwrap().manifest.provides.hooks)
}
/// Get all theme names provided by plugins
#[allow(dead_code)]
pub fn theme_names(&self) -> Vec<String> {
self.enabled_plugins()
.flat_map(|p| p.borrow().manifest.provides.themes.clone())
.flat_map(|p| p.lock().unwrap().manifest.provides.themes.clone())
.collect()
}

View File

@@ -12,17 +12,70 @@
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Once};
use std::sync::{Arc, Once, OnceLock, RwLock};
use libloading::Library;
use log::{debug, error, info, warn};
use owlry_plugin_api::{
API_VERSION, HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo,
ProviderKind, RStr,
ProviderKind, ROption, RStr, RString,
};
use crate::config::Config;
use crate::notify;
// ============================================================================
// Plugin config access
// ============================================================================
/// Shared config reference, set by the host before any plugins are loaded.
static PLUGIN_CONFIG: OnceLock<Arc<RwLock<Config>>> = OnceLock::new();
/// Share the config with the native plugin loader so plugins can read their
/// own config sections. Must be called before `NativePluginLoader::discover()`.
pub fn set_shared_config(config: Arc<RwLock<Config>>) {
let _ = PLUGIN_CONFIG.set(config);
}
extern "C" fn host_get_config_string(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<RString> {
let Some(cfg_arc) = PLUGIN_CONFIG.get() else {
return ROption::RNone;
};
let Ok(cfg) = cfg_arc.read() else {
return ROption::RNone;
};
match cfg.get_plugin_string(plugin_id.as_str(), key.as_str()) {
Some(v) => ROption::RSome(RString::from(v)),
None => ROption::RNone,
}
}
extern "C" fn host_get_config_int(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<i64> {
let Some(cfg_arc) = PLUGIN_CONFIG.get() else {
return ROption::RNone;
};
let Ok(cfg) = cfg_arc.read() else {
return ROption::RNone;
};
match cfg.get_plugin_int(plugin_id.as_str(), key.as_str()) {
Some(v) => ROption::RSome(v),
None => ROption::RNone,
}
}
extern "C" fn host_get_config_bool(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<bool> {
let Some(cfg_arc) = PLUGIN_CONFIG.get() else {
return ROption::RNone;
};
let Ok(cfg) = cfg_arc.read() else {
return ROption::RNone;
};
match cfg.get_plugin_bool(plugin_id.as_str(), key.as_str()) {
Some(v) => ROption::RSome(v),
None => ROption::RNone,
}
}
// ============================================================================
// Host API Implementation
// ============================================================================
@@ -71,6 +124,9 @@ static HOST_API: HostAPI = HostAPI {
log_info: host_log_info,
log_warn: host_log_warn,
log_error: host_log_error,
get_config_string: host_get_config_string,
get_config_int: host_get_config_int,
get_config_bool: host_get_config_bool,
};
/// Initialize the host API (called once before loading plugins)

View File

@@ -10,14 +10,15 @@
//! Note: This module is infrastructure for the runtime architecture. Full integration
//! is pending Phase 5 (AUR Packaging) when runtime packages will be available.
use std::mem::ManuallyDrop;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use libloading::{Library, Symbol};
use owlry_plugin_api::{PluginItem, RStr, RString, RVec};
use super::error::{PluginError, PluginResult};
use crate::providers::{LaunchItem, Provider, ProviderType};
use crate::providers::{ItemSource, LaunchItem, Provider, ProviderType};
/// System directory for runtime libraries
pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes";
@@ -50,6 +51,11 @@ pub type LuaProviderInfo = ScriptProviderInfo;
#[derive(Clone, Copy)]
pub struct RuntimeHandle(pub *mut ());
// SAFETY: The underlying runtime state (Lua VM, Rune VM) is Send — mlua enables
// the "send" feature and Rune wraps its state in Mutex internally. Access is always
// serialized through Arc<Mutex<RuntimeHandle>>, so there are no data races.
unsafe impl Send for RuntimeHandle {}
/// VTable for script runtime functions (used by both Lua and Rune)
#[repr(C)]
pub struct ScriptRuntimeVTable {
@@ -69,12 +75,17 @@ pub struct ScriptRuntimeVTable {
pub struct LoadedRuntime {
/// Runtime name (for logging)
name: &'static str,
/// Keep library alive
_library: Arc<Library>,
/// Keep library alive — wrapped in ManuallyDrop so we never call dlclose().
/// dlclose() unmaps the library code; any thread-local destructors inside the
/// library then SIGSEGV when they try to run against the unmapped addresses.
/// Runtime libraries live for the process lifetime, so leaking the handle is safe.
_library: ManuallyDrop<Arc<Library>>,
/// Runtime vtable
vtable: &'static ScriptRuntimeVTable,
/// Runtime handle (state)
handle: RuntimeHandle,
/// Runtime handle shared with all RuntimeProvider instances for this runtime.
/// Mutex serializes concurrent vtable calls. Arc shares ownership so all
/// RuntimeProviders can call into the runtime through the same handle.
handle: Arc<Mutex<RuntimeHandle>>,
/// Provider information
providers: Vec<ScriptProviderInfo>,
}
@@ -124,10 +135,14 @@ impl LoadedRuntime {
// Initialize the runtime
let plugins_dir_str = plugins_dir.to_string_lossy();
let handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version));
let raw_handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version));
let handle = Arc::new(Mutex::new(raw_handle));
// Get provider information
let providers_rvec = (vtable.providers)(handle);
// Get provider information — lock to serialize the vtable call
let providers_rvec = {
let h = handle.lock().unwrap();
(vtable.providers)(*h)
};
let providers: Vec<ScriptProviderInfo> = providers_rvec.into_iter().collect();
log::info!(
@@ -138,7 +153,7 @@ impl LoadedRuntime {
Ok(Self {
name,
_library: library,
_library: ManuallyDrop::new(library),
vtable,
handle,
providers,
@@ -155,8 +170,12 @@ impl LoadedRuntime {
self.providers
.iter()
.map(|info| {
let provider =
RuntimeProvider::new(self.name, self.vtable, self.handle, info.clone());
let provider = RuntimeProvider::new(
self.name,
self.vtable,
Arc::clone(&self.handle),
info.clone(),
);
Box::new(provider) as Box<dyn Provider>
})
.collect()
@@ -165,17 +184,16 @@ impl LoadedRuntime {
impl Drop for LoadedRuntime {
fn drop(&mut self) {
(self.vtable.drop)(self.handle);
let h = self.handle.lock().unwrap();
(self.vtable.drop)(*h);
// Do NOT drop _library: ManuallyDrop ensures dlclose() is never called.
// See field comment for rationale.
}
}
// LoadedRuntime needs to be Send + Sync because ProviderManager is shared across
// threads via Arc<RwLock<ProviderManager>>.
// Safety: RuntimeHandle is an opaque FFI handle accessed only through extern "C"
// vtable functions. The same safety argument that applies to RuntimeProvider applies
// here — all access is mediated by the vtable, and the runtime itself serializes access.
unsafe impl Send for LoadedRuntime {}
unsafe impl Sync for LoadedRuntime {}
// LoadedRuntime is Send + Sync because:
// - Arc<Mutex<RuntimeHandle>> is Send + Sync (RuntimeHandle: Send via unsafe impl above)
// - All other fields are 'static references or Send types
// No unsafe impl needed — this is derived automatically.
/// A provider backed by a dynamically loaded runtime
pub struct RuntimeProvider {
@@ -183,7 +201,9 @@ pub struct RuntimeProvider {
#[allow(dead_code)]
runtime_name: &'static str,
vtable: &'static ScriptRuntimeVTable,
handle: RuntimeHandle,
/// Shared with the owning LoadedRuntime and sibling RuntimeProviders.
/// Mutex serializes concurrent vtable calls on the same runtime handle.
handle: Arc<Mutex<RuntimeHandle>>,
info: ScriptProviderInfo,
items: Vec<LaunchItem>,
}
@@ -192,7 +212,7 @@ impl RuntimeProvider {
fn new(
runtime_name: &'static str,
vtable: &'static ScriptRuntimeVTable,
handle: RuntimeHandle,
handle: Arc<Mutex<RuntimeHandle>>,
info: ScriptProviderInfo,
) -> Self {
Self {
@@ -214,6 +234,7 @@ impl RuntimeProvider {
command: item.command.to_string(),
terminal: item.terminal,
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
source: ItemSource::ScriptPlugin,
}
}
}
@@ -233,7 +254,10 @@ impl Provider for RuntimeProvider {
}
let name_rstr = RStr::from_str(self.info.name.as_str());
let items_rvec = (self.vtable.refresh)(self.handle, name_rstr);
let items_rvec = {
let h = self.handle.lock().unwrap();
(self.vtable.refresh)(*h, name_rstr)
};
self.items = items_rvec
.into_iter()
.map(|i| self.convert_item(i))
@@ -251,12 +275,10 @@ impl Provider for RuntimeProvider {
}
}
// RuntimeProvider needs to be Send + Sync for the Provider trait.
// Safety: RuntimeHandle is an opaque FFI handle accessed only through
// extern "C" vtable functions. The same safety argument that justifies
// Send applies to Sync — all access is mediated by the vtable.
unsafe impl Send for RuntimeProvider {}
unsafe impl Sync for RuntimeProvider {}
// RuntimeProvider is Send + Sync because:
// - Arc<Mutex<RuntimeHandle>> is Send + Sync (RuntimeHandle: Send via unsafe impl above)
// - vtable is &'static (Send + Sync), info and items are Send
// No unsafe impl needed — this is derived automatically.
/// Check if the Lua runtime is available
pub fn lua_runtime_available() -> bool {

View File

@@ -89,8 +89,13 @@ fn watch_loop(
if has_relevant_change {
info!("Plugin file change detected, reloading runtimes...");
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
pm_guard.reload_runtimes();
match pm.write() {
Ok(mut pm_guard) => pm_guard.reload_runtimes(),
Err(_) => {
log::error!("Plugin watcher: provider lock poisoned; stopping watcher");
return Err(Box::from("provider lock poisoned"));
}
}
}
}
Ok(Err(error)) => {

View File

@@ -1,4 +1,6 @@
use super::{LaunchItem, Provider, ProviderType};
use std::collections::HashSet;
use super::{ItemSource, LaunchItem, Provider, ProviderType};
use crate::paths;
use freedesktop_desktop_entry::{DesktopEntry, Iter};
use log::{debug, warn};
@@ -118,7 +120,21 @@ impl Provider for ApplicationProvider {
.map(|s| s.to_string())
.collect();
// Track seen .desktop file basenames to skip duplicates.
// XDG dirs are iterated user-first per spec, so the first occurrence wins.
let mut seen_basenames: HashSet<String> = HashSet::new();
for path in Iter::new(dirs.into_iter()) {
// Skip if we've already loaded a .desktop with this basename from a higher-priority dir.
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|basename| !seen_basenames.insert(basename.to_string()))
{
debug!("Skipping duplicate desktop entry: {:?}", path);
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
@@ -196,6 +212,7 @@ impl Provider for ApplicationProvider {
command: run_cmd,
terminal: desktop_entry.terminal(),
tags,
source: ItemSource::Core,
};
self.items.push(item);

View File

@@ -1,4 +1,4 @@
use super::{DynamicProvider, LaunchItem, ProviderType};
use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType};
/// Built-in calculator provider. Evaluates mathematical expressions via `meval`.
///
@@ -42,6 +42,7 @@ impl DynamicProvider for CalculatorProvider {
command: copy_cmd,
terminal: false,
tags: vec!["math".into(), "calculator".into()],
source: ItemSource::Core,
}]
}
Err(_) => Vec::new(),

View File

@@ -1,4 +1,4 @@
use super::{LaunchItem, Provider, ProviderType};
use super::{ItemSource, LaunchItem, Provider, ProviderType};
use log::debug;
use std::collections::HashSet;
use std::os::unix::fs::PermissionsExt;
@@ -89,6 +89,7 @@ impl Provider for CommandProvider {
command: name,
terminal: false,
tags: Vec::new(),
source: ItemSource::Core,
};
self.items.push(item);

View File

@@ -2,7 +2,7 @@ use std::sync::{Arc, RwLock};
use log::warn;
use super::{DynamicProvider, LaunchItem, ProviderType};
use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType};
use crate::config::Config;
const ICON: &str = "preferences-system-symbolic";
@@ -23,24 +23,15 @@ const SEARCH_ENGINES: &[&str] = &[
const BUILTIN_THEMES: &[&str] = &["owl"];
/// Boolean provider fields that can be toggled via CONFIG:toggle:providers.*.
/// Only built-in providers are listed here; plugins are enabled/disabled via
/// [plugins] disabled_plugins in config.toml or `owlry plugin enable/disable`.
const PROVIDER_TOGGLES: &[(&str, &str)] = &[
("applications", "Applications"),
("commands", "Commands"),
("uuctl", "Systemd Units"),
("calculator", "Calculator"),
("converter", "Unit Converter"),
("frecency", "Frecency Ranking"),
("websearch", "Web Search"),
("system", "System Actions"),
("ssh", "SSH Connections"),
("clipboard", "Clipboard History"),
("bookmarks", "Bookmarks"),
("emoji", "Emoji Picker"),
("scripts", "Scripts"),
("files", "File Search"),
("media", "Media Widget"),
("weather", "Weather Widget"),
("pomodoro", "Pomodoro Widget"),
("frecency", "Frecency Ranking"),
];
/// Built-in config editor provider. Interprets query text as a navigation path
@@ -55,27 +46,34 @@ 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 {
if let Ok(cfg) = self.config.read() {
if let Err(e) = cfg.save() {
warn!("Failed to save config: {}", e);
}
}
if result
&& let Err(e) = cfg.save()
{
warn!("Failed to save config: {}", e);
}
result
@@ -83,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;
@@ -98,10 +91,6 @@ impl ConfigProvider {
cfg.providers.commands = !cfg.providers.commands;
true
}
"providers.uuctl" => {
cfg.providers.uuctl = !cfg.providers.uuctl;
true
}
"providers.calculator" => {
cfg.providers.calculator = !cfg.providers.calculator;
true
@@ -110,52 +99,12 @@ impl ConfigProvider {
cfg.providers.converter = !cfg.providers.converter;
true
}
"providers.frecency" => {
cfg.providers.frecency = !cfg.providers.frecency;
true
}
"providers.websearch" => {
cfg.providers.websearch = !cfg.providers.websearch;
true
}
"providers.system" => {
cfg.providers.system = !cfg.providers.system;
true
}
"providers.ssh" => {
cfg.providers.ssh = !cfg.providers.ssh;
true
}
"providers.clipboard" => {
cfg.providers.clipboard = !cfg.providers.clipboard;
true
}
"providers.bookmarks" => {
cfg.providers.bookmarks = !cfg.providers.bookmarks;
true
}
"providers.emoji" => {
cfg.providers.emoji = !cfg.providers.emoji;
true
}
"providers.scripts" => {
cfg.providers.scripts = !cfg.providers.scripts;
true
}
"providers.files" => {
cfg.providers.files = !cfg.providers.files;
true
}
"providers.media" => {
cfg.providers.media = !cfg.providers.media;
true
}
"providers.weather" => {
cfg.providers.weather = !cfg.providers.weather;
true
}
"providers.pomodoro" => {
cfg.providers.pomodoro = !cfg.providers.pomodoro;
"providers.frecency" => {
cfg.providers.frecency = !cfg.providers.frecency;
true
}
"general.show_icons" => {
@@ -172,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" => {
@@ -240,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(),
@@ -256,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
@@ -267,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);
@@ -329,6 +262,7 @@ impl ConfigProvider {
command: format!("CONFIG:toggle:providers.{}", field),
terminal: false,
tags: vec!["config".into(), "settings".into()],
source: ItemSource::Core,
}
})
.collect()
@@ -383,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()
@@ -413,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()
@@ -439,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
@@ -453,6 +390,7 @@ impl ConfigProvider {
command: format!("CONFIG:set:providers.frecency_weight:{}", clamped),
terminal: false,
tags: vec!["config".into(), "settings".into()],
source: ItemSource::Core,
});
}
@@ -479,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
@@ -495,6 +434,7 @@ impl ConfigProvider {
command: format!("CONFIG:set:{}:{}", config_path, input),
terminal: false,
tags: vec!["config".into(), "settings".into()],
source: ItemSource::Core,
});
}
}
@@ -538,6 +478,7 @@ impl ConfigProvider {
command: String::new(),
terminal: false,
tags: vec!["config".into(), "settings".into()],
source: ItemSource::Core,
});
}
@@ -563,6 +504,7 @@ impl ConfigProvider {
command: format!("CONFIG:profile:create:{}", name),
terminal: false,
tags: vec!["config".into(), "settings".into()],
source: ItemSource::Core,
}]
}
@@ -591,6 +533,7 @@ impl ConfigProvider {
command: format!("CONFIG:profile:delete:{}", profile_name),
terminal: false,
tags: vec!["config".into(), "settings".into()],
source: ItemSource::Core,
},
]
}
@@ -627,6 +570,7 @@ impl ConfigProvider {
),
terminal: false,
tags: vec!["config".into(), "settings".into()],
source: ItemSource::Core,
}
})
.collect()
@@ -754,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,
}
}
@@ -762,21 +707,10 @@ fn get_provider_bool(cfg: &Config, field: &str) -> bool {
match field {
"applications" => cfg.providers.applications,
"commands" => cfg.providers.commands,
"uuctl" => cfg.providers.uuctl,
"calculator" => cfg.providers.calculator,
"converter" => cfg.providers.converter,
"frecency" => cfg.providers.frecency,
"websearch" => cfg.providers.websearch,
"system" => cfg.providers.system,
"ssh" => cfg.providers.ssh,
"clipboard" => cfg.providers.clipboard,
"bookmarks" => cfg.providers.bookmarks,
"emoji" => cfg.providers.emoji,
"scripts" => cfg.providers.scripts,
"files" => cfg.providers.files,
"media" => cfg.providers.media,
"weather" => cfg.providers.weather,
"pomodoro" => cfg.providers.pomodoro,
"frecency" => cfg.providers.frecency,
_ => false,
}
}

View File

@@ -2,7 +2,7 @@ mod currency;
mod parser;
mod units;
use super::{DynamicProvider, LaunchItem, ProviderType};
use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType};
const PROVIDER_TYPE_ID: &str = "conv";
const PROVIDER_ICON: &str = "edit-find-replace-symbolic";
@@ -69,6 +69,7 @@ impl DynamicProvider for ConverterProvider {
),
terminal: false,
tags: vec!["converter".into(), "units".into()],
source: ItemSource::Core,
})
.collect()
}

View File

@@ -3,12 +3,11 @@
//! This module provides a `LuaProvider` struct that implements the `Provider` trait
//! by delegating to a Lua plugin's registered provider functions.
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use crate::plugins::{LoadedPlugin, PluginItem, ProviderRegistration};
use super::{LaunchItem, Provider, ProviderType};
use super::{ItemSource, LaunchItem, Provider, ProviderType};
/// A provider backed by a Lua plugin
///
@@ -17,15 +16,16 @@ use super::{LaunchItem, Provider, ProviderType};
pub struct LuaProvider {
/// Provider registration info
registration: ProviderRegistration,
/// Reference to the loaded plugin (shared with other providers from same plugin)
plugin: Rc<RefCell<LoadedPlugin>>,
/// Reference to the loaded plugin (shared with other providers from same plugin).
/// Mutex serializes concurrent refresh calls; Arc allows sharing across threads.
plugin: Arc<Mutex<LoadedPlugin>>,
/// Cached items from last refresh
items: Vec<LaunchItem>,
}
impl LuaProvider {
/// Create a new LuaProvider
pub fn new(registration: ProviderRegistration, plugin: Rc<RefCell<LoadedPlugin>>) -> Self {
pub fn new(registration: ProviderRegistration, plugin: Arc<Mutex<LoadedPlugin>>) -> Self {
Self {
registration,
plugin,
@@ -35,6 +35,9 @@ impl LuaProvider {
/// Convert a PluginItem to a LaunchItem
fn convert_item(&self, item: PluginItem) -> LaunchItem {
if item.command.is_none() {
log::warn!("Plugin item '{}' has no command", item.name);
}
LaunchItem {
id: item.id,
name: item.name,
@@ -44,6 +47,7 @@ impl LuaProvider {
command: item.command.unwrap_or_default(),
terminal: item.terminal,
tags: item.tags,
source: ItemSource::ScriptPlugin,
}
}
}
@@ -63,7 +67,7 @@ impl Provider for LuaProvider {
return;
}
let plugin = self.plugin.borrow();
let plugin = self.plugin.lock().unwrap();
match plugin.call_provider_refresh(&self.registration.name) {
Ok(items) => {
self.items = items.into_iter().map(|i| self.convert_item(i)).collect();
@@ -89,17 +93,15 @@ impl Provider for LuaProvider {
}
}
// LuaProvider needs to be Send + Sync for the Provider trait.
// Rc<RefCell<>> is !Send and !Sync, but the ProviderManager RwLock ensures
// Rc<RefCell<>> is only accessed during refresh() (write lock = exclusive access).
// Read-only operations (items(), search) only touch self.items (Vec<LaunchItem>).
unsafe impl Send for LuaProvider {}
unsafe impl Sync for LuaProvider {}
// LuaProvider is Send + Sync because:
// - Arc<Mutex<LoadedPlugin>> is Send + Sync (LoadedPlugin: Send with mlua "send" feature)
// - All other fields are Send + Sync
// No unsafe impl needed.
/// Create LuaProviders from all registered providers in a plugin
pub fn create_providers_from_plugin(plugin: Rc<RefCell<LoadedPlugin>>) -> Vec<Box<dyn Provider>> {
pub fn create_providers_from_plugin(plugin: Arc<Mutex<LoadedPlugin>>) -> Vec<Box<dyn Provider>> {
let registrations = {
let p = plugin.borrow();
let p = plugin.lock().unwrap();
match p.get_provider_registrations() {
Ok(regs) => regs,
Err(e) => {

View File

@@ -23,11 +23,13 @@ pub use native_provider::NativeProvider;
use chrono::Utc;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use log::info;
use log::{info, warn};
#[cfg(feature = "dev-logging")]
use log::debug;
use std::sync::{Arc, RwLock};
use crate::config::Config;
use crate::data::FrecencyStore;
use crate::plugins::runtime_loader::LoadedRuntime;
@@ -42,6 +44,38 @@ pub struct ProviderDescriptor {
pub position: String,
}
/// Trust level of a [`LaunchItem`]'s command, used to gate `sh -c` execution.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ItemSource {
/// Built-in provider compiled into owlry-core (trusted).
Core,
/// Native plugin (.so from /usr/lib/owlry/plugins/) — trusted at install time.
NativePlugin,
/// Script plugin (Lua/Rune from ~/.config/owlry/plugins/) — user-installed, untrusted.
ScriptPlugin,
}
impl ItemSource {
pub fn as_str(&self) -> &'static str {
match self {
ItemSource::Core => "core",
ItemSource::NativePlugin => "native_plugin",
ItemSource::ScriptPlugin => "script_plugin",
}
}
}
impl std::str::FromStr for ItemSource {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"native_plugin" => Ok(ItemSource::NativePlugin),
"script_plugin" => Ok(ItemSource::ScriptPlugin),
_ => Ok(ItemSource::Core),
}
}
}
/// Represents a single searchable/launchable item
#[derive(Debug, Clone)]
pub struct LaunchItem {
@@ -55,21 +89,29 @@ pub struct LaunchItem {
pub terminal: bool,
/// Tags/categories for filtering (e.g., from .desktop Categories)
pub tags: Vec<String>,
/// Trust level — gates `sh -c` execution for script plugin items.
pub source: ItemSource,
}
/// Provider type identifier for filtering and badge display
/// Provider type identifier for filtering and badge display.
///
/// Core types are built-in providers. All native plugins use Plugin(type_id).
/// This keeps the core app free of plugin-specific knowledge.
/// **Glossary:**
/// - *Provider*: An abstract source of [`LaunchItem`]s (interface).
/// - *Built-in provider*: A provider compiled into owlry-core (Application, Command).
/// - *Plugin*: External code (native `.so` or Lua/Rune script) loaded at runtime.
/// - *Plugin provider*: A provider registered by a plugin, identified by its `type_id`.
///
/// All plugin-provided types use `Plugin(type_id)`. The core has no hardcoded
/// knowledge of individual plugin types — this keeps the core app extensible.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ProviderType {
/// Built-in: Desktop applications from XDG directories
/// Built-in provider: desktop applications from XDG data directories.
Application,
/// Built-in: Shell commands from PATH
/// Built-in provider: shell commands from `$PATH`.
Command,
/// Built-in: Pipe-based input (dmenu compatibility)
/// Built-in provider: pipe-based input for dmenu compatibility (client-local only).
Dmenu,
/// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji")
/// Plugin provider with its declared `type_id` (e.g. `"calc"`, `"weather"`, `"emoji"`).
Plugin(String),
}
@@ -145,6 +187,9 @@ pub struct ProviderManager {
runtimes: Vec<LoadedRuntime>,
/// Type IDs of providers from script runtimes (for hot-reload removal)
runtime_type_ids: std::collections::HashSet<String>,
/// Registry of native plugins that were loaded or suppressed at startup.
/// Used by `Request::PluginList` to report plugin status to the CLI.
pub plugin_registry: Vec<crate::ipc::PluginEntry>,
}
impl ProviderManager {
@@ -166,6 +211,7 @@ impl ProviderManager {
matcher: SkimMatcherV2::default(),
runtimes: Vec::new(),
runtime_type_ids: std::collections::HashSet::new(),
plugin_registry: Vec::new(),
};
// Categorize native plugins based on their declared ProviderKind and ProviderPosition
@@ -207,9 +253,8 @@ impl ProviderManager {
/// Loads native plugins, creates core providers (Application + Command),
/// categorizes everything, and performs initial refresh. Used by the daemon
/// which doesn't have the UI-driven setup path from `app.rs`.
pub fn new_with_config(config: &Config) -> Self {
pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
use crate::plugins::native_loader::NativePluginLoader;
use std::sync::Arc;
// Create core providers
let mut core_providers: Vec<Box<dyn Provider>> = vec![
@@ -217,9 +262,23 @@ impl ProviderManager {
Box::new(CommandProvider::new()),
];
// Take a read lock once for configuration reads during setup.
let (disabled_plugins, calc_enabled, conv_enabled, sys_enabled) = match config.read() {
Ok(cfg) => (
cfg.plugins.disabled_plugins.clone(),
cfg.providers.calculator,
cfg.providers.converter,
cfg.providers.system,
),
Err(_) => {
warn!("Config lock poisoned during provider init; using defaults");
(Vec::new(), true, true, true)
}
};
// Load native plugins
let mut loader = NativePluginLoader::new();
loader.set_disabled(config.plugins.disabled_plugins.clone());
loader.set_disabled(disabled_plugins);
let native_providers = match loader.discover() {
Ok(count) => {
@@ -304,23 +363,22 @@ impl ProviderManager {
// Built-in dynamic providers
let mut builtin_dynamic: Vec<Box<dyn DynamicProvider>> = Vec::new();
if config.providers.calculator {
if calc_enabled {
builtin_dynamic.push(Box::new(calculator::CalculatorProvider));
info!("Registered built-in calculator provider");
}
if config.providers.converter {
if conv_enabled {
builtin_dynamic.push(Box::new(converter::ConverterProvider::new()));
info!("Registered built-in converter provider");
}
// Config editor — always enabled
let config_arc = std::sync::Arc::new(std::sync::RwLock::new(config.clone()));
builtin_dynamic.push(Box::new(config_editor::ConfigProvider::new(config_arc)));
// Config editor — always enabled; shares the same Arc<RwLock<Config>>
builtin_dynamic.push(Box::new(config_editor::ConfigProvider::new(Arc::clone(&config))));
info!("Registered built-in config editor provider");
// Built-in static providers
if config.providers.system {
if sys_enabled {
core_providers.push(Box::new(system::SystemProvider::new()));
info!("Registered built-in system provider");
}
@@ -345,15 +403,28 @@ impl ProviderManager {
ids
};
let mut suppressed_registry: Vec<crate::ipc::PluginEntry> = Vec::new();
let native_providers: Vec<NativeProvider> = native_providers
.into_iter()
.filter(|provider| {
let type_id = provider.type_id();
if builtin_ids.contains(type_id) {
info!(
"Skipping native plugin '{}' built-in provider exists",
log::warn!(
"Native plugin '{}' suppressed — a built-in provider with the same type ID exists",
type_id
);
suppressed_registry.push(crate::ipc::PluginEntry {
id: provider.plugin_id().to_string(),
name: provider.plugin_name().to_string(),
version: provider.plugin_version().to_string(),
runtime: "native".to_string(),
status: "suppressed".to_string(),
status_detail: format!(
"built-in provider '{}' takes precedence",
type_id
),
providers: vec![type_id.to_string()],
});
false
} else {
true
@@ -361,10 +432,28 @@ impl ProviderManager {
})
.collect();
// Capture active native plugin entries before ownership moves into Self::new().
let active_registry: Vec<crate::ipc::PluginEntry> = native_providers
.iter()
.map(|p| crate::ipc::PluginEntry {
id: p.plugin_id().to_string(),
name: p.plugin_name().to_string(),
version: p.plugin_version().to_string(),
runtime: "native".to_string(),
status: "active".to_string(),
status_detail: String::new(),
providers: vec![p.type_id().to_string()],
})
.collect();
let mut manager = Self::new(core_providers, native_providers);
manager.builtin_dynamic = builtin_dynamic;
manager.runtimes = runtimes;
manager.runtime_type_ids = runtime_type_ids;
manager.plugin_registry = active_registry;
manager.plugin_registry.extend(suppressed_registry);
manager
}
@@ -378,11 +467,11 @@ impl ProviderManager {
!self.runtime_type_ids.contains(&type_str)
});
// Drop old runtimes (catch panics from runtime cleanup)
// Drop old runtimes. Panics here will poison the ProviderManager RwLock,
// which is caught and reported by the watcher thread (see plugins/watcher.rs).
info!("Dropping old runtimes before reload");
let old_runtimes = std::mem::take(&mut self.runtimes);
drop(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
drop(old_runtimes);
})));
drop(old_runtimes);
self.runtime_type_ids.clear();
let owlry_version = env!("CARGO_PKG_VERSION");
@@ -600,6 +689,7 @@ impl ProviderManager {
query: &str,
max_results: usize,
filter: &crate::filter::ProviderFilter,
tag_filter: Option<&str>,
) -> Vec<(LaunchItem, i64)> {
// Collect items from core providers
let core_items = self
@@ -615,16 +705,15 @@ impl ProviderManager {
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
let all_items = core_items.chain(native_items).filter(|item| {
tag_filter.is_none_or(|t| item.tags.iter().any(|it| it == t))
});
if query.is_empty() {
return core_items
.chain(native_items)
.take(max_results)
.map(|item| (item, 0))
.collect();
return all_items.take(max_results).map(|item| (item, 0)).collect();
}
let mut results: Vec<(LaunchItem, i64)> = core_items
.chain(native_items)
let mut results: Vec<(LaunchItem, i64)> = all_items
.filter_map(|item| {
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item
@@ -1146,6 +1235,7 @@ mod tests {
command: format!("run-{}", id),
terminal: false,
tags: Vec::new(),
source: ItemSource::Core,
}
}

View File

@@ -13,7 +13,7 @@ use owlry_plugin_api::{
PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition,
};
use super::{LaunchItem, Provider, ProviderType};
use super::{ItemSource, LaunchItem, Provider, ProviderType};
use crate::plugins::native_loader::NativePlugin;
/// A provider backed by a native plugin
@@ -50,6 +50,21 @@ impl NativeProvider {
ProviderType::Plugin(self.info.type_id.to_string())
}
/// The ID of the plugin that owns this provider.
pub fn plugin_id(&self) -> &str {
self.plugin.id()
}
/// The human-readable name of the plugin that owns this provider.
pub fn plugin_name(&self) -> &str {
self.plugin.name()
}
/// The version string of the plugin that owns this provider.
pub fn plugin_version(&self) -> &str {
self.plugin.info.version.as_str()
}
/// Convert a plugin API item to a core LaunchItem
fn convert_item(&self, item: ApiPluginItem) -> LaunchItem {
LaunchItem {
@@ -61,6 +76,7 @@ impl NativeProvider {
command: item.command.to_string(),
terminal: item.terminal,
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
source: ItemSource::NativePlugin,
}
}

View File

@@ -1,4 +1,4 @@
use super::{LaunchItem, Provider, ProviderType};
use super::{ItemSource, LaunchItem, Provider, ProviderType};
/// Built-in system provider. Returns a fixed set of power and session management actions.
///
@@ -72,6 +72,7 @@ impl SystemProvider {
command: command.to_string(),
terminal: false,
tags: vec!["system".into()],
source: ItemSource::Core,
})
.collect();

View File

@@ -2,6 +2,7 @@ use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use std::thread;
@@ -9,6 +10,73 @@ use std::thread;
/// Maximum allowed size for a single IPC request line (1 MiB).
const MAX_REQUEST_SIZE: usize = 1_048_576;
/// Maximum number of concurrently active client connections.
const MAX_CONNECTIONS: usize = 16;
/// Tracks active connection count across all handler threads.
static ACTIVE_CONNECTIONS: AtomicUsize = AtomicUsize::new(0);
/// RAII guard that increments the connection counter on creation and decrements on drop.
struct ConnectionGuard;
impl ConnectionGuard {
/// Try to acquire a connection slot. Returns `None` if at capacity.
fn try_acquire() -> Option<Self> {
let prev = ACTIVE_CONNECTIONS.fetch_add(1, Ordering::SeqCst);
if prev >= MAX_CONNECTIONS {
ACTIVE_CONNECTIONS.fetch_sub(1, Ordering::SeqCst);
None
} else {
Some(ConnectionGuard)
}
}
}
impl Drop for ConnectionGuard {
fn drop(&mut self) {
ACTIVE_CONNECTIONS.fetch_sub(1, Ordering::SeqCst);
}
}
/// Read a newline-terminated line from `reader` without allocating beyond `max` bytes.
///
/// Unlike `BufRead::read_line`, this checks the size limit incrementally against
/// the internal buffer rather than after the full allocation. Returns `Ok(None)`
/// on clean EOF, `Err(InvalidData)` when `max` is exceeded before finding `\n`.
fn read_bounded_line(reader: &mut BufReader<UnixStream>, max: usize) -> io::Result<Option<String>> {
let mut buf: Vec<u8> = Vec::with_capacity(4096);
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return if buf.is_empty() {
Ok(None)
} else {
Ok(Some(String::from_utf8_lossy(&buf).into_owned()))
};
}
if let Some(pos) = available.iter().position(|&b| b == b'\n') {
if buf.len() + pos > max {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("request too large (exceeded {} bytes)", max),
));
}
buf.extend_from_slice(&available[..pos]);
reader.consume(pos + 1);
return Ok(Some(String::from_utf8_lossy(&buf).into_owned()));
}
let len = available.len();
if buf.len() + len > max {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("request too large (exceeded {} bytes)", max),
));
}
buf.extend_from_slice(available);
reader.consume(len);
}
}
use log::{error, info, warn};
use crate::config::Config;
@@ -24,7 +92,7 @@ pub struct Server {
socket_path: PathBuf,
provider_manager: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
config: Arc<RwLock<Config>>,
}
impl Server {
@@ -42,8 +110,10 @@ impl Server {
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
info!("IPC server listening on {:?}", socket_path);
let config = Config::load_or_default();
let provider_manager = ProviderManager::new_with_config(&config);
let config = Arc::new(RwLock::new(Config::load_or_default()));
// Share config with native plugin loader so plugins can read their own config sections.
crate::plugins::native_loader::set_shared_config(Arc::clone(&config));
let provider_manager = ProviderManager::new_with_config(Arc::clone(&config));
let frecency = FrecencyStore::new();
Ok(Self {
@@ -51,7 +121,7 @@ impl Server {
socket_path: socket_path.to_path_buf(),
provider_manager: Arc::new(RwLock::new(provider_manager)),
frecency: Arc::new(RwLock::new(frecency)),
config: Arc::new(config),
config,
})
}
@@ -60,18 +130,106 @@ impl Server {
// Start filesystem watcher for user plugin hot-reload
crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager));
// SIGHUP handler: reload config from disk into the shared Arc<RwLock<Config>>.
{
use signal_hook::consts::SIGHUP;
use signal_hook::iterator::Signals;
let config = Arc::clone(&self.config);
let mut signals = Signals::new([SIGHUP])
.map_err(io::Error::other)?;
thread::spawn(move || {
for _sig in signals.forever() {
match Config::load() {
Ok(new_cfg) => {
match config.write() {
Ok(mut cfg) => {
*cfg = new_cfg;
info!("Config reloaded via SIGHUP");
}
Err(_) => {
warn!("SIGHUP: config lock poisoned; reload skipped");
}
}
}
Err(e) => {
warn!("SIGHUP: failed to reload config: {}", e);
}
}
}
});
}
// SIGTERM/SIGINT handler: save frecency before exiting.
// Replaces the ctrlc handler in main.rs so all signal management lives here.
{
use signal_hook::consts::{SIGINT, SIGTERM};
use signal_hook::iterator::Signals;
let frecency = Arc::clone(&self.frecency);
let socket_path = self.socket_path.clone();
let mut signals = Signals::new([SIGTERM, SIGINT])
.map_err(io::Error::other)?;
thread::spawn(move || {
// Block until we receive SIGTERM or SIGINT, then save and exit.
let _ = signals.forever().next();
match frecency.write() {
Ok(mut f) => {
if let Err(e) = f.save() {
warn!("Shutdown: frecency save failed: {}", e);
} else {
info!("Shutdown: frecency saved");
}
}
Err(_) => {
warn!("Shutdown: frecency lock poisoned; skipping save");
}
}
let _ = std::fs::remove_file(&socket_path);
std::process::exit(0);
});
}
// Periodic frecency auto-save every 5 minutes.
{
let frecency = Arc::clone(&self.frecency);
thread::spawn(move || loop {
thread::sleep(Duration::from_secs(300));
match frecency.write() {
Ok(mut f) => {
if let Err(e) = f.save() {
warn!("Periodic frecency save failed: {}", e);
}
}
Err(_) => {
warn!("Periodic frecency save: lock poisoned; skipping");
}
}
});
}
info!("Server entering accept loop");
for stream in self.listener.incoming() {
match stream {
Ok(stream) => {
let pm = Arc::clone(&self.provider_manager);
let frecency = Arc::clone(&self.frecency);
let config = Arc::clone(&self.config);
thread::spawn(move || {
if let Err(e) = Self::handle_client(stream, pm, frecency, config) {
warn!("Client handler error: {}", e);
Ok(mut stream) => {
match ConnectionGuard::try_acquire() {
Some(guard) => {
let pm = Arc::clone(&self.provider_manager);
let frecency = Arc::clone(&self.frecency);
let config = Arc::clone(&self.config);
thread::spawn(move || {
let _guard = guard; // released on thread exit
if let Err(e) = Self::handle_client(stream, pm, frecency, config) {
warn!("Client handler error: {}", e);
}
});
}
});
None => {
warn!("Connection limit reached ({} max); rejecting client", MAX_CONNECTIONS);
let resp = Response::Error {
message: format!("server busy: max {} concurrent connections", MAX_CONNECTIONS),
};
let _ = write_response(&mut stream, &resp);
}
}
}
Err(e) => {
error!("Failed to accept connection: {}", e);
@@ -101,30 +259,25 @@ impl Server {
stream: UnixStream,
pm: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
config: Arc<RwLock<Config>>,
) -> io::Result<()> {
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
let mut reader = BufReader::new(stream.try_clone()?);
let mut writer = stream;
loop {
let mut line = String::new();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
break;
}
if line.len() > MAX_REQUEST_SIZE {
let resp = Response::Error {
message: format!(
"request too large ({} bytes, max {})",
line.len(),
MAX_REQUEST_SIZE
),
};
write_response(&mut writer, &resp)?;
break;
}
let line = match read_bounded_line(&mut reader, MAX_REQUEST_SIZE) {
Ok(Some(l)) => l,
Ok(None) => break,
Err(e) if e.kind() == io::ErrorKind::InvalidData => {
let resp = Response::Error {
message: format!("request too large (max {} bytes)", MAX_REQUEST_SIZE),
};
write_response(&mut writer, &resp)?;
break;
}
Err(e) => return Err(e),
};
let trimmed = line.trim();
if trimmed.is_empty() {
@@ -156,7 +309,7 @@ impl Server {
request: &Request,
pm: &Arc<RwLock<ProviderManager>>,
frecency: &Arc<RwLock<FrecencyStore>>,
config: &Arc<Config>,
config: &Arc<RwLock<Config>>,
) -> Response {
match request {
Request::Query { text, modes } => {
@@ -164,11 +317,22 @@ impl Server {
Some(m) => ProviderFilter::from_mode_strings(m),
None => ProviderFilter::all(),
};
let max = config.general.max_results;
let weight = config.providers.frecency_weight;
let (max, weight) = {
let cfg = match config.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: config lock poisoned".into() },
};
(cfg.general.max_results, cfg.providers.frecency_weight)
};
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
let frecency_guard = frecency.read().unwrap_or_else(|e| e.into_inner());
let pm_guard = match pm.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
};
let frecency_guard = match frecency.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: frecency lock poisoned".into() },
};
let results = pm_guard.search_with_frecency(
text,
max,
@@ -190,13 +354,19 @@ impl Server {
item_id,
provider: _,
} => {
let mut frecency_guard = frecency.write().unwrap_or_else(|e| e.into_inner());
let mut frecency_guard = match frecency.write() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: frecency lock poisoned".into() },
};
frecency_guard.record_launch(item_id);
Response::Ack
}
Request::Providers => {
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
let pm_guard = match pm.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
};
let descs = pm_guard.available_providers();
Response::Providers {
list: descs.into_iter().map(descriptor_to_desc).collect(),
@@ -204,7 +374,10 @@ impl Server {
}
Request::Refresh { provider } => {
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
let mut pm_guard = match pm.write() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
};
pm_guard.refresh_provider(provider);
Response::Ack
}
@@ -215,7 +388,10 @@ impl Server {
}
Request::Submenu { plugin_id, data } => {
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
let pm_guard = match pm.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
};
match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) {
Some((_name, actions)) => Response::SubmenuItems {
items: actions
@@ -230,7 +406,10 @@ impl Server {
}
Request::PluginAction { command } => {
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
let pm_guard = match pm.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
};
if pm_guard.execute_plugin_action(command) {
Response::Ack
} else {
@@ -239,6 +418,16 @@ impl Server {
}
}
}
Request::PluginList => {
let pm_guard = match pm.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
};
Response::PluginList {
entries: pm_guard.plugin_registry.clone(),
}
}
}
}
}
@@ -272,6 +461,7 @@ fn launch_item_to_result(item: LaunchItem, score: i64) -> ResultItem {
command: Some(item.command),
terminal: item.terminal,
tags: item.tags,
source: item.source.as_str().to_string(),
}
}
@@ -284,3 +474,57 @@ fn descriptor_to_desc(desc: crate::providers::ProviderDescriptor) -> ProviderDes
position: desc.position,
}
}
#[cfg(test)]
mod tests {
use super::*;
// Wrap a Cursor in a BufReader backed by a UnixStream-like interface.
// Since read_bounded_line takes BufReader<UnixStream>, we test it indirectly
// through an in-memory byte slice via a helper.
fn bounded_line_from_bytes(data: &[u8], max: usize) -> io::Result<Option<String>> {
// Use a pipe to simulate UnixStream I/O.
use std::os::unix::net::UnixStream;
let (mut write_end, read_end) = UnixStream::pair()?;
write_end.write_all(data)?;
drop(write_end); // Signal EOF to reader
let mut reader = BufReader::new(read_end);
read_bounded_line(&mut reader, max)
}
#[test]
fn normal_line_within_limit() {
let result = bounded_line_from_bytes(b"hello world\n", 100).unwrap();
assert_eq!(result, Some("hello world".to_string()));
}
#[test]
fn line_at_exactly_max_succeeds() {
// "aaa...a\n" where content is exactly max bytes
let mut data = vec![b'a'; 100];
data.push(b'\n');
let result = bounded_line_from_bytes(&data, 100).unwrap();
assert_eq!(result, Some("a".repeat(100)));
}
#[test]
fn line_exceeding_max_errors() {
let mut data = vec![b'a'; 101];
data.push(b'\n');
let result = bounded_line_from_bytes(&data, 100);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData);
}
#[test]
fn empty_input_returns_none() {
let result = bounded_line_from_bytes(b"", 100).unwrap();
assert_eq!(result, None);
}
#[test]
fn no_trailing_newline_returns_content() {
let result = bounded_line_from_bytes(b"hello", 100).unwrap();
assert_eq!(result, Some("hello".to_string()));
}
}

View File

@@ -47,6 +47,7 @@ fn test_results_response_roundtrip() {
command: Some("firefox".into()),
terminal: false,
tags: vec![],
source: "core".into(),
}],
};
let json = serde_json::to_string(&resp).unwrap();
@@ -140,6 +141,7 @@ fn test_terminal_field_roundtrip() {
command: Some("htop".into()),
terminal: true,
tags: vec![],
source: "cmd".into(),
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("\"terminal\":true"));

View File

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

View File

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

View File

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

View File

@@ -68,8 +68,11 @@ pub struct RuntimeHandle {
pub ptr: *mut (),
}
// SAFETY: LuaRuntimeState (pointed to by RuntimeHandle) contains mlua::Lua, which is
// Send when the "send" feature is enabled (enabled in Cargo.toml). RuntimeHandle itself
// is Copy and has no interior mutability — Sync is NOT implemented because concurrent
// access is serialized by Arc<Mutex<RuntimeHandle>> in the runtime loader.
unsafe impl Send for RuntimeHandle {}
unsafe impl Sync for RuntimeHandle {}
impl RuntimeHandle {
/// Create a null handle (reserved for error cases)

View File

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

View File

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

View File

@@ -33,7 +33,8 @@ pub use abi_stable::std_types::{ROption, RStr, RString, RVec};
/// Current plugin API version - plugins must match this
/// v2: Added ProviderPosition for widget support
/// v3: Added priority field for plugin-declared result ordering
pub const API_VERSION: u32 = 3;
/// v4: Added get_config_string/int/bool to HostAPI for plugin config access
pub const API_VERSION: u32 = 4;
/// Plugin metadata returned by the info function
#[repr(C)]
@@ -295,6 +296,18 @@ pub struct HostAPI {
/// Log a message at error level
pub log_error: extern "C" fn(message: RStr<'_>),
/// Read a string value from this plugin's config section.
/// Parameters: plugin_id (the calling plugin's ID), key
/// Returns RSome(value) if set, RNone otherwise.
pub get_config_string:
extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<RString>,
/// Read an integer value from this plugin's config section.
pub get_config_int: extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<i64>,
/// Read a boolean value from this plugin's config section.
pub get_config_bool: extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<bool>,
}
use std::sync::OnceLock;
@@ -378,6 +391,30 @@ pub fn log_error(message: &str) {
}
}
/// Read a string value from this plugin's config section (convenience wrapper).
/// `plugin_id` must match the ID the plugin declares in its `PluginInfo`.
pub fn get_config_string(plugin_id: &str, key: &str) -> Option<String> {
host_api().and_then(|api| {
(api.get_config_string)(RStr::from_str(plugin_id), RStr::from_str(key))
.into_option()
.map(|s| s.into_string())
})
}
/// Read an integer value from this plugin's config section (convenience wrapper).
pub fn get_config_int(plugin_id: &str, key: &str) -> Option<i64> {
host_api().and_then(|api| {
(api.get_config_int)(RStr::from_str(plugin_id), RStr::from_str(key)).into_option()
})
}
/// Read a boolean value from this plugin's config section (convenience wrapper).
pub fn get_config_bool(plugin_id: &str, key: &str) -> Option<bool> {
host_api().and_then(|api| {
(api.get_config_bool)(RStr::from_str(plugin_id), RStr::from_str(key)).into_option()
})
}
/// Helper macro for defining plugin vtables
///
/// Usage:

View File

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

View File

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

View File

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

View File

@@ -91,17 +91,20 @@ impl OwlryApp {
.iter()
.map(|s| ProviderFilter::mode_string_to_provider_type(s))
.collect();
let tabs = &config.borrow().general.tabs.clone();
if provider_types.len() == 1 {
ProviderFilter::new(
Some(provider_types[0].clone()),
None,
&config.borrow().providers,
tabs,
)
} else {
ProviderFilter::new(None, Some(provider_types), &config.borrow().providers)
ProviderFilter::new(None, Some(provider_types), &config.borrow().providers, tabs)
}
} else {
ProviderFilter::new(None, None, &config.borrow().providers)
let tabs = config.borrow().general.tabs.clone();
ProviderFilter::new(None, None, &config.borrow().providers, &tabs)
};
let filter = Rc::new(RefCell::new(filter));

View File

@@ -9,7 +9,7 @@ use owlry_core::config::Config;
use owlry_core::data::FrecencyStore;
use owlry_core::filter::ProviderFilter;
use owlry_core::ipc::ResultItem;
use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType};
use owlry_core::providers::{ItemSource, LaunchItem, ProviderManager, ProviderType};
use std::sync::{Arc, Mutex};
/// Parameters needed to run a search query on a background thread.
@@ -167,7 +167,7 @@ impl SearchBackend {
.collect()
} else {
providers
.search_filtered(query, max_results, filter)
.search_filtered(query, max_results, filter, None)
.into_iter()
.map(|(item, _)| item)
.collect()
@@ -230,7 +230,7 @@ impl SearchBackend {
.collect()
} else {
providers
.search_filtered(query, max_results, filter)
.search_filtered(query, max_results, filter, tag_filter)
.into_iter()
.map(|(item, _)| item)
.collect()
@@ -378,6 +378,7 @@ impl SearchBackend {
/// Convert an IPC ResultItem to the internal LaunchItem type.
fn result_to_launch_item(item: ResultItem) -> LaunchItem {
let provider: ProviderType = item.provider.parse().unwrap_or(ProviderType::Application);
let source: ItemSource = item.source.parse().unwrap_or(ItemSource::Core);
LaunchItem {
id: item.id,
name: item.title,
@@ -395,5 +396,6 @@ fn result_to_launch_item(item: ResultItem) -> LaunchItem {
command: item.command.unwrap_or_default(),
terminal: item.terminal,
tags: item.tags,
source,
}
}

View File

@@ -3,7 +3,46 @@ use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
use owlry_core::ipc::{PluginEntry, ProviderDesc, Request, Response, ResultItem};
/// Maximum allowed size for a single IPC response line (4 MiB).
/// Larger than the request limit because responses carry result sets.
const MAX_RESPONSE_SIZE: usize = 4_194_304;
/// Read a newline-terminated line from `reader` without allocating beyond `max` bytes.
fn read_bounded_line(reader: &mut BufReader<UnixStream>, max: usize) -> io::Result<Option<String>> {
let mut buf: Vec<u8> = Vec::with_capacity(4096);
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return if buf.is_empty() {
Ok(None)
} else {
Ok(Some(String::from_utf8_lossy(&buf).into_owned()))
};
}
if let Some(pos) = available.iter().position(|&b| b == b'\n') {
if buf.len() + pos > max {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("response too large (exceeded {} bytes)", max),
));
}
buf.extend_from_slice(&available[..pos]);
reader.consume(pos + 1);
return Ok(Some(String::from_utf8_lossy(&buf).into_owned()));
}
let len = available.len();
if buf.len() + len > max {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("response too large (exceeded {} bytes)", max),
));
}
buf.extend_from_slice(available);
reader.consume(len);
}
}
/// IPC client that connects to the owlryd daemon Unix socket
/// and provides typed methods for all IPC operations.
@@ -157,6 +196,19 @@ impl CoreClient {
}
}
/// Query the daemon's native plugin registry (loaded + suppressed entries).
pub fn plugin_list(&mut self) -> io::Result<Vec<PluginEntry>> {
self.send(&Request::PluginList)?;
match self.receive()? {
Response::PluginList { entries } => Ok(entries),
Response::Error { message } => Err(io::Error::other(message)),
other => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected response to PluginList: {other:?}"),
)),
}
}
/// Query a plugin's submenu actions.
pub fn submenu(&mut self, plugin_id: &str, data: &str) -> io::Result<Vec<ResultItem>> {
self.send(&Request::Submenu {
@@ -186,14 +238,15 @@ impl CoreClient {
}
fn receive(&mut self) -> io::Result<Response> {
let mut line = String::new();
self.reader.read_line(&mut line)?;
if line.is_empty() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"daemon closed the connection",
));
}
let line = match read_bounded_line(&mut self.reader, MAX_RESPONSE_SIZE)? {
Some(l) => l,
None => {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"daemon closed the connection",
))
}
};
serde_json::from_str(line.trim()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
}
@@ -256,6 +309,7 @@ mod tests {
command: Some("firefox".into()),
terminal: false,
tags: vec![],
source: "app".into(),
}],
};
@@ -327,6 +381,7 @@ mod tests {
command: Some("systemctl --user start foo".into()),
terminal: false,
tags: vec![],
source: "native_plugin".into(),
}],
};

View File

@@ -7,6 +7,7 @@ use std::io::{self, Write};
use std::path::{Path, PathBuf};
use crate::cli::{PluginCommand as CliPluginCommand, PluginRuntime};
use crate::client::CoreClient;
use owlry_core::config::Config;
use owlry_core::paths;
use owlry_core::plugins::manifest::{PluginManifest, discover_plugins};
@@ -135,48 +136,53 @@ fn cmd_list_installed(
json_output: bool,
) -> CommandResult {
let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?;
if !plugins_dir.exists() {
if json_output {
println!("[]");
} else {
println!("No plugins installed.");
}
return Ok(());
}
let discovered = discover_plugins(&plugins_dir).map_err(|e| e.to_string())?;
let config = Config::load().unwrap_or_default();
let disabled_list = &config.plugins.disabled_plugins;
let lua_available = lua_runtime_available();
let rune_available = rune_runtime_available();
let mut plugins: Vec<_> = discovered
.iter()
.map(|(id, (manifest, _path))| {
let is_disabled = disabled_list.contains(id);
let runtime = detect_runtime(manifest);
(id.clone(), manifest.clone(), is_disabled, runtime)
})
.collect();
// ── Script plugins (from filesystem) ────────────────────────────────
let mut script_plugins: Vec<_> = if plugins_dir.exists() {
let discovered = discover_plugins(&plugins_dir).map_err(|e| e.to_string())?;
discovered
.into_iter()
.map(|(id, (manifest, _path))| {
let is_disabled = disabled_list.contains(&id);
let runtime = detect_runtime(&manifest);
(id, manifest, is_disabled, runtime)
})
.collect()
} else {
Vec::new()
};
// Apply filters
// Apply filters to script plugins
if only_enabled {
plugins.retain(|(_, _, is_disabled, _)| !*is_disabled);
script_plugins.retain(|(_, _, is_disabled, _)| !*is_disabled);
}
if only_disabled {
plugins.retain(|(_, _, is_disabled, _)| *is_disabled);
script_plugins.retain(|(_, _, is_disabled, _)| *is_disabled);
}
if let Some(rt) = runtime_filter {
plugins.retain(|(_, _, _, runtime)| *runtime == rt);
if let Some(ref rt) = runtime_filter {
let rt_clone = *rt;
script_plugins.retain(|(_, _, _, runtime)| *runtime == rt_clone);
}
script_plugins.sort_by(|a, b| a.0.cmp(&b.0));
// Sort by ID
plugins.sort_by(|a, b| a.0.cmp(&b.0));
// ── Native plugins (from daemon, if running) ─────────────────────────
// Skip native plugins if a runtime filter is active (they have no script runtime).
let native_entries = if runtime_filter.is_none() {
CoreClient::connect(&CoreClient::socket_path())
.ok()
.and_then(|mut client| client.plugin_list().ok())
.unwrap_or_default()
} else {
Vec::new()
};
// ── Output ───────────────────────────────────────────────────────────
if json_output {
let json_list: Vec<_> = plugins
let mut json_list: Vec<_> = script_plugins
.iter()
.map(|(id, manifest, is_disabled, runtime)| {
let runtime_available = match runtime {
@@ -191,39 +197,82 @@ fn cmd_list_installed(
"enabled": !is_disabled,
"runtime": runtime.to_string(),
"runtime_available": runtime_available,
"source": "script",
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&json_list).unwrap());
} else if plugins.is_empty() {
println!("No plugins found.");
} else {
println!("Installed plugins:\n");
for (id, manifest, is_disabled, runtime) in &plugins {
let status = if *is_disabled { " (disabled)" } else { "" };
let runtime_available = match runtime {
PluginRuntime::Lua => lua_available,
PluginRuntime::Rune => rune_available,
};
let runtime_status = if !runtime_available {
format!(" [{} - NOT INSTALLED]", runtime)
} else {
format!(" [{}]", runtime)
};
println!(
" {} v{}{}{}\n {}",
id,
manifest.plugin.version,
status,
runtime_status,
if manifest.plugin.description.is_empty() {
"No description"
} else {
&manifest.plugin.description
}
);
for entry in &native_entries {
json_list.push(serde_json::json!({
"id": entry.id,
"name": entry.name,
"version": entry.version,
"status": entry.status,
"status_detail": entry.status_detail,
"runtime": entry.runtime,
"providers": entry.providers,
"source": "native",
}));
}
println!("\n{} plugin(s) installed.", plugins.len());
println!("{}", serde_json::to_string_pretty(&json_list).unwrap());
} else {
let total = script_plugins.len() + native_entries.len();
if total == 0 {
println!("No plugins found.");
return Ok(());
}
if !script_plugins.is_empty() {
println!("Script plugins:\n");
for (id, manifest, is_disabled, runtime) in &script_plugins {
let status = if *is_disabled { " (disabled)" } else { "" };
let runtime_available = match runtime {
PluginRuntime::Lua => lua_available,
PluginRuntime::Rune => rune_available,
};
let runtime_status = if !runtime_available {
format!(" [{} - NOT INSTALLED]", runtime)
} else {
format!(" [{}]", runtime)
};
println!(
" {} v{}{}{}\n {}",
id,
manifest.plugin.version,
status,
runtime_status,
if manifest.plugin.description.is_empty() {
"No description"
} else {
&manifest.plugin.description
}
);
}
}
if !native_entries.is_empty() {
if !script_plugins.is_empty() {
println!();
}
println!("Native plugins:\n");
for entry in &native_entries {
let status_label = if entry.status == "suppressed" {
format!(" (suppressed: {})", entry.status_detail)
} else {
String::new()
};
let providers_label = if entry.providers.is_empty() {
String::new()
} else {
format!(" [{}]", entry.providers.join(", "))
};
println!(" {} v{}{}{}", entry.id, entry.version, status_label, providers_label);
if !entry.name.is_empty() && entry.name != entry.id {
println!(" {}", entry.name);
}
}
}
println!("\n{} plugin(s) total.", total);
}
Ok(())
@@ -366,6 +415,14 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
"runtime": runtime.to_string(),
"runtime_available": runtime_available,
"path": plugin_path.display().to_string(),
"providers": manifest.providers.iter().map(|p| serde_json::json!({
"id": p.id,
"name": p.name,
"type": p.provider_type,
"type_id": p.type_id,
"prefix": p.prefix,
"icon": p.icon,
})).collect::<Vec<_>>(),
"provides": {
"providers": manifest.provides.providers,
"actions": manifest.provides.actions,
@@ -406,9 +463,13 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
);
println!("Path: {}", plugin_path.display());
println!();
println!("Provides:");
println!("Providers:");
for p in &manifest.providers {
let prefix = p.prefix.as_deref().map(|s| format!(" ({})", s)).unwrap_or_default();
println!(" {} [{}]{}", p.name, p.provider_type, prefix);
}
if !manifest.provides.providers.is_empty() {
println!(" Providers: {}", manifest.provides.providers.join(", "));
println!(" {}", manifest.provides.providers.join(", "));
}
if manifest.provides.actions {
println!(" Actions: yes");
@@ -754,10 +815,16 @@ fn cmd_create(
let desc = description.unwrap_or("A custom owlry plugin");
let (entry_file, entry_ext) = match runtime {
PluginRuntime::Lua => ("init.lua", "lua"),
PluginRuntime::Rune => ("init.rn", "rn"),
PluginRuntime::Lua => ("main.lua", "lua"),
PluginRuntime::Rune => ("main.rn", "rn"),
};
// Derive a short type_id from the plugin name (strip common prefixes)
let type_id = name
.strip_prefix("owlry-")
.unwrap_or(name)
.replace('-', "_");
// Create plugin.toml
let manifest = format!(
r#"[plugin]
@@ -765,25 +832,21 @@ id = "{name}"
name = "{display}"
version = "0.1.0"
description = "{desc}"
author = ""
owlry_version = ">=0.3.0"
entry = "{entry_file}"
entry_point = "{entry_file}"
[provides]
providers = ["{name}"]
actions = false
themes = []
hooks = false
[permissions]
network = false
filesystem = []
run_commands = []
[[providers]]
id = "{name}"
name = "{display}"
type = "static"
type_id = "{type_id}"
icon = "application-x-addon"
# prefix = ":{type_id}"
"#,
name = name,
display = display,
desc = desc,
entry_file = entry_file,
type_id = type_id,
);
fs::write(plugin_dir.join("plugin.toml"), manifest)
@@ -792,91 +855,51 @@ run_commands = []
// Create entry point template based on runtime
match runtime {
PluginRuntime::Lua => {
let init_lua = format!(
let main_lua = format!(
r#"-- {display} Plugin for Owlry
-- {desc}
-- Register the provider
owlry.provider.register({{
name = "{name}",
display_name = "{display}",
type_id = "{name}",
default_icon = "application-x-executable",
refresh = function()
-- Return a list of items
return {{
{{
id = "{name}:example",
name = "Example Item",
description = "This is an example item from {display}",
icon = "dialog-information",
command = "echo 'Hello from {name}!'",
terminal = false,
tags = {{}}
}}
}}
end
}})
owlry.log.info("{display} plugin loaded")
function refresh()
return {{
{{
id = "{name}:example",
name = "Example Item",
description = "This is an example item from {display}",
icon = "dialog-information",
command = "echo 'Hello from {name}!'",
tags = {{}},
}},
}}
end
"#,
name = name,
display = display,
desc = desc,
);
fs::write(plugin_dir.join(entry_file), init_lua)
fs::write(plugin_dir.join(entry_file), main_lua)
.map_err(|e| format!("Failed to write {}: {}", entry_file, e))?;
}
PluginRuntime::Rune => {
// Note: Rune uses #{{ for object literals, so we build manually
let init_rn = format!(
r#"//! {display} Plugin for Owlry
//! {desc}
let main_rn = format!(
r#"use owlry::Item;
/// Plugin item structure
struct Item {{{{
id: String,
name: String,
description: String,
icon: String,
command: String,
terminal: bool,
tags: Vec<String>,
}}}}
pub fn refresh() {{
let items = [];
/// Provider registration
pub fn register(owlry) {{{{
owlry.provider.register(#{{{{
name: "{name}",
display_name: "{display}",
type_id: "{name}",
default_icon: "application-x-executable",
items.push(
Item::new("{name}:example", "Example Item", "echo 'Hello from {name}!'")
.description("This is an example item from {display}")
.icon("dialog-information")
.keywords(["example"]),
);
refresh: || {{{{
// Return a list of items
[
Item {{{{
id: "{name}:example",
name: "Example Item",
description: "This is an example item from {display}",
icon: "dialog-information",
command: "echo 'Hello from {name}!'",
terminal: false,
tags: [],
}}}},
]
}}}},
}}}});
owlry.log.info("{display} plugin loaded");
}}}}
items
}}
"#,
name = name,
display = display,
desc = desc,
);
fs::write(plugin_dir.join(entry_file), init_rn)
fs::write(plugin_dir.join(entry_file), main_rn)
.map_err(|e| format!("Failed to write {}: {}", entry_file, e))?;
}
}
@@ -955,13 +978,14 @@ fn cmd_validate(path: Option<&str>) -> CommandResult {
));
}
// Check for empty provides
if manifest.provides.providers.is_empty()
&& !manifest.provides.actions
&& manifest.provides.themes.is_empty()
&& !manifest.provides.hooks
{
warnings.push("Plugin does not provide any features".to_string());
// Check for empty provides (accept either [[providers]] or [provides])
let has_providers = !manifest.providers.is_empty()
|| !manifest.provides.providers.is_empty()
|| manifest.provides.actions
|| !manifest.provides.themes.is_empty()
|| manifest.provides.hooks;
if !has_providers {
warnings.push("Plugin does not declare any providers".to_string());
}
println!(" Plugin ID: {}", manifest.plugin.id);
@@ -1013,11 +1037,11 @@ fn cmd_runtimes() -> CommandResult {
if lua_available {
println!(" ✓ Lua - Installed");
println!(" Package: owlry-lua");
println!(" Entry point: init.lua");
println!(" Entry point: main.lua");
} else {
println!(" ✗ Lua - Not installed");
println!(" Install: yay -S owlry-lua");
println!(" Entry point: init.lua");
println!(" Entry point: main.lua");
}
println!();
@@ -1026,11 +1050,11 @@ fn cmd_runtimes() -> CommandResult {
if rune_available {
println!(" ✓ Rune - Installed");
println!(" Package: owlry-rune");
println!(" Entry point: init.rn");
println!(" Entry point: main.rn");
} else {
println!(" ✗ Rune - Not installed");
println!(" Install: yay -S owlry-rune");
println!(" Entry point: init.rn");
println!(" Entry point: main.rn");
}
println!();

View File

@@ -1,5 +1,5 @@
use log::debug;
use owlry_core::providers::{LaunchItem, Provider, ProviderType};
use owlry_core::providers::{ItemSource, LaunchItem, Provider, ProviderType};
use std::io::{self, BufRead};
/// Provider for dmenu-style input from stdin
@@ -102,6 +102,7 @@ impl Provider for DmenuProvider {
command: line.to_string(),
terminal: false,
tags: Vec::new(),
source: ItemSource::Core,
};
self.items.push(item);

View File

@@ -1,5 +1,6 @@
use crate::backend::SearchBackend;
use crate::ui::ResultRow;
use crate::ui::provider_meta;
use crate::ui::submenu;
use gtk4::gdk::Key;
use gtk4::prelude::*;
@@ -10,7 +11,7 @@ use gtk4::{
use log::info;
use owlry_core::config::Config;
use owlry_core::filter::ProviderFilter;
use owlry_core::providers::{LaunchItem, ProviderType};
use owlry_core::providers::{ItemSource, LaunchItem, ProviderType};
#[cfg(feature = "dev-logging")]
use log::debug;
@@ -248,7 +249,12 @@ impl MainWindow {
// scroll position and selection.
if !matches!(&*main_window.backend.borrow(), SearchBackend::Daemon(_)) {
let backend_for_auto = main_window.backend.clone();
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || {
let debounce_for_auto = main_window.debounce_source.clone();
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(10), move || {
// Skip widget refresh while the user is actively typing.
if debounce_for_auto.borrow().is_some() {
return gtk4::glib::ControlFlow::Continue;
}
backend_for_auto.borrow_mut().refresh_widgets();
gtk4::glib::ControlFlow::Continue
});
@@ -315,86 +321,26 @@ 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(", "))
}
/// Build dynamic hints based on enabled providers
/// Build hints string for the status bar based on enabled built-in providers.
/// Plugin trigger hints (? web, / files, etc.) are not included here since
/// plugin availability is not tracked in ProvidersConfig.
fn build_hints(config: &owlry_core::config::ProvidersConfig) -> String {
let mut parts: Vec<String> = vec![
"Tab: cycle".to_string(),
@@ -403,38 +349,14 @@ impl MainWindow {
"Esc: close".to_string(),
];
// Add trigger hints for enabled dynamic providers
if config.calculator {
parts.push("= calc".to_string());
}
if config.websearch {
parts.push("? web".to_string());
if config.converter {
parts.push("> conv".to_string());
}
if config.files {
parts.push("/ files".to_string());
}
// Add prefix hints for static providers
let mut prefixes = Vec::new();
if config.system {
prefixes.push(":sys");
}
if config.emoji {
prefixes.push(":emoji");
}
if config.ssh {
prefixes.push(":ssh");
}
if config.clipboard {
prefixes.push(":clip");
}
if config.bookmarks {
prefixes.push(":bm");
}
// Only show first few prefixes to avoid overflow
if !prefixes.is_empty() {
parts.push(prefixes[..prefixes.len().min(4)].join(" "));
parts.push(":sys".to_string());
}
parts.join(" ")
@@ -1159,6 +1081,7 @@ impl MainWindow {
for provider in tab_order {
f.enable(provider.clone());
}
f.restore_all_mode();
}
for (_, button) in buttons.borrow().iter() {
button.set_active(true);
@@ -1368,6 +1291,36 @@ impl MainWindow {
item.terminal, item.provider, item.id
);
// Reject script plugin commands that don't match the known-safe allowlist.
// Script plugins (Lua/Rune user plugins) are untrusted code; only allow
// patterns that can't escalate privileges or exfiltrate data.
if item.source == ItemSource::ScriptPlugin {
let cmd = &item.command;
let allowed = cmd.is_empty()
|| cmd.starts_with("xdg-open ")
|| cmd.starts_with("wl-copy")
|| cmd.starts_with("wl-paste")
|| cmd.starts_with("SUBMENU:")
|| cmd.starts_with('!');
if !allowed {
let msg = format!(
"Blocked untrusted script plugin command from '{}': {}",
item.name, cmd
);
log::warn!("{}", msg);
owlry_core::notify::notify("Command blocked", &msg);
return;
}
}
// Reject items with no command — nothing to execute.
if item.command.is_empty() && !matches!(item.provider, ProviderType::Application) {
let msg = format!("Item '{}' has no command; cannot launch", item.name);
log::warn!("{}", msg);
owlry_core::notify::notify("Launch failed", &msg);
return;
}
// Check if this is a desktop application (has .desktop file as ID)
let is_desktop_app =
matches!(item.provider, ProviderType::Application) && item.id.ends_with(".desktop");

View File

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

View File

@@ -0,0 +1,101 @@
use owlry_core::providers::ProviderType;
/// Display metadata for a provider.
pub struct ProviderMeta {
pub tab_label: &'static str,
pub css_class: &'static str,
pub search_noun: &'static str,
}
/// Return display metadata for a provider type.
pub fn meta_for(provider: &ProviderType) -> ProviderMeta {
match provider {
ProviderType::Application => ProviderMeta {
tab_label: "Apps",
css_class: "owlry-filter-app",
search_noun: "applications",
},
ProviderType::Command => ProviderMeta {
tab_label: "Cmds",
css_class: "owlry-filter-cmd",
search_noun: "commands",
},
ProviderType::Dmenu => ProviderMeta {
tab_label: "Dmenu",
css_class: "owlry-filter-dmenu",
search_noun: "options",
},
ProviderType::Plugin(type_id) => match type_id.as_str() {
"bookmarks" => ProviderMeta {
tab_label: "Bookmarks",
css_class: "owlry-filter-bookmark",
search_noun: "bookmarks",
},
"calc" => ProviderMeta {
tab_label: "Calc",
css_class: "owlry-filter-calc",
search_noun: "calculator",
},
"clipboard" => ProviderMeta {
tab_label: "Clip",
css_class: "owlry-filter-clip",
search_noun: "clipboard",
},
"emoji" => ProviderMeta {
tab_label: "Emoji",
css_class: "owlry-filter-emoji",
search_noun: "emoji",
},
"filesearch" => ProviderMeta {
tab_label: "Files",
css_class: "owlry-filter-file",
search_noun: "files",
},
"media" => ProviderMeta {
tab_label: "Media",
css_class: "owlry-filter-media",
search_noun: "media",
},
"pomodoro" => ProviderMeta {
tab_label: "Pomo",
css_class: "owlry-filter-pomodoro",
search_noun: "pomodoro",
},
"scripts" => ProviderMeta {
tab_label: "Scripts",
css_class: "owlry-filter-script",
search_noun: "scripts",
},
"ssh" => ProviderMeta {
tab_label: "SSH",
css_class: "owlry-filter-ssh",
search_noun: "SSH hosts",
},
"system" => ProviderMeta {
tab_label: "System",
css_class: "owlry-filter-sys",
search_noun: "system",
},
"uuctl" => ProviderMeta {
tab_label: "uuctl",
css_class: "owlry-filter-uuctl",
search_noun: "uuctl units",
},
"weather" => ProviderMeta {
tab_label: "Weather",
css_class: "owlry-filter-weather",
search_noun: "weather",
},
"websearch" => ProviderMeta {
tab_label: "Web",
css_class: "owlry-filter-web",
search_noun: "web",
},
_ => ProviderMeta {
tab_label: "Plugin",
css_class: "owlry-filter-plugin",
search_noun: "plugins",
},
},
}
}

View File

@@ -66,7 +66,7 @@ pub fn is_submenu_item(item: &LaunchItem) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use owlry_core::providers::ProviderType;
use owlry_core::providers::{ItemSource, ProviderType};
#[test]
fn test_parse_submenu_command() {
@@ -94,6 +94,7 @@ mod tests {
command: "SUBMENU:plugin:data".to_string(),
terminal: false,
tags: vec![],
source: ItemSource::NativePlugin,
};
assert!(is_submenu_item(&submenu_item));
@@ -106,6 +107,7 @@ mod tests {
command: "some-command".to_string(),
terminal: false,
tags: vec![],
source: ItemSource::NativePlugin,
};
assert!(!is_submenu_item(&normal_item));
}

View File

@@ -59,8 +59,10 @@ max_results = 100
# Requires: uwsm to be installed
# use_uwsm = true
# Header tabs - providers shown as toggle buttons (Ctrl+1, Ctrl+2, etc.)
# Values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
# Header tabs provider tabs shown in the UI bar (Ctrl+1..9 to toggle)
# Core values: app, cmd, dmenu
# Plugin values: uuctl, calc, clip, emoji, file, script, ssh, sys, web, bm
# Any installed plugin's type_id is valid (e.g. "weather", "media")
tabs = ["app", "cmd", "uuctl"]
# ═══════════════════════════════════════════════════════════════════════
@@ -140,55 +142,25 @@ disabled_plugins = []
# PROVIDERS
# ═══════════════════════════════════════════════════════════════════════
#
# Enable/disable providers and configure their settings.
# Core providers (applications, commands) are built into the binary.
# Plugin providers require their .so to be installed.
# Controls built-in providers only. Plugins are enabled/disabled via
# [plugins] disabled_plugins or `owlry plugin enable/disable <name>`.
[providers]
# Core providers (always available)
# Core providers
applications = true # .desktop applications from XDG dirs
commands = true # Executables from $PATH
# Frecency - boost frequently/recently used items
# Built-in providers (compiled into owlry-core)
calculator = true # Math expressions (= or calc trigger)
converter = true # Unit/currency conversion (> trigger)
system = true # System actions: shutdown, reboot, lock, etc.
# Frecency — boost frequently/recently used items
# Data stored in: ~/.local/share/owlry/frecency.json
frecency = true
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
# ─────────────────────────────────────────────────────────────────────────
# Plugin provider toggles (require corresponding plugin installed)
# ─────────────────────────────────────────────────────────────────────────
uuctl = true # systemd user units
system = true # System commands (shutdown, reboot, etc.)
ssh = true # SSH hosts from ~/.ssh/config
clipboard = true # Clipboard history (requires cliphist)
bookmarks = true # Browser bookmarks
emoji = true # Emoji picker
scripts = true # Custom scripts from ~/.local/share/owlry/scripts/
files = true # File search (requires fd or mlocate)
calculator = true # Calculator (= expression)
websearch = true # Web search (? query)
# ─────────────────────────────────────────────────────────────────────────
# Widget providers (displayed at top of results)
# ─────────────────────────────────────────────────────────────────────────
media = true # MPRIS media player controls
weather = false # Weather widget (disabled by default)
pomodoro = false # Pomodoro timer (disabled by default)
# ─────────────────────────────────────────────────────────────────────────
# Provider settings
# ─────────────────────────────────────────────────────────────────────────
# Web search engine
# Web search engine (used by owlry-plugin-websearch)
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
# Or custom URL: "https://search.example.com/?q={query}"
# Or a custom URL: "https://search.example.com/?q={query}"
search_engine = "duckduckgo"
# Weather settings (when weather = true)
# weather_provider = "wttr.in" # Options: wttr.in, openweathermap, open-meteo
# weather_location = "Berlin" # City name or coordinates
# weather_api_key = "" # Required for openweathermap
# Pomodoro settings (when pomodoro = true)
# pomodoro_work_mins = 25 # Work session duration
# pomodoro_break_mins = 5 # Break duration

View File

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

View File

@@ -135,6 +135,45 @@ done
[[ ${#INPUT_PKGS[@]} -eq 0 ]] && usage
# ─── Inject deduplication ────────────────────────────────────────────────────
# Extract the package name from a .pkg.tar.zst filename.
# Arch package filenames follow: {pkgname}-{pkgver}-{pkgrel}-{arch}.pkg.tar.zst
# pkgver is guaranteed to have no dashes, so stripping the last three
# dash-separated segments leaves pkgname.
pkg_name_from_file() {
local base
base=$(basename "$1" .pkg.tar.zst)
base="${base%-*}" # strip arch
base="${base%-*}" # strip pkgrel
base="${base%-*}" # strip pkgver (no dashes in pkgver by Arch policy)
echo "$base"
}
# Deduplicate a list of .pkg.tar.zst paths by package name.
# When the same package name appears more than once, keep the highest version
# (determined by sort -V on the filenames) and warn about the dropped ones.
dedup_inject_files() {
[[ $# -eq 0 ]] && return 0
local -A best=()
local f name winner
for f in "$@"; do
name=$(pkg_name_from_file "$f")
if [[ -v "best[$name]" ]]; then
winner=$(printf '%s\n%s\n' "${best[$name]}" "$f" | sort -V | tail -1)
if [[ "$winner" == "$f" ]]; then
warn "Dropping duplicate inject (older): $(basename "${best[$name]}")"
best[$name]="$f"
else
warn "Dropping duplicate inject (older): $(basename "$f")"
fi
else
best[$name]="$f"
fi
done
printf '%s\n' "${best[@]}"
}
# ─── Dependency resolution ───────────────────────────────────────────────────
# Return the names of local AUR packages that PKG depends on.
@@ -296,6 +335,11 @@ build_one() {
# ─── Main ────────────────────────────────────────────────────────────────────
# Deduplicate external inject files by package name (keep highest version)
if [[ ${#EXTRA_INJECT[@]} -gt 1 ]]; then
mapfile -t EXTRA_INJECT < <(dedup_inject_files "${EXTRA_INJECT[@]}")
fi
# Validate all requested packages exist
for pkg in "${INPUT_PKGS[@]}"; do
[[ -d "$AUR_DIR/$pkg" && -f "$AUR_DIR/$pkg/PKGBUILD" ]] \

View File

@@ -6,6 +6,7 @@ After=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/bin/owlryd
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=3
Environment=RUST_LOG=warn