Compare commits

..

92 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
e3c4988e01 chore(owlry-rune): bump version to 1.1.3 2026-04-05 18:18:05 +02:00
46b5d8518f chore(aur): update owlry-rune to 1.1.2 2026-04-05 18:05:59 +02:00
95a698225c chore(owlry-rune): bump version to 1.1.2 2026-04-05 18:05:54 +02:00
709e1b04cb chore(aur): update owlry-lua to 1.1.2 2026-04-05 18:05:31 +02:00
827bf383ea chore(owlry-lua): bump version to 1.1.2 2026-04-05 18:05:25 +02:00
b706347ec9 chore(aur): update owlry-core to 1.3.2 2026-04-05 17:59:24 +02:00
32b4b144f4 chore(owlry-core): bump version to 1.3.2 2026-04-05 17:59:19 +02:00
5615002062 fix: switch reqwest TLS backend from rustls to native-tls
reqwest 0.13 defaults to rustls -> aws-lc-rs which requires cmake/nasm
in minimal build environments (AUR chroot). Switch all direct reqwest
users to native-tls (system OpenSSL) to fix clean chroot build failures
reported by users.

Affected crates: owlry-core, owlry-lua, owlry-rune
PKGBUILD: add openssl to depends for all three runtime packages
Also add scripts/aur-local-test for clean chroot testing workflow
2026-04-05 17:58:36 +02:00
0a3af9fa56 refactor(filter): consolidate parse_query prefix arrays
Merge four separate prefix arrays (core full, plugin full, core
partial, plugin partial) into two arrays with a single loop each
that checks both full and partial match. Halves the data and
eliminates the duplicate iteration.
2026-03-29 20:45:52 +02:00
c93b11e899 perf(application): single-pass double-space collapse
Replace while-contains-replace loop with a single-pass char
iterator. Eliminates O(n²) behavior and repeated allocations
on pathological desktop file Exec values.
2026-03-29 20:44:27 +02:00
bd69f8eafe perf(ui): use ListBox::remove_all() instead of per-child loop
Replaces five while-loop child removal patterns with the batched
remove_all() method available since GTK 4.12. Avoids per-removal
layout invalidation.
2026-03-29 20:43:41 +02:00
edfb079bb1 perf(frecency): remove blocking auto-save on every launch
record_launch no longer calls save() synchronously. The dirty flag
is set and the Drop impl flushes on shutdown. Removes a JSON
serialize + fs::write from the hot launch path.
2026-03-29 20:41:56 +02:00
3de382cd73 perf(search): score by reference, clone only top-N results
Refactor search_with_frecency to score static provider items by
reference (&LaunchItem, i64) instead of cloning every match.
Use select_nth_unstable_by for O(n) partial sort, then clone
only the max_results survivors. Reduces clones from O(total_matches)
to O(max_results) — typically from hundreds to ~15.
2026-03-29 20:33:29 +02:00
82f35e5a54 fix(native-provider): remove unsound unsafe in items()
Replace RwLock<Vec<LaunchItem>> with plain Vec. The inner RwLock
was unnecessary — refresh() takes &mut self (exclusive access
guaranteed by the outer Arc<RwLock<ProviderManager>>). The unsafe
block in items() dropped the RwLockReadGuard while returning a
slice backed by the raw pointer, creating a dangling reference.
2026-03-29 20:28:49 +02:00
a920588df9 chore(aur): update owlry-lua 1.1.1, owlry-rune 1.1.1 2026-03-28 13:43:30 +01:00
c32b6c5456 chore(owlry-rune): bump version to 1.1.1 2026-03-28 13:43:06 +01:00
2a5f184230 chore(owlry-lua): bump version to 1.1.1 2026-03-28 13:43:04 +01:00
b2f068269a chore: remove unused builtin_type_ids method and test 2026-03-28 13:37:54 +01:00
e210a604f7 chore(aur): update owlry-core to 1.3.1 2026-03-28 13:30:28 +01:00
1adec7bf47 chore(owlry-core): bump version to 1.3.1 2026-03-28 13:30:23 +01:00
7f07a93dec fix(core): add :config and :conv to filter prefix tables
:config and :conv were not in the prefix lists, so typing them
showed 'Plugin' mode but didn't route to the config/converter
providers. Also added :settings, :converter aliases.
2026-03-28 13:30:10 +01:00
7351ba868e docs: revise README for current state
- Architecture diagram reflects owlryd binary name and built-in providers
- Add config editor, converter trigger (>) to prefix tables
- Add apex-neon to theme list (10 themes)
- Add --owlry-shadow CSS variable
- Fix build instructions (no deleted plugins)
- Add built-in provider toggles to example config
- Cross-reference :config throughout (Quick Start, Disabling Plugins, Theming)
2026-03-28 13:28:32 +01:00
44e1430ea5 chore(aur): update owlry-core to 1.3.0 2026-03-28 13:17:29 +01:00
80312a28f7 chore(owlry-core): bump version to 1.3.0 2026-03-28 13:17:11 +01:00
37abe98c9b docs: add config editor usage to README 2026-03-28 13:16:36 +01:00
d95b81bbcb feat(core): wire config editor into ProviderManager
Register ConfigProvider as built-in dynamic provider. Extend
execute_plugin_action to dispatch CONFIG:* commands via the
DynamicProvider::execute_action trait method.
2026-03-28 13:15:28 +01:00
562b38deba feat(core): add built-in config editor provider 2026-03-28 13:10:54 +01:00
2888677e38 docs: add config editor implementation plan 2026-03-28 13:05:57 +01:00
940ad58ee2 docs: add config editor design spec 2026-03-28 12:54:11 +01:00
18775d71fc chore(aur): update owlry 1.0.6, owlry-core 1.2.1 2026-03-28 12:40:33 +01:00
f189f4b1ce chore(owlry): bump version to 1.0.6 2026-03-28 12:40:20 +01:00
422ea6d816 chore(owlry-core): bump version to 1.2.1 2026-03-28 12:40:18 +01:00
8b444eec3b refactor: rename daemon binary from owlry-core to owlryd
- Binary: owlry-core → owlryd
- Systemd: owlry-core.service → owlryd.service, owlry-core.socket → owlryd.socket
- Client: systemctl start owlryd
- AUR package name stays owlry-core (installs owlryd binary)
2026-03-28 12:39:37 +01:00
6d0bf1c401 chore(aur): update owlry-core to 1.2.0 2026-03-28 12:26:40 +01:00
c8d8298274 chore(owlry-core): bump version to 1.2.0 2026-03-28 12:26:15 +01:00
62f6e1d4b0 docs: update README for built-in providers migration
Calculator, converter, and system are now built into owlry-core.
Remove meta package references. Update install instructions and
package tables.
2026-03-28 12:25:33 +01:00
bf1d759cb2 chore: remove retired meta package AUR dirs
owlry-meta-essentials, owlry-meta-full, owlry-meta-tools, and
owlry-meta-widgets have been deleted from AUR. Remove local dirs.
2026-03-28 12:24:27 +01:00
3f9f4bb112 feat(core): skip native plugins that conflict with built-in providers
When users upgrade owlry-core but still have old .so plugins installed,
the conflict detection skips the native plugin to prevent duplicate
results.
2026-03-28 12:22:13 +01:00
c5f1f35167 feat(core): register built-in providers in ProviderManager
Calculator and converter registered as built-in dynamic providers.
System registered as built-in static provider. All gated by config
toggles (calculator, converter, system — default true).
2026-03-28 12:19:12 +01:00
81626c33dd feat(core): add built-in converter provider 2026-03-28 12:14:31 +01:00
99d38a66b8 feat(core): add built-in system provider 2026-03-28 12:09:19 +01:00
8b4c704501 feat(core): add built-in calculator provider 2026-03-28 12:07:43 +01:00
27e296e333 feat(core): add DynamicProvider trait and builtin_dynamic support
Foundation for built-in calculator, converter, and system providers.
DynamicProvider trait for per-keystroke providers. ProviderManager
iterates builtin_dynamic alongside native dynamic plugins in search.
2026-03-28 12:03:45 +01:00
173d72ad43 docs: add built-in providers migration implementation plan 2026-03-28 11:59:00 +01:00
3eea902c7f docs: add built-in providers migration design spec 2026-03-28 11:52:58 +01:00
a12e850c94 fix(ui): remove periodic re-query that reset selection position
The 5-second timer emitted 'changed' on the search entry in daemon
mode, triggering a full re-query that rebuilt the result list and
selected row 0 — jumping the user back to the top while browsing.

Widget refresh is a daemon-side concern; the UI gets updated data
on the next user-initiated search. Only keep the timer for local
(dmenu) mode where the UI owns the providers directly.
2026-03-28 11:41:37 +01:00
eccfb217d4 chore(aur): update owlry 1.0.5, owlry-core 1.1.3 2026-03-28 11:35:57 +01:00
c3c35611fd chore(owlry-core): bump version to 1.1.3 2026-03-28 11:35:23 +01:00
5ecd0a6412 chore(owlry): bump version to 1.0.5 2026-03-28 11:35:22 +01:00
6fe7213b6f fix(core): group auto-detect plugin results together in ranking
Calculator and converter results now get a 10k grouping bonus so all
their results stay together above websearch/filesearch. Previously
websearch (priority 9000) would interleave with converter results
(9000, 8999, 8998...) since they had the same base priority.
2026-03-28 11:34:26 +01:00
b768bfd181 chore(ui): remove dead update_results method 2026-03-28 11:30:40 +01:00
c9a1ff28f4 fix(ui): only highlight calc and converter, not websearch/filesearch
Websearch is a generic fallback — it always shows a result, so
highlighting it adds no signal. Filesearch returns fuzzy matches,
not auto-detected conversions. Only calc and conv produce direct
answers that deserve highlighting.
2026-03-28 11:28:37 +01:00
623572ec14 fix: use git add -A in aur-publish-pkg 2026-03-28 11:20:57 +01:00
5196255594 chore(aur): update owlry 1.0.4, owlry-core 1.1.2 2026-03-28 11:19:05 +01:00
b87447156e chore(owlry-core): bump version to 1.1.2 2026-03-28 11:18:27 +01:00
12d554959a chore(owlry): bump version to 1.0.4 2026-03-28 11:18:26 +01:00
83fa22d84c feat(ui): add result highlighting and remove window shadow
Highlighting:
- Dynamic plugin results (calculator, converter, websearch, filesearch)
  get a subtle accent left-border + background tint when auto-detected
- Exact name matches (case-insensitive) are highlighted the same way
- Exact match on apps gets a higher score boost (50k) than other
  providers (30k), so apps rank first when names match exactly

Shadow:
- Removed hardcoded box-shadow from all theme CSS files
- Added --owlry-shadow variable in base.css (defaults to none)
- Themes can opt into shadow via --owlry-shadow if desired

CSS class: .owlry-result-highlight on ResultRow
2026-03-28 11:17:45 +01:00
ade5d3aeef fix(ui): check icon theme exists on disk before fallback
has_icon() returns true even for broken themes since it checks all
search paths. Instead, verify the theme directory actually exists
in the search path. Falls back to Adwaita only when the configured
theme is genuinely missing from disk.
2026-03-28 11:08:01 +01:00
617c943147 fix: aur-stage glob handling for packages without .install files 2026-03-28 10:51:46 +01:00
1b1e12124b chore(aur): update owlry PKGBUILD to 1.0.3 2026-03-28 10:49:57 +01:00
94556f1fe0 chore(owlry): bump version to 1.0.3 2026-03-28 10:48:55 +01:00
2b98f0651c fix(ui): fall back to Adwaita when system icon theme is broken
If the configured icon theme (e.g. Sweet-Blue) doesn't exist on disk,
GTK falls back to hicolor which has almost no icons. Detect this by
probing for a standard icon, and set Adwaita as the theme — it's
guaranteed to exist as a GTK4 dependency.

This replaces the broken add_search_path("/usr/share/icons/Adwaita")
approach which doesn't work because search paths are scoped to the
active theme name, not the directory name.
2026-03-28 10:48:39 +01:00
75fa770c94 chore: overhaul justfile for current deployment pipeline
Key fixes:
- aur-update-pkg uses correct per-crate tag URLs ({crate}-v{version})
- tag-crate creates per-crate tags instead of generic v{version}
- aur-stage handles embedded .git dirs in AUR subdirectories
- aur-commit stages all AUR files with .git workaround
- release-crate does full pipeline: bump → push → tag → AUR update → publish
- Removed stale release-core recipe that used wrong tag format
2026-03-28 10:31:32 +01:00
c6ba91f06d fix(aur): restrict owlry-core check() to unit tests
The integration test (server_test) loads native plugins which segfault
in the clean makepkg build environment. Use --lib to run only unit tests.
2026-03-28 10:06:07 +01:00
235103e854 fix(aur): correct b2sums for owlry and owlry-core tarballs 2026-03-28 09:54:12 +01:00
8ccaaf28c8 docs: update README for client/daemon package split
- Separate package tables for core, plugins, and meta bundles
- Add owlry-plugin-converter to plugin list and meta-essentials
- Fix build instructions: plugins are in owlry-plugins repo
- Update plugin count to 14
- Remove dead link to gitignored CLAUDE.md
2026-03-28 09:51:29 +01:00
cfd143fe4a chore: track AUR package files (PKGBUILD, .SRCINFO)
The aur/ directory was entirely gitignored, preventing PKGBUILD and
.SRCINFO files from being tracked. Fix .gitignore to only ignore
build artifacts and nested .git dirs, matching the owlry-plugins
repo convention.
2026-03-28 09:34:21 +01:00
10a685c62f chore(owlry): bump version to 1.0.2 2026-03-28 09:16:40 +01:00
34db33c75f chore(owlry-core): bump version to 1.1.1 2026-03-28 09:16:38 +01:00
4bff83b5e6 perf(ui): eliminate redundant results.clone() in search handlers
The full results Vec was cloned into lazy_state.all_results and then
separately consumed for current_results. Now we slice for current_results
and move the original into lazy_state, avoiding one full Vec allocation
per query.
2026-03-28 09:14:11 +01:00
8f7501038d perf(ui): move search IPC off the GTK main thread
Search queries in daemon mode now run on a background thread via
DaemonHandle::query_async(). Results are posted back to the main
thread via glib::spawn_future_local + futures_channel::oneshot.
The GTK event loop is never blocked by IPC, eliminating perceived
input lag.

Local mode (dmenu) continues to use synchronous search since it
has no IPC overhead.
2026-03-28 09:12:20 +01:00
4032205800 perf(ui): defer initial query to after window.present()
update_results('') was called inside MainWindow::new(), blocking the
window from appearing until the daemon responded. Move it to a
glib::idle_add_local_once callback scheduled after present() so the
window renders immediately.
2026-03-28 08:51:33 +01:00
99985c7f3b perf(ui): use tracked count in scroll_to_row instead of child walk
scroll_to_row walked all GTK children via first_child/next_sibling
to count rows. The count is already available in LazyLoadState, so
use that directly. Eliminates O(n) widget traversal per arrow key.
2026-03-28 08:48:52 +01:00
6113217f7b perf(core): sample Utc::now() once per search instead of per-item
get_score() called Utc::now() inside calculate_frecency() for every
item in the search loop. Added get_score_at() that accepts a pre-sampled
timestamp. Eliminates hundreds of unnecessary clock_gettime syscalls
per keystroke.
2026-03-28 08:45:21 +01:00
558d415e12 perf(config): replace which subprocesses with in-process PATH scan
detect_terminal() was spawning up to 17 'which' subprocesses sequentially
on every startup. Replace with std::env::split_paths + is_file() check.
Eliminates 200-500ms of fork+exec overhead on cold cache.
2026-03-28 08:40:22 +01:00
6bde1504b1 chore: add .worktrees/ to gitignore 2026-03-28 08:35:51 +01:00
92 changed files with 13219 additions and 2217 deletions

5
.gitignore vendored
View File

@@ -1,8 +1,10 @@
/target /target
CLAUDE.md CLAUDE.md
.worktrees/
media.md media.md
# AUR packages (each is its own git repo for aur.archlinux.org) # AUR packages (each is its own git repo for aur.archlinux.org)
# Track PKGBUILD and .SRCINFO, ignore build artifacts and sub-repo .git
aur/*/.git/ aur/*/.git/
aur/*/pkg/ aur/*/pkg/
aur/*/src/ aur/*/src/
@@ -10,6 +12,3 @@ aur/*/*.tar.zst
aur/*/*.tar.gz aur/*/*.tar.gz
aur/*/*.tar.xz aur/*/*.tar.xz
aur/*/*.pkg.tar.* aur/*/*.pkg.tar.*
# Keep PKGBUILD and .SRCINFO tracked
.SRCINFO
aur/

924
Cargo.lock generated

File diff suppressed because it is too large Load Diff

197
README.md
View File

@@ -11,16 +11,18 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
## Features ## Features
- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory - **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory
- **Modular plugin architecture** — Install only what you need - **Built-in providers** — Calculator, unit/currency converter, and system actions out of the box
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags - **Built-in settings editor** — Configure everything from within the launcher (`:config`)
- **13 native plugins** — Calculator, clipboard, emoji, weather, media, and more - **11 optional plugins** — Clipboard, emoji, weather, media, bookmarks, and more
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results - **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
- **Config profiles** — Named mode presets for different workflows - **Config profiles** — Named mode presets for different workflows
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc. - **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:config`, `:tag:X`, etc.
- **Frecency ranking** — Frequently/recently used items rank higher - **Frecency ranking** — Frequently/recently used items rank higher
- **Toggle behavior** — Bind one key to open/close the launcher - **Toggle behavior** — Bind one key to open/close the launcher
- **GTK4 theming** — System theme by default, with 9 built-in themes - **GTK4 theming** — System theme by default, with 10 built-in themes
- **Wayland native** — Uses Layer Shell for proper overlay behavior - **Wayland native** — Uses Layer Shell for proper overlay behavior
- **dmenu compatible** — Pipe-based selection mode, no daemon required
- **Extensible** — Create custom plugins in Lua or Rune - **Extensible** — Create custom plugins in Lua or Rune
## Installation ## Installation
@@ -28,41 +30,45 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
### Arch Linux (AUR) ### Arch Linux (AUR)
```bash ```bash
# Minimal core (applications + commands only) # Core (includes calculator, converter, system actions, settings editor)
yay -S owlry yay -S owlry
# Add individual plugins # Add individual plugins as needed
yay -S owlry-plugin-calculator owlry-plugin-weather yay -S owlry-plugin-bookmarks owlry-plugin-weather owlry-plugin-clipboard
# Or install bundles: # For custom Lua/Rune user plugins
yay -S owlry-meta-essentials # calculator, system, ssh, scripts, bookmarks
yay -S owlry-meta-widgets # weather, media, pomodoro
yay -S owlry-meta-tools # clipboard, emoji, websearch, filesearch, systemd
yay -S owlry-meta-full # everything
# For custom Lua/Rune plugins
yay -S owlry-lua # Lua 5.4 runtime yay -S owlry-lua # Lua 5.4 runtime
yay -S owlry-rune # Rune runtime yay -S owlry-rune # Rune runtime
``` ```
### Available Packages ### Available Packages
**Core packages** (this repo):
| Package | Description | | Package | Description |
|---------|-------------| |---------|-------------|
| `owlry` | Core: UI client (`owlry`) and daemon (`owlry-core`) | | `owlry` | GTK4 UI client |
| `owlry-plugin-calculator` | Math expressions (`= 5+3`) | | `owlry-core` | Daemon (`owlryd`) with built-in calculator, converter, system, and settings providers |
| `owlry-plugin-system` | Shutdown, reboot, suspend, lock | | `owlry-lua` | Lua 5.4 script runtime for user plugins |
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` | | `owlry-rune` | Rune script runtime for user plugins |
**Plugin packages** ([owlry-plugins](https://somegit.dev/Owlibou/owlry-plugins) repo):
| Package | Description |
|---------|-------------|
| `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
| `owlry-plugin-clipboard` | History via cliphist | | `owlry-plugin-clipboard` | History via cliphist |
| `owlry-plugin-emoji` | 400+ searchable emoji | | `owlry-plugin-emoji` | 400+ searchable emoji |
| `owlry-plugin-scripts` | User scripts |
| `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
| `owlry-plugin-websearch` | Web search (`? query`) |
| `owlry-plugin-filesearch` | File search (`/ filename`) | | `owlry-plugin-filesearch` | File search (`/ filename`) |
| `owlry-plugin-systemd` | User services with actions |
| `owlry-plugin-weather` | Weather widget |
| `owlry-plugin-media` | MPRIS media controls | | `owlry-plugin-media` | MPRIS media controls |
| `owlry-plugin-pomodoro` | Pomodoro timer widget | | `owlry-plugin-pomodoro` | Pomodoro timer widget |
| `owlry-plugin-scripts` | User scripts |
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
| `owlry-plugin-systemd` | User services with actions |
| `owlry-plugin-weather` | Weather widget |
| `owlry-plugin-websearch` | Web search (`? query`) |
> **Note:** Calculator, converter, and system actions are built into `owlry-core` and do not require separate packages.
### Build from Source ### Build from Source
@@ -83,26 +89,33 @@ sudo dnf install gtk4-devel gtk4-layer-shell-devel
git clone https://somegit.dev/Owlibou/owlry.git git clone https://somegit.dev/Owlibou/owlry.git
cd owlry cd owlry
# Build core only (daemon + UI) # Build daemon + UI
cargo build --release -p owlry -p owlry-core cargo build --release -p owlry -p owlry-core
# Build specific plugin # Build runtimes (for user plugins)
cargo build --release -p owlry-plugin-calculator cargo build --release -p owlry-lua -p owlry-rune
# Build everything # Build everything in this workspace
cargo build --release --workspace cargo build --release --workspace
``` ```
**Plugins** are in a [separate repo](https://somegit.dev/Owlibou/owlry-plugins):
```bash
git clone https://somegit.dev/Owlibou/owlry-plugins.git
cd owlry-plugins
cargo build --release -p owlry-plugin-bookmarks # or any plugin
```
**Install locally:** **Install locally:**
```bash ```bash
just install-local just install-local
``` ```
This installs both binaries, all plugins, runtimes, and the systemd service files. This installs the UI (`owlry`), daemon (`owlryd`), runtimes, and systemd service files.
## Getting Started ## Getting Started
Owlry uses a client/daemon architecture. The daemon (`owlry-core`) loads providers and plugins into memory. The UI client (`owlry`) connects to the daemon over a Unix socket for instant results. Owlry uses a client/daemon architecture. The daemon (`owlryd`) loads providers and plugins into memory. The UI client (`owlry`) connects to the daemon over a Unix socket for instant results.
### Starting the Daemon ### Starting the Daemon
@@ -114,25 +127,25 @@ Add to your compositor config:
```bash ```bash
# Hyprland (~/.config/hypr/hyprland.conf) # Hyprland (~/.config/hypr/hyprland.conf)
exec-once = owlry-core exec-once = owlryd
# Sway (~/.config/sway/config) # Sway (~/.config/sway/config)
exec owlry-core exec owlryd
``` ```
**2. Systemd user service** **2. Systemd user service**
```bash ```bash
systemctl --user enable --now owlry-core.service systemctl --user enable --now owlryd.service
``` ```
**3. Socket activation (auto-start on first use)** **3. Socket activation (auto-start on first use)**
```bash ```bash
systemctl --user enable owlry-core.socket systemctl --user enable owlryd.socket
``` ```
The daemon starts automatically when the UI client first connects. No manual startup needed. The daemon starts automatically when the UI client first connects.
### Launching the UI ### Launching the UI
@@ -146,7 +159,7 @@ bind = SUPER, Space, exec, owlry
bindsym $mod+space exec owlry bindsym $mod+space exec owlry
``` ```
Running `owlry` a second time while it is already open sends a toggle command — the window closes. This means a single keybind acts as open/close. Running `owlry` a second time while it is already open sends a toggle command — the window closes. A single keybind acts as open/close.
If the daemon is not running when the UI launches, it will attempt to start it via systemd automatically. If the daemon is not running when the UI launches, it will attempt to start it via systemd automatically.
@@ -156,7 +169,7 @@ If the daemon is not running when the UI launches, it will attempt to start it v
owlry # Launch with all providers owlry # Launch with all providers
owlry -m app # Applications only owlry -m app # Applications only
owlry -m cmd # PATH commands only owlry -m cmd # PATH commands only
owlry -m calc # Calculator plugin only (if installed) owlry -m calc # Calculator only
owlry --profile dev # Use a named profile from config owlry --profile dev # Use a named profile from config
owlry --help # Show all options with examples owlry --help # Show all options with examples
``` ```
@@ -191,14 +204,16 @@ bind = SUPER, D, exec, owlry --profile dev
bind = SUPER, M, exec, owlry --profile media bind = SUPER, M, exec, owlry --profile media
``` ```
Profiles can also be managed from the launcher itself — see [Settings Editor](#settings-editor).
### dmenu Mode ### dmenu Mode
Owlry is dmenu-compatible. Pipe input for interactive selection — the selected item is printed to stdout (not executed), so you pipe the output to execute it. Owlry is dmenu-compatible. Pipe input for interactive selection — the selected item is printed to stdout (not executed), so you pipe the output to execute it.
dmenu mode is self-contained: it does not use the daemon and works without `owlry-core` running. dmenu mode is self-contained: it does not use the daemon and works without `owlryd` running.
```bash ```bash
# Screenshot menu (execute selected command) # Screenshot menu
printf '%s\n' \ printf '%s\n' \
"grimblast --notify copy screen" \ "grimblast --notify copy screen" \
"grimblast --notify copy area" \ "grimblast --notify copy area" \
@@ -217,9 +232,6 @@ find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
# Package manager search # Package manager search
pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S
# Open selected file
ls ~/Documents | owlry -m dmenu | xargs xdg-open
``` ```
The `-p` / `--prompt` flag sets a custom label for the search input. The `-p` / `--prompt` flag sets a custom label for the search input.
@@ -235,6 +247,26 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
| `Shift+Tab` | Cycle filter tabs (reverse) | | `Shift+Tab` | Cycle filter tabs (reverse) |
| `Ctrl+1..9` | Toggle tab by position | | `Ctrl+1..9` | Toggle tab by position |
### Settings Editor
Type `:config` to browse and modify settings without editing files:
| Command | What it does |
|---------|-------------|
| `:config` | Show all setting categories |
| `: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 |
| `:config fontsize 16` | Set font size (restart to apply) |
| `:config profiles` | List profiles |
| `:config profile create dev` | Create a new profile |
| `:config profile dev modes` | Edit which modes a profile includes |
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 ### Search Prefixes
| Prefix | Provider | Example | | Prefix | Provider | Example |
@@ -251,6 +283,7 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
| `:calc` | Calculator | `:calc sqrt(16)` | | `:calc` | Calculator | `:calc sqrt(16)` |
| `:web` | Web search | `:web rust docs` | | `:web` | Web search | `:web rust docs` |
| `:uuctl` | systemd | `:uuctl docker` | | `:uuctl` | systemd | `:uuctl docker` |
| `:config` | Settings | `:config theme` |
| `:tag:X` | Filter by tag | `:tag:development` | | `:tag:X` | Filter by tag | `:tag:development` |
### Trigger Prefixes ### Trigger Prefixes
@@ -259,6 +292,7 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
|---------|----------|---------| |---------|----------|---------|
| `=` | Calculator | `= 5+3` | | `=` | Calculator | `= 5+3` |
| `calc ` | Calculator | `calc sqrt(16)` | | `calc ` | Calculator | `calc sqrt(16)` |
| `>` | Converter | `> 20 km to mi` |
| `?` | Web search | `? rust programming` | | `?` | Web search | `? rust programming` |
| `web ` | Web search | `web linux tips` | | `web ` | Web search | `web linux tips` |
| `/` | File search | `/ .bashrc` | | `/` | File search | `/ .bashrc` |
@@ -278,6 +312,7 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
| `~/.local/share/owlry/frecency.json` | Usage history | | `~/.local/share/owlry/frecency.json` | Usage history |
System locations: System locations:
| Path | Purpose | | Path | Purpose |
|------|---------| |------|---------|
| `/usr/lib/owlry/plugins/*.so` | Installed native plugins | | `/usr/lib/owlry/plugins/*.so` | Installed native plugins |
@@ -292,35 +327,61 @@ mkdir -p ~/.config/owlry
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
``` ```
Or configure from within the launcher: type `:config` to interactively change settings.
### Example Configuration ### Example Configuration
```toml ```toml
[general] [general]
show_icons = true show_icons = true
max_results = 10 max_results = 100
tabs = ["app", "cmd", "uuctl"] tabs = ["app", "cmd", "uuctl"] # Provider tabs shown in the header bar
# terminal_command = "kitty" # Auto-detected # terminal_command = "kitty" # Auto-detected; overrides $TERMINAL and xdg-terminal-exec
# use_uwsm = false # Enable for systemd session integration # use_uwsm = false # Enable for systemd session integration (uwsm app --)
[appearance] [appearance]
width = 850 width = 850
height = 650 height = 650
font_size = 14 font_size = 14
border_radius = 12 border_radius = 12
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc. # theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc. (see Theming section)
[plugins] # Optional per-element color overrides — all fields are optional, unset inherits from theme
disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"] # [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] [providers]
applications = true # .desktop files applications = true # .desktop files
commands = true # PATH executables commands = true # PATH executables
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 = 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" 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: named sets of modes
[profiles.dev] [profiles.dev]
modes = ["app", "cmd", "ssh"] modes = ["app", "cmd", "ssh"]
@@ -333,7 +394,7 @@ See `/usr/share/doc/owlry/config.example.toml` for all options with documentatio
## Plugin System ## Plugin System
Owlry uses a modular plugin architecture. Plugins are loaded by the daemon (`owlry-core`) from: Owlry uses a modular plugin architecture. Plugins are loaded by the daemon from:
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages) - `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`) - `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
@@ -344,9 +405,18 @@ Add plugin IDs to the disabled list in your config:
```toml ```toml
[plugins] [plugins]
disabled = ["emoji", "pomodoro"] disabled_plugins = ["emoji", "pomodoro"]
``` ```
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 ### Plugin Management CLI
```bash ```bash
@@ -402,12 +472,15 @@ See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
| `tokyo-night` | Tokyo city lights | | `tokyo-night` | Tokyo city lights |
| `solarized-dark` | Precision colors | | `solarized-dark` | Precision colors |
| `one-dark` | Atom's One Dark | | `one-dark` | Atom's One Dark |
| `apex-neon` | Neon cyberpunk |
```toml ```toml
[appearance] [appearance]
theme = "catppuccin-mocha" theme = "catppuccin-mocha"
``` ```
Or select interactively: type `:config theme` in the launcher.
### Custom Theme ### Custom Theme
Create `~/.config/owlry/themes/mytheme.css`: Create `~/.config/owlry/themes/mytheme.css`:
@@ -435,18 +508,24 @@ Create `~/.config/owlry/themes/mytheme.css`:
| `--owlry-text-secondary` | Muted text | | `--owlry-text-secondary` | Muted text |
| `--owlry-accent` | Accent color | | `--owlry-accent` | Accent color |
| `--owlry-accent-bright` | Bright accent | | `--owlry-accent-bright` | Bright accent |
| `--owlry-shadow` | Window shadow (default: none) |
## Architecture ## Architecture
Owlry uses a client/daemon split: Owlry uses a client/daemon split:
``` ```
owlry-core (daemon) owlry (GTK4 UI client) owlryd (daemon) owlry (GTK4 UI client)
├── Loads config + plugins ├── Connects to daemon via Unix socket ├── Loads config + plugins ├── Connects to daemon via Unix socket
├── Applications provider ├── Renders results in GTK4 window ├── Built-in providers ├── Renders results in GTK4 window
├── Commands provider ├── Handles keyboard input │ ├── Applications (.desktop) ├── Handles keyboard input
├── Plugin loader ├── Toggle: second launch closes window │ ├── Commands (PATH) ├── Toggle: second launch closes window
│ ├── /usr/lib/owlry/plugins/*.so └── dmenu mode (self-contained, no daemon) │ ├── Calculator (math) └── dmenu mode (self-contained, no daemon)
│ ├── Converter (units/currency)
│ ├── System (power/session)
│ └── Config editor (settings)
├── Plugin loader
│ ├── /usr/lib/owlry/plugins/*.so
│ ├── /usr/lib/owlry/runtimes/ │ ├── /usr/lib/owlry/runtimes/
│ └── ~/.config/owlry/plugins/ │ └── ~/.config/owlry/plugins/
├── Frecency tracking ├── Frecency tracking
@@ -457,8 +536,6 @@ owlry-core (daemon) owlry (GTK4 UI client)
The daemon keeps providers and plugins loaded in memory, so the UI appears instantly when launched. The UI client is a thin GTK4 layer that sends queries and receives results over the socket. The daemon keeps providers and plugins loaded in memory, so the UI appears instantly when launched. The UI client is a thin GTK4 layer that sends queries and receives results over the socket.
For detailed architecture information, see [CLAUDE.md](CLAUDE.md).
## License ## License
GNU General Public License v3.0 — see [LICENSE](LICENSE). GNU General Public License v3.0 — see [LICENSE](LICENSE).

14
aur/owlry-core/.SRCINFO Normal file
View File

@@ -0,0 +1,14 @@
pkgbase = owlry-core
pkgdesc = Core daemon for the Owlry application launcher — manages plugins, providers, and search
pkgver = 1.3.4
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = gcc-libs
depends = openssl
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/

41
aur/owlry-core/PKGBUILD Normal file
View File

@@ -0,0 +1,41 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-core
pkgver=1.3.4
pkgrel=1
pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search'
arch=('x86_64')
url='https://somegit.dev/Owlibou/owlry'
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=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build -p owlry-core --frozen --release
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p owlry-core --frozen --lib
}
package() {
cd "owlry"
install -Dm755 "target/release/owlryd" "$pkgdir/usr/bin/owlryd"
install -Dm644 "systemd/owlryd.service" "$pkgdir/usr/lib/systemd/user/owlryd.service"
install -Dm644 "systemd/owlryd.socket" "$pkgdir/usr/lib/systemd/user/owlryd.socket"
install -dm755 "$pkgdir/usr/lib/owlry/plugins"
install -dm755 "$pkgdir/usr/lib/owlry/runtimes"
}

14
aur/owlry-lua/.SRCINFO Normal file
View File

@@ -0,0 +1,14 @@
pkgbase = owlry-lua
pkgdesc = Lua scripting runtime for Owlry — enables user-created Lua plugins
pkgver = 1.1.3
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = owlry-core
depends = openssl
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/

40
aur/owlry-lua/PKGBUILD Normal file
View File

@@ -0,0 +1,40 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-lua
pkgver=1.1.3
pkgrel=1
pkgdesc="Lua scripting runtime for Owlry — enables user-created Lua plugins"
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlry"
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=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
_cratename=owlry-lua
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build -p $_cratename --frozen --release
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p $_cratename --frozen --lib
}
package() {
cd "owlry"
install -Dm755 "target/release/lib${_cratename//-/_}.so" \
"$pkgdir/usr/lib/owlry/runtimes/liblua.so"
}

Submodule aur/owlry-meta-essentials added at ed91b61709

1
aur/owlry-meta-full Submodule

Submodule aur/owlry-meta-full added at 2115aa08f8

1
aur/owlry-meta-tools Submodule

Submodule aur/owlry-meta-tools added at bc821ff47f

Submodule aur/owlry-meta-widgets added at 8ba6dd318c

14
aur/owlry-rune/.SRCINFO Normal file
View File

@@ -0,0 +1,14 @@
pkgbase = owlry-rune
pkgdesc = Rune scripting runtime for Owlry — enables user-created Rune plugins
pkgver = 1.1.4
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = owlry-core
depends = openssl
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/

40
aur/owlry-rune/PKGBUILD Normal file
View File

@@ -0,0 +1,40 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-rune
pkgver=1.1.4
pkgrel=1
pkgdesc="Rune scripting runtime for Owlry — enables user-created Rune plugins"
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlry"
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=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
_cratename=owlry-rune
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build -p $_cratename --frozen --release
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p $_cratename --frozen --release
}
package() {
cd "owlry"
install -Dm755 "target/release/lib${_cratename//-/_}.so" \
"$pkgdir/usr/lib/owlry/runtimes/librune.so"
}

34
aur/owlry/.SRCINFO Normal file
View File

@@ -0,0 +1,34 @@
pkgbase = owlry
pkgdesc = Lightweight Wayland application launcher with plugin support
pkgver = 1.0.8
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = owlry-core
depends = gcc-libs
depends = gtk4
depends = gtk4-layer-shell
optdepends = cliphist: clipboard provider support
optdepends = wl-clipboard: clipboard and emoji copy support
optdepends = fd: fast file search
optdepends = owlry-plugin-calculator: calculator provider
optdepends = owlry-plugin-clipboard: clipboard provider
optdepends = owlry-plugin-emoji: emoji picker
optdepends = owlry-plugin-bookmarks: browser bookmarks
optdepends = owlry-plugin-ssh: SSH host launcher
optdepends = owlry-plugin-scripts: custom scripts provider
optdepends = owlry-plugin-system: system actions (shutdown, reboot, etc.)
optdepends = owlry-plugin-websearch: web search provider
optdepends = owlry-plugin-filesearch: file search provider
optdepends = owlry-plugin-systemd: systemd service management
optdepends = owlry-plugin-weather: weather widget
optdepends = owlry-plugin-media: media player controls
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.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/

76
aur/owlry/PKGBUILD Normal file
View File

@@ -0,0 +1,76 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry
pkgver=1.0.8
pkgrel=1
pkgdesc="Lightweight Wayland application launcher with plugin support"
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlry"
license=('GPL-3.0-or-later')
depends=('owlry-core' 'gcc-libs' 'gtk4' 'gtk4-layer-shell')
makedepends=('cargo')
optdepends=(
'cliphist: clipboard provider support'
'wl-clipboard: clipboard and emoji copy support'
'fd: fast file search'
'owlry-plugin-calculator: calculator provider'
'owlry-plugin-clipboard: clipboard provider'
'owlry-plugin-emoji: emoji picker'
'owlry-plugin-bookmarks: browser bookmarks'
'owlry-plugin-ssh: SSH host launcher'
'owlry-plugin-scripts: custom scripts provider'
'owlry-plugin-system: system actions (shutdown, reboot, etc.)'
'owlry-plugin-websearch: web search provider'
'owlry-plugin-filesearch: file search provider'
'owlry-plugin-systemd: systemd service management'
'owlry-plugin-weather: weather widget'
'owlry-plugin-media: media player controls'
'owlry-plugin-pomodoro: pomodoro timer widget'
'owlry-lua: Lua runtime for user plugins'
'owlry-rune: Rune runtime for user plugins'
)
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz")
b2sums=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
# Build only the core binary without embedded Lua (Lua runtime is separate package)
cargo build -p owlry --frozen --release --no-default-features
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p owlry --frozen --no-default-features
}
package() {
cd "owlry"
# Core binary
install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
# Documentation
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
# Example configuration files
install -Dm644 data/config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml"
install -Dm644 data/style.example.css "$pkgdir/usr/share/doc/$pkgname/style.example.css"
install -Dm755 data/scripts/example.sh "$pkgdir/usr/share/doc/$pkgname/scripts/example.sh"
# Install themes
install -d "$pkgdir/usr/share/$pkgname/themes"
install -Dm644 data/themes/*.css "$pkgdir/usr/share/$pkgname/themes/"
# Example plugins (for user plugin development)
install -d "$pkgdir/usr/share/$pkgname/examples/plugins"
cp -r examples/plugins/* "$pkgdir/usr/share/$pkgname/examples/plugins/"
}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-core" name = "owlry-core"
version = "1.1.0" version = "1.3.4"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
@@ -12,7 +12,7 @@ name = "owlry_core"
path = "src/lib.rs" path = "src/lib.rs"
[[bin]] [[bin]]
name = "owlry-core" name = "owlryd"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
@@ -30,6 +30,7 @@ semver = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.8" toml = "0.8"
fs2 = "0.4"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
dirs = "5" dirs = "5"
@@ -41,22 +42,24 @@ notify = "7"
notify-debouncer-mini = "0.5" notify-debouncer-mini = "0.5"
# Signal handling # Signal handling
ctrlc = { version = "3", features = ["termination"] } signal-hook = "0.3"
# Logging & notifications # Logging & notifications
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
notify-rust = "4" notify-rust = "4"
# Built-in providers
meval = "0.2"
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] }
# Optional: embedded Lua runtime # Optional: embedded Lua runtime
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true } mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true }
meval = { version = "0.2", optional = true }
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
[features] [features]
default = [] default = []
lua = ["dep:mlua", "dep:meval", "dep:reqwest"] lua = ["dep:mlua"]
dev-logging = [] dev-logging = []

View File

@@ -1,8 +1,8 @@
use fs2::FileExt;
use log::{debug, info, warn}; use log::{debug, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command;
use crate::paths; use crate::paths;
@@ -33,6 +33,10 @@ pub struct Config {
pub plugins: PluginsConfig, pub plugins: PluginsConfig,
#[serde(default)] #[serde(default)]
pub profiles: HashMap<String, ProfileConfig>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -158,79 +162,26 @@ pub struct ProvidersConfig {
pub applications: bool, pub applications: bool,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub commands: bool, pub commands: bool,
#[serde(default = "default_true")] /// Enable built-in calculator provider (= or calc trigger)
pub uuctl: bool,
/// Enable calculator provider (= expression or calc expression)
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub calculator: bool, pub calculator: bool,
/// 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 /// Enable frecency-based result ranking
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub frecency: bool, pub frecency: bool,
/// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost) /// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost)
#[serde(default = "default_frecency_weight")] #[serde(default = "default_frecency_weight")]
pub frecency_weight: f64, pub frecency_weight: f64,
/// Enable web search provider (? query or web query) /// Search engine for web search (used by owlry-plugin-websearch)
#[serde(default = "default_true")]
pub websearch: bool,
/// Search engine for web search
/// Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia /// 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")] #[serde(default = "default_search_engine")]
pub search_engine: String, 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 { impl Default for ProvidersConfig {
@@ -238,27 +189,12 @@ impl Default for ProvidersConfig {
Self { Self {
applications: true, applications: true,
commands: true, commands: true,
uuctl: true,
calculator: true, calculator: true,
converter: true,
system: true,
frecency: true, frecency: true,
frecency_weight: 0.3, frecency_weight: 0.3,
websearch: true,
search_engine: "duckduckgo".to_string(), 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,
} }
} }
} }
@@ -284,10 +220,6 @@ pub struct PluginsConfig {
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub enabled: bool, 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 /// List of plugin IDs to explicitly disable
#[serde(default)] #[serde(default)]
pub disabled_plugins: Vec<String>, pub disabled_plugins: Vec<String>,
@@ -301,11 +233,6 @@ pub struct PluginsConfig {
#[serde(default)] #[serde(default)]
pub registry_url: Option<String>, 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 /// Sandbox settings for plugin security
@@ -332,43 +259,13 @@ impl Default for PluginsConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: true, enabled: true,
enabled_plugins: Vec::new(),
disabled_plugins: Vec::new(), disabled_plugins: Vec::new(),
sandbox: SandboxConfig::default(), sandbox: SandboxConfig::default(),
registry_url: None, 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 { impl Default for SandboxConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -396,18 +293,6 @@ fn default_frecency_weight() -> f64 {
0.3 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 /// Detect the best available terminal emulator
/// Fallback chain: /// Fallback chain:
/// 1. $TERMINAL env var (user's explicit preference) /// 1. $TERMINAL env var (user's explicit preference)
@@ -522,22 +407,65 @@ fn detect_de_terminal() -> Option<String> {
None None
} }
/// Check if a command exists in PATH /// Check if a command exists in PATH (in-process, no subprocess spawning)
fn command_exists(cmd: &str) -> bool { fn command_exists(cmd: &str) -> bool {
Command::new("which") std::env::var_os("PATH")
.arg(cmd) .map(|paths| {
.output() std::env::split_paths(&paths).any(|dir| {
.map(|o| o.status.success()) let full = dir.join(cmd);
full.is_file()
})
})
.unwrap_or(false) .unwrap_or(false)
} }
// Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default // 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 { impl Config {
pub fn config_path() -> Option<PathBuf> { pub fn config_path() -> Option<PathBuf> {
paths::config_file() 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 { pub fn load_or_default() -> Self {
Self::load().unwrap_or_else(|e| { Self::load().unwrap_or_else(|e| {
warn!("Failed to load config: {}, using defaults", e); warn!("Failed to load config: {}, using defaults", e);
@@ -553,8 +481,27 @@ impl Config {
Self::default() Self::default()
} else { } else {
let content = std::fs::read_to_string(&path)?; 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); 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 config
}; };
@@ -579,15 +526,61 @@ impl Config {
Ok(config) Ok(config)
} }
#[allow(dead_code)]
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> { pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?; let path = Self::config_path().ok_or("Could not determine config path")?;
paths::ensure_parent_dir(&path)?; paths::ensure_parent_dir(&path)?;
let content = toml::to_string_pretty(self)?; // Acquire an exclusive advisory lock via a sibling lock file.
std::fs::write(&path, content)?; // 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); info!("Saved config to {:?}", path);
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
#[test]
fn command_exists_finds_sh() {
// /bin/sh exists on every Unix system
assert!(super::command_exists("sh"));
}
#[test]
fn command_exists_rejects_nonexistent() {
assert!(!super::command_exists("owlry_nonexistent_binary_abc123"));
}
}

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 /// Frecency store for tracking and boosting recently/frequently used items
pub struct FrecencyStore { pub struct FrecencyStore {
data: FrecencyData, data: FrecencyData,
@@ -44,10 +48,49 @@ impl FrecencyStore {
info!("Frecency store loaded with {} entries", data.entries.len()); info!("Frecency store loaded with {} entries", data.entries.len());
Self { let mut store = Self {
data, data,
path, path,
dirty: false, 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;
} }
} }
@@ -115,11 +158,6 @@ impl FrecencyStore {
"Recorded launch for '{}': count={}, last={}", "Recorded launch for '{}': count={}, last={}",
item_id, entry.launch_count, entry.last_launch item_id, entry.launch_count, entry.last_launch
); );
// Auto-save after recording
if let Err(e) = self.save() {
warn!("Failed to save frecency data: {}", e);
}
} }
/// Calculate frecency score for an item /// Calculate frecency score for an item
@@ -131,23 +169,36 @@ impl FrecencyStore {
} }
} }
/// Calculate frecency score using a pre-sampled timestamp.
/// Use this in hot loops to avoid repeated Utc::now() syscalls.
pub fn get_score_at(&self, item_id: &str, now: DateTime<Utc>) -> f64 {
match self.data.entries.get(item_id) {
Some(entry) => Self::calculate_frecency_at(entry.launch_count, entry.last_launch, now),
None => 0.0,
}
}
/// Calculate frecency using Firefox-style algorithm /// Calculate frecency using Firefox-style algorithm
fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 { fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 {
let now = Utc::now(); let now = Utc::now();
Self::calculate_frecency_at(launch_count, last_launch, now)
}
/// Calculate frecency using a caller-provided timestamp.
fn calculate_frecency_at(launch_count: u32, last_launch: DateTime<Utc>, now: DateTime<Utc>) -> f64 {
let age = now.signed_duration_since(last_launch); let age = now.signed_duration_since(last_launch);
let age_days = age.num_hours() as f64 / 24.0; let age_days = age.num_hours() as f64 / 24.0;
// Recency weight based on how recently the item was used
let recency_weight = if age_days < 1.0 { let recency_weight = if age_days < 1.0 {
100.0 // Today 100.0
} else if age_days < 7.0 { } else if age_days < 7.0 {
70.0 // This week 70.0
} else if age_days < 30.0 { } else if age_days < 30.0 {
50.0 // This month 50.0
} else if age_days < 90.0 { } else if age_days < 90.0 {
30.0 // This quarter 30.0
} else { } else {
10.0 // Older 10.0
}; };
launch_count as f64 * recency_weight launch_count as f64 * recency_weight
@@ -206,6 +257,32 @@ mod tests {
assert!(score_month < score_week); assert!(score_month < score_week);
} }
#[test]
fn get_score_at_matches_get_score() {
let mut store = FrecencyStore {
data: FrecencyData {
version: 1,
entries: HashMap::new(),
},
path: PathBuf::from("/dev/null"),
dirty: false,
};
store.data.entries.insert(
"test".to_string(),
FrecencyEntry {
launch_count: 5,
last_launch: Utc::now(),
},
);
let now = Utc::now();
let score_at = store.get_score_at("test", now);
let score = store.get_score("test");
// Both should be very close (same timestamp, within rounding)
assert!((score_at - score).abs() < 1.0);
}
#[test] #[test]
fn test_launch_count_matters() { fn test_launch_count_matters() {
let now = Utc::now(); let now = Utc::now();
@@ -216,4 +293,18 @@ mod tests {
assert!(score_many > score_few); assert!(score_many > score_few);
assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x
} }
#[test]
fn record_launch_sets_dirty_without_saving() {
let mut store = FrecencyStore {
data: FrecencyData::default(),
path: PathBuf::from("/dev/null"),
dirty: false,
};
store.record_launch("test-item");
assert!(store.dirty, "record_launch should set dirty flag");
assert_eq!(store.data.entries["test-item"].launch_count, 1);
}
} }

View File

@@ -26,11 +26,17 @@ pub struct ParsedQuery {
} }
impl ProviderFilter { 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( pub fn new(
cli_mode: Option<ProviderType>, cli_mode: Option<ProviderType>,
cli_providers: Option<Vec<ProviderType>>, cli_providers: Option<Vec<ProviderType>>,
config_providers: &ProvidersConfig, config_providers: &ProvidersConfig,
tabs: &[String],
) -> Self { ) -> Self {
let accept_all = cli_mode.is_none() && cli_providers.is_none(); let accept_all = cli_mode.is_none() && cli_providers.is_none();
@@ -41,50 +47,23 @@ impl ProviderFilter {
// --providers overrides config // --providers overrides config
providers.into_iter().collect() providers.into_iter().collect()
} else { } else {
// Use config file settings, default to apps only // No CLI restriction: accept_all=true, so is_active() returns true for
let mut set = HashSet::new(); // everything. Build the enabled set only for UI tab display, driven by
// Core providers // general.tabs. Falls back to Application + Command if tabs is empty.
if config_providers.applications { let mut set: HashSet<ProviderType> = tabs
set.insert(ProviderType::Application); .iter()
} .map(|s| Self::mode_string_to_provider_type(s))
if config_providers.commands { .collect();
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
if set.is_empty() { 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 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) { pub fn toggle(&mut self, provider: ProviderType) {
if self.enabled.contains(&provider) { if self.enabled.contains(&provider) {
self.enabled.remove(&provider); self.enabled.remove(&provider);
@@ -137,6 +117,7 @@ impl ProviderFilter {
provider_debug, self.enabled provider_debug, self.enabled
); );
} }
self.accept_all = false;
} }
/// Enable a specific provider /// Enable a specific provider
@@ -156,6 +137,12 @@ impl ProviderFilter {
pub fn set_single_mode(&mut self, provider: ProviderType) { pub fn set_single_mode(&mut self, provider: ProviderType) {
self.enabled.clear(); self.enabled.clear();
self.enabled.insert(provider); 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.) /// Set prefix mode (from :app, :cmd, etc.)
@@ -229,43 +216,48 @@ impl ProviderFilter {
} }
} }
// Core provider prefixes // Core prefixes — each entry is tried as ":name " (full) and ":name" (partial)
let core_prefixes: &[(&str, ProviderType)] = &[ let core_prefixes: &[(&str, ProviderType)] = &[
(":app ", ProviderType::Application), ("app", ProviderType::Application),
(":apps ", ProviderType::Application), ("apps", ProviderType::Application),
(":cmd ", ProviderType::Command), ("cmd", ProviderType::Command),
(":command ", ProviderType::Command), ("command", ProviderType::Command),
]; ];
// Plugin provider prefixes - mapped to Plugin(type_id) // Plugin prefixes — each entry maps to a plugin type_id
let plugin_prefixes: &[(&str, &str)] = &[ let plugin_prefixes: &[(&str, &str)] = &[
(":bm ", "bookmarks"), ("bm", "bookmarks"),
(":bookmark ", "bookmarks"), ("bookmark", "bookmarks"),
(":bookmarks ", "bookmarks"), ("bookmarks", "bookmarks"),
(":calc ", "calc"), ("calc", "calc"),
(":calculator ", "calc"), ("calculator", "calc"),
(":clip ", "clipboard"), ("clip", "clipboard"),
(":clipboard ", "clipboard"), ("clipboard", "clipboard"),
(":emoji ", "emoji"), ("emoji", "emoji"),
(":emojis ", "emoji"), ("emojis", "emoji"),
(":file ", "filesearch"), ("file", "filesearch"),
(":files ", "filesearch"), ("files", "filesearch"),
(":find ", "filesearch"), ("find", "filesearch"),
(":script ", "scripts"), ("script", "scripts"),
(":scripts ", "scripts"), ("scripts", "scripts"),
(":ssh ", "ssh"), ("ssh", "ssh"),
(":sys ", "system"), ("sys", "system"),
(":system ", "system"), ("system", "system"),
(":power ", "system"), ("power", "system"),
(":uuctl ", "uuctl"), ("uuctl", "uuctl"),
(":systemd ", "uuctl"), ("systemd", "uuctl"),
(":web ", "websearch"), ("web", "websearch"),
(":search ", "websearch"), ("search", "websearch"),
("config", "config"),
("settings", "config"),
("conv", "conv"),
("converter", "conv"),
]; ];
// Check core prefixes // Single-pass: try each core prefix as both full (":name query") and partial (":name")
for (prefix_str, provider) in core_prefixes { for (name, provider) in core_prefixes {
if let Some(rest) = trimmed.strip_prefix(prefix_str) { let with_space = format!(":{} ", name);
if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
#[cfg(feature = "dev-logging")] #[cfg(feature = "dev-logging")]
debug!( debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", "[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
@@ -277,60 +269,8 @@ impl ProviderFilter {
query: rest.to_string(), query: rest.to_string(),
}; };
} }
} let exact = format!(":{}", name);
if trimmed == exact {
// Check plugin prefixes
for (prefix_str, type_id) in plugin_prefixes {
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
query, provider, rest
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: rest.to_string(),
};
}
}
// Handle partial prefixes (still typing)
let partial_core: &[(&str, ProviderType)] = &[
(":app", ProviderType::Application),
(":apps", ProviderType::Application),
(":cmd", ProviderType::Command),
(":command", ProviderType::Command),
];
let partial_plugin: &[(&str, &str)] = &[
(":bm", "bookmarks"),
(":bookmark", "bookmarks"),
(":bookmarks", "bookmarks"),
(":calc", "calc"),
(":calculator", "calc"),
(":clip", "clipboard"),
(":clipboard", "clipboard"),
(":emoji", "emoji"),
(":emojis", "emoji"),
(":file", "filesearch"),
(":files", "filesearch"),
(":find", "filesearch"),
(":script", "scripts"),
(":scripts", "scripts"),
(":ssh", "ssh"),
(":sys", "system"),
(":system", "system"),
(":power", "system"),
(":uuctl", "uuctl"),
(":systemd", "uuctl"),
(":web", "websearch"),
(":search", "websearch"),
];
for (prefix_str, provider) in partial_core {
if trimmed == *prefix_str {
#[cfg(feature = "dev-logging")] #[cfg(feature = "dev-logging")]
debug!( debug!(
"[Filter] parse_query({:?}) -> partial prefix {:?}", "[Filter] parse_query({:?}) -> partial prefix {:?}",
@@ -344,8 +284,24 @@ impl ProviderFilter {
} }
} }
for (prefix_str, type_id) in partial_plugin { // Single-pass: try each plugin prefix as both full and partial
if trimmed == *prefix_str { for (name, type_id) in plugin_prefixes {
let with_space = format!(":{} ", name);
if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
query, provider, rest
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: rest.to_string(),
};
}
let exact = format!(":{}", name);
if trimmed == exact {
let provider = ProviderType::Plugin(type_id.to_string()); let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")] #[cfg(feature = "dev-logging")]
debug!( debug!(

View File

@@ -24,6 +24,8 @@ pub enum Request {
PluginAction { PluginAction {
command: String, command: String,
}, },
/// Query the daemon's plugin registry (native plugins + suppressed entries).
PluginList,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -32,10 +34,30 @@ pub enum Response {
Results { items: Vec<ResultItem> }, Results { items: Vec<ResultItem> },
Providers { list: Vec<ProviderDesc> }, Providers { list: Vec<ProviderDesc> },
SubmenuItems { items: Vec<ResultItem> }, SubmenuItems { items: Vec<ResultItem> },
PluginList { entries: Vec<PluginEntry> },
Ack, Ack,
Error { message: String }, 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResultItem { pub struct ResultItem {
pub id: String, pub id: String,
@@ -50,6 +72,14 @@ pub struct ResultItem {
pub terminal: bool, pub terminal: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>, 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)] #[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::paths;
use owlry_core::server::Server; use owlry_core::server::Server;
@@ -7,7 +7,7 @@ fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let sock = paths::socket_path(); let sock = paths::socket_path();
info!("Starting owlry-core daemon..."); info!("Starting owlryd daemon...");
// Ensure the socket parent directory exists // Ensure the socket parent directory exists
if let Err(e) = paths::ensure_parent_dir(&sock) { if let Err(e) = paths::ensure_parent_dir(&sock) {
@@ -18,19 +18,13 @@ fn main() {
let server = match Server::bind(&sock) { let server = match Server::bind(&sock) {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
eprintln!("Failed to start owlry-core: {e}"); eprintln!("Failed to start owlryd: {e}");
std::process::exit(1); std::process::exit(1);
} }
}; };
// Graceful shutdown on SIGTERM/SIGINT // SIGTERM/SIGINT are handled inside Server::run() via signal-hook,
let sock_cleanup = sock.clone(); // which saves frecency before exiting.
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);
}
if let Err(e) = server.run() { if let Err(e) = server.run() {
eprintln!("Server error: {e}"); eprintln!("Server error: {e}");

View File

@@ -10,6 +10,10 @@ use super::error::{PluginError, PluginResult};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest { pub struct PluginManifest {
pub plugin: PluginInfo, pub plugin: PluginInfo,
/// Provider declarations from [[providers]] sections (new-style)
#[serde(default)]
pub providers: Vec<ProviderSpec>,
/// Legacy provides block (old-style)
#[serde(default)] #[serde(default)]
pub provides: PluginProvides, pub provides: PluginProvides,
#[serde(default)] #[serde(default)]
@@ -43,7 +47,7 @@ pub struct PluginInfo {
#[serde(default = "default_owlry_version")] #[serde(default = "default_owlry_version")]
pub owlry_version: String, pub owlry_version: String,
/// Entry point file (relative to plugin directory) /// Entry point file (relative to plugin directory)
#[serde(default = "default_entry")] #[serde(default = "default_entry", alias = "entry_point")]
pub entry: String, pub entry: String,
} }
@@ -52,7 +56,27 @@ fn default_owlry_version() -> String {
} }
fn default_entry() -> 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 /// What the plugin provides
@@ -278,7 +302,7 @@ version = "1.0.0"
assert_eq!(manifest.plugin.id, "test-plugin"); assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.name, "Test Plugin"); assert_eq!(manifest.plugin.name, "Test Plugin");
assert_eq!(manifest.plugin.version, "1.0.0"); assert_eq!(manifest.plugin.version, "1.0.0");
assert_eq!(manifest.plugin.entry, "init.lua"); assert_eq!(manifest.plugin.entry, "main.lua");
} }
#[test] #[test]
@@ -317,6 +341,70 @@ api_url = "https://api.example.com"
assert_eq!(manifest.permissions.run_commands, vec!["myapp"]); 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] #[test]
fn test_version_compatibility() { fn test_version_compatibility() {
let toml_str = r#" let toml_str = r#"

View File

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

View File

@@ -12,17 +12,70 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Once}; use std::sync::{Arc, Once, OnceLock, RwLock};
use libloading::Library; use libloading::Library;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use owlry_plugin_api::{ use owlry_plugin_api::{
API_VERSION, HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo, API_VERSION, HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo,
ProviderKind, RStr, ProviderKind, ROption, RStr, RString,
}; };
use crate::config::Config;
use crate::notify; 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 // Host API Implementation
// ============================================================================ // ============================================================================
@@ -71,6 +124,9 @@ static HOST_API: HostAPI = HostAPI {
log_info: host_log_info, log_info: host_log_info,
log_warn: host_log_warn, log_warn: host_log_warn,
log_error: host_log_error, 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) /// 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 //! Note: This module is infrastructure for the runtime architecture. Full integration
//! is pending Phase 5 (AUR Packaging) when runtime packages will be available. //! is pending Phase 5 (AUR Packaging) when runtime packages will be available.
use std::mem::ManuallyDrop;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use libloading::{Library, Symbol}; use libloading::{Library, Symbol};
use owlry_plugin_api::{PluginItem, RStr, RString, RVec}; use owlry_plugin_api::{PluginItem, RStr, RString, RVec};
use super::error::{PluginError, PluginResult}; use super::error::{PluginError, PluginResult};
use crate::providers::{LaunchItem, Provider, ProviderType}; use crate::providers::{ItemSource, LaunchItem, Provider, ProviderType};
/// System directory for runtime libraries /// System directory for runtime libraries
pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes"; pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes";
@@ -50,6 +51,11 @@ pub type LuaProviderInfo = ScriptProviderInfo;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct RuntimeHandle(pub *mut ()); 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) /// VTable for script runtime functions (used by both Lua and Rune)
#[repr(C)] #[repr(C)]
pub struct ScriptRuntimeVTable { pub struct ScriptRuntimeVTable {
@@ -69,12 +75,17 @@ pub struct ScriptRuntimeVTable {
pub struct LoadedRuntime { pub struct LoadedRuntime {
/// Runtime name (for logging) /// Runtime name (for logging)
name: &'static str, name: &'static str,
/// Keep library alive /// Keep library alive — wrapped in ManuallyDrop so we never call dlclose().
_library: Arc<Library>, /// 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 /// Runtime vtable
vtable: &'static ScriptRuntimeVTable, vtable: &'static ScriptRuntimeVTable,
/// Runtime handle (state) /// Runtime handle shared with all RuntimeProvider instances for this runtime.
handle: RuntimeHandle, /// 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 /// Provider information
providers: Vec<ScriptProviderInfo>, providers: Vec<ScriptProviderInfo>,
} }
@@ -124,10 +135,14 @@ impl LoadedRuntime {
// Initialize the runtime // Initialize the runtime
let plugins_dir_str = plugins_dir.to_string_lossy(); 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 // Get provider information — lock to serialize the vtable call
let providers_rvec = (vtable.providers)(handle); let providers_rvec = {
let h = handle.lock().unwrap();
(vtable.providers)(*h)
};
let providers: Vec<ScriptProviderInfo> = providers_rvec.into_iter().collect(); let providers: Vec<ScriptProviderInfo> = providers_rvec.into_iter().collect();
log::info!( log::info!(
@@ -138,7 +153,7 @@ impl LoadedRuntime {
Ok(Self { Ok(Self {
name, name,
_library: library, _library: ManuallyDrop::new(library),
vtable, vtable,
handle, handle,
providers, providers,
@@ -155,8 +170,12 @@ impl LoadedRuntime {
self.providers self.providers
.iter() .iter()
.map(|info| { .map(|info| {
let provider = let provider = RuntimeProvider::new(
RuntimeProvider::new(self.name, self.vtable, self.handle, info.clone()); self.name,
self.vtable,
Arc::clone(&self.handle),
info.clone(),
);
Box::new(provider) as Box<dyn Provider> Box::new(provider) as Box<dyn Provider>
}) })
.collect() .collect()
@@ -165,17 +184,16 @@ impl LoadedRuntime {
impl Drop for LoadedRuntime { impl Drop for LoadedRuntime {
fn drop(&mut self) { 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 is Send + Sync because:
// LoadedRuntime needs to be Send + Sync because ProviderManager is shared across // - Arc<Mutex<RuntimeHandle>> is Send + Sync (RuntimeHandle: Send via unsafe impl above)
// threads via Arc<RwLock<ProviderManager>>. // - All other fields are 'static references or Send types
// Safety: RuntimeHandle is an opaque FFI handle accessed only through extern "C" // No unsafe impl needed — this is derived automatically.
// vtable functions. The same safety argument that applies to RuntimeProvider applies
// here — all access is mediated by the vtable, and the runtime itself serializes access.
unsafe impl Send for LoadedRuntime {}
unsafe impl Sync for LoadedRuntime {}
/// A provider backed by a dynamically loaded runtime /// A provider backed by a dynamically loaded runtime
pub struct RuntimeProvider { pub struct RuntimeProvider {
@@ -183,7 +201,9 @@ pub struct RuntimeProvider {
#[allow(dead_code)] #[allow(dead_code)]
runtime_name: &'static str, runtime_name: &'static str,
vtable: &'static ScriptRuntimeVTable, 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, info: ScriptProviderInfo,
items: Vec<LaunchItem>, items: Vec<LaunchItem>,
} }
@@ -192,7 +212,7 @@ impl RuntimeProvider {
fn new( fn new(
runtime_name: &'static str, runtime_name: &'static str,
vtable: &'static ScriptRuntimeVTable, vtable: &'static ScriptRuntimeVTable,
handle: RuntimeHandle, handle: Arc<Mutex<RuntimeHandle>>,
info: ScriptProviderInfo, info: ScriptProviderInfo,
) -> Self { ) -> Self {
Self { Self {
@@ -214,6 +234,7 @@ impl RuntimeProvider {
command: item.command.to_string(), command: item.command.to_string(),
terminal: item.terminal, terminal: item.terminal,
tags: item.keywords.iter().map(|s| s.to_string()).collect(), 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 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 self.items = items_rvec
.into_iter() .into_iter()
.map(|i| self.convert_item(i)) .map(|i| self.convert_item(i))
@@ -251,12 +275,10 @@ impl Provider for RuntimeProvider {
} }
} }
// RuntimeProvider needs to be Send + Sync for the Provider trait. // RuntimeProvider is Send + Sync because:
// Safety: RuntimeHandle is an opaque FFI handle accessed only through // - Arc<Mutex<RuntimeHandle>> is Send + Sync (RuntimeHandle: Send via unsafe impl above)
// extern "C" vtable functions. The same safety argument that justifies // - vtable is &'static (Send + Sync), info and items are Send
// Send applies to Sync — all access is mediated by the vtable. // No unsafe impl needed — this is derived automatically.
unsafe impl Send for RuntimeProvider {}
unsafe impl Sync for RuntimeProvider {}
/// Check if the Lua runtime is available /// Check if the Lua runtime is available
pub fn lua_runtime_available() -> bool { pub fn lua_runtime_available() -> bool {

View File

@@ -89,8 +89,13 @@ fn watch_loop(
if has_relevant_change { if has_relevant_change {
info!("Plugin file change detected, reloading runtimes..."); info!("Plugin file change detected, reloading runtimes...");
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner()); match pm.write() {
pm_guard.reload_runtimes(); 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)) => { 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 crate::paths;
use freedesktop_desktop_entry::{DesktopEntry, Iter}; use freedesktop_desktop_entry::{DesktopEntry, Iter};
use log::{debug, warn}; use log::{debug, warn};
@@ -58,9 +60,19 @@ fn clean_desktop_exec_field(cmd: &str) -> String {
} }
// Clean up any double spaces that may have resulted from removing field codes // Clean up any double spaces that may have resulted from removing field codes
let mut cleaned = result.trim().to_string(); let trimmed = result.trim();
while cleaned.contains(" ") { let mut cleaned = String::with_capacity(trimmed.len());
cleaned = cleaned.replace(" ", " "); let mut prev_space = false;
for c in trimmed.chars() {
if c == ' ' {
if !prev_space {
cleaned.push(' ');
}
prev_space = true;
} else {
cleaned.push(c);
prev_space = false;
}
} }
cleaned cleaned
@@ -108,7 +120,21 @@ impl Provider for ApplicationProvider {
.map(|s| s.to_string()) .map(|s| s.to_string())
.collect(); .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()) { 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) { let content = match std::fs::read_to_string(&path) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@@ -186,6 +212,7 @@ impl Provider for ApplicationProvider {
command: run_cmd, command: run_cmd,
terminal: desktop_entry.terminal(), terminal: desktop_entry.terminal(),
tags, tags,
source: ItemSource::Core,
}; };
self.items.push(item); self.items.push(item);
@@ -271,4 +298,11 @@ mod tests {
"env FOO=bar BAZ=qux myapp" "env FOO=bar BAZ=qux myapp"
); );
} }
#[test]
fn test_clean_desktop_exec_collapses_spaces() {
assert_eq!(clean_desktop_exec_field("app --flag arg"), "app --flag arg");
let input = format!("app{}arg", " ".repeat(100));
assert_eq!(clean_desktop_exec_field(&input), "app arg");
}
} }

View File

@@ -0,0 +1,238 @@
use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType};
/// Built-in calculator provider. Evaluates mathematical expressions via `meval`.
///
/// Triggered by:
/// - `= expr` / `=expr` / `calc expr` (explicit prefix)
/// - Raw math expressions containing operators or known functions (auto-detect)
pub(crate) struct CalculatorProvider;
impl DynamicProvider for CalculatorProvider {
fn name(&self) -> &str {
"Calculator"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin("calc".into())
}
fn priority(&self) -> u32 {
10_000
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
let expr = match extract_expression(query) {
Some(e) if !e.is_empty() => e,
_ => return Vec::new(),
};
match meval::eval_str(expr) {
Ok(result) => {
let display = format_result(result);
let copy_cmd = format!(
"printf '%s' '{}' | wl-copy",
display.replace('\'', "'\\''")
);
vec![LaunchItem {
id: format!("calc:{}", expr),
name: display.clone(),
description: Some(format!("= {}", expr)),
icon: Some("accessories-calculator".into()),
provider: ProviderType::Plugin("calc".into()),
command: copy_cmd,
terminal: false,
tags: vec!["math".into(), "calculator".into()],
source: ItemSource::Core,
}]
}
Err(_) => Vec::new(),
}
}
}
/// Extract the math expression from a query string.
///
/// Handles:
/// - `= expr` and `=expr` (explicit calculator prefix)
/// - `calc expr` (word prefix)
/// - Raw expressions if they look like math (auto-detect)
///
/// Returns `None` only when input is empty after trimming.
fn extract_expression(query: &str) -> Option<&str> {
let trimmed = query.trim();
if trimmed.is_empty() {
return None;
}
// Explicit prefixes
if let Some(rest) = trimmed.strip_prefix("= ") {
return Some(rest.trim());
}
if let Some(rest) = trimmed.strip_prefix('=') {
return Some(rest.trim());
}
if let Some(rest) = trimmed.strip_prefix("calc ") {
return Some(rest.trim());
}
// Auto-detect: only forward if the expression looks like math.
// Plain words like "firefox" should not reach meval.
if looks_like_math(trimmed) {
Some(trimmed)
} else {
None
}
}
/// Heuristic: does this string look like a math expression?
///
/// Returns true when the string contains binary operators, digits mixed with
/// operators, or known function names. Plain alphabetic words return false.
fn looks_like_math(s: &str) -> bool {
// Must contain at least one digit or a known constant/function name
let has_digit = s.chars().any(|c| c.is_ascii_digit());
let has_operator = s.contains('+')
|| s.contains('*')
|| s.contains('/')
|| s.contains('^')
|| s.contains('%');
// Subtraction/negation is ambiguous; only count it as an operator when
// there are already digits present to avoid matching bare words with hyphens.
let has_minus_operator = has_digit && s.contains('-');
// Known math functions that are safe to auto-evaluate
const MATH_FUNCTIONS: &[&str] = &[
"sqrt", "sin", "cos", "tan", "log", "ln", "abs", "floor", "ceil", "round",
];
let has_function = MATH_FUNCTIONS.iter().any(|f| s.contains(f));
has_digit && (has_operator || has_minus_operator) || has_function
}
/// Format a floating-point result for display.
///
/// Integer-valued results are shown as integers with thousands separators.
/// Non-integer results are shown with up to 10 decimal places, trailing zeros trimmed.
fn format_result(result: f64) -> String {
if result.fract() == 0.0 && result.abs() < 1e15 {
format_integer_with_separators(result as i64)
} else {
let formatted = format!("{:.10}", result);
formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
}
fn format_integer_with_separators(n: i64) -> String {
let s = n.unsigned_abs().to_string();
let with_commas = s
.as_bytes()
.rchunks(3)
.rev()
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.collect::<Vec<_>>()
.join(",");
if n < 0 {
format!("-{}", with_commas)
} else {
with_commas
}
}
#[cfg(test)]
mod tests {
use super::*;
fn query(q: &str) -> Vec<LaunchItem> {
CalculatorProvider.query(q)
}
// --- Trigger prefix tests ---
#[test]
fn equals_prefix_addition() {
let results = query("= 5+3");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "8");
}
#[test]
fn calc_prefix_multiplication() {
let results = query("calc 10*2");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "20");
}
// --- Auto-detect tests ---
#[test]
fn auto_detect_addition() {
let results = query("5+3");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "8");
}
#[test]
fn equals_prefix_complex_expression() {
let results = query("= sqrt(16) + 2^3");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "12");
}
#[test]
fn decimal_result() {
let results = query("= 10/3");
assert_eq!(results.len(), 1);
assert!(
results[0].name.starts_with("3.333"),
"expected result starting with 3.333, got: {}",
results[0].name
);
}
#[test]
fn large_integer_thousands_separators() {
let results = query("= 1000000");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "1,000,000");
}
// --- Invalid / non-math input ---
#[test]
fn invalid_expression_returns_empty() {
let results = query("= 5 +");
assert!(results.is_empty());
}
#[test]
fn plain_text_returns_empty() {
let results = query("firefox");
assert!(results.is_empty());
}
// --- Metadata tests ---
#[test]
fn provider_type_is_calc_plugin() {
assert_eq!(
CalculatorProvider.provider_type(),
ProviderType::Plugin("calc".into())
);
}
#[test]
fn description_shows_expression() {
let results = query("= 5+3");
assert_eq!(results[0].description.as_deref(), Some("= 5+3"));
}
#[test]
fn copy_command_contains_wl_copy() {
let results = query("= 5+3");
assert!(results[0].command.contains("wl-copy"));
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
const ECB_URL: &str = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
const CACHE_MAX_AGE_SECS: u64 = 86400; // 24 hours
static CACHED_RATES: Mutex<Option<CurrencyRates>> = Mutex::new(None);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrencyRates {
pub date: String,
pub rates: HashMap<String, f64>,
}
struct CurrencyAlias {
code: &'static str,
aliases: &'static [&'static str],
}
static CURRENCY_ALIASES: &[CurrencyAlias] = &[
CurrencyAlias {
code: "EUR",
aliases: &["eur", "euro", "euros", ""],
},
CurrencyAlias {
code: "USD",
aliases: &["usd", "dollar", "dollars", "$", "us_dollar"],
},
CurrencyAlias {
code: "GBP",
aliases: &["gbp", "pound_sterling", "£", "british_pound", "pounds"],
},
CurrencyAlias {
code: "JPY",
aliases: &["jpy", "yen", "¥", "japanese_yen"],
},
CurrencyAlias {
code: "CHF",
aliases: &["chf", "swiss_franc", "francs"],
},
CurrencyAlias {
code: "CAD",
aliases: &["cad", "canadian_dollar", "c$"],
},
CurrencyAlias {
code: "AUD",
aliases: &["aud", "australian_dollar", "a$"],
},
CurrencyAlias {
code: "CNY",
aliases: &["cny", "yuan", "renminbi", "rmb"],
},
CurrencyAlias {
code: "SEK",
aliases: &["sek", "swedish_krona", "kronor"],
},
CurrencyAlias {
code: "NOK",
aliases: &["nok", "norwegian_krone"],
},
CurrencyAlias {
code: "DKK",
aliases: &["dkk", "danish_krone"],
},
CurrencyAlias {
code: "PLN",
aliases: &["pln", "zloty", "złoty"],
},
CurrencyAlias {
code: "CZK",
aliases: &["czk", "czech_koruna"],
},
CurrencyAlias {
code: "HUF",
aliases: &["huf", "forint"],
},
CurrencyAlias {
code: "TRY",
aliases: &["try", "turkish_lira", "lira"],
},
];
pub fn resolve_currency_code(alias: &str) -> Option<&'static str> {
let lower = alias.to_lowercase();
// Check aliases
for ca in CURRENCY_ALIASES {
if ca.aliases.contains(&lower.as_str()) {
return Some(ca.code); // ca.code is already &'static str
}
}
// Check if it's a raw 3-letter ISO code we know about
let upper = alias.to_uppercase();
if upper.len() == 3 {
if upper == "EUR" {
return Some("EUR");
}
if let Some(rates) = get_rates()
&& rates.rates.contains_key(&upper)
{
for ca in CURRENCY_ALIASES {
if ca.code == upper {
return Some(ca.code);
}
}
}
}
None
}
#[allow(dead_code)]
pub fn is_currency_alias(alias: &str) -> bool {
resolve_currency_code(alias).is_some()
}
pub fn get_rates() -> Option<CurrencyRates> {
// Check memory cache first
{
let cache = CACHED_RATES.lock().ok()?;
if let Some(ref rates) = *cache {
return Some(rates.clone());
}
}
// Try disk cache
if let Some(rates) = load_cache()
&& !is_stale(&rates)
{
let mut cache = CACHED_RATES.lock().ok()?;
*cache = Some(rates.clone());
return Some(rates);
}
// Fetch fresh rates
if let Some(rates) = fetch_rates() {
save_cache(&rates);
let mut cache = CACHED_RATES.lock().ok()?;
*cache = Some(rates.clone());
return Some(rates);
}
// Fall back to stale cache
load_cache()
}
fn cache_path() -> Option<PathBuf> {
let cache_dir = dirs::cache_dir()?.join("owlry");
Some(cache_dir.join("ecb_rates.json"))
}
fn load_cache() -> Option<CurrencyRates> {
let path = cache_path()?;
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn save_cache(rates: &CurrencyRates) {
if let Some(path) = cache_path() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ok();
}
if let Ok(json) = serde_json::to_string_pretty(rates) {
fs::write(path, json).ok();
}
}
}
fn is_stale(_rates: &CurrencyRates) -> bool {
let path = match cache_path() {
Some(p) => p,
None => return true,
};
let metadata = match fs::metadata(path) {
Ok(m) => m,
Err(_) => return true,
};
let modified = match metadata.modified() {
Ok(t) => t,
Err(_) => return true,
};
match SystemTime::now().duration_since(modified) {
Ok(age) => age.as_secs() > CACHE_MAX_AGE_SECS,
Err(_) => true,
}
}
fn fetch_rates() -> Option<CurrencyRates> {
let response = reqwest::blocking::get(ECB_URL).ok()?;
let body = response.text().ok()?;
parse_ecb_xml(&body)
}
fn parse_ecb_xml(xml: &str) -> Option<CurrencyRates> {
let mut rates = HashMap::new();
let mut date = String::new();
for line in xml.lines() {
let trimmed = line.trim();
// Extract date: <Cube time='2026-03-26'>
if trimmed.contains("time=")
&& let Some(start) = trimmed.find("time='")
{
let rest = &trimmed[start + 6..];
if let Some(end) = rest.find('\'') {
date = rest[..end].to_string();
}
}
// Extract rate: <Cube currency='USD' rate='1.0832'/>
if trimmed.contains("currency=") && trimmed.contains("rate=") {
let currency = extract_attr(trimmed, "currency")?;
let rate_str = extract_attr(trimmed, "rate")?;
let rate: f64 = rate_str.parse().ok()?;
rates.insert(currency, rate);
}
}
if rates.is_empty() {
return None;
}
Some(CurrencyRates { date, rates })
}
fn extract_attr(line: &str, attr: &str) -> Option<String> {
let needle = format!("{}='", attr);
let start = line.find(&needle)? + needle.len();
let rest = &line[start..];
let end = rest.find('\'')?;
Some(rest[..end].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_currency_code_iso() {
assert_eq!(resolve_currency_code("usd"), Some("USD"));
assert_eq!(resolve_currency_code("EUR"), Some("EUR"));
}
#[test]
fn test_resolve_currency_code_name() {
assert_eq!(resolve_currency_code("dollar"), Some("USD"));
assert_eq!(resolve_currency_code("euro"), Some("EUR"));
assert_eq!(resolve_currency_code("pounds"), Some("GBP"));
}
#[test]
fn test_resolve_currency_code_symbol() {
assert_eq!(resolve_currency_code("$"), Some("USD"));
assert_eq!(resolve_currency_code(""), Some("EUR"));
assert_eq!(resolve_currency_code("£"), Some("GBP"));
}
#[test]
fn test_resolve_currency_unknown() {
assert_eq!(resolve_currency_code("xyz"), None);
}
#[test]
fn test_is_currency_alias() {
assert!(is_currency_alias("usd"));
assert!(is_currency_alias("euro"));
assert!(is_currency_alias("$"));
assert!(!is_currency_alias("km"));
}
#[test]
fn test_parse_ecb_xml() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01" xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
<gesmes:subject>Reference rates</gesmes:subject>
<Cube>
<Cube time='2026-03-26'>
<Cube currency='USD' rate='1.0832'/>
<Cube currency='JPY' rate='161.94'/>
<Cube currency='GBP' rate='0.83450'/>
</Cube>
</Cube>
</gesmes:Envelope>"#;
let rates = parse_ecb_xml(xml).unwrap();
assert!((rates.rates["USD"] - 1.0832).abs() < 0.001);
assert!((rates.rates["GBP"] - 0.8345).abs() < 0.001);
assert!((rates.rates["JPY"] - 161.94).abs() < 0.01);
}
#[test]
fn test_cache_roundtrip() {
let rates = CurrencyRates {
date: "2026-03-26".to_string(),
rates: {
let mut m = HashMap::new();
m.insert("USD".to_string(), 1.0832);
m.insert("GBP".to_string(), 0.8345);
m
},
};
let json = serde_json::to_string(&rates).unwrap();
let parsed: CurrencyRates = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.rates["USD"], 1.0832);
}
}

View File

@@ -0,0 +1,184 @@
mod currency;
mod parser;
mod units;
use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType};
const PROVIDER_TYPE_ID: &str = "conv";
const PROVIDER_ICON: &str = "edit-find-replace-symbolic";
pub struct ConverterProvider;
impl ConverterProvider {
pub fn new() -> Self {
Self
}
}
impl DynamicProvider for ConverterProvider {
fn name(&self) -> &str {
"Converter"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(PROVIDER_TYPE_ID.into())
}
fn priority(&self) -> u32 {
9_000
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
let query_str = query.trim();
// Strip prefix
let input = if let Some(rest) = query_str.strip_prefix('>') {
rest.trim()
} else {
query_str
};
let parsed = match parser::parse_conversion(input) {
Some(p) => p,
None => return Vec::new(),
};
let results = if let Some(ref target) = parsed.target_unit {
units::convert_to(&parsed.value, &parsed.from_unit, target)
.into_iter()
.collect()
} else {
units::convert_common(&parsed.value, &parsed.from_unit)
};
results
.into_iter()
.map(|r| LaunchItem {
id: format!("conv:{}:{}:{}", parsed.from_unit, r.target_symbol, r.value),
name: r.display_value.clone(),
description: Some(format!(
"{} {} = {}",
format_number(parsed.value),
parsed.from_symbol,
r.display_value,
)),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!(
"printf '%s' '{}' | wl-copy",
r.raw_value.replace('\'', "'\\''")
),
terminal: false,
tags: vec!["converter".into(), "units".into()],
source: ItemSource::Core,
})
.collect()
}
}
fn format_number(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 1e15 {
let i = n as i64;
if i.abs() >= 1000 {
format_with_separators(i)
} else {
format!("{}", i)
}
} else {
format!("{:.4}", n)
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
}
pub(crate) fn format_with_separators(n: i64) -> String {
let s = n.abs().to_string();
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(c);
}
if n < 0 {
result.push('-');
}
result.chars().rev().collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn query(input: &str) -> Vec<LaunchItem> {
ConverterProvider::new().query(input)
}
#[test]
fn test_prefix_trigger() {
let r = query("> 100 km to mi");
assert!(!r.is_empty());
}
#[test]
fn test_auto_detect() {
let r = query("100 km to mi");
assert!(!r.is_empty());
}
#[test]
fn test_common_conversions() {
let r = query("> 100 km");
assert!(r.len() > 1);
}
#[test]
fn test_temperature() {
let r = query("102F to C");
assert!(!r.is_empty());
}
#[test]
fn test_nonsense_returns_empty() {
assert!(query("hello world").is_empty());
}
#[test]
fn test_provider_type() {
assert_eq!(
ConverterProvider::new().provider_type(),
ProviderType::Plugin("conv".into())
);
}
#[test]
fn test_no_double_unit() {
let r = query("100 km to mi");
if let Some(item) = r.first() {
let desc = item.description.as_deref().unwrap();
assert!(!desc.ends_with(" mi mi"), "double unit in: {}", desc);
}
}
#[test]
fn test_format_number_integer() {
assert_eq!(format_number(42.0), "42");
}
#[test]
fn test_format_number_large_integer() {
assert_eq!(format_number(1000000.0), "1,000,000");
}
#[test]
fn test_format_number_decimal() {
assert_eq!(format_number(3.14), "3.14");
}
#[test]
fn test_format_with_separators() {
assert_eq!(format_with_separators(1234567), "1,234,567");
assert_eq!(format_with_separators(999), "999");
assert_eq!(format_with_separators(-1234), "-1,234");
}
}

View File

@@ -0,0 +1,235 @@
use super::units;
pub struct ParsedQuery {
pub value: f64,
pub from_unit: String,
pub from_symbol: String,
pub target_unit: Option<String>,
}
pub fn parse_conversion(input: &str) -> Option<ParsedQuery> {
let input = input.trim();
if input.is_empty() {
return None;
}
// Extract leading number
let (value, rest) = extract_number(input)?;
let rest = rest.trim();
if rest.is_empty() {
return None;
}
// Split on " to " or " in " (case-insensitive)
let (from_str, target_str) = split_on_connector(rest);
// Resolve from unit
let from_lower = from_str.trim().to_lowercase();
let from_symbol = units::find_unit(&from_lower)?;
let from_symbol_str = from_symbol.to_string();
// Resolve target unit if present
let target_unit = target_str.and_then(|t| {
let t_lower = t.trim().to_lowercase();
if t_lower.is_empty() {
None
} else {
units::find_unit(&t_lower).map(|_| t_lower)
}
});
Some(ParsedQuery {
value,
from_unit: from_lower,
from_symbol: from_symbol_str,
target_unit,
})
}
fn extract_number(input: &str) -> Option<(f64, &str)> {
let bytes = input.as_bytes();
let mut i = 0;
// Optional negative sign
if i < bytes.len() && bytes[i] == b'-' {
i += 1;
}
// Must have at least one digit or start with .
if i >= bytes.len() {
return None;
}
let start_digits = i;
// Integer part
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
// Decimal part
if i < bytes.len() && bytes[i] == b'.' {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
}
if i == start_digits && !(i > 0 && bytes[0] == b'-') {
// No digits found (and not just a negative sign before a dot)
// Handle ".5" case
if bytes[start_digits] == b'.' {
// already advanced past dot above
} else {
return None;
}
}
if i == 0 || (i == 1 && bytes[0] == b'-') {
return None;
}
let num_str = &input[..i];
let value: f64 = num_str.parse().ok()?;
let rest = &input[i..];
Some((value, rest))
}
fn split_on_connector(input: &str) -> (&str, Option<&str>) {
let lower = input.to_lowercase();
// Try " to " first
if let Some(pos) = lower.find(" to ") {
let from = &input[..pos];
let target = &input[pos + 4..];
return (from, Some(target));
}
// Try " in "
if let Some(pos) = lower.find(" in ") {
let from = &input[..pos];
let target = &input[pos + 4..];
return (from, Some(target));
}
(input, None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_number_and_unit_with_space() {
let p = parse_conversion("100 km").unwrap();
assert!((p.value - 100.0).abs() < 0.001);
assert_eq!(p.from_unit, "km");
assert!(p.target_unit.is_none());
}
#[test]
fn test_number_and_unit_no_space() {
let p = parse_conversion("100km").unwrap();
assert!((p.value - 100.0).abs() < 0.001);
assert_eq!(p.from_unit, "km");
}
#[test]
fn test_with_target_to() {
let p = parse_conversion("100 km to mi").unwrap();
assert!((p.value - 100.0).abs() < 0.001);
assert_eq!(p.from_unit, "km");
assert_eq!(p.target_unit.as_deref(), Some("mi"));
}
#[test]
fn test_with_target_in() {
let p = parse_conversion("100 km in mi").unwrap();
assert_eq!(p.target_unit.as_deref(), Some("mi"));
}
#[test]
fn test_temperature_no_space() {
let p = parse_conversion("102F to C").unwrap();
assert!((p.value - 102.0).abs() < 0.001);
assert_eq!(p.from_unit, "f");
assert_eq!(p.target_unit.as_deref(), Some("c"));
}
#[test]
fn test_temperature_with_space() {
let p = parse_conversion("102 F in K").unwrap();
assert!((p.value - 102.0).abs() < 0.001);
assert_eq!(p.from_unit, "f");
assert_eq!(p.target_unit.as_deref(), Some("k"));
}
#[test]
fn test_decimal_number() {
let p = parse_conversion("3.5 kg to lb").unwrap();
assert!((p.value - 3.5).abs() < 0.001);
}
#[test]
fn test_decimal_starting_with_dot() {
let p = parse_conversion(".5 kg").unwrap();
assert!((p.value - 0.5).abs() < 0.001);
}
#[test]
fn test_full_unit_names() {
let p = parse_conversion("100 kilometers to miles").unwrap();
assert_eq!(p.from_unit, "kilometers");
assert_eq!(p.target_unit.as_deref(), Some("miles"));
}
#[test]
fn test_case_insensitive() {
let p = parse_conversion("100 KM TO MI").unwrap();
assert_eq!(p.from_unit, "km");
assert_eq!(p.target_unit.as_deref(), Some("mi"));
}
#[test]
fn test_currency() {
let p = parse_conversion("100 eur to usd").unwrap();
assert_eq!(p.from_unit, "eur");
assert_eq!(p.target_unit.as_deref(), Some("usd"));
}
#[test]
fn test_no_number_returns_none() {
assert!(parse_conversion("km to mi").is_none());
}
#[test]
fn test_unknown_unit_returns_none() {
assert!(parse_conversion("100 xyz to abc").is_none());
}
#[test]
fn test_empty_returns_none() {
assert!(parse_conversion("").is_none());
}
#[test]
fn test_number_only_returns_none() {
assert!(parse_conversion("100").is_none());
}
#[test]
fn test_compound_unit_alias() {
let p = parse_conversion("100 km/h to mph").unwrap();
assert_eq!(p.from_unit, "km/h");
assert_eq!(p.target_unit.as_deref(), Some("mph"));
}
#[test]
fn test_multi_word_unit() {
let p = parse_conversion("100 fl_oz to ml").unwrap();
assert_eq!(p.from_unit, "fl_oz");
}
}

View File

@@ -0,0 +1,944 @@
use std::collections::HashMap;
use std::sync::LazyLock;
use super::currency;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Category {
Temperature,
Length,
Weight,
Volume,
Speed,
Area,
Data,
Time,
Pressure,
Energy,
Currency,
}
#[derive(Clone)]
enum Conversion {
Factor(f64),
Custom {
to_base: fn(f64) -> f64,
from_base: fn(f64) -> f64,
},
}
#[derive(Clone)]
pub(crate) struct UnitDef {
_id: &'static str,
symbol: &'static str,
aliases: &'static [&'static str],
category: Category,
conversion: Conversion,
}
impl UnitDef {
fn to_base(&self, value: f64) -> f64 {
match &self.conversion {
Conversion::Factor(f) => value * f,
Conversion::Custom { to_base, .. } => to_base(value),
}
}
fn convert_from_base(&self, value: f64) -> f64 {
match &self.conversion {
Conversion::Factor(f) => value / f,
Conversion::Custom { from_base, .. } => from_base(value),
}
}
}
pub struct ConversionResult {
pub value: f64,
pub raw_value: String,
pub display_value: String,
pub target_symbol: String,
}
static UNITS: LazyLock<Vec<UnitDef>> = LazyLock::new(build_unit_table);
static ALIAS_MAP: LazyLock<HashMap<String, usize>> = LazyLock::new(|| {
let mut map = HashMap::new();
for (i, unit) in UNITS.iter().enumerate() {
for alias in unit.aliases {
map.insert(alias.to_lowercase(), i);
}
}
map
});
// Common conversions per category (symbols to show when no target specified)
static COMMON_TARGETS: LazyLock<HashMap<Category, Vec<&'static str>>> = LazyLock::new(|| {
let mut m = HashMap::new();
m.insert(Category::Temperature, vec!["°C", "°F", "K"]);
m.insert(Category::Length, vec!["m", "km", "ft", "mi", "in"]);
m.insert(Category::Weight, vec!["kg", "lb", "oz", "g", "st"]);
m.insert(Category::Volume, vec!["l", "gal", "ml", "cup", "fl oz"]);
m.insert(Category::Speed, vec!["km/h", "mph", "m/s", "kn"]);
m.insert(Category::Area, vec!["", "ft²", "ac", "ha", "km²"]);
m.insert(Category::Data, vec!["MB", "GB", "MiB", "GiB", "TB"]);
m.insert(Category::Time, vec!["s", "min", "h", "d", "wk"]);
m.insert(Category::Pressure, vec!["bar", "psi", "atm", "hPa", "mmHg"]);
m.insert(Category::Energy, vec!["kJ", "kcal", "kWh", "BTU", "Wh"]);
m.insert(Category::Currency, vec!["USD", "EUR", "GBP", "JPY", "CNY"]);
m
});
pub fn find_unit(alias: &str) -> Option<&'static str> {
let lower = alias.to_lowercase();
if let Some(&i) = ALIAS_MAP.get(&lower) {
return Some(UNITS[i].symbol);
}
currency::resolve_currency_code(&lower)
}
pub fn lookup_unit(alias: &str) -> Option<(usize, &UnitDef)> {
let lower = alias.to_lowercase();
ALIAS_MAP.get(&lower).map(|&i| (i, &UNITS[i]))
}
pub fn convert_to(value: &f64, from: &str, to: &str) -> Option<ConversionResult> {
// Try currency first — currency aliases (dollar, euro, etc.) aren't in the UNITS table
if currency::is_currency_alias(from) || currency::is_currency_alias(to) {
return convert_currency(*value, from, to);
}
let (_, from_def) = lookup_unit(from)?;
let (_, to_def) = lookup_unit(to)?;
// Currency via UNITS table (shouldn't reach here, but just in case)
if from_def.category == Category::Currency || to_def.category == Category::Currency {
return convert_currency(*value, from, to);
}
// Must be same category
if from_def.category != to_def.category {
return None;
}
let base_value = from_def.to_base(*value);
let result = to_def.convert_from_base(base_value);
Some(format_result(result, to_def.symbol))
}
pub fn convert_common(value: &f64, from: &str) -> Vec<ConversionResult> {
// Try currency first — currency aliases (dollar, euro, etc.) aren't in the UNITS table
if currency::is_currency_alias(from) {
return convert_currency_common(*value, from);
}
let (_, from_def) = match lookup_unit(from) {
Some(u) => u,
None => return vec![],
};
let category = from_def.category;
let from_symbol = from_def.symbol;
if category == Category::Currency {
return convert_currency_common(*value, from);
}
let targets = match COMMON_TARGETS.get(&category) {
Some(t) => t,
None => return vec![],
};
let base_value = from_def.to_base(*value);
targets
.iter()
.filter(|&&sym| sym != from_symbol)
.filter_map(|&sym| {
let (_, to_def) = lookup_unit(sym)?;
let result = to_def.convert_from_base(base_value);
Some(format_result(result, to_def.symbol))
})
.take(5)
.collect()
}
fn convert_currency(value: f64, from: &str, to: &str) -> Option<ConversionResult> {
let rates = currency::get_rates()?;
let from_code = currency::resolve_currency_code(from)?;
let to_code = currency::resolve_currency_code(to)?;
let from_rate = if from_code == "EUR" { 1.0 } else { *rates.rates.get(from_code)? };
let to_rate = if to_code == "EUR" { 1.0 } else { *rates.rates.get(to_code)? };
let result = value / from_rate * to_rate;
Some(format_currency_result(result, to_code))
}
fn convert_currency_common(value: f64, from: &str) -> Vec<ConversionResult> {
let rates = match currency::get_rates() {
Some(r) => r,
None => return vec![],
};
let from_code = match currency::resolve_currency_code(from) {
Some(c) => c,
None => return vec![],
};
let targets = COMMON_TARGETS.get(&Category::Currency).unwrap();
let from_rate = if from_code == "EUR" {
1.0
} else {
match rates.rates.get(from_code) {
Some(&r) => r,
None => return vec![],
}
};
targets
.iter()
.filter(|&&sym| sym != from_code)
.filter_map(|&sym| {
let to_rate = if sym == "EUR" { 1.0 } else { *rates.rates.get(sym)? };
let result = value / from_rate * to_rate;
Some(format_currency_result(result, sym))
})
.take(5)
.collect()
}
fn format_result(value: f64, symbol: &str) -> ConversionResult {
let raw = if value.fract() == 0.0 && value.abs() < 1e15 {
format!("{}", value as i64)
} else {
format!("{:.4}", value)
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
};
let display = if value.abs() >= 1000.0 && value.fract() == 0.0 && value.abs() < 1e15 {
super::format_with_separators(value as i64)
} else {
raw.clone()
};
ConversionResult {
value,
raw_value: raw,
display_value: format!("{} {}", display, symbol),
target_symbol: symbol.to_string(),
}
}
fn format_currency_result(value: f64, code: &str) -> ConversionResult {
let raw = format!("{:.2}", value);
let display = raw.clone();
ConversionResult {
value,
raw_value: raw,
display_value: format!("{} {}", display, code),
target_symbol: code.to_string(),
}
}
fn build_unit_table() -> Vec<UnitDef> {
vec![
// Temperature (base: Kelvin)
UnitDef {
_id: "celsius",
symbol: "°C",
aliases: &["c", "°c", "celsius", "degc", "centigrade"],
category: Category::Temperature,
conversion: Conversion::Custom {
to_base: |v| v + 273.15,
from_base: |v| v - 273.15,
},
},
UnitDef {
_id: "fahrenheit",
symbol: "°F",
aliases: &["f", "°f", "fahrenheit", "degf"],
category: Category::Temperature,
conversion: Conversion::Custom {
to_base: |v| (v - 32.0) * 5.0 / 9.0 + 273.15,
from_base: |v| (v - 273.15) * 9.0 / 5.0 + 32.0,
},
},
UnitDef {
_id: "kelvin",
symbol: "K",
aliases: &["k", "kelvin"],
category: Category::Temperature,
conversion: Conversion::Factor(1.0), // base
},
// Length (base: meter)
UnitDef {
_id: "millimeter",
symbol: "mm",
aliases: &["mm", "millimeter", "millimeters", "millimetre"],
category: Category::Length,
conversion: Conversion::Factor(0.001),
},
UnitDef {
_id: "centimeter",
symbol: "cm",
aliases: &["cm", "centimeter", "centimeters", "centimetre"],
category: Category::Length,
conversion: Conversion::Factor(0.01),
},
UnitDef {
_id: "meter",
symbol: "m",
aliases: &["m", "meter", "meters", "metre", "metres"],
category: Category::Length,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "kilometer",
symbol: "km",
aliases: &["km", "kms", "kilometer", "kilometers", "kilometre"],
category: Category::Length,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "inch",
symbol: "in",
aliases: &["in", "inch", "inches"],
category: Category::Length,
conversion: Conversion::Factor(0.0254),
},
UnitDef {
_id: "foot",
symbol: "ft",
aliases: &["ft", "foot", "feet"],
category: Category::Length,
conversion: Conversion::Factor(0.3048),
},
UnitDef {
_id: "yard",
symbol: "yd",
aliases: &["yd", "yard", "yards"],
category: Category::Length,
conversion: Conversion::Factor(0.9144),
},
UnitDef {
_id: "mile",
symbol: "mi",
aliases: &["mi", "mile", "miles"],
category: Category::Length,
conversion: Conversion::Factor(1609.344),
},
UnitDef {
_id: "nautical_mile",
symbol: "nmi",
aliases: &["nmi", "nautical_mile", "nautical_miles"],
category: Category::Length,
conversion: Conversion::Factor(1852.0),
},
// Weight (base: kg)
UnitDef {
_id: "milligram",
symbol: "mg",
aliases: &["mg", "milligram", "milligrams"],
category: Category::Weight,
conversion: Conversion::Factor(0.000001),
},
UnitDef {
_id: "gram",
symbol: "g",
aliases: &["g", "gram", "grams"],
category: Category::Weight,
conversion: Conversion::Factor(0.001),
},
UnitDef {
_id: "kilogram",
symbol: "kg",
aliases: &["kg", "kilogram", "kilograms", "kilo", "kilos"],
category: Category::Weight,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "tonne",
symbol: "t",
aliases: &["t", "ton", "tons", "tonne", "tonnes", "metric_ton"],
category: Category::Weight,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "short_ton",
symbol: "short_ton",
aliases: &["short_ton", "ton_us"],
category: Category::Weight,
conversion: Conversion::Factor(907.185),
},
UnitDef {
_id: "ounce",
symbol: "oz",
aliases: &["oz", "ounce", "ounces"],
category: Category::Weight,
conversion: Conversion::Factor(0.0283495),
},
UnitDef {
_id: "pound",
symbol: "lb",
aliases: &["lb", "lbs", "pound", "pounds"],
category: Category::Weight,
conversion: Conversion::Factor(0.453592),
},
UnitDef {
_id: "stone",
symbol: "st",
aliases: &["st", "stone", "stones"],
category: Category::Weight,
conversion: Conversion::Factor(6.35029),
},
// Volume (base: liter)
UnitDef {
_id: "milliliter",
symbol: "ml",
aliases: &["ml", "milliliter", "milliliters", "millilitre"],
category: Category::Volume,
conversion: Conversion::Factor(0.001),
},
UnitDef {
_id: "liter",
symbol: "l",
aliases: &["l", "liter", "liters", "litre", "litres"],
category: Category::Volume,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "us_gallon",
symbol: "gal",
aliases: &["gal", "gallon", "gallons"],
category: Category::Volume,
conversion: Conversion::Factor(3.78541),
},
UnitDef {
_id: "imp_gallon",
symbol: "imp gal",
aliases: &["imp_gal", "gal_uk", "imperial_gallon"],
category: Category::Volume,
conversion: Conversion::Factor(4.54609),
},
UnitDef {
_id: "quart",
symbol: "qt",
aliases: &["qt", "quart", "quarts"],
category: Category::Volume,
conversion: Conversion::Factor(0.946353),
},
UnitDef {
_id: "pint",
symbol: "pt",
aliases: &["pt", "pint", "pints"],
category: Category::Volume,
conversion: Conversion::Factor(0.473176),
},
UnitDef {
_id: "cup",
symbol: "cup",
aliases: &["cup", "cups"],
category: Category::Volume,
conversion: Conversion::Factor(0.236588),
},
UnitDef {
_id: "fluid_ounce",
symbol: "fl oz",
aliases: &["floz", "fl_oz", "fluid_ounce", "fluid_ounces"],
category: Category::Volume,
conversion: Conversion::Factor(0.0295735),
},
UnitDef {
_id: "tablespoon",
symbol: "tbsp",
aliases: &["tbsp", "tablespoon", "tablespoons"],
category: Category::Volume,
conversion: Conversion::Factor(0.0147868),
},
UnitDef {
_id: "teaspoon",
symbol: "tsp",
aliases: &["tsp", "teaspoon", "teaspoons"],
category: Category::Volume,
conversion: Conversion::Factor(0.00492892),
},
// Speed (base: m/s)
UnitDef {
_id: "mps",
symbol: "m/s",
aliases: &["m/s", "mps", "meters_per_second"],
category: Category::Speed,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "kmh",
symbol: "km/h",
aliases: &["km/h", "kmh", "kph", "kilometers_per_hour"],
category: Category::Speed,
conversion: Conversion::Factor(0.277778),
},
UnitDef {
_id: "mph",
symbol: "mph",
aliases: &["mph", "miles_per_hour"],
category: Category::Speed,
conversion: Conversion::Factor(0.44704),
},
UnitDef {
_id: "knot",
symbol: "kn",
aliases: &["kn", "kt", "knot", "knots"],
category: Category::Speed,
conversion: Conversion::Factor(0.514444),
},
UnitDef {
_id: "fps",
symbol: "ft/s",
aliases: &["ft/s", "fps", "feet_per_second"],
category: Category::Speed,
conversion: Conversion::Factor(0.3048),
},
// Area (base: m²)
UnitDef {
_id: "sqmm",
symbol: "mm²",
aliases: &["mm2", "sqmm", "square_millimeter"],
category: Category::Area,
conversion: Conversion::Factor(0.000001),
},
UnitDef {
_id: "sqcm",
symbol: "cm²",
aliases: &["cm2", "sqcm", "square_centimeter"],
category: Category::Area,
conversion: Conversion::Factor(0.0001),
},
UnitDef {
_id: "sqm",
symbol: "",
aliases: &["m2", "sqm", "square_meter", "square_meters"],
category: Category::Area,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "sqkm",
symbol: "km²",
aliases: &["km2", "sqkm", "square_kilometer"],
category: Category::Area,
conversion: Conversion::Factor(1000000.0),
},
UnitDef {
_id: "sqft",
symbol: "ft²",
aliases: &["ft2", "sqft", "square_foot", "square_feet"],
category: Category::Area,
conversion: Conversion::Factor(0.092903),
},
UnitDef {
_id: "acre",
symbol: "ac",
aliases: &["ac", "acre", "acres"],
category: Category::Area,
conversion: Conversion::Factor(4046.86),
},
UnitDef {
_id: "hectare",
symbol: "ha",
aliases: &["ha", "hectare", "hectares"],
category: Category::Area,
conversion: Conversion::Factor(10000.0),
},
// Data (base: byte)
UnitDef {
_id: "byte",
symbol: "B",
aliases: &["b", "byte", "bytes"],
category: Category::Data,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "kilobyte",
symbol: "KB",
aliases: &["kb", "kilobyte", "kilobytes"],
category: Category::Data,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "megabyte",
symbol: "MB",
aliases: &["mb", "megabyte", "megabytes"],
category: Category::Data,
conversion: Conversion::Factor(1_000_000.0),
},
UnitDef {
_id: "gigabyte",
symbol: "GB",
aliases: &["gb", "gigabyte", "gigabytes"],
category: Category::Data,
conversion: Conversion::Factor(1_000_000_000.0),
},
UnitDef {
_id: "terabyte",
symbol: "TB",
aliases: &["tb", "terabyte", "terabytes"],
category: Category::Data,
conversion: Conversion::Factor(1_000_000_000_000.0),
},
UnitDef {
_id: "kibibyte",
symbol: "KiB",
aliases: &["kib", "kibibyte", "kibibytes"],
category: Category::Data,
conversion: Conversion::Factor(1024.0),
},
UnitDef {
_id: "mebibyte",
symbol: "MiB",
aliases: &["mib", "mebibyte", "mebibytes"],
category: Category::Data,
conversion: Conversion::Factor(1_048_576.0),
},
UnitDef {
_id: "gibibyte",
symbol: "GiB",
aliases: &["gib", "gibibyte", "gibibytes"],
category: Category::Data,
conversion: Conversion::Factor(1_073_741_824.0),
},
UnitDef {
_id: "tebibyte",
symbol: "TiB",
aliases: &["tib", "tebibyte", "tebibytes"],
category: Category::Data,
conversion: Conversion::Factor(1_099_511_627_776.0),
},
// Time (base: second)
UnitDef {
_id: "second",
symbol: "s",
aliases: &["s", "sec", "second", "seconds"],
category: Category::Time,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "minute",
symbol: "min",
aliases: &["min", "minute", "minutes"],
category: Category::Time,
conversion: Conversion::Factor(60.0),
},
UnitDef {
_id: "hour",
symbol: "h",
aliases: &["h", "hr", "hour", "hours"],
category: Category::Time,
conversion: Conversion::Factor(3600.0),
},
UnitDef {
_id: "day",
symbol: "d",
aliases: &["d", "day", "days"],
category: Category::Time,
conversion: Conversion::Factor(86400.0),
},
UnitDef {
_id: "week",
symbol: "wk",
aliases: &["wk", "week", "weeks"],
category: Category::Time,
conversion: Conversion::Factor(604800.0),
},
UnitDef {
_id: "month",
symbol: "mo",
aliases: &["mo", "month", "months"],
category: Category::Time,
conversion: Conversion::Factor(2_592_000.0),
},
UnitDef {
_id: "year",
symbol: "yr",
aliases: &["yr", "year", "years"],
category: Category::Time,
conversion: Conversion::Factor(31_536_000.0),
},
// Pressure (base: Pa)
UnitDef {
_id: "pascal",
symbol: "Pa",
aliases: &["pa", "pascal", "pascals"],
category: Category::Pressure,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "hectopascal",
symbol: "hPa",
aliases: &["hpa", "hectopascal"],
category: Category::Pressure,
conversion: Conversion::Factor(100.0),
},
UnitDef {
_id: "kilopascal",
symbol: "kPa",
aliases: &["kpa", "kilopascal"],
category: Category::Pressure,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "bar",
symbol: "bar",
aliases: &["bar", "bars"],
category: Category::Pressure,
conversion: Conversion::Factor(100_000.0),
},
UnitDef {
_id: "millibar",
symbol: "mbar",
aliases: &["mbar", "millibar"],
category: Category::Pressure,
conversion: Conversion::Factor(100.0),
},
UnitDef {
_id: "psi",
symbol: "psi",
aliases: &["psi", "pounds_per_square_inch"],
category: Category::Pressure,
conversion: Conversion::Factor(6894.76),
},
UnitDef {
_id: "atmosphere",
symbol: "atm",
aliases: &["atm", "atmosphere", "atmospheres"],
category: Category::Pressure,
conversion: Conversion::Factor(101_325.0),
},
UnitDef {
_id: "mmhg",
symbol: "mmHg",
aliases: &["mmhg", "torr"],
category: Category::Pressure,
conversion: Conversion::Factor(133.322),
},
// Energy (base: Joule)
UnitDef {
_id: "joule",
symbol: "J",
aliases: &["j", "joule", "joules"],
category: Category::Energy,
conversion: Conversion::Factor(1.0),
},
UnitDef {
_id: "kilojoule",
symbol: "kJ",
aliases: &["kj", "kilojoule", "kilojoules"],
category: Category::Energy,
conversion: Conversion::Factor(1000.0),
},
UnitDef {
_id: "calorie",
symbol: "cal",
aliases: &["cal", "calorie", "calories"],
category: Category::Energy,
conversion: Conversion::Factor(4.184),
},
UnitDef {
_id: "kilocalorie",
symbol: "kcal",
aliases: &["kcal", "kilocalorie", "kilocalories"],
category: Category::Energy,
conversion: Conversion::Factor(4184.0),
},
UnitDef {
_id: "watt_hour",
symbol: "Wh",
aliases: &["wh", "watt_hour"],
category: Category::Energy,
conversion: Conversion::Factor(3600.0),
},
UnitDef {
_id: "kilowatt_hour",
symbol: "kWh",
aliases: &["kwh", "kilowatt_hour"],
category: Category::Energy,
conversion: Conversion::Factor(3_600_000.0),
},
UnitDef {
_id: "btu",
symbol: "BTU",
aliases: &["btu", "british_thermal_unit"],
category: Category::Energy,
conversion: Conversion::Factor(1055.06),
},
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_celsius_to_fahrenheit() {
let r = convert_to(&100.0, "c", "f").unwrap();
assert!((r.value - 212.0).abs() < 0.01);
}
#[test]
fn test_fahrenheit_to_celsius() {
let r = convert_to(&32.0, "f", "c").unwrap();
assert!((r.value - 0.0).abs() < 0.01);
}
#[test]
fn test_body_temp_f_to_c() {
let r = convert_to(&98.6, "f", "c").unwrap();
assert!((r.value - 37.0).abs() < 0.01);
}
#[test]
fn test_km_to_miles() {
let r = convert_to(&100.0, "km", "mi").unwrap();
assert!((r.value - 62.1371).abs() < 0.01);
}
#[test]
fn test_miles_to_km() {
let r = convert_to(&1.0, "mi", "km").unwrap();
assert!((r.value - 1.60934).abs() < 0.01);
}
#[test]
fn test_kg_to_lb() {
let r = convert_to(&1.0, "kg", "lb").unwrap();
assert!((r.value - 2.20462).abs() < 0.01);
}
#[test]
fn test_lb_to_kg() {
let r = convert_to(&100.0, "lbs", "kg").unwrap();
assert!((r.value - 45.3592).abs() < 0.01);
}
#[test]
fn test_liters_to_gallons() {
let r = convert_to(&3.78541, "l", "gal").unwrap();
assert!((r.value - 1.0).abs() < 0.01);
}
#[test]
fn test_kmh_to_mph() {
let r = convert_to(&100.0, "kmh", "mph").unwrap();
assert!((r.value - 62.1371).abs() < 0.01);
}
#[test]
fn test_gb_to_mb() {
let r = convert_to(&1.0, "gb", "mb").unwrap();
assert!((r.value - 1000.0).abs() < 0.01);
}
#[test]
fn test_gib_to_mib() {
let r = convert_to(&1.0, "gib", "mib").unwrap();
assert!((r.value - 1024.0).abs() < 0.01);
}
#[test]
fn test_hours_to_minutes() {
let r = convert_to(&2.5, "h", "min").unwrap();
assert!((r.value - 150.0).abs() < 0.01);
}
#[test]
fn test_bar_to_psi() {
let r = convert_to(&1.0, "bar", "psi").unwrap();
assert!((r.value - 14.5038).abs() < 0.01);
}
#[test]
fn test_kcal_to_kj() {
let r = convert_to(&1.0, "kcal", "kj").unwrap();
assert!((r.value - 4.184).abs() < 0.01);
}
#[test]
fn test_sqm_to_sqft() {
let r = convert_to(&1.0, "m2", "ft2").unwrap();
assert!((r.value - 10.7639).abs() < 0.01);
}
#[test]
fn test_unknown_unit_returns_none() {
assert!(convert_to(&100.0, "xyz", "abc").is_none());
}
#[test]
fn test_cross_category_returns_none() {
assert!(convert_to(&100.0, "km", "kg").is_none());
}
#[test]
fn test_convert_common_returns_results() {
let results = convert_common(&100.0, "km");
assert!(!results.is_empty());
assert!(results.len() <= 5);
}
#[test]
fn test_convert_common_excludes_source() {
let results = convert_common(&100.0, "km");
for r in &results {
assert_ne!(r.target_symbol, "km");
}
}
#[test]
fn test_alias_case_insensitive() {
let r1 = convert_to(&100.0, "KM", "MI").unwrap();
let r2 = convert_to(&100.0, "km", "mi").unwrap();
assert!((r1.value - r2.value).abs() < 0.001);
}
#[test]
fn test_full_name_alias() {
let r = convert_to(&100.0, "kilometers", "miles").unwrap();
assert!((r.value - 62.1371).abs() < 0.01);
}
#[test]
fn test_format_currency_two_decimals() {
let r = convert_to(&1.0, "km", "mi").unwrap();
// display_value should have reasonable formatting
assert!(!r.display_value.is_empty());
}
#[test]
fn test_currency_alias_convert_to() {
// "dollar" and "euro" are aliases, not in the UNITS table
let r = convert_to(&20.0, "dollar", "euro");
// May return None if ECB rates unavailable (network), but should not panic
// In a network-available environment, this should return Some
if let Some(r) = r {
assert!(r.value > 0.0);
assert_eq!(r.target_symbol, "EUR");
}
}
#[test]
fn test_currency_alias_convert_common() {
let results = convert_common(&20.0, "dollar");
// May be empty if ECB rates unavailable, but should not panic
for r in &results {
assert!(r.value > 0.0);
}
}
#[test]
fn test_display_value_no_double_unit() {
let r = convert_to(&100.0, "km", "mi").unwrap();
// display_value should contain the symbol exactly once
let count = r.display_value.matches(&r.target_symbol).count();
assert_eq!(count, 1, "display_value '{}' should contain '{}' exactly once", r.display_value, r.target_symbol);
}
}

View File

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

View File

@@ -1,6 +1,10 @@
// Core providers (no plugin equivalents) // Core providers (no plugin equivalents)
mod application; mod application;
mod command; mod command;
pub(crate) mod calculator;
pub(crate) mod config_editor;
pub(crate) mod converter;
pub(crate) mod system;
// Native plugin bridge // Native plugin bridge
pub mod native_provider; pub mod native_provider;
@@ -16,13 +20,16 @@ pub use command::CommandProvider;
// Re-export native provider for plugin loading // Re-export native provider for plugin loading
pub use native_provider::NativeProvider; pub use native_provider::NativeProvider;
use chrono::Utc;
use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::skim::SkimMatcherV2;
use log::info; use log::{info, warn};
#[cfg(feature = "dev-logging")] #[cfg(feature = "dev-logging")]
use log::debug; use log::debug;
use std::sync::{Arc, RwLock};
use crate::config::Config; use crate::config::Config;
use crate::data::FrecencyStore; use crate::data::FrecencyStore;
use crate::plugins::runtime_loader::LoadedRuntime; use crate::plugins::runtime_loader::LoadedRuntime;
@@ -37,6 +44,38 @@ pub struct ProviderDescriptor {
pub position: String, 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 /// Represents a single searchable/launchable item
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LaunchItem { pub struct LaunchItem {
@@ -50,21 +89,29 @@ pub struct LaunchItem {
pub terminal: bool, pub terminal: bool,
/// Tags/categories for filtering (e.g., from .desktop Categories) /// Tags/categories for filtering (e.g., from .desktop Categories)
pub tags: Vec<String>, 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). /// **Glossary:**
/// This keeps the core app free of plugin-specific knowledge. /// - *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)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ProviderType { pub enum ProviderType {
/// Built-in: Desktop applications from XDG directories /// Built-in provider: desktop applications from XDG data directories.
Application, Application,
/// Built-in: Shell commands from PATH /// Built-in provider: shell commands from `$PATH`.
Command, Command,
/// Built-in: Pipe-based input (dmenu compatibility) /// Built-in provider: pipe-based input for dmenu compatibility (client-local only).
Dmenu, 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), Plugin(String),
} }
@@ -103,10 +150,29 @@ pub trait Provider: Send + Sync {
fn items(&self) -> &[LaunchItem]; fn items(&self) -> &[LaunchItem];
} }
/// Trait for built-in providers that produce results per-keystroke.
/// Unlike static `Provider`s which cache items via `refresh()`/`items()`,
/// dynamic providers generate results on every query.
pub(crate) trait DynamicProvider: Send + Sync {
#[allow(dead_code)]
fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType;
fn query(&self, query: &str) -> Vec<LaunchItem>;
fn priority(&self) -> u32;
/// Handle a plugin action command. Returns true if handled.
fn execute_action(&self, _command: &str) -> bool {
false
}
}
/// Manages all providers and handles searching /// Manages all providers and handles searching
pub struct ProviderManager { pub struct ProviderManager {
/// Core static providers (apps, commands, dmenu) /// Core static providers (apps, commands, dmenu)
providers: Vec<Box<dyn Provider>>, providers: Vec<Box<dyn Provider>>,
/// Built-in dynamic providers (calculator, converter)
/// These are queried per-keystroke, like native dynamic plugins
builtin_dynamic: Vec<Box<dyn DynamicProvider>>,
/// Static native plugin providers (need query() for submenu support) /// Static native plugin providers (need query() for submenu support)
static_native_providers: Vec<NativeProvider>, static_native_providers: Vec<NativeProvider>,
/// Dynamic providers from native plugins (calculator, websearch, filesearch) /// Dynamic providers from native plugins (calculator, websearch, filesearch)
@@ -121,6 +187,9 @@ pub struct ProviderManager {
runtimes: Vec<LoadedRuntime>, runtimes: Vec<LoadedRuntime>,
/// Type IDs of providers from script runtimes (for hot-reload removal) /// Type IDs of providers from script runtimes (for hot-reload removal)
runtime_type_ids: std::collections::HashSet<String>, 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 { impl ProviderManager {
@@ -135,12 +204,14 @@ impl ProviderManager {
) -> Self { ) -> Self {
let mut manager = Self { let mut manager = Self {
providers: core_providers, providers: core_providers,
builtin_dynamic: Vec::new(),
static_native_providers: Vec::new(), static_native_providers: Vec::new(),
dynamic_providers: Vec::new(), dynamic_providers: Vec::new(),
widget_providers: Vec::new(), widget_providers: Vec::new(),
matcher: SkimMatcherV2::default(), matcher: SkimMatcherV2::default(),
runtimes: Vec::new(), runtimes: Vec::new(),
runtime_type_ids: std::collections::HashSet::new(), runtime_type_ids: std::collections::HashSet::new(),
plugin_registry: Vec::new(),
}; };
// Categorize native plugins based on their declared ProviderKind and ProviderPosition // Categorize native plugins based on their declared ProviderKind and ProviderPosition
@@ -182,9 +253,8 @@ impl ProviderManager {
/// Loads native plugins, creates core providers (Application + Command), /// Loads native plugins, creates core providers (Application + Command),
/// categorizes everything, and performs initial refresh. Used by the daemon /// categorizes everything, and performs initial refresh. Used by the daemon
/// which doesn't have the UI-driven setup path from `app.rs`. /// 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 crate::plugins::native_loader::NativePluginLoader;
use std::sync::Arc;
// Create core providers // Create core providers
let mut core_providers: Vec<Box<dyn Provider>> = vec![ let mut core_providers: Vec<Box<dyn Provider>> = vec![
@@ -192,9 +262,23 @@ impl ProviderManager {
Box::new(CommandProvider::new()), 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 // Load native plugins
let mut loader = NativePluginLoader::new(); let mut loader = NativePluginLoader::new();
loader.set_disabled(config.plugins.disabled_plugins.clone()); loader.set_disabled(disabled_plugins);
let native_providers = match loader.discover() { let native_providers = match loader.discover() {
Ok(count) => { Ok(count) => {
@@ -276,9 +360,100 @@ impl ProviderManager {
core_providers.push(provider); core_providers.push(provider);
} }
// Built-in dynamic providers
let mut builtin_dynamic: Vec<Box<dyn DynamicProvider>> = Vec::new();
if calc_enabled {
builtin_dynamic.push(Box::new(calculator::CalculatorProvider));
info!("Registered built-in calculator provider");
}
if conv_enabled {
builtin_dynamic.push(Box::new(converter::ConverterProvider::new()));
info!("Registered built-in converter provider");
}
// 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 sys_enabled {
core_providers.push(Box::new(system::SystemProvider::new()));
info!("Registered built-in system provider");
}
// Compute built-in type IDs to detect conflicts with native plugins.
// A native plugin whose type_id matches a built-in provider would
// produce duplicate results, so we skip it.
let builtin_ids: std::collections::HashSet<String> = {
let mut ids = std::collections::HashSet::new();
// Dynamic built-ins (calculator, converter)
for p in &builtin_dynamic {
if let ProviderType::Plugin(id) = p.provider_type() {
ids.insert(id);
}
}
// Static built-ins added to core_providers (e.g. system)
for p in &core_providers {
if let ProviderType::Plugin(id) = p.provider_type() {
ids.insert(id);
}
}
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) {
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
}
})
.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); let mut manager = Self::new(core_providers, native_providers);
manager.builtin_dynamic = builtin_dynamic;
manager.runtimes = runtimes; manager.runtimes = runtimes;
manager.runtime_type_ids = runtime_type_ids; manager.runtime_type_ids = runtime_type_ids;
manager.plugin_registry = active_registry;
manager.plugin_registry.extend(suppressed_registry);
manager manager
} }
@@ -292,11 +467,11 @@ impl ProviderManager {
!self.runtime_type_ids.contains(&type_str) !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); 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(); self.runtime_type_ids.clear();
let owlry_version = env!("CARGO_PKG_VERSION"); let owlry_version = env!("CARGO_PKG_VERSION");
@@ -435,6 +610,14 @@ impl ProviderManager {
return true; return true;
} }
} }
// Check built-in dynamic providers
for provider in &self.builtin_dynamic {
if provider.execute_action(command) {
return true;
}
}
false false
} }
@@ -506,6 +689,7 @@ impl ProviderManager {
query: &str, query: &str,
max_results: usize, max_results: usize,
filter: &crate::filter::ProviderFilter, filter: &crate::filter::ProviderFilter,
tag_filter: Option<&str>,
) -> Vec<(LaunchItem, i64)> { ) -> Vec<(LaunchItem, i64)> {
// Collect items from core providers // Collect items from core providers
let core_items = self let core_items = self
@@ -521,16 +705,15 @@ impl ProviderManager {
.filter(|p| filter.is_active(p.provider_type())) .filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned()); .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() { if query.is_empty() {
return core_items return all_items.take(max_results).map(|item| (item, 0)).collect();
.chain(native_items)
.take(max_results)
.map(|item| (item, 0))
.collect();
} }
let mut results: Vec<(LaunchItem, i64)> = core_items let mut results: Vec<(LaunchItem, i64)> = all_items
.chain(native_items)
.filter_map(|item| { .filter_map(|item| {
let name_score = self.matcher.fuzzy_match(&item.name, query); let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item let desc_score = item
@@ -570,6 +753,7 @@ impl ProviderManager {
query, max_results, frecency_weight query, max_results, frecency_weight
); );
let now = Utc::now();
let mut results: Vec<(LaunchItem, i64)> = Vec::new(); let mut results: Vec<(LaunchItem, i64)> = Vec::new();
// Add widget items first (highest priority) - only when: // Add widget items first (highest priority) - only when:
@@ -600,30 +784,59 @@ impl ProviderManager {
let dynamic_results = provider.query(query); let dynamic_results = provider.query(query);
// Priority comes from plugin-declared priority field // Priority comes from plugin-declared priority field
let base_score = provider.priority() as i64; let base_score = provider.priority() as i64;
// Auto-detect plugins (calc, conv) get a grouping bonus so
// all their results stay together above generic search results
let grouping_bonus: i64 = match provider.provider_type() {
ProviderType::Plugin(ref id)
if matches!(id.as_str(), "calc" | "conv") =>
{
10_000
}
_ => 0,
};
for (idx, item) in dynamic_results.into_iter().enumerate() { for (idx, item) in dynamic_results.into_iter().enumerate() {
results.push((item, base_score - idx as i64)); results.push((item, base_score + grouping_bonus - idx as i64));
}
}
// Built-in dynamic providers (calculator, converter)
for provider in &self.builtin_dynamic {
if !filter.is_active(provider.provider_type()) {
continue;
}
let dynamic_results = provider.query(query);
let base_score = provider.priority() as i64;
let grouping_bonus: i64 = match provider.provider_type() {
ProviderType::Plugin(ref id)
if matches!(id.as_str(), "calc" | "conv") =>
{
10_000
}
_ => 0,
};
for (idx, item) in dynamic_results.into_iter().enumerate() {
results.push((item, base_score + grouping_bonus - idx as i64));
} }
} }
} }
// Empty query (after checking special providers) - return frecency-sorted items // Empty query (after checking special providers) - return frecency-sorted items
if query.is_empty() { if query.is_empty() {
// Collect items from core providers let mut scored_refs: Vec<(&LaunchItem, i64)> = self
let core_items = self
.providers .providers
.iter() .iter()
.filter(|p| filter.is_active(p.provider_type())) .filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned()); .flat_map(|p| p.items().iter())
.chain(
// Collect items from static native providers self.static_native_providers
let native_items = self .iter()
.static_native_providers .filter(|p| filter.is_active(p.provider_type()))
.iter() .flat_map(|p| p.items().iter()),
.filter(|p| filter.is_active(p.provider_type())) )
.flat_map(|p| p.items().iter().cloned());
let items: Vec<(LaunchItem, i64)> = core_items
.chain(native_items)
.filter(|item| { .filter(|item| {
// Apply tag filter if present // Apply tag filter if present
if let Some(tag) = tag_filter { if let Some(tag) = tag_filter {
@@ -633,14 +846,21 @@ impl ProviderManager {
} }
}) })
.map(|item| { .map(|item| {
let frecency_score = frecency.get_score(&item.id); let frecency_score = frecency.get_score_at(&item.id, now);
let boosted = (frecency_score * frecency_weight * 100.0) as i64; let boosted = (frecency_score * frecency_weight * 100.0) as i64;
(item, boosted) (item, boosted)
}) })
.collect(); .collect();
// Combine widgets (already in results) with frecency items // Partial sort: O(n) average to find top max_results, then O(k log k) to order them
results.extend(items); if scored_refs.len() > max_results {
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
scored_refs.truncate(max_results);
}
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
// Clone only the survivors
results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score)));
results.sort_by(|a, b| b.1.cmp(&a.1)); results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results); results.truncate(max_results);
return results; return results;
@@ -648,7 +868,7 @@ impl ProviderManager {
// Regular search with frecency boost and tag matching // Regular search with frecency boost and tag matching
// Helper closure for scoring items // Helper closure for scoring items
let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> { let score_item = |item: &LaunchItem| -> Option<i64> {
// Apply tag filter if present // Apply tag filter if present
if let Some(tag) = tag_filter if let Some(tag) = tag_filter
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) && !item.tags.iter().any(|t| t.to_lowercase().contains(tag))
@@ -682,35 +902,59 @@ impl ProviderManager {
}; };
base_score.map(|s| { base_score.map(|s| {
let frecency_score = frecency.get_score(&item.id); let frecency_score = frecency.get_score_at(&item.id, now);
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64; let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
(item.clone(), s + frecency_boost)
// Exact name match bonus — apps get a higher boost
let exact_match_boost = if item.name.eq_ignore_ascii_case(query) {
match &item.provider {
ProviderType::Application => 50_000,
_ => 30_000,
}
} else {
0
};
s + frecency_boost + exact_match_boost
}) })
}; };
// Search core providers // Score static items by reference (no cloning)
let mut scored_refs: Vec<(&LaunchItem, i64)> = Vec::new();
for provider in &self.providers { for provider in &self.providers {
if !filter.is_active(provider.provider_type()) { if !filter.is_active(provider.provider_type()) {
continue; continue;
} }
for item in provider.items() { for item in provider.items() {
if let Some(scored) = score_item(item) { if let Some(score) = score_item(item) {
results.push(scored); scored_refs.push((item, score));
} }
} }
} }
// Search static native providers
for provider in &self.static_native_providers { for provider in &self.static_native_providers {
if !filter.is_active(provider.provider_type()) { if !filter.is_active(provider.provider_type()) {
continue; continue;
} }
for item in provider.items() { for item in provider.items() {
if let Some(scored) = score_item(item) { if let Some(score) = score_item(item) {
results.push(scored); scored_refs.push((item, score));
} }
} }
} }
// Partial sort: O(n) average to find top max_results, then O(k log k) to order them
if scored_refs.len() > max_results {
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
scored_refs.truncate(max_results);
}
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
// Clone only the survivors
results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score)));
// Final sort merges dynamic results (already in `results`) with static top-N
results.sort_by(|a, b| b.1.cmp(&a.1)); results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results); results.truncate(max_results);
@@ -991,6 +1235,7 @@ mod tests {
command: format!("run-{}", id), command: format!("run-{}", id),
terminal: false, terminal: false,
tags: Vec::new(), tags: Vec::new(),
source: ItemSource::Core,
} }
} }
@@ -1076,4 +1321,5 @@ mod tests {
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].0.name, "Firefox"); assert_eq!(results[0].0.name, "Firefox");
} }
} }

View File

@@ -6,14 +6,14 @@
//! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files //! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files
//! and provide search providers via an ABI-stable interface. //! and provide search providers via an ABI-stable interface.
use std::sync::{Arc, RwLock}; use std::sync::Arc;
use log::debug; use log::debug;
use owlry_plugin_api::{ use owlry_plugin_api::{
PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition, PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition,
}; };
use super::{LaunchItem, Provider, ProviderType}; use super::{ItemSource, LaunchItem, Provider, ProviderType};
use crate::plugins::native_loader::NativePlugin; use crate::plugins::native_loader::NativePlugin;
/// A provider backed by a native plugin /// A provider backed by a native plugin
@@ -28,7 +28,7 @@ pub struct NativeProvider {
/// Handle to the provider state in the plugin /// Handle to the provider state in the plugin
handle: ProviderHandle, handle: ProviderHandle,
/// Cached items (for static providers) /// Cached items (for static providers)
items: RwLock<Vec<LaunchItem>>, items: Vec<LaunchItem>,
} }
impl NativeProvider { impl NativeProvider {
@@ -40,7 +40,7 @@ impl NativeProvider {
plugin, plugin,
info, info,
handle, handle,
items: RwLock::new(Vec::new()), items: Vec::new(),
} }
} }
@@ -50,6 +50,21 @@ impl NativeProvider {
ProviderType::Plugin(self.info.type_id.to_string()) 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 /// Convert a plugin API item to a core LaunchItem
fn convert_item(&self, item: ApiPluginItem) -> LaunchItem { fn convert_item(&self, item: ApiPluginItem) -> LaunchItem {
LaunchItem { LaunchItem {
@@ -61,6 +76,7 @@ impl NativeProvider {
command: item.command.to_string(), command: item.command.to_string(),
terminal: item.terminal, terminal: item.terminal,
tags: item.keywords.iter().map(|s| s.to_string()).collect(), tags: item.keywords.iter().map(|s| s.to_string()).collect(),
source: ItemSource::NativePlugin,
} }
} }
@@ -74,7 +90,7 @@ impl NativeProvider {
let is_special_query = query.starts_with("?SUBMENU:") || query.starts_with("!"); let is_special_query = query.starts_with("?SUBMENU:") || query.starts_with("!");
if self.info.provider_type != ProviderKind::Dynamic && !is_special_query { if self.info.provider_type != ProviderKind::Dynamic && !is_special_query {
return self.items.read().unwrap().clone(); return self.items.clone();
} }
let api_items = self.plugin.query_provider(self.handle, query); let api_items = self.plugin.query_provider(self.handle, query);
@@ -171,22 +187,11 @@ impl Provider for NativeProvider {
items.len() items.len()
); );
*self.items.write().unwrap() = items; self.items = items;
} }
fn items(&self) -> &[LaunchItem] { fn items(&self) -> &[LaunchItem] {
// This is tricky with RwLock - we need to return a reference but can't &self.items
// hold the lock across the return. We use a raw pointer approach.
//
// SAFETY: The items Vec is only modified during refresh() which takes
// &mut self, so no concurrent modification can occur while this
// reference is live.
unsafe {
let guard = self.items.read().unwrap();
let ptr = guard.as_ptr();
let len = guard.len();
std::slice::from_raw_parts(ptr, len)
}
} }
} }

View File

@@ -0,0 +1,149 @@
use super::{ItemSource, LaunchItem, Provider, ProviderType};
/// Built-in system provider. Returns a fixed set of power and session management actions.
///
/// This is a static provider — items are populated in `new()` and `refresh()` is a no-op.
pub(crate) struct SystemProvider {
items: Vec<LaunchItem>,
}
impl SystemProvider {
pub fn new() -> Self {
let commands: &[(&str, &str, &str, &str, &str)] = &[
(
"shutdown",
"Shutdown",
"Power off the system",
"system-shutdown",
"systemctl poweroff",
),
(
"reboot",
"Reboot",
"Restart the system",
"system-reboot",
"systemctl reboot",
),
(
"reboot-bios",
"Reboot to BIOS",
"Restart into UEFI/BIOS setup",
"system-reboot",
"systemctl reboot --firmware-setup",
),
(
"suspend",
"Suspend",
"Suspend to RAM",
"system-suspend",
"systemctl suspend",
),
(
"hibernate",
"Hibernate",
"Suspend to disk",
"system-suspend-hibernate",
"systemctl hibernate",
),
(
"lock",
"Lock Screen",
"Lock the session",
"system-lock-screen",
"loginctl lock-session",
),
(
"logout",
"Log Out",
"End the current session",
"system-log-out",
"loginctl terminate-session self",
),
];
let items = commands
.iter()
.map(|(action_id, name, description, icon, command)| LaunchItem {
id: format!("sys:{}", action_id),
name: name.to_string(),
description: Some(description.to_string()),
icon: Some(icon.to_string()),
provider: ProviderType::Plugin("sys".into()),
command: command.to_string(),
terminal: false,
tags: vec!["system".into()],
source: ItemSource::Core,
})
.collect();
Self { items }
}
}
impl Provider for SystemProvider {
fn name(&self) -> &str {
"System"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin("sys".into())
}
fn refresh(&mut self) {
// Static provider — no-op
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn has_seven_actions() {
let provider = SystemProvider::new();
assert_eq!(provider.items().len(), 7);
}
#[test]
fn contains_expected_action_names() {
let provider = SystemProvider::new();
let names: Vec<&str> = provider.items().iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"Shutdown"));
assert!(names.contains(&"Reboot"));
assert!(names.contains(&"Lock Screen"));
assert!(names.contains(&"Log Out"));
}
#[test]
fn provider_type_is_sys_plugin() {
let provider = SystemProvider::new();
assert_eq!(provider.provider_type(), ProviderType::Plugin("sys".into()));
}
#[test]
fn shutdown_command_is_correct() {
let provider = SystemProvider::new();
let shutdown = provider
.items()
.iter()
.find(|i| i.name == "Shutdown")
.expect("Shutdown item must exist");
assert_eq!(shutdown.command, "systemctl poweroff");
}
#[test]
fn all_items_have_system_tag() {
let provider = SystemProvider::new();
for item in provider.items() {
assert!(
item.tags.contains(&"system".to_string()),
"item '{}' is missing 'system' tag",
item.name
);
}
}
}

View File

@@ -2,6 +2,7 @@ use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use std::time::Duration; use std::time::Duration;
use std::thread; use std::thread;
@@ -9,6 +10,73 @@ use std::thread;
/// Maximum allowed size for a single IPC request line (1 MiB). /// Maximum allowed size for a single IPC request line (1 MiB).
const MAX_REQUEST_SIZE: usize = 1_048_576; 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 log::{error, info, warn};
use crate::config::Config; use crate::config::Config;
@@ -24,7 +92,7 @@ pub struct Server {
socket_path: PathBuf, socket_path: PathBuf,
provider_manager: Arc<RwLock<ProviderManager>>, provider_manager: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>, frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>, config: Arc<RwLock<Config>>,
} }
impl Server { impl Server {
@@ -42,8 +110,10 @@ impl Server {
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?; std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
info!("IPC server listening on {:?}", socket_path); info!("IPC server listening on {:?}", socket_path);
let config = Config::load_or_default(); let config = Arc::new(RwLock::new(Config::load_or_default()));
let provider_manager = ProviderManager::new_with_config(&config); // 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(); let frecency = FrecencyStore::new();
Ok(Self { Ok(Self {
@@ -51,7 +121,7 @@ impl Server {
socket_path: socket_path.to_path_buf(), socket_path: socket_path.to_path_buf(),
provider_manager: Arc::new(RwLock::new(provider_manager)), provider_manager: Arc::new(RwLock::new(provider_manager)),
frecency: Arc::new(RwLock::new(frecency)), 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 // Start filesystem watcher for user plugin hot-reload
crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager)); 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"); info!("Server entering accept loop");
for stream in self.listener.incoming() { for stream in self.listener.incoming() {
match stream { match stream {
Ok(stream) => { Ok(mut stream) => {
let pm = Arc::clone(&self.provider_manager); match ConnectionGuard::try_acquire() {
let frecency = Arc::clone(&self.frecency); Some(guard) => {
let config = Arc::clone(&self.config); let pm = Arc::clone(&self.provider_manager);
thread::spawn(move || { let frecency = Arc::clone(&self.frecency);
if let Err(e) = Self::handle_client(stream, pm, frecency, config) { let config = Arc::clone(&self.config);
warn!("Client handler error: {}", e); 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) => { Err(e) => {
error!("Failed to accept connection: {}", e); error!("Failed to accept connection: {}", e);
@@ -101,30 +259,25 @@ impl Server {
stream: UnixStream, stream: UnixStream,
pm: Arc<RwLock<ProviderManager>>, pm: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>, frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>, config: Arc<RwLock<Config>>,
) -> io::Result<()> { ) -> io::Result<()> {
stream.set_read_timeout(Some(Duration::from_secs(30)))?; stream.set_read_timeout(Some(Duration::from_secs(30)))?;
let mut reader = BufReader::new(stream.try_clone()?); let mut reader = BufReader::new(stream.try_clone()?);
let mut writer = stream; let mut writer = stream;
loop { loop {
let mut line = String::new(); let line = match read_bounded_line(&mut reader, MAX_REQUEST_SIZE) {
let bytes_read = reader.read_line(&mut line)?; Ok(Some(l)) => l,
if bytes_read == 0 { Ok(None) => break,
break; Err(e) if e.kind() == io::ErrorKind::InvalidData => {
} let resp = Response::Error {
message: format!("request too large (max {} bytes)", MAX_REQUEST_SIZE),
if line.len() > MAX_REQUEST_SIZE { };
let resp = Response::Error { write_response(&mut writer, &resp)?;
message: format!( break;
"request too large ({} bytes, max {})", }
line.len(), Err(e) => return Err(e),
MAX_REQUEST_SIZE };
),
};
write_response(&mut writer, &resp)?;
break;
}
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -156,7 +309,7 @@ impl Server {
request: &Request, request: &Request,
pm: &Arc<RwLock<ProviderManager>>, pm: &Arc<RwLock<ProviderManager>>,
frecency: &Arc<RwLock<FrecencyStore>>, frecency: &Arc<RwLock<FrecencyStore>>,
config: &Arc<Config>, config: &Arc<RwLock<Config>>,
) -> Response { ) -> Response {
match request { match request {
Request::Query { text, modes } => { Request::Query { text, modes } => {
@@ -164,11 +317,22 @@ impl Server {
Some(m) => ProviderFilter::from_mode_strings(m), Some(m) => ProviderFilter::from_mode_strings(m),
None => ProviderFilter::all(), None => ProviderFilter::all(),
}; };
let max = config.general.max_results; let (max, weight) = {
let weight = config.providers.frecency_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 pm_guard = match pm.read() {
let frecency_guard = frecency.read().unwrap_or_else(|e| e.into_inner()); 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( let results = pm_guard.search_with_frecency(
text, text,
max, max,
@@ -190,13 +354,19 @@ impl Server {
item_id, item_id,
provider: _, 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); frecency_guard.record_launch(item_id);
Response::Ack Response::Ack
} }
Request::Providers => { 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(); let descs = pm_guard.available_providers();
Response::Providers { Response::Providers {
list: descs.into_iter().map(descriptor_to_desc).collect(), list: descs.into_iter().map(descriptor_to_desc).collect(),
@@ -204,7 +374,10 @@ impl Server {
} }
Request::Refresh { provider } => { 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); pm_guard.refresh_provider(provider);
Response::Ack Response::Ack
} }
@@ -215,7 +388,10 @@ impl Server {
} }
Request::Submenu { plugin_id, data } => { 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) { match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) {
Some((_name, actions)) => Response::SubmenuItems { Some((_name, actions)) => Response::SubmenuItems {
items: actions items: actions
@@ -230,7 +406,10 @@ impl Server {
} }
Request::PluginAction { command } => { 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) { if pm_guard.execute_plugin_action(command) {
Response::Ack Response::Ack
} else { } 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), command: Some(item.command),
terminal: item.terminal, terminal: item.terminal,
tags: item.tags, 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, 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()), command: Some("firefox".into()),
terminal: false, terminal: false,
tags: vec![], tags: vec![],
source: "core".into(),
}], }],
}; };
let json = serde_json::to_string(&resp).unwrap(); let json = serde_json::to_string(&resp).unwrap();
@@ -140,6 +141,7 @@ fn test_terminal_field_roundtrip() {
command: Some("htop".into()), command: Some("htop".into()),
terminal: true, terminal: true,
tags: vec![], tags: vec![],
source: "cmd".into(),
}; };
let json = serde_json::to_string(&item).unwrap(); let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("\"terminal\":true")); assert!(json.contains("\"terminal\":true"));

View File

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

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>> { pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
provider::call_query(lua, provider_name, query) 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(()) 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 /// Get all registered providers
pub fn get_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> { pub fn get_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
// Suppress unused warning // Suppress unused warning

View File

@@ -68,8 +68,11 @@ pub struct RuntimeHandle {
pub ptr: *mut (), 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 Send for RuntimeHandle {}
unsafe impl Sync for RuntimeHandle {}
impl RuntimeHandle { impl RuntimeHandle {
/// Create a null handle (reserved for error cases) /// Create a null handle (reserved for error cases)

View File

@@ -96,8 +96,28 @@ impl LoadedPlugin {
.as_ref() .as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?; .ok_or_else(|| "Plugin not initialized".to_string())?;
api::get_provider_registrations(lua) let mut regs = api::get_provider_registrations(lua)
.map_err(|e| format!("Failed to get registrations: {}", e)) .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 /// Call a provider's refresh function
@@ -107,7 +127,17 @@ impl LoadedPlugin {
.as_ref() .as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?; .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 /// Call a provider's query function
@@ -156,9 +186,18 @@ pub fn discover_plugins(
match PluginManifest::load(&manifest_path) { match PluginManifest::load(&manifest_path) {
Ok(manifest) => { 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(); let id = manifest.plugin.id.clone();
if plugins.contains_key(&id) { if plugins.contains_key(&id) {
eprintln!( log::warn!(
"owlry-lua: Duplicate plugin ID '{}', skipping {}", "owlry-lua: Duplicate plugin ID '{}', skipping {}",
id, id,
path.display() path.display()
@@ -168,7 +207,7 @@ pub fn discover_plugins(
plugins.insert(id, (manifest, path)); plugins.insert(id, (manifest, path));
} }
Err(e) => { Err(e) => {
eprintln!( log::warn!(
"owlry-lua: Failed to load plugin at {}: {}", "owlry-lua: Failed to load plugin at {}: {}",
path.display(), path.display(),
e e
@@ -229,4 +268,79 @@ version = "1.0.0"
let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap(); let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap();
assert!(plugins.is_empty()); 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest { pub struct PluginManifest {
pub plugin: PluginInfo, pub plugin: PluginInfo,
/// Provider declarations from [[providers]] sections (new-style)
#[serde(default)]
pub providers: Vec<ProviderDecl>,
/// Legacy provides block (old-style)
#[serde(default)] #[serde(default)]
pub provides: PluginProvides, pub provides: PluginProvides,
#[serde(default)] #[serde(default)]
@@ -16,6 +20,26 @@ pub struct PluginManifest {
pub settings: HashMap<String, toml::Value>, 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 /// Core plugin information
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo { 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(()) 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 /// Current plugin API version - plugins must match this
/// v2: Added ProviderPosition for widget support /// v2: Added ProviderPosition for widget support
/// v3: Added priority field for plugin-declared result ordering /// 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 /// Plugin metadata returned by the info function
#[repr(C)] #[repr(C)]
@@ -295,6 +296,18 @@ pub struct HostAPI {
/// Log a message at error level /// Log a message at error level
pub log_error: extern "C" fn(message: RStr<'_>), 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; 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 /// Helper macro for defining plugin vtables
/// ///
/// Usage: /// Usage:

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "owlry-rune" name = "owlry-rune"
version = "1.1.0" version = "1.1.4"
edition = "2024" edition = "2024"
rust-version = "1.90" rust-version = "1.90"
description = "Rune scripting runtime for owlry plugins" description = "Rune scripting runtime for owlry plugins"
@@ -15,14 +15,13 @@ owlry-plugin-api = { path = "../owlry-plugin-api" }
# Rune scripting language # Rune scripting language
rune = "0.14" rune = "0.14"
rune-modules = { version = "0.14", features = ["full"] }
# Logging # Logging
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
# HTTP client for network API # HTTP client for network API
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] } reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] }
# Serialization # Serialization
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View File

@@ -203,6 +203,7 @@ pub fn discover_rune_plugins(plugins_dir: &Path) -> Result<HashMap<String, Loade
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::fs;
use tempfile::TempDir; use tempfile::TempDir;
#[test] #[test]
@@ -211,4 +212,81 @@ mod tests {
let plugins = discover_rune_plugins(temp.path()).unwrap(); let plugins = discover_rune_plugins(temp.path()).unwrap();
assert!(plugins.is_empty()); 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] [package]
name = "owlry" name = "owlry"
version = "1.0.1" version = "1.0.8"
edition = "2024" edition = "2024"
rust-version = "1.90" rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland" description = "A lightweight, owl-themed application launcher for Wayland"
@@ -46,6 +46,9 @@ dirs = "5"
# Semantic versioning (needed by plugin commands) # Semantic versioning (needed by plugin commands)
semver = "1" semver = "1"
# Async oneshot channel (background thread -> main loop)
futures-channel = "0.3"
[build-dependencies] [build-dependencies]
# GResource compilation for bundled icons # GResource compilation for bundled icons
glib-build-tools = "0.20" glib-build-tools = "0.20"

View File

@@ -1,12 +1,11 @@
fn main() { fn main() {
// Compile GResource bundle for icons // Compile GResource bundle for plugin-specific icons (weather, media, pomodoro)
glib_build_tools::compile_resources( glib_build_tools::compile_resources(
&["src/resources/icons"], &["src/resources/icons"],
"src/resources/icons.gresource.xml", "src/resources/icons.gresource.xml",
"icons.gresource", "icons.gresource",
); );
// Rerun if icon files change
println!("cargo:rerun-if-changed=src/resources/icons.gresource.xml"); println!("cargo:rerun-if-changed=src/resources/icons.gresource.xml");
println!("cargo:rerun-if-changed=src/resources/icons/"); println!("cargo:rerun-if-changed=src/resources/icons/");
} }

View File

@@ -69,7 +69,7 @@ impl OwlryApp {
match CoreClient::connect_or_start() { match CoreClient::connect_or_start() {
Ok(client) => { Ok(client) => {
info!("Connected to owlry-core daemon"); info!("Connected to owlry-core daemon");
SearchBackend::Daemon(client) SearchBackend::Daemon(crate::backend::DaemonHandle::new(client))
} }
Err(e) => { Err(e) => {
warn!( warn!(
@@ -91,17 +91,20 @@ impl OwlryApp {
.iter() .iter()
.map(|s| ProviderFilter::mode_string_to_provider_type(s)) .map(|s| ProviderFilter::mode_string_to_provider_type(s))
.collect(); .collect();
let tabs = &config.borrow().general.tabs.clone();
if provider_types.len() == 1 { if provider_types.len() == 1 {
ProviderFilter::new( ProviderFilter::new(
Some(provider_types[0].clone()), Some(provider_types[0].clone()),
None, None,
&config.borrow().providers, &config.borrow().providers,
tabs,
) )
} else { } else {
ProviderFilter::new(None, Some(provider_types), &config.borrow().providers) ProviderFilter::new(None, Some(provider_types), &config.borrow().providers, tabs)
} }
} else { } 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)); let filter = Rc::new(RefCell::new(filter));
@@ -135,6 +138,9 @@ impl OwlryApp {
Self::load_css(&config.borrow()); Self::load_css(&config.borrow());
window.present(); window.present();
// Populate results AFTER present() so the window appears immediately
window.schedule_initial_results();
} }
/// Create a local backend as fallback when daemon is unavailable. /// Create a local backend as fallback when daemon is unavailable.
@@ -182,16 +188,25 @@ impl OwlryApp {
} }
fn setup_icon_theme() { fn setup_icon_theme() {
// Ensure we have icon fallbacks for weather/media icons
// These may not exist in all icon themes
if let Some(display) = gtk4::gdk::Display::default() { if let Some(display) = gtk4::gdk::Display::default() {
let icon_theme = gtk4::IconTheme::for_display(&display); let icon_theme = gtk4::IconTheme::for_display(&display);
// Add Adwaita as fallback search path (has weather and media icons) // If the system icon theme doesn't exist on disk (e.g., set in
icon_theme.add_search_path("/usr/share/icons/Adwaita"); // gsettings but not installed), GTK falls back to hicolor which
icon_theme.add_search_path("/usr/share/icons/breeze"); // has almost no icons. Detect this and use Adwaita instead.
let theme_name = icon_theme.theme_name();
let theme_exists = icon_theme
.search_path()
.iter()
.any(|p| p.join(theme_name.as_str()).is_dir());
debug!("Icon theme search paths configured with Adwaita/breeze fallbacks"); if !theme_exists && theme_name != "hicolor" && theme_name != "Adwaita" {
info!(
"Icon theme '{}' not found on disk, falling back to Adwaita",
theme_name
);
icon_theme.set_theme_name(Some("Adwaita"));
}
} }
} }

View File

@@ -9,13 +9,88 @@ use owlry_core::config::Config;
use owlry_core::data::FrecencyStore; use owlry_core::data::FrecencyStore;
use owlry_core::filter::ProviderFilter; use owlry_core::filter::ProviderFilter;
use owlry_core::ipc::ResultItem; 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.
pub struct QueryParams {
pub query: String,
#[allow(dead_code)]
pub max_results: usize,
pub modes: Option<Vec<String>>,
pub tag_filter: Option<String>,
}
/// Result of an async search, sent back to the main thread.
pub struct QueryResult {
#[allow(dead_code)]
pub query: String,
pub items: Vec<LaunchItem>,
}
/// Thread-safe handle to the daemon IPC connection.
pub struct DaemonHandle {
pub(crate) client: Arc<Mutex<CoreClient>>,
}
impl DaemonHandle {
pub fn new(client: CoreClient) -> Self {
Self {
client: Arc::new(Mutex::new(client)),
}
}
/// Dispatch an IPC query on a background thread.
///
/// Returns a `futures_channel::oneshot::Receiver` that resolves with
/// the `QueryResult` once the background thread completes IPC. The
/// caller should `.await` it inside `glib::spawn_future_local` to
/// process results on the GTK main thread without `Send` constraints.
pub fn query_async(
&self,
params: QueryParams,
) -> futures_channel::oneshot::Receiver<QueryResult> {
let (tx, rx) = futures_channel::oneshot::channel();
let client = Arc::clone(&self.client);
let query_for_result = params.query.clone();
std::thread::spawn(move || {
let items = match client.lock() {
Ok(mut c) => {
let effective_query = if let Some(ref tag) = params.tag_filter {
format!(":tag:{} {}", tag, params.query)
} else {
params.query
};
match c.query(&effective_query, params.modes) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => {
warn!("IPC query failed: {}", e);
Vec::new()
}
}
}
Err(e) => {
warn!("Failed to lock daemon client: {}", e);
Vec::new()
}
};
let _ = tx.send(QueryResult {
query: query_for_result,
items,
});
});
rx
}
}
/// Backend for search operations. Wraps either an IPC client (daemon mode) /// Backend for search operations. Wraps either an IPC client (daemon mode)
/// or a local ProviderManager (dmenu mode). /// or a local ProviderManager (dmenu mode).
pub enum SearchBackend { pub enum SearchBackend {
/// IPC client connected to owlry-core daemon /// IPC client connected to owlry-core daemon
Daemon(CoreClient), Daemon(DaemonHandle),
/// Direct local provider manager (dmenu mode only) /// Direct local provider manager (dmenu mode only)
Local { Local {
providers: Box<ProviderManager>, providers: Box<ProviderManager>,
@@ -24,6 +99,22 @@ pub enum SearchBackend {
} }
impl SearchBackend { impl SearchBackend {
/// Build the modes parameter from a ProviderFilter.
/// When accept_all, returns None so the daemon doesn't restrict to a specific set
/// (otherwise dynamically loaded plugin types would be filtered out).
fn build_modes_param(filter: &ProviderFilter) -> Option<Vec<String>> {
if filter.is_accept_all() {
None
} else {
let modes: Vec<String> = filter
.enabled_providers()
.iter()
.map(|p| p.to_string())
.collect();
if modes.is_empty() { None } else { Some(modes) }
}
}
/// Search for items matching the query. /// Search for items matching the query.
/// ///
/// In daemon mode, sends query over IPC. The modes list is derived from /// In daemon mode, sends query over IPC. The modes list is derived from
@@ -38,24 +129,18 @@ impl SearchBackend {
config: &Config, config: &Config,
) -> Vec<LaunchItem> { ) -> Vec<LaunchItem> {
match self { match self {
SearchBackend::Daemon(client) => { SearchBackend::Daemon(handle) => {
// When accept_all, send None so daemon doesn't restrict to a specific set let modes_param = Self::build_modes_param(filter);
// (otherwise dynamically loaded plugin types would be filtered out) match handle.client.lock() {
let modes_param = if filter.is_accept_all() { Ok(mut client) => match client.query(query, modes_param) {
None Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
} else { Err(e) => {
let modes: Vec<String> = filter warn!("IPC query failed: {}", e);
.enabled_providers() Vec::new()
.iter() }
.map(|p| p.to_string()) },
.collect();
if modes.is_empty() { None } else { Some(modes) }
};
match client.query(query, modes_param) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => { Err(e) => {
warn!("IPC query failed: {}", e); warn!("Failed to lock daemon client: {}", e);
Vec::new() Vec::new()
} }
} }
@@ -82,7 +167,7 @@ impl SearchBackend {
.collect() .collect()
} else { } else {
providers providers
.search_filtered(query, max_results, filter) .search_filtered(query, max_results, filter, None)
.into_iter() .into_iter()
.map(|(item, _)| item) .map(|(item, _)| item)
.collect() .collect()
@@ -101,32 +186,24 @@ impl SearchBackend {
tag_filter: Option<&str>, tag_filter: Option<&str>,
) -> Vec<LaunchItem> { ) -> Vec<LaunchItem> {
match self { match self {
SearchBackend::Daemon(client) => { SearchBackend::Daemon(handle) => {
// Daemon doesn't support tag filtering in IPC yet — pass query as-is.
// If there's a tag filter, prepend it so the daemon can handle it.
let effective_query = if let Some(tag) = tag_filter { let effective_query = if let Some(tag) = tag_filter {
format!(":tag:{} {}", tag, query) format!(":tag:{} {}", tag, query)
} else { } else {
query.to_string() query.to_string()
}; };
// When accept_all, send None so daemon doesn't restrict to a specific set let modes_param = Self::build_modes_param(filter);
// (otherwise dynamically loaded plugin types would be filtered out) match handle.client.lock() {
let modes_param = if filter.is_accept_all() { Ok(mut client) => match client.query(&effective_query, modes_param) {
None Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
} else { Err(e) => {
let modes: Vec<String> = filter warn!("IPC query failed: {}", e);
.enabled_providers() Vec::new()
.iter() }
.map(|p| p.to_string()) },
.collect();
if modes.is_empty() { None } else { Some(modes) }
};
match client.query(&effective_query, modes_param) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => { Err(e) => {
warn!("IPC query failed: {}", e); warn!("Failed to lock daemon client: {}", e);
Vec::new() Vec::new()
} }
} }
@@ -153,7 +230,7 @@ impl SearchBackend {
.collect() .collect()
} else { } else {
providers providers
.search_filtered(query, max_results, filter) .search_filtered(query, max_results, filter, tag_filter)
.into_iter() .into_iter()
.map(|(item, _)| item) .map(|(item, _)| item)
.collect() .collect()
@@ -162,13 +239,43 @@ impl SearchBackend {
} }
} }
/// Dispatch async search (daemon mode only).
/// Returns `Some(Receiver)` if dispatched, `None` for local mode.
pub fn query_async(
&self,
query: &str,
max_results: usize,
filter: &ProviderFilter,
_config: &Config,
tag_filter: Option<&str>,
) -> Option<futures_channel::oneshot::Receiver<QueryResult>> {
match self {
SearchBackend::Daemon(handle) => {
let params = QueryParams {
query: query.to_string(),
max_results,
modes: Self::build_modes_param(filter),
tag_filter: tag_filter.map(|s| s.to_string()),
};
Some(handle.query_async(params))
}
SearchBackend::Local { .. } => None,
}
}
/// Execute a plugin action command. Returns true if handled. /// Execute a plugin action command. Returns true if handled.
pub fn execute_plugin_action(&mut self, command: &str) -> bool { pub fn execute_plugin_action(&mut self, command: &str) -> bool {
match self { match self {
SearchBackend::Daemon(client) => match client.plugin_action(command) { SearchBackend::Daemon(handle) => match handle.client.lock() {
Ok(handled) => handled, Ok(mut client) => match client.plugin_action(command) {
Ok(handled) => handled,
Err(e) => {
warn!("IPC plugin_action failed: {}", e);
false
}
},
Err(e) => { Err(e) => {
warn!("IPC plugin_action failed: {}", e); warn!("Failed to lock daemon client: {}", e);
false false
} }
}, },
@@ -185,15 +292,21 @@ impl SearchBackend {
display_name: &str, display_name: &str,
) -> Option<(String, Vec<LaunchItem>)> { ) -> Option<(String, Vec<LaunchItem>)> {
match self { match self {
SearchBackend::Daemon(client) => match client.submenu(plugin_id, data) { SearchBackend::Daemon(handle) => match handle.client.lock() {
Ok(items) if !items.is_empty() => { Ok(mut client) => match client.submenu(plugin_id, data) {
let actions: Vec<LaunchItem> = Ok(items) if !items.is_empty() => {
items.into_iter().map(result_to_launch_item).collect(); let actions: Vec<LaunchItem> =
Some((display_name.to_string(), actions)) items.into_iter().map(result_to_launch_item).collect();
} Some((display_name.to_string(), actions))
Ok(_) => None, }
Ok(_) => None,
Err(e) => {
warn!("IPC submenu query failed: {}", e);
None
}
},
Err(e) => { Err(e) => {
warn!("IPC submenu query failed: {}", e); warn!("Failed to lock daemon client: {}", e);
None None
} }
}, },
@@ -206,9 +319,13 @@ impl SearchBackend {
/// Record a launch event for frecency tracking. /// Record a launch event for frecency tracking.
pub fn record_launch(&mut self, item_id: &str, provider: &str) { pub fn record_launch(&mut self, item_id: &str, provider: &str) {
match self { match self {
SearchBackend::Daemon(client) => { SearchBackend::Daemon(handle) => {
if let Err(e) = client.launch(item_id, provider) { if let Ok(mut client) = handle.client.lock() {
warn!("IPC launch notification failed: {}", e); if let Err(e) = client.launch(item_id, provider) {
warn!("IPC launch notification failed: {}", e);
}
} else {
warn!("Failed to lock daemon client for launch");
} }
} }
SearchBackend::Local { frecency, .. } => { SearchBackend::Local { frecency, .. } => {
@@ -236,10 +353,16 @@ impl SearchBackend {
#[allow(dead_code)] #[allow(dead_code)]
pub fn available_provider_ids(&mut self) -> Vec<String> { pub fn available_provider_ids(&mut self) -> Vec<String> {
match self { match self {
SearchBackend::Daemon(client) => match client.providers() { SearchBackend::Daemon(handle) => match handle.client.lock() {
Ok(descs) => descs.into_iter().map(|d| d.id).collect(), Ok(mut client) => match client.providers() {
Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
Err(e) => {
warn!("IPC providers query failed: {}", e);
Vec::new()
}
},
Err(e) => { Err(e) => {
warn!("IPC providers query failed: {}", e); warn!("Failed to lock daemon client: {}", e);
Vec::new() Vec::new()
} }
}, },
@@ -255,6 +378,7 @@ impl SearchBackend {
/// Convert an IPC ResultItem to the internal LaunchItem type. /// Convert an IPC ResultItem to the internal LaunchItem type.
fn result_to_launch_item(item: ResultItem) -> LaunchItem { fn result_to_launch_item(item: ResultItem) -> LaunchItem {
let provider: ProviderType = item.provider.parse().unwrap_or(ProviderType::Application); let provider: ProviderType = item.provider.parse().unwrap_or(ProviderType::Application);
let source: ItemSource = item.source.parse().unwrap_or(ItemSource::Core);
LaunchItem { LaunchItem {
id: item.id, id: item.id,
name: item.title, name: item.title,
@@ -272,5 +396,6 @@ fn result_to_launch_item(item: ResultItem) -> LaunchItem {
command: item.command.unwrap_or_default(), command: item.command.unwrap_or_default(),
terminal: item.terminal, terminal: item.terminal,
tags: item.tags, tags: item.tags,
source,
} }
} }

View File

@@ -3,9 +3,48 @@ use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem}; use owlry_core::ipc::{PluginEntry, ProviderDesc, Request, Response, ResultItem};
/// IPC client that connects to the owlry-core daemon Unix socket /// 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. /// and provides typed methods for all IPC operations.
pub struct CoreClient { pub struct CoreClient {
stream: UnixStream, stream: UnixStream,
@@ -38,15 +77,15 @@ impl CoreClient {
// Socket not available — try to start the daemon. // Socket not available — try to start the daemon.
let status = std::process::Command::new("systemctl") let status = std::process::Command::new("systemctl")
.args(["--user", "start", "owlry-core"]) .args(["--user", "start", "owlryd"])
.status() .status()
.map_err(|e| { .map_err(|e| {
io::Error::other(format!("failed to start owlry-core via systemd: {e}")) io::Error::other(format!("failed to start owlryd via systemd: {e}"))
})?; })?;
if !status.success() { if !status.success() {
return Err(io::Error::other(format!( return Err(io::Error::other(format!(
"systemctl --user start owlry-core exited with status {}", "systemctl --user start owlryd exited with status {}",
status status
))); )));
} }
@@ -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. /// Query a plugin's submenu actions.
pub fn submenu(&mut self, plugin_id: &str, data: &str) -> io::Result<Vec<ResultItem>> { pub fn submenu(&mut self, plugin_id: &str, data: &str) -> io::Result<Vec<ResultItem>> {
self.send(&Request::Submenu { self.send(&Request::Submenu {
@@ -186,14 +238,15 @@ impl CoreClient {
} }
fn receive(&mut self) -> io::Result<Response> { fn receive(&mut self) -> io::Result<Response> {
let mut line = String::new(); let line = match read_bounded_line(&mut self.reader, MAX_RESPONSE_SIZE)? {
self.reader.read_line(&mut line)?; Some(l) => l,
if line.is_empty() { None => {
return Err(io::Error::new( return Err(io::Error::new(
io::ErrorKind::UnexpectedEof, io::ErrorKind::UnexpectedEof,
"daemon closed the connection", "daemon closed the connection",
)); ))
} }
};
serde_json::from_str(line.trim()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) 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()), command: Some("firefox".into()),
terminal: false, terminal: false,
tags: vec![], tags: vec![],
source: "app".into(),
}], }],
}; };
@@ -327,6 +381,7 @@ mod tests {
command: Some("systemctl --user start foo".into()), command: Some("systemctl --user start foo".into()),
terminal: false, terminal: false,
tags: vec![], tags: vec![],
source: "native_plugin".into(),
}], }],
}; };

View File

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

View File

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

View File

@@ -14,6 +14,7 @@
background-color: var(--owlry-bg, @theme_bg_color); background-color: var(--owlry-bg, @theme_bg_color);
border-radius: var(--owlry-border-radius, 12px); border-radius: var(--owlry-border-radius, 12px);
border: 1px solid var(--owlry-border, @borders); border: 1px solid var(--owlry-border, @borders);
box-shadow: var(--owlry-shadow, none);
padding: 12px; padding: 12px;
} }
@@ -56,6 +57,16 @@
color: var(--owlry-accent-bright, @theme_selected_fg_color); color: var(--owlry-accent-bright, @theme_selected_fg_color);
} }
/* Highlighted result row (exact match or auto-detected plugin result) */
.owlry-result-highlight {
background-color: alpha(var(--owlry-accent, @theme_selected_bg_color), 0.08);
border-left: 3px solid var(--owlry-accent, @theme_selected_bg_color);
}
.owlry-result-highlight:selected {
border-left: 3px solid var(--owlry-accent-bright, @theme_selected_fg_color);
}
/* Result icon */ /* Result icon */
.owlry-result-icon { .owlry-result-icon {
color: var(--owlry-text, @theme_fg_color); color: var(--owlry-text, @theme_fg_color);

View File

@@ -31,8 +31,6 @@
.owlry-main { .owlry-main {
background-color: rgba(26, 27, 38, 0.95); background-color: rgba(26, 27, 38, 0.95);
border: 1px solid rgba(65, 72, 104, 0.6); border: 1px solid rgba(65, 72, 104, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(224, 175, 104, 0.1);
} }
/* Search entry */ /* Search entry */

View File

@@ -1,5 +1,6 @@
use crate::backend::SearchBackend; use crate::backend::SearchBackend;
use crate::ui::ResultRow; use crate::ui::ResultRow;
use crate::ui::provider_meta;
use crate::ui::submenu; use crate::ui::submenu;
use gtk4::gdk::Key; use gtk4::gdk::Key;
use gtk4::prelude::*; use gtk4::prelude::*;
@@ -10,7 +11,7 @@ use gtk4::{
use log::info; use log::info;
use owlry_core::config::Config; use owlry_core::config::Config;
use owlry_core::filter::ProviderFilter; use owlry_core::filter::ProviderFilter;
use owlry_core::providers::{LaunchItem, ProviderType}; use owlry_core::providers::{ItemSource, LaunchItem, ProviderType};
#[cfg(feature = "dev-logging")] #[cfg(feature = "dev-logging")]
use log::debug; use log::debug;
@@ -42,6 +43,8 @@ struct LazyLoadState {
all_results: Vec<LaunchItem>, all_results: Vec<LaunchItem>,
/// Number of items currently displayed /// Number of items currently displayed
displayed_count: usize, displayed_count: usize,
/// The query that produced these results (for highlighting in lazy-loaded batches)
query: String,
} }
/// Number of items to display initially and per batch /// Number of items to display initially and per batch
@@ -224,7 +227,6 @@ impl MainWindow {
main_window.setup_signals(); main_window.setup_signals();
main_window.setup_lazy_loading(); main_window.setup_lazy_loading();
main_window.update_results("");
// Ensure search entry has focus when window is shown // Ensure search entry has focus when window is shown
main_window.search_entry.grab_focus(); main_window.search_entry.grab_focus();
@@ -240,36 +242,23 @@ impl MainWindow {
search_entry_for_refresh.emit_by_name::<()>("changed", &[]); search_entry_for_refresh.emit_by_name::<()>("changed", &[]);
}); });
// Set up periodic widget auto-refresh (every 5 seconds) — local backend only // Periodic widget refresh — local backend only.
// In daemon mode, the daemon handles widget refresh and results come via IPC // In daemon mode, the daemon handles widget refresh internally;
if main_window.is_dmenu_mode { // the UI gets updated data on the next user-initiated search.
// dmenu typically has no widgets, but this is harmless // We do NOT re-query in daemon mode because it resets the user's
} // scroll position and selection.
let backend_for_auto = main_window.backend.clone(); if !matches!(&*main_window.backend.borrow(), SearchBackend::Daemon(_)) {
let current_results_for_auto = main_window.current_results.clone(); let backend_for_auto = main_window.backend.clone();
let submenu_state_for_auto = main_window.submenu_state.clone(); let debounce_for_auto = main_window.debounce_source.clone();
let search_entry_for_auto = main_window.search_entry.clone(); gtk4::glib::timeout_add_local(std::time::Duration::from_secs(10), move || {
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || { // Skip widget refresh while the user is actively typing.
let in_submenu = submenu_state_for_auto.borrow().active; if debounce_for_auto.borrow().is_some() {
return gtk4::glib::ControlFlow::Continue;
// For local backend: refresh widgets (daemon handles this itself)
backend_for_auto.borrow_mut().refresh_widgets();
// For daemon backend: re-query to get updated widget data
if !in_submenu {
if let SearchBackend::Daemon(_) = &*backend_for_auto.borrow() {
// Trigger a re-search to pick up updated widget items from daemon
search_entry_for_auto.emit_by_name::<()>("changed", &[]);
} else {
// Local backend: update widget items in-place (legacy behavior)
// This path is only hit in dmenu mode which doesn't have widgets,
// but keep it for completeness.
let _results = current_results_for_auto.borrow();
// No-op for local mode without widget access
} }
} backend_for_auto.borrow_mut().refresh_widgets();
gtk4::glib::ControlFlow::Continue gtk4::glib::ControlFlow::Continue
}); });
}
main_window main_window
} }
@@ -332,86 +321,26 @@ impl MainWindow {
/// Get display label for a provider tab /// Get display label for a provider tab
/// Core types have fixed labels; plugins derive labels from type_id /// Core types have fixed labels; plugins derive labels from type_id
fn provider_tab_label(provider: &ProviderType) -> &'static str { fn provider_tab_label(provider: &ProviderType) -> &'static str {
match provider { provider_meta::meta_for(provider).tab_label
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",
},
}
} }
/// 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 { fn provider_css_class(provider: &ProviderType) -> &'static str {
match provider { provider_meta::meta_for(provider).css_class
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",
},
}
} }
fn build_placeholder(filter: &ProviderFilter) -> String { fn build_placeholder(filter: &ProviderFilter) -> String {
let active: Vec<&str> = filter let active: Vec<&str> = filter
.enabled_providers() .enabled_providers()
.iter() .iter()
.map(|p| match p { .map(|p| provider_meta::meta_for(p).search_noun)
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",
},
})
.collect(); .collect();
format!("Search {}...", active.join(", ")) 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 { fn build_hints(config: &owlry_core::config::ProvidersConfig) -> String {
let mut parts: Vec<String> = vec![ let mut parts: Vec<String> = vec![
"Tab: cycle".to_string(), "Tab: cycle".to_string(),
@@ -420,45 +349,26 @@ impl MainWindow {
"Esc: close".to_string(), "Esc: close".to_string(),
]; ];
// Add trigger hints for enabled dynamic providers
if config.calculator { if config.calculator {
parts.push("= calc".to_string()); parts.push("= calc".to_string());
} }
if config.websearch { if config.converter {
parts.push("? web".to_string()); 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 { if config.system {
prefixes.push(":sys"); parts.push(":sys".to_string());
}
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.join(" ") parts.join(" ")
} }
/// Scroll the given row into view within the scrolled window /// Scroll the given row into view within the scrolled window
fn scroll_to_row(scrolled: &ScrolledWindow, results_list: &ListBox, row: &ListBoxRow) { fn scroll_to_row(
scrolled: &ScrolledWindow,
results_list: &ListBox,
row: &ListBoxRow,
lazy_state: &Rc<RefCell<LazyLoadState>>,
) {
let vadj = scrolled.vadjustment(); let vadj = scrolled.vadjustment();
let row_index = row.index(); let row_index = row.index();
@@ -470,15 +380,7 @@ impl MainWindow {
let current_scroll = vadj.value(); let current_scroll = vadj.value();
let list_height = results_list.height() as f64; let list_height = results_list.height() as f64;
let row_count = { let row_count = lazy_state.borrow().displayed_count.max(1) as f64;
let mut count = 0;
let mut child = results_list.first_child();
while child.is_some() {
count += 1;
child = child.and_then(|c| c.next_sibling());
}
count.max(1) as f64
};
let row_height = list_height / row_count; let row_height = list_height / row_count;
let row_top = row_index as f64 * row_height; let row_top = row_index as f64 * row_height;
@@ -527,12 +429,10 @@ impl MainWindow {
search_entry.set_placeholder_text(Some(&format!("Filter {} actions...", display_name))); search_entry.set_placeholder_text(Some(&format!("Filter {} actions...", display_name)));
// Display actions // Display actions
while let Some(child) = results_list.first_child() { results_list.remove_all();
results_list.remove(&child);
}
for item in &actions { for item in &actions {
let row = ResultRow::new(item); let row = ResultRow::new(item, "");
results_list.append(&row); results_list.append(&row);
} }
@@ -609,12 +509,10 @@ impl MainWindow {
.collect(); .collect();
// Clear and repopulate // Clear and repopulate
while let Some(child) = results_list.first_child() { results_list.remove_all();
results_list.remove(&child);
}
for item in &filtered { for item in &filtered {
let row = ResultRow::new(item); let row = ResultRow::new(item, "");
results_list.append(&row); results_list.append(&row);
} }
@@ -675,6 +573,11 @@ impl MainWindow {
let filter = filter.clone(); let filter = filter.clone();
let lazy_state = lazy_state.clone(); let lazy_state = lazy_state.clone();
let debounce_source_for_closure = debounce_source.clone(); let debounce_source_for_closure = debounce_source.clone();
let query_str = parsed.query.clone();
let tag = parsed.tag_filter.clone();
// Capture the raw entry text at dispatch time for staleness detection.
let raw_text_at_dispatch = entry.text().to_string();
let search_entry_for_stale = search_entry_for_change.clone();
// Schedule debounced search // Schedule debounced search
let source_id = gtk4::glib::timeout_add_local_once( let source_id = gtk4::glib::timeout_add_local_once(
@@ -687,40 +590,91 @@ impl MainWindow {
let max_results = cfg.general.max_results; let max_results = cfg.general.max_results;
drop(cfg); drop(cfg);
let results = backend.borrow_mut().search_with_tag( // Try async path (daemon mode)
&parsed.query, let receiver = {
max_results, let be = backend.borrow();
&filter.borrow(), let f = filter.borrow();
&config.borrow(), let c = config.borrow();
parsed.tag_filter.as_deref(), be.query_async(
); &query_str,
max_results,
&f,
&c,
tag.as_deref(),
)
};
// Clear existing results if let Some(rx) = receiver {
while let Some(child) = results_list.first_child() { // Daemon mode: results arrive asynchronously on the main loop.
results_list.remove(&child); // spawn_future_local runs the async block on the GTK main
} // thread, so non-Send types (Rc, GTK widgets) are fine.
let results_list_cb = results_list.clone();
let current_results_cb = current_results.clone();
let lazy_state_cb = lazy_state.clone();
let query_for_highlight = query_str.clone();
// Lazy loading: store all results but only display initial batch gtk4::glib::spawn_future_local(async move {
let initial_count = INITIAL_RESULTS.min(results.len()); if let Ok(result) = rx.await {
{ // Discard stale results: the user has typed something new
// since this query was dispatched.
if search_entry_for_stale.text().as_str() != raw_text_at_dispatch {
return;
}
results_list_cb.remove_all();
let items = result.items;
let initial_count =
INITIAL_RESULTS.min(items.len());
for item in items.iter().take(initial_count) {
let row = ResultRow::new(item, &query_for_highlight);
results_list_cb.append(&row);
}
if let Some(first_row) =
results_list_cb.row_at_index(0)
{
results_list_cb.select_row(Some(&first_row));
}
*current_results_cb.borrow_mut() =
items[..initial_count].to_vec();
let mut lazy = lazy_state_cb.borrow_mut();
lazy.all_results = items;
lazy.displayed_count = initial_count;
lazy.query = query_for_highlight;
}
});
} else {
// Local mode (dmenu): synchronous search
let results = backend.borrow_mut().search_with_tag(
&query_str,
max_results,
&filter.borrow(),
&config.borrow(),
tag.as_deref(),
);
results_list.remove_all();
let initial_count = INITIAL_RESULTS.min(results.len());
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item, &query_str);
results_list.append(&row);
}
if let Some(first_row) = results_list.row_at_index(0) {
results_list.select_row(Some(&first_row));
}
*current_results.borrow_mut() =
results[..initial_count].to_vec();
let mut lazy = lazy_state.borrow_mut(); let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results.clone(); lazy.all_results = results;
lazy.query = query_str;
lazy.displayed_count = initial_count; lazy.displayed_count = initial_count;
} }
// Display only initial batch
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
results_list.append(&row);
}
if let Some(first_row) = results_list.row_at_index(0) {
results_list.select_row(Some(&first_row));
}
// current_results holds only what's displayed (for selection/activation)
*current_results.borrow_mut() =
results.into_iter().take(initial_count).collect();
}, },
); );
@@ -856,6 +810,7 @@ impl MainWindow {
let submenu_state = self.submenu_state.clone(); let submenu_state = self.submenu_state.clone();
let tab_order = self.tab_order.clone(); let tab_order = self.tab_order.clone();
let is_dmenu_mode = self.is_dmenu_mode; let is_dmenu_mode = self.is_dmenu_mode;
let lazy_state_for_keys = self.lazy_state.clone();
key_controller.connect_key_pressed(move |_, key, _, modifiers| { key_controller.connect_key_pressed(move |_, key, _, modifiers| {
let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK); let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
@@ -919,7 +874,7 @@ impl MainWindow {
let next_index = current.index() + 1; let next_index = current.index() + 1;
if let Some(next_row) = results_list.row_at_index(next_index) { if let Some(next_row) = results_list.row_at_index(next_index) {
results_list.select_row(Some(&next_row)); results_list.select_row(Some(&next_row));
Self::scroll_to_row(&scrolled, &results_list, &next_row); Self::scroll_to_row(&scrolled, &results_list, &next_row, &lazy_state_for_keys);
} }
} }
gtk4::glib::Propagation::Stop gtk4::glib::Propagation::Stop
@@ -931,7 +886,7 @@ impl MainWindow {
&& let Some(prev_row) = results_list.row_at_index(prev_index) && let Some(prev_row) = results_list.row_at_index(prev_index)
{ {
results_list.select_row(Some(&prev_row)); results_list.select_row(Some(&prev_row));
Self::scroll_to_row(&scrolled, &results_list, &prev_row); Self::scroll_to_row(&scrolled, &results_list, &prev_row, &lazy_state_for_keys);
} }
} }
gtk4::glib::Propagation::Stop gtk4::glib::Propagation::Stop
@@ -1126,6 +1081,7 @@ impl MainWindow {
for provider in tab_order { for provider in tab_order {
f.enable(provider.clone()); f.enable(provider.clone());
} }
f.restore_all_mode();
} }
for (_, button) in buttons.borrow().iter() { for (_, button) in buttons.borrow().iter() {
button.set_active(true); button.set_active(true);
@@ -1183,43 +1139,47 @@ impl MainWindow {
entry.emit_by_name::<()>("changed", &[]); entry.emit_by_name::<()>("changed", &[]);
} }
fn update_results(&self, query: &str) { /// Schedule initial results population via idle callback.
let cfg = self.config.borrow(); /// Call this AFTER `window.present()` so the window appears immediately.
let max_results = cfg.general.max_results; pub fn schedule_initial_results(&self) {
drop(cfg); let backend = self.backend.clone();
let results_list = self.results_list.clone();
let config = self.config.clone();
let filter = self.filter.clone();
let current_results = self.current_results.clone();
let lazy_state = self.lazy_state.clone();
let results = self.backend.borrow_mut().search( gtk4::glib::idle_add_local_once(move || {
query, let cfg = config.borrow();
max_results, let max_results = cfg.general.max_results;
&self.filter.borrow(), drop(cfg);
&self.config.borrow(),
);
// Clear existing results let results = backend.borrow_mut().search(
while let Some(child) = self.results_list.first_child() { "",
self.results_list.remove(&child); max_results,
} &filter.borrow(),
&config.borrow(),
);
// Store all results for lazy loading // Clear existing results
let initial_count = INITIAL_RESULTS.min(results.len()); results_list.remove_all();
{
let mut lazy = self.lazy_state.borrow_mut(); let initial_count = INITIAL_RESULTS.min(results.len());
lazy.all_results = results.clone();
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item, "");
results_list.append(&row);
}
if let Some(first_row) = results_list.row_at_index(0) {
results_list.select_row(Some(&first_row));
}
*current_results.borrow_mut() = results[..initial_count].to_vec();
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results;
lazy.displayed_count = initial_count; lazy.displayed_count = initial_count;
} });
// Display initial batch only
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
self.results_list.append(&row);
}
if let Some(first_row) = self.results_list.row_at_index(0) {
self.results_list.select_row(Some(&first_row));
}
// current_results holds what's currently displayed
*self.current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
} }
/// Set up lazy loading scroll detection /// Set up lazy loading scroll detection
@@ -1276,8 +1236,9 @@ impl MainWindow {
if displayed < all_count { if displayed < all_count {
// Load next batch // Load next batch
let new_end = (displayed + LOAD_MORE_BATCH).min(all_count); let new_end = (displayed + LOAD_MORE_BATCH).min(all_count);
let query = lazy.query.clone();
for item in lazy.all_results[displayed..new_end].iter() { for item in lazy.all_results[displayed..new_end].iter() {
let row = ResultRow::new(item); let row = ResultRow::new(item, &query);
results_list.append(&row); results_list.append(&row);
} }
lazy.displayed_count = new_end; lazy.displayed_count = new_end;
@@ -1330,6 +1291,36 @@ impl MainWindow {
item.terminal, item.provider, item.id 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) // Check if this is a desktop application (has .desktop file as ID)
let is_desktop_app = let is_desktop_app =
matches!(item.provider, ProviderType::Application) && item.id.ends_with(".desktop"); matches!(item.provider, ProviderType::Application) && item.id.ends_with(".desktop");

View File

@@ -1,4 +1,5 @@
mod main_window; mod main_window;
pub mod provider_meta;
mod result_row; mod result_row;
pub mod submenu; 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

@@ -1,6 +1,6 @@
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget}; use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget};
use owlry_core::providers::LaunchItem; use owlry_core::providers::{LaunchItem, ProviderType};
#[allow(dead_code)] #[allow(dead_code)]
pub struct ResultRow { pub struct ResultRow {
@@ -18,9 +18,31 @@ fn is_emoji_icon(s: &str) -> bool {
!first_char.is_ascii() && s.chars().count() <= 8 !first_char.is_ascii() && s.chars().count() <= 8
} }
/// Check if this item should be highlighted based on the query.
/// Highlighted when:
/// - Item is from an auto-detecting plugin (calculator, converter) that parsed
/// the query into a result — these produce direct answers, not search results
/// - Item name exactly matches the query (case-insensitive)
fn should_highlight(item: &LaunchItem, query: &str) -> bool {
if query.is_empty() {
return false;
}
// Exact name match (case-insensitive)
if item.name.eq_ignore_ascii_case(query) {
return true;
}
// Auto-detect plugins that produce direct answers (not search tools)
matches!(
&item.provider,
ProviderType::Plugin(id) if matches!(id.as_str(), "calc" | "conv")
)
}
impl ResultRow { impl ResultRow {
#[allow(clippy::new_ret_no_self)] #[allow(clippy::new_ret_no_self)]
pub fn new(item: &LaunchItem) -> ListBoxRow { pub fn new(item: &LaunchItem, query: &str) -> ListBoxRow {
let row = ListBoxRow::builder() let row = ListBoxRow::builder()
.selectable(true) .selectable(true)
.activatable(true) .activatable(true)
@@ -28,6 +50,10 @@ impl ResultRow {
row.add_css_class("owlry-result-row"); row.add_css_class("owlry-result-row");
if should_highlight(item, query) {
row.add_css_class("owlry-result-highlight");
}
let hbox = GtkBox::builder() let hbox = GtkBox::builder()
.orientation(Orientation::Horizontal) .orientation(Orientation::Horizontal)
.spacing(12) .spacing(12)

View File

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

View File

@@ -59,8 +59,10 @@ max_results = 100
# Requires: uwsm to be installed # Requires: uwsm to be installed
# use_uwsm = true # use_uwsm = true
# Header tabs - providers shown as toggle buttons (Ctrl+1, Ctrl+2, etc.) # Header tabs provider tabs shown in the UI bar (Ctrl+1..9 to toggle)
# Values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web # 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"] tabs = ["app", "cmd", "uuctl"]
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
@@ -140,55 +142,25 @@ disabled_plugins = []
# PROVIDERS # PROVIDERS
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# #
# Enable/disable providers and configure their settings. # Controls built-in providers only. Plugins are enabled/disabled via
# Core providers (applications, commands) are built into the binary. # [plugins] disabled_plugins or `owlry plugin enable/disable <name>`.
# Plugin providers require their .so to be installed.
[providers] [providers]
# Core providers (always available) # Core providers
applications = true # .desktop applications from XDG dirs applications = true # .desktop applications from XDG dirs
commands = true # Executables from $PATH 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 # Data stored in: ~/.local/share/owlry/frecency.json
frecency = true frecency = true
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
# ───────────────────────────────────────────────────────────────────────── # Web search engine (used by owlry-plugin-websearch)
# 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
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia # 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" 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

@@ -77,8 +77,6 @@
.owlry-main { .owlry-main {
background-color: rgba(5, 5, 5, 0.98); background-color: rgba(5, 5, 5, 0.98);
border: 1px solid rgba(38, 38, 38, 0.8); border: 1px solid rgba(38, 38, 38, 0.8);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8),
0 0 0 1px rgba(255, 0, 68, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(30, 30, 46, 0.95); background-color: rgba(30, 30, 46, 0.95);
border: 1px solid rgba(69, 71, 90, 0.6); border: 1px solid rgba(69, 71, 90, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(203, 166, 247, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(40, 42, 54, 0.95); background-color: rgba(40, 42, 54, 0.95);
border: 1px solid rgba(98, 114, 164, 0.6); border: 1px solid rgba(98, 114, 164, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(189, 147, 249, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(40, 40, 40, 0.95); background-color: rgba(40, 40, 40, 0.95);
border: 1px solid rgba(80, 73, 69, 0.6); border: 1px solid rgba(80, 73, 69, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(254, 128, 25, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(46, 52, 64, 0.95); background-color: rgba(46, 52, 64, 0.95);
border: 1px solid rgba(76, 86, 106, 0.6); border: 1px solid rgba(76, 86, 106, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(136, 192, 208, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(40, 44, 52, 0.95); background-color: rgba(40, 44, 52, 0.95);
border: 1px solid rgba(24, 26, 31, 0.6); border: 1px solid rgba(24, 26, 31, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(97, 175, 239, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -33,8 +33,6 @@
.owlry-main { .owlry-main {
background-color: rgba(26, 27, 38, 0.95); background-color: rgba(26, 27, 38, 0.95);
border: 1px solid rgba(65, 72, 104, 0.6); border: 1px solid rgba(65, 72, 104, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(224, 175, 104, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(25, 23, 36, 0.95); background-color: rgba(25, 23, 36, 0.95);
border: 1px solid rgba(38, 35, 58, 0.6); border: 1px solid rgba(38, 35, 58, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(196, 167, 231, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(0, 43, 54, 0.95); background-color: rgba(0, 43, 54, 0.95);
border: 1px solid rgba(88, 110, 117, 0.6); border: 1px solid rgba(88, 110, 117, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(38, 139, 210, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -24,8 +24,6 @@
.owlry-main { .owlry-main {
background-color: rgba(26, 27, 38, 0.95); background-color: rgba(26, 27, 38, 0.95);
border: 1px solid rgba(65, 72, 104, 0.6); border: 1px solid rgba(65, 72, 104, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(122, 162, 247, 0.1);
} }
.owlry-search { .owlry-search {

View File

@@ -0,0 +1,967 @@
# Codebase Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 15 soundness, security, robustness, and quality issues across owlry core and owlry-plugins repos.
**Architecture:** Point fixes organized into 5 severity tiers. Each tier is one commit. Core repo (owlry) tiers 1-3 first, then plugins repo (owlry-plugins) tiers 4-5. No new features, no refactoring beyond what each fix requires.
**Tech Stack:** Rust 1.90+, abi_stable 0.11, toml 0.8, dirs 5.0
**Repos:**
- Core: `/home/cnachtigall/ssd/git/archive/owlibou/owlry`
- Plugins: `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`
---
## Task 1: Tier 1 — Critical / Soundness (owlry core)
**Files:**
- Modify: `crates/owlry-plugin-api/src/lib.rs:297-320`
- Modify: `crates/owlry-core/src/server.rs:1-6,91-123,127-215`
### 1a. Replace `static mut HOST_API` with `OnceLock`
- [ ] **Step 1: Replace the static mut and init function**
In `crates/owlry-plugin-api/src/lib.rs`, replace lines 297-320:
```rust
// Old:
// static mut HOST_API: Option<&'static HostAPI> = None;
//
// pub unsafe fn init_host_api(api: &'static HostAPI) {
// unsafe {
// HOST_API = Some(api);
// }
// }
//
// pub fn host_api() -> Option<&'static HostAPI> {
// unsafe { HOST_API }
// }
// New:
use std::sync::OnceLock;
static HOST_API: OnceLock<&'static HostAPI> = OnceLock::new();
/// Initialize the host API (called by the host)
///
/// # Safety
/// Must only be called once by the host before any plugins use the API
pub unsafe fn init_host_api(api: &'static HostAPI) {
let _ = HOST_API.set(api);
}
/// Get the host API
///
/// Returns None if the host hasn't initialized the API yet
pub fn host_api() -> Option<&'static HostAPI> {
HOST_API.get().copied()
}
```
Note: `init_host_api` keeps its `unsafe` signature for API compatibility even though `OnceLock::set` is safe. The `unsafe` documents the caller contract.
- [ ] **Step 2: Verify the plugin-api crate compiles**
Run: `cargo check -p owlry-plugin-api`
Expected: success, no warnings about `static mut`
### 1b. Add IPC message size limit
- [ ] **Step 3: Add size-limited read loop in server.rs**
In `crates/owlry-core/src/server.rs`, add the constant near the top of the file (after the imports):
```rust
/// Maximum size of a single IPC request line (1 MB)
const MAX_REQUEST_SIZE: usize = 1_048_576;
```
Replace the `handle_client` method (lines 91-123). Change the `for line in reader.lines()` loop to a manual `read_line` loop with size checking:
```rust
fn handle_client(
stream: UnixStream,
pm: Arc<Mutex<ProviderManager>>,
frecency: Arc<Mutex<FrecencyStore>>,
config: Arc<Config>,
) -> io::Result<()> {
let reader = BufReader::new(stream.try_clone()?);
let mut writer = stream;
let mut reader = reader;
let mut line = String::new();
loop {
line.clear();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
break; // EOF
}
if line.len() > MAX_REQUEST_SIZE {
let resp = Response::Error {
message: "request too large".to_string(),
};
write_response(&mut writer, &resp)?;
break; // Drop connection
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let request: Request = match serde_json::from_str(trimmed) {
Ok(req) => req,
Err(e) => {
let resp = Response::Error {
message: format!("invalid request JSON: {}", e),
};
write_response(&mut writer, &resp)?;
continue;
}
};
let response = Self::handle_request(&request, &pm, &frecency, &config);
write_response(&mut writer, &response)?;
}
Ok(())
}
```
### 1c. Handle mutex poisoning gracefully
- [ ] **Step 4: Replace all lock().unwrap() in handle_request**
In `crates/owlry-core/src/server.rs`, in the `handle_request` method, replace every occurrence of `.lock().unwrap()` with `.lock().unwrap_or_else(|e| e.into_inner())`. There are instances in the `Query`, `Launch`, `Providers`, `Refresh`, `Submenu`, and `PluginAction` arms.
For example, the Query arm changes from:
```rust
let pm_guard = pm.lock().unwrap();
let frecency_guard = frecency.lock().unwrap();
```
to:
```rust
let pm_guard = pm.lock().unwrap_or_else(|e| e.into_inner());
let frecency_guard = frecency.lock().unwrap_or_else(|e| e.into_inner());
```
Apply this pattern to all `.lock().unwrap()` calls in `handle_request`.
- [ ] **Step 5: Build and test the core crate**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all checks pass, all existing tests pass
- [ ] **Step 6: Commit Tier 1**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
git add crates/owlry-plugin-api/src/lib.rs crates/owlry-core/src/server.rs
git commit -m "fix: soundness — OnceLock for HOST_API, IPC size limits, mutex poisoning recovery"
```
---
## Task 2: Tier 2 — Security (owlry core)
**Files:**
- Modify: `crates/owlry-core/src/server.rs:1-6,29-36,91-123`
- Modify: `crates/owlry-core/src/main.rs:26-32`
### 2a. Set socket permissions after bind
- [ ] **Step 1: Add permission setting in Server::bind**
In `crates/owlry-core/src/server.rs`, add the import at the top:
```rust
use std::os::unix::fs::PermissionsExt;
```
In `Server::bind()`, after the `UnixListener::bind(socket_path)?;` line, add:
```rust
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
```
### 2b. Log signal handler failure
- [ ] **Step 2: Replace .ok() with warning log in main.rs**
In `crates/owlry-core/src/main.rs`, add `use log::warn;` to the imports, then replace lines 26-32:
```rust
// Old:
// ctrlc::set_handler(move || {
// let _ = std::fs::remove_file(&sock_cleanup);
// std::process::exit(0);
// })
// .ok();
// New:
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);
}
```
### 2c. Add client read timeout
- [ ] **Step 3: Set read timeout on accepted connections**
In `crates/owlry-core/src/server.rs`, add `use std::time::Duration;` to the imports.
In the `handle_client` method, at the very top (before the `BufReader` creation), add:
```rust
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
```
This means the `stream` passed to `handle_client` needs to be mutable, or we set it on the clone. Since `set_read_timeout` takes `&self` (not `&mut self`), we can call it directly:
```rust
fn handle_client(
stream: UnixStream,
pm: Arc<...>,
frecency: Arc<...>,
config: Arc<Config>,
) -> io::Result<()> {
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
let reader = BufReader::new(stream.try_clone()?);
// ... rest unchanged
```
- [ ] **Step 4: Build and test**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all checks pass, all existing tests pass
- [ ] **Step 5: Commit Tier 2**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
git add crates/owlry-core/src/server.rs crates/owlry-core/src/main.rs
git commit -m "fix: security — socket perms 0600, signal handler logging, client read timeout"
```
---
## Task 3: Tier 3 — Robustness / Quality (owlry core)
**Files:**
- Modify: `crates/owlry-core/src/server.rs:1-6,17-23,53-73,91-215`
### 3a. Log malformed JSON requests
- [ ] **Step 1: Add warn! for JSON parse errors**
In `crates/owlry-core/src/server.rs`, in the `handle_client` method, in the JSON parse error arm, add a warning log before the error response:
```rust
Err(e) => {
warn!("Malformed request from client: {}", e);
let resp = Response::Error {
message: format!("invalid request JSON: {}", e),
};
write_response(&mut writer, &resp)?;
continue;
}
```
### 3b. Replace Mutex with RwLock
- [ ] **Step 2: Change Server struct and imports**
In `crates/owlry-core/src/server.rs`, change the import from `Mutex` to `RwLock`:
```rust
use std::sync::{Arc, RwLock};
```
Change the `Server` struct fields:
```rust
pub struct Server {
listener: UnixListener,
socket_path: PathBuf,
provider_manager: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
}
```
- [ ] **Step 3: Update Server::bind**
In `Server::bind()`, change `Arc::new(Mutex::new(...))` to `Arc::new(RwLock::new(...))`:
```rust
Ok(Self {
listener,
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),
})
```
- [ ] **Step 4: Update handle_client and handle_request signatures**
Change `handle_client` parameter types:
```rust
fn handle_client(
stream: UnixStream,
pm: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
) -> io::Result<()> {
```
Change `handle_request` parameter types:
```rust
fn handle_request(
request: &Request,
pm: &Arc<RwLock<ProviderManager>>,
frecency: &Arc<RwLock<FrecencyStore>>,
config: &Arc<Config>,
) -> Response {
```
Also update `handle_one_for_testing` if it passes these types through.
- [ ] **Step 5: Update lock calls per request type**
In `handle_request`, change each lock call according to the read/write mapping:
**Query** (read PM, read frecency):
```rust
Request::Query { text, modes } => {
let filter = match modes {
Some(m) => ProviderFilter::from_mode_strings(m),
None => ProviderFilter::all(),
};
let max = config.general.max_results;
let weight = config.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 results = pm_guard.search_with_frecency(
text, max, &filter, &frecency_guard, weight, None,
);
Response::Results {
items: results
.into_iter()
.map(|(item, score)| launch_item_to_result(item, score))
.collect(),
}
}
```
**Launch** (write frecency):
```rust
Request::Launch { item_id, provider: _ } => {
let mut frecency_guard = frecency.write().unwrap_or_else(|e| e.into_inner());
frecency_guard.record_launch(item_id);
Response::Ack
}
```
**Providers** (read PM):
```rust
Request::Providers => {
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
let descs = pm_guard.available_providers();
Response::Providers {
list: descs.into_iter().map(descriptor_to_desc).collect(),
}
}
```
**Refresh** (write PM):
```rust
Request::Refresh { provider } => {
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
pm_guard.refresh_provider(provider);
Response::Ack
}
```
**Toggle** (no locks):
```rust
Request::Toggle => Response::Ack,
```
**Submenu** (read PM):
```rust
Request::Submenu { plugin_id, data } => {
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) {
Some((_name, actions)) => Response::SubmenuItems {
items: actions
.into_iter()
.map(|item| launch_item_to_result(item, 0))
.collect(),
},
None => Response::Error {
message: format!("no submenu actions for plugin '{}'", plugin_id),
},
}
}
```
**PluginAction** (read PM):
```rust
Request::PluginAction { command } => {
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
if pm_guard.execute_plugin_action(command) {
Response::Ack
} else {
Response::Error {
message: format!("no plugin handled action '{}'", command),
}
}
}
```
- [ ] **Step 6: Build and test**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all checks pass, all existing tests pass
- [ ] **Step 7: Commit Tier 3**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
git add crates/owlry-core/src/server.rs
git commit -m "fix: robustness — RwLock for concurrent reads, log malformed JSON requests"
```
---
## Task 4: Tier 4 — Critical fixes (owlry-plugins)
**Files:**
- Modify: `crates/owlry-plugin-converter/src/currency.rs:88-113,244-265`
- Modify: `crates/owlry-plugin-converter/src/units.rs:90-101,160-213`
- Modify: `crates/owlry-plugin-bookmarks/src/lib.rs:40-45,228-260,317-353`
All paths relative to `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`.
### 4a. Fix Box::leak memory leak in converter
- [ ] **Step 1: Change resolve_currency_code return type**
In `crates/owlry-plugin-converter/src/currency.rs`, change the `resolve_currency_code` function (line 88) from returning `Option<String>` to `Option<&'static str>`:
```rust
pub fn resolve_currency_code(alias: &str) -> Option<&'static str> {
let lower = alias.to_lowercase();
// Check aliases
for ca in CURRENCY_ALIASES {
if ca.aliases.contains(&lower.as_str()) {
return Some(ca.code);
}
}
// Check if it's a raw 3-letter ISO code we know about
let upper = alias.to_uppercase();
if upper.len() == 3 {
if upper == "EUR" {
return Some("EUR");
}
// Check if we have rates for it — return the matching alias code
if let Some(rates) = get_rates()
&& rates.rates.contains_key(&upper)
{
// Find a matching CURRENCY_ALIASES entry for this code
for ca in CURRENCY_ALIASES {
if ca.code == upper {
return Some(ca.code);
}
}
// Not in our aliases but valid in ECB rates — we can't return
// a &'static str for an arbitrary code, so skip
}
}
None
}
```
Note: For ISO codes that are in ECB rates but NOT in `CURRENCY_ALIASES`, we lose the ability to resolve them. This is acceptable because:
1. `CURRENCY_ALIASES` already covers the 15 most common currencies
2. The alternative (Box::leak) was leaking memory on every keystroke
- [ ] **Step 2: Update is_currency_alias**
No change needed — it already just calls `resolve_currency_code(alias).is_some()`.
- [ ] **Step 3: Update find_unit in units.rs**
In `crates/owlry-plugin-converter/src/units.rs`, replace lines 90-101:
```rust
pub fn find_unit(alias: &str) -> Option<&'static str> {
let lower = alias.to_lowercase();
if let Some(&i) = ALIAS_MAP.get(&lower) {
return Some(UNITS[i].symbol);
}
// Check currency — resolve_currency_code now returns &'static str directly
currency::resolve_currency_code(&lower)
}
```
- [ ] **Step 4: Update convert_currency in units.rs**
In `crates/owlry-plugin-converter/src/units.rs`, update `convert_currency` (line 160). The `from_code` and `to_code` are now `&'static str`. HashMap lookups with `rates.rates.get(code)` work because `HashMap<String, f64>::get` accepts `&str` via `Borrow`:
```rust
fn convert_currency(value: f64, from: &str, to: &str) -> Option<ConversionResult> {
let rates = currency::get_rates()?;
let from_code = currency::resolve_currency_code(from)?;
let to_code = currency::resolve_currency_code(to)?;
let from_rate = if from_code == "EUR" {
1.0
} else {
*rates.rates.get(from_code)?
};
let to_rate = if to_code == "EUR" {
1.0
} else {
*rates.rates.get(to_code)?
};
let result = value / from_rate * to_rate;
Some(format_currency_result(result, to_code))
}
```
- [ ] **Step 5: Update convert_currency_common in units.rs**
In `crates/owlry-plugin-converter/src/units.rs`, update `convert_currency_common` (line 180). Change `from_code` handling:
```rust
fn convert_currency_common(value: f64, from: &str) -> Vec<ConversionResult> {
let rates = match currency::get_rates() {
Some(r) => r,
None => return vec![],
};
let from_code = match currency::resolve_currency_code(from) {
Some(c) => c,
None => return vec![],
};
let targets = COMMON_TARGETS.get(&Category::Currency).unwrap();
let from_rate = if from_code == "EUR" {
1.0
} else {
match rates.rates.get(from_code) {
Some(&r) => r,
None => return vec![],
}
};
targets
.iter()
.filter(|&&sym| sym != from_code)
.filter_map(|&sym| {
let to_rate = if sym == "EUR" {
1.0
} else {
*rates.rates.get(sym)?
};
let result = value / from_rate * to_rate;
Some(format_currency_result(result, sym))
})
.take(5)
.collect()
}
```
- [ ] **Step 6: Update currency tests**
In `crates/owlry-plugin-converter/src/currency.rs`, update test assertions to use `&str` instead of `String`:
```rust
#[test]
fn test_resolve_currency_code_iso() {
assert_eq!(resolve_currency_code("usd"), Some("USD"));
assert_eq!(resolve_currency_code("EUR"), Some("EUR"));
}
#[test]
fn test_resolve_currency_code_name() {
assert_eq!(resolve_currency_code("dollar"), Some("USD"));
assert_eq!(resolve_currency_code("euro"), Some("EUR"));
assert_eq!(resolve_currency_code("pounds"), Some("GBP"));
}
#[test]
fn test_resolve_currency_code_symbol() {
assert_eq!(resolve_currency_code("$"), Some("USD"));
assert_eq!(resolve_currency_code(""), Some("EUR"));
assert_eq!(resolve_currency_code("£"), Some("GBP"));
}
#[test]
fn test_resolve_currency_unknown() {
assert_eq!(resolve_currency_code("xyz"), None);
}
```
### 4b. Fix bookmarks temp file race condition
- [ ] **Step 7: Use PID-based temp filenames**
In `crates/owlry-plugin-bookmarks/src/lib.rs`, replace the `read_firefox_bookmarks` method. Change lines 318-319 and the corresponding favicons temp path:
```rust
fn read_firefox_bookmarks(places_path: &PathBuf, items: &mut Vec<PluginItem>) {
let temp_dir = std::env::temp_dir();
let pid = std::process::id();
let temp_db = temp_dir.join(format!("owlry_places_{}.sqlite", pid));
// Copy database to temp location to avoid locking issues
if fs::copy(places_path, &temp_db).is_err() {
return;
}
// Also copy WAL file if it exists
let wal_path = places_path.with_extension("sqlite-wal");
if wal_path.exists() {
let temp_wal = temp_db.with_extension("sqlite-wal");
let _ = fs::copy(&wal_path, &temp_wal);
}
// Copy favicons database if available
let favicons_path = Self::firefox_favicons_path(places_path);
let temp_favicons = temp_dir.join(format!("owlry_favicons_{}.sqlite", pid));
if let Some(ref fp) = favicons_path {
let _ = fs::copy(fp, &temp_favicons);
let fav_wal = fp.with_extension("sqlite-wal");
if fav_wal.exists() {
let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal"));
}
}
let cache_dir = Self::ensure_favicon_cache_dir();
// Read bookmarks from places.sqlite
let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref());
// Clean up temp files
let _ = fs::remove_file(&temp_db);
let _ = fs::remove_file(temp_db.with_extension("sqlite-wal"));
let _ = fs::remove_file(&temp_favicons);
let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal"));
// ... rest of method unchanged (the for loop adding items)
```
### 4c. Fix bookmarks background refresh never updating state
- [ ] **Step 8: Change BookmarksState to use Arc<Mutex<Vec<PluginItem>>>**
In `crates/owlry-plugin-bookmarks/src/lib.rs`, add `use std::sync::Mutex;` to imports (it's already importing `Arc` and `AtomicBool`).
Change the struct:
```rust
struct BookmarksState {
/// Cached bookmark items (shared with background thread)
items: Arc<Mutex<Vec<PluginItem>>>,
/// Flag to prevent concurrent background loads
loading: Arc<AtomicBool>,
}
impl BookmarksState {
fn new() -> Self {
Self {
items: Arc::new(Mutex::new(Vec::new())),
loading: Arc::new(AtomicBool::new(false)),
}
}
```
- [ ] **Step 9: Update load_bookmarks to write through Arc<Mutex>**
Update the `load_bookmarks` method:
```rust
fn load_bookmarks(&self) {
// Fast path: load from cache immediately if items are empty
{
let mut items = self.items.lock().unwrap_or_else(|e| e.into_inner());
if items.is_empty() {
*items = Self::load_cached_bookmarks();
}
}
// Don't start another background load if one is already running
if self.loading.swap(true, Ordering::SeqCst) {
return;
}
// Spawn background thread to refresh bookmarks
let loading = self.loading.clone();
let items_ref = self.items.clone();
thread::spawn(move || {
let mut new_items = Vec::new();
// Load Chrome/Chromium bookmarks (fast - just JSON parsing)
for path in Self::chromium_bookmark_paths() {
if path.exists() {
Self::read_chrome_bookmarks_static(&path, &mut new_items);
}
}
// Load Firefox bookmarks with favicons (synchronous with rusqlite)
for path in Self::firefox_places_paths() {
Self::read_firefox_bookmarks(&path, &mut new_items);
}
// Save to cache for next startup
Self::save_cached_bookmarks(&new_items);
// Update shared state so next refresh returns fresh data
if let Ok(mut items) = items_ref.lock() {
*items = new_items;
}
loading.store(false, Ordering::SeqCst);
});
}
```
Note: `load_bookmarks` now takes `&self` instead of `&mut self`.
- [ ] **Step 10: Update provider_refresh to read from Arc<Mutex>**
Update the `provider_refresh` function:
```rust
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<BookmarksState>
let state = unsafe { &*(handle.ptr as *const BookmarksState) };
// Load bookmarks
state.load_bookmarks();
// Return items
let items = state.items.lock().unwrap_or_else(|e| e.into_inner());
items.to_vec().into()
}
```
Note: Uses `&*` (shared ref) instead of `&mut *` since `load_bookmarks` now takes `&self`.
- [ ] **Step 11: Build and test plugins**
Run: `cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins && cargo check && cargo test`
Expected: all checks pass, all existing tests pass
- [ ] **Step 12: Commit Tier 4**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
git add crates/owlry-plugin-converter/src/currency.rs crates/owlry-plugin-converter/src/units.rs crates/owlry-plugin-bookmarks/src/lib.rs
git commit -m "fix: critical — eliminate Box::leak in converter, secure temp files, fix background refresh"
```
---
## Task 5: Tier 5 — Quality fixes (owlry-plugins)
**Files:**
- Modify: `crates/owlry-plugin-ssh/Cargo.toml`
- Modify: `crates/owlry-plugin-ssh/src/lib.rs:17-48`
- Modify: `crates/owlry-plugin-websearch/Cargo.toml`
- Modify: `crates/owlry-plugin-websearch/src/lib.rs:46-76,174-177`
- Modify: `crates/owlry-plugin-emoji/src/lib.rs:34-37,463-481`
- Modify: `crates/owlry-plugin-calculator/src/lib.rs:139`
- Modify: `crates/owlry-plugin-converter/src/lib.rs:95`
All paths relative to `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`.
### 5a. SSH plugin: read terminal from config
- [ ] **Step 1: Add toml dependency to SSH plugin**
In `crates/owlry-plugin-ssh/Cargo.toml`, add:
```toml
# TOML config parsing
toml = "0.8"
```
- [ ] **Step 2: Add config loading and update SshState::new**
In `crates/owlry-plugin-ssh/src/lib.rs`, add `use std::fs;` to imports, remove the `DEFAULT_TERMINAL` constant, and update `SshState::new`:
```rust
impl SshState {
fn new() -> Self {
let terminal = Self::load_terminal_from_config();
Self {
items: Vec::new(),
terminal_command: terminal,
}
}
fn load_terminal_from_config() -> String {
// Try [plugins.ssh] in config.toml
let config_path = dirs::config_dir().map(|d| d.join("owlry").join("config.toml"));
if let Some(content) = config_path.and_then(|p| fs::read_to_string(p).ok())
&& let Ok(toml) = content.parse::<toml::Table>()
{
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(ssh) = plugins.get("ssh").and_then(|v| v.as_table())
&& let Some(terminal) = ssh.get("terminal").and_then(|v| v.as_str())
{
return terminal.to_string();
}
}
// Fall back to $TERMINAL env var
if let Ok(terminal) = std::env::var("TERMINAL") {
return terminal;
}
// Last resort
"xdg-terminal-exec".to_string()
}
```
### 5b. WebSearch plugin: read engine from config
- [ ] **Step 3: Add dependencies to websearch plugin**
In `crates/owlry-plugin-websearch/Cargo.toml`, add:
```toml
# TOML config parsing
toml = "0.8"
# XDG directories for config
dirs = "5.0"
```
- [ ] **Step 4: Add config loading and update provider_init**
In `crates/owlry-plugin-websearch/src/lib.rs`, add `use std::fs;` to imports. Add a config loading function and update `provider_init`:
```rust
fn load_engine_from_config() -> String {
let config_path = dirs::config_dir().map(|d| d.join("owlry").join("config.toml"));
if let Some(content) = config_path.and_then(|p| fs::read_to_string(p).ok())
&& let Ok(toml) = content.parse::<toml::Table>()
{
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(websearch) = plugins.get("websearch").and_then(|v| v.as_table())
&& let Some(engine) = websearch.get("engine").and_then(|v| v.as_str())
{
return engine.to_string();
}
}
DEFAULT_ENGINE.to_string()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let engine = load_engine_from_config();
let state = Box::new(WebSearchState::with_engine(&engine));
ProviderHandle::from_box(state)
}
```
Remove the TODO comment from the old `provider_init`.
### 5c. Emoji plugin: build items once at init
- [ ] **Step 5: Move load_emojis to constructor**
In `crates/owlry-plugin-emoji/src/lib.rs`, change `EmojiState::new` to call `load_emojis`:
```rust
impl EmojiState {
fn new() -> Self {
let mut state = Self { items: Vec::new() };
state.load_emojis();
state
}
```
Update `provider_refresh` to just return the cached items without reloading:
```rust
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<EmojiState>
let state = unsafe { &*(handle.ptr as *const EmojiState) };
// Return cached items (loaded once at init)
state.items.to_vec().into()
}
```
Note: Uses `&*` (shared ref) since we're only reading.
### 5d. Calculator/Converter: safer shell commands
- [ ] **Step 6: Fix calculator command**
In `crates/owlry-plugin-calculator/src/lib.rs`, in `evaluate_expression` (around line 139), replace:
```rust
// Old:
format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str)
// New:
format!("printf '%s' '{}' | wl-copy", result_str.replace('\'', "'\\''"))
```
- [ ] **Step 7: Fix converter command**
In `crates/owlry-plugin-converter/src/lib.rs`, in `provider_query` (around line 95), replace:
```rust
// Old:
format!("sh -c 'echo -n \"{}\" | wl-copy'", r.raw_value)
// New:
format!("printf '%s' '{}' | wl-copy", r.raw_value.replace('\'', "'\\''"))
```
- [ ] **Step 8: Build and test all plugins**
Run: `cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins && cargo check && cargo test`
Expected: all checks pass, all existing tests pass
- [ ] **Step 9: Commit Tier 5**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
git add crates/owlry-plugin-ssh/Cargo.toml crates/owlry-plugin-ssh/src/lib.rs \
crates/owlry-plugin-websearch/Cargo.toml crates/owlry-plugin-websearch/src/lib.rs \
crates/owlry-plugin-emoji/src/lib.rs \
crates/owlry-plugin-calculator/src/lib.rs \
crates/owlry-plugin-converter/src/lib.rs
git commit -m "fix: quality — config-based terminal/engine, emoji init perf, safer shell commands"
```

View File

@@ -0,0 +1,810 @@
# Script Runtime Integration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enable the owlry-core daemon to discover and load Lua/Rune user plugins from `~/.config/owlry/plugins/`, with automatic hot-reload on file changes.
**Architecture:** Fix ABI mismatches between core and runtimes, wire `LoadedRuntime` into `ProviderManager::new_with_config()`, add filesystem watcher for automatic plugin reload. Runtimes are external `.so` libraries loaded from `/usr/lib/owlry/runtimes/`.
**Tech Stack:** Rust 1.90+, notify 7, notify-debouncer-mini 0.5, libloading 0.8
**Repos:**
- Core: `/home/cnachtigall/ssd/git/archive/owlibou/owlry`
- Plugins (docs only): `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`
---
## Task 1: Fix Lua RuntimeInfo ABI and vtable init signature
**Files:**
- Modify: `crates/owlry-lua/src/lib.rs:42-74,260-279,322-336`
- Modify: `crates/owlry-rune/src/lib.rs:42-46,73-84,90-95,97-146,215-229`
- Modify: `crates/owlry-core/src/plugins/runtime_loader.rs:55-68,84-146,267-277`
### 1a. Shrink Lua RuntimeInfo to 2 fields
- [ ] **Step 1: Update RuntimeInfo struct and runtime_info() in owlry-lua**
In `crates/owlry-lua/src/lib.rs`:
Remove the `LUA_RUNTIME_API_VERSION` constant (line 43).
Replace the `RuntimeInfo` struct (lines 67-74):
```rust
/// Runtime info returned by the runtime
#[repr(C)]
pub struct RuntimeInfo {
pub name: RString,
pub version: RString,
}
```
Replace `runtime_info()` (lines 260-268):
```rust
extern "C" fn runtime_info() -> RuntimeInfo {
RuntimeInfo {
name: RString::from("Lua"),
version: RString::from(env!("CARGO_PKG_VERSION")),
}
}
```
Remove unused constants `RUNTIME_ID` and `RUNTIME_DESCRIPTION` (lines 37, 40) if no longer referenced.
### 1b. Add owlry_version parameter to vtable init
- [ ] **Step 2: Update ScriptRuntimeVTable in core**
In `crates/owlry-core/src/plugins/runtime_loader.rs`, change the `init` field (line 59):
```rust
pub struct ScriptRuntimeVTable {
pub info: extern "C" fn() -> RuntimeInfo,
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<ScriptProviderInfo>,
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
pub query: extern "C" fn(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem>,
pub drop: extern "C" fn(handle: RuntimeHandle),
}
```
- [ ] **Step 3: Update LoadedRuntime to pass version**
In `crates/owlry-core/src/plugins/runtime_loader.rs`, update `load_lua`, `load_rune`, and `load_from_path` to accept and pass the version:
```rust
impl LoadedRuntime {
pub fn load_lua(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
Self::load_from_path(
"Lua",
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"),
b"owlry_lua_runtime_vtable",
plugins_dir,
owlry_version,
)
}
fn load_from_path(
name: &'static str,
library_path: &Path,
vtable_symbol: &[u8],
plugins_dir: &Path,
owlry_version: &str,
) -> PluginResult<Self> {
// ... existing library loading code ...
// Initialize the runtime with version
let plugins_dir_str = plugins_dir.to_string_lossy();
let handle = (vtable.init)(
RStr::from_str(&plugins_dir_str),
RStr::from_str(owlry_version),
);
// ... rest unchanged ...
}
}
impl LoadedRuntime {
pub fn load_rune(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
Self::load_from_path(
"Rune",
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"),
b"owlry_rune_runtime_vtable",
plugins_dir,
owlry_version,
)
}
}
```
- [ ] **Step 4: Update Lua runtime_init to accept version**
In `crates/owlry-lua/src/lib.rs`, update `runtime_init` (line 270) and the vtable:
```rust
extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
let plugins_dir = PathBuf::from(plugins_dir.as_str());
let mut state = Box::new(LuaRuntimeState::new(plugins_dir));
state.discover_and_load(owlry_version.as_str());
RuntimeHandle::from_box(state)
}
```
Update the `LuaRuntimeVTable` struct `init` field to match:
```rust
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
```
- [ ] **Step 5: Update Rune runtime_init to accept version**
In `crates/owlry-rune/src/lib.rs`, update `runtime_init` (line 97) and the vtable:
```rust
extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
let _ = env_logger::try_init();
let plugins_dir = PathBuf::from(plugins_dir.as_str());
let _version = owlry_version.as_str();
log::info!(
"Initializing Rune runtime with plugins from: {}",
plugins_dir.display()
);
// ... rest unchanged — Rune doesn't currently do version checking ...
```
Update the `RuneRuntimeVTable` struct `init` field:
```rust
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
```
- [ ] **Step 6: Build all three crates**
Run: `cargo check -p owlry-core && cargo check -p owlry-lua && cargo check -p owlry-rune`
Expected: all pass
- [ ] **Step 7: Run tests**
Run: `cargo test -p owlry-core && cargo test -p owlry-lua && cargo test -p owlry-rune`
Expected: all pass
- [ ] **Step 8: Commit**
```bash
git add crates/owlry-core/src/plugins/runtime_loader.rs \
crates/owlry-lua/src/lib.rs \
crates/owlry-rune/src/lib.rs
git commit -m "fix: align runtime ABI — shrink Lua RuntimeInfo, pass owlry_version to init"
```
---
## Task 2: Change default entry points to `main` and add alias
**Files:**
- Modify: `crates/owlry-lua/src/manifest.rs:52-54`
- Modify: `crates/owlry-rune/src/manifest.rs:36-38,29`
- [ ] **Step 1: Update Lua manifest default entry**
In `crates/owlry-lua/src/manifest.rs`, change `default_entry()` (line 52):
```rust
fn default_entry() -> String {
"main.lua".to_string()
}
```
Add `serde(alias)` to the `entry` field in `PluginInfo` (line 45):
```rust
#[serde(default = "default_entry", alias = "entry_point")]
pub entry: String,
```
- [ ] **Step 2: Update Rune manifest default entry**
In `crates/owlry-rune/src/manifest.rs`, change `default_entry()` (line 36):
```rust
fn default_entry() -> String {
"main.rn".to_string()
}
```
Add `serde(alias)` to the `entry` field in `PluginInfo` (line 29):
```rust
#[serde(default = "default_entry", alias = "entry_point")]
pub entry: String,
```
- [ ] **Step 3: Update tests that reference init.lua/init.rn**
In `crates/owlry-lua/src/manifest.rs` test `test_parse_minimal_manifest`:
```rust
assert_eq!(manifest.plugin.entry, "main.lua");
```
In `crates/owlry-lua/src/loader.rs` test `create_test_plugin`:
```rust
fs::write(plugin_dir.join("main.lua"), "-- empty plugin").unwrap();
```
In `crates/owlry-rune/src/manifest.rs` test `test_parse_minimal_manifest`:
```rust
assert_eq!(manifest.plugin.entry, "main.rn");
```
- [ ] **Step 4: Build and test**
Run: `cargo test -p owlry-lua && cargo test -p owlry-rune`
Expected: all pass
- [ ] **Step 5: Commit**
```bash
git add crates/owlry-lua/src/manifest.rs crates/owlry-lua/src/loader.rs \
crates/owlry-rune/src/manifest.rs
git commit -m "feat: change default entry points to main.lua/main.rn, add entry_point alias"
```
---
## Task 3: Wire runtime loading into ProviderManager
**Files:**
- Modify: `crates/owlry-core/src/providers/mod.rs:106-119,173-224`
- Modify: `crates/owlry-core/src/plugins/runtime_loader.rs:13` (remove allow dead_code)
- [ ] **Step 1: Add runtimes field to ProviderManager**
In `crates/owlry-core/src/providers/mod.rs`, add import and field:
```rust
use crate::plugins::runtime_loader::LoadedRuntime;
```
Add to the `ProviderManager` struct (after `matcher` field):
```rust
pub struct ProviderManager {
providers: Vec<Box<dyn Provider>>,
static_native_providers: Vec<NativeProvider>,
dynamic_providers: Vec<NativeProvider>,
widget_providers: Vec<NativeProvider>,
matcher: SkimMatcherV2,
/// Loaded script runtimes (Lua, Rune) — must stay alive to keep Library handles
runtimes: Vec<LoadedRuntime>,
/// Type IDs of providers that came from script runtimes (for hot-reload removal)
runtime_type_ids: std::collections::HashSet<String>,
}
```
Update `ProviderManager::new()` to initialize the new fields:
```rust
let mut manager = Self {
providers: core_providers,
static_native_providers: Vec::new(),
dynamic_providers: Vec::new(),
widget_providers: Vec::new(),
matcher: SkimMatcherV2::default(),
runtimes: Vec::new(),
runtime_type_ids: std::collections::HashSet::new(),
};
```
- [ ] **Step 2: Add runtime loading to new_with_config**
In `ProviderManager::new_with_config()`, after the native plugin loading block (after line 221) and before `Self::new(core_providers, native_providers)` (line 223), add runtime loading:
```rust
// Load script runtimes (Lua, Rune) for user plugins
let mut runtime_providers: Vec<Box<dyn Provider>> = Vec::new();
let mut runtimes: Vec<LoadedRuntime> = Vec::new();
let mut runtime_type_ids = std::collections::HashSet::new();
let owlry_version = env!("CARGO_PKG_VERSION");
if let Some(plugins_dir) = crate::paths::plugins_dir() {
// Try Lua runtime
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Loaded Lua runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
runtime_type_ids.insert(type_id);
runtime_providers.push(provider);
}
runtimes.push(rt);
}
Err(e) => {
info!("Lua runtime not available: {}", e);
}
}
// Try Rune runtime
match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Loaded Rune runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
runtime_type_ids.insert(type_id);
runtime_providers.push(provider);
}
runtimes.push(rt);
}
Err(e) => {
info!("Rune runtime not available: {}", e);
}
}
}
let mut manager = Self::new(core_providers, native_providers);
manager.runtimes = runtimes;
manager.runtime_type_ids = runtime_type_ids;
// Add runtime providers to the core providers list
for provider in runtime_providers {
info!("Registered runtime provider: {}", provider.name());
manager.providers.push(provider);
}
// Refresh runtime providers
for provider in &mut manager.providers {
// Only refresh the ones we just added (runtime providers)
// They need an initial refresh to populate items
}
manager.refresh_all();
manager
```
Note: This replaces the current `Self::new(core_providers, native_providers)` return. The `refresh_all()` at the end of `new()` will be called, plus we call it again — but that's fine since refresh is idempotent. Actually, `new()` already calls `refresh_all()`, so we should remove the duplicate. Let me adjust:
The cleaner approach is to construct the manager via `Self::new()` which calls `refresh_all()`, then set the runtime fields and add providers, then call `refresh_all()` once more for the newly added runtime providers. Or better — add runtime providers to `core_providers` before calling `new()`:
```rust
// Merge runtime providers into core providers
let mut all_core_providers = core_providers;
for provider in runtime_providers {
info!("Registered runtime provider: {}", provider.name());
all_core_providers.push(provider);
}
let mut manager = Self::new(all_core_providers, native_providers);
manager.runtimes = runtimes;
manager.runtime_type_ids = runtime_type_ids;
manager
```
This way `new()` handles the single `refresh_all()` call.
- [ ] **Step 3: Remove allow(dead_code) from runtime_loader**
In `crates/owlry-core/src/plugins/runtime_loader.rs`, remove `#![allow(dead_code)]` (line 13).
Fix any resulting dead code warnings by removing unused `#[allow(dead_code)]` attributes on individual items that are now actually used, or adding targeted `#[allow(dead_code)]` only on truly unused items.
- [ ] **Step 4: Build and test**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all pass. May see info logs about runtimes loading (if installed on the build machine).
- [ ] **Step 5: Commit**
```bash
git add crates/owlry-core/src/providers/mod.rs \
crates/owlry-core/src/plugins/runtime_loader.rs
git commit -m "feat: wire script runtime loading into daemon ProviderManager"
```
---
## Task 4: Filesystem watcher for hot-reload
**Files:**
- Create: `crates/owlry-core/src/plugins/watcher.rs`
- Modify: `crates/owlry-core/src/plugins/mod.rs:23-28` (add module)
- Modify: `crates/owlry-core/src/providers/mod.rs` (add reload method)
- Modify: `crates/owlry-core/src/server.rs:59-78` (start watcher)
- Modify: `crates/owlry-core/Cargo.toml` (add deps)
- [ ] **Step 1: Add dependencies**
In `crates/owlry-core/Cargo.toml`, add to `[dependencies]`:
```toml
# Filesystem watching for plugin hot-reload
notify = "7"
notify-debouncer-mini = "0.5"
```
- [ ] **Step 2: Add reload_runtimes method to ProviderManager**
In `crates/owlry-core/src/providers/mod.rs`, add a method:
```rust
/// Reload all script runtime providers (called by filesystem watcher)
pub fn reload_runtimes(&mut self) {
// Remove old runtime providers from the core providers list
self.providers.retain(|p| {
let type_str = format!("{}", p.provider_type());
!self.runtime_type_ids.contains(&type_str)
});
// Drop old runtimes
self.runtimes.clear();
self.runtime_type_ids.clear();
let owlry_version = env!("CARGO_PKG_VERSION");
let plugins_dir = match crate::paths::plugins_dir() {
Some(d) => d,
None => return,
};
// Reload Lua runtime
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Reloaded Lua runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
self.runtime_type_ids.insert(type_id);
self.providers.push(provider);
}
self.runtimes.push(rt);
}
Err(e) => {
info!("Lua runtime not available on reload: {}", e);
}
}
// Reload Rune runtime
match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Reloaded Rune runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
self.runtime_type_ids.insert(type_id);
self.providers.push(provider);
}
self.runtimes.push(rt);
}
Err(e) => {
info!("Rune runtime not available on reload: {}", e);
}
}
// Refresh the newly added providers
for provider in &mut self.providers {
provider.refresh();
}
info!("Runtime reload complete");
}
```
- [ ] **Step 3: Create the watcher module**
Create `crates/owlry-core/src/plugins/watcher.rs`:
```rust
//! Filesystem watcher for user plugin hot-reload
//!
//! Watches `~/.config/owlry/plugins/` for changes and triggers
//! runtime reload when plugin files are modified.
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
use log::{info, warn};
use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
use crate::providers::ProviderManager;
/// Start watching the user plugins directory for changes.
///
/// Spawns a background thread that monitors the directory and triggers
/// a full runtime reload on any file change. Returns immediately.
///
/// If the plugins directory doesn't exist or the watcher fails to start,
/// logs a warning and returns without spawning a thread.
pub fn start_watching(pm: Arc<RwLock<ProviderManager>>) {
let plugins_dir = match crate::paths::plugins_dir() {
Some(d) => d,
None => {
info!("No plugins directory configured, skipping file watcher");
return;
}
};
if !plugins_dir.exists() {
// Create the directory so the watcher has something to watch
if std::fs::create_dir_all(&plugins_dir).is_err() {
warn!("Failed to create plugins directory: {}", plugins_dir.display());
return;
}
}
thread::spawn(move || {
if let Err(e) = watch_loop(&plugins_dir, &pm) {
warn!("Plugin watcher stopped: {}", e);
}
});
info!("Plugin file watcher started for {}", plugins_dir.display());
}
fn watch_loop(
plugins_dir: &PathBuf,
pm: &Arc<RwLock<ProviderManager>>,
) -> Result<(), Box<dyn std::error::Error>> {
let (tx, rx) = std::sync::mpsc::channel();
let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?;
debouncer
.watcher()
.watch(plugins_dir.as_ref(), notify::RecursiveMode::Recursive)?;
info!("Watching {} for plugin changes", plugins_dir.display());
loop {
match rx.recv() {
Ok(Ok(events)) => {
// Check if any event is relevant (not just access/metadata)
let has_relevant_change = events.iter().any(|e| {
matches!(e.kind, DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous)
});
if has_relevant_change {
info!("Plugin file change detected, reloading runtimes...");
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
pm_guard.reload_runtimes();
}
}
Ok(Err(errors)) => {
for e in errors {
warn!("File watcher error: {}", e);
}
}
Err(e) => {
// Channel closed — watcher was dropped
return Err(Box::new(e));
}
}
}
}
```
- [ ] **Step 4: Register the watcher module**
In `crates/owlry-core/src/plugins/mod.rs`, add after line 28 (`pub mod runtime_loader;`):
```rust
pub mod watcher;
```
- [ ] **Step 5: Start watcher in Server::run**
In `crates/owlry-core/src/server.rs`, in the `run()` method, before the accept loop, add:
```rust
pub fn run(&self) -> io::Result<()> {
// Start filesystem watcher for user plugin hot-reload
crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager));
info!("Server entering accept loop");
for stream in self.listener.incoming() {
```
- [ ] **Step 6: Build and test**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all pass
- [ ] **Step 7: Manual smoke test**
```bash
# Start the daemon
RUST_LOG=info cargo run -p owlry-core
# In another terminal, create a test plugin
mkdir -p ~/.config/owlry/plugins/hotreload-test
cat > ~/.config/owlry/plugins/hotreload-test/plugin.toml << 'EOF'
[plugin]
id = "hotreload-test"
name = "Hot Reload Test"
version = "0.1.0"
EOF
cat > ~/.config/owlry/plugins/hotreload-test/main.lua << 'EOF'
owlry.provider.register({
name = "hotreload-test",
refresh = function()
return {{ id = "hr1", name = "Hot Reload Works!", command = "echo yes" }}
end,
})
EOF
# Watch daemon logs — should see "Plugin file change detected, reloading runtimes..."
# Clean up after testing
rm -rf ~/.config/owlry/plugins/hotreload-test
```
- [ ] **Step 8: Commit**
```bash
git add crates/owlry-core/Cargo.toml \
crates/owlry-core/src/plugins/watcher.rs \
crates/owlry-core/src/plugins/mod.rs \
crates/owlry-core/src/providers/mod.rs \
crates/owlry-core/src/server.rs
git commit -m "feat: add filesystem watcher for automatic user plugin hot-reload"
```
---
## Task 5: Update plugin development documentation
**Files:**
- Modify: `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins/docs/PLUGIN_DEVELOPMENT.md`
- [ ] **Step 1: Update Lua plugin section**
In `docs/PLUGIN_DEVELOPMENT.md`, update the Lua Quick Start section (around line 101):
Change `entry_point = "init.lua"` to `entry = "main.lua"` in the manifest example.
Replace the Lua code example with the `owlry.provider.register()` API:
```lua
owlry.provider.register({
name = "myluaprovider",
display_name = "My Lua Provider",
type_id = "mylua",
default_icon = "application-x-executable",
prefix = ":mylua",
refresh = function()
return {
{ id = "item-1", name = "Hello from Lua", command = "echo 'Hello Lua!'" },
}
end,
})
```
Remove `local owlry = require("owlry")` — the `owlry` table is pre-registered globally.
- [ ] **Step 2: Update Rune plugin section**
Update the Rune manifest example to use `entry = "main.rn"` instead of `entry_point = "main.rn"`.
- [ ] **Step 3: Update manifest reference**
In the Lua Plugin API manifest section (around line 325), change `entry_point` to `entry` and add a note:
```toml
[plugin]
id = "my-plugin"
name = "My Plugin"
version = "1.0.0"
description = "Plugin description"
entry = "main.lua" # Default: main.lua (Lua) / main.rn (Rune)
# Alias: entry_point also accepted
owlry_version = ">=1.0.0" # Optional version constraint
```
- [ ] **Step 4: Add hot-reload documentation**
Add a new section after "Best Practices" (before "Publishing to AUR"):
```markdown
## Hot Reload
User plugins in `~/.config/owlry/plugins/` are automatically reloaded when files change.
The daemon watches the plugins directory and reloads all script runtimes when any file
is created, modified, or deleted. No daemon restart is needed.
**What triggers a reload:**
- Creating a new plugin directory with `plugin.toml`
- Editing a plugin's script files (`main.lua`, `main.rn`, etc.)
- Editing a plugin's `plugin.toml`
- Deleting a plugin directory
**What does NOT trigger a reload:**
- Changes to native plugins (`.so` files) — these require a daemon restart
- Changes to runtime libraries in `/usr/lib/owlry/runtimes/` — daemon restart needed
**Reload behavior:**
- All script runtimes (Lua, Rune) are fully reloaded
- Existing search results may briefly show stale data during reload
- Errors in plugins are logged but don't affect other plugins
```
- [ ] **Step 5: Update Lua provider functions section**
Replace the bare `refresh()`/`query()` examples (around line 390) with the register API:
```lua
-- Static provider: called once at startup and on reload
owlry.provider.register({
name = "my-provider",
display_name = "My Provider",
prefix = ":my",
refresh = function()
return {
{ id = "id1", name = "Item 1", command = "command1" },
{ id = "id2", name = "Item 2", command = "command2" },
}
end,
})
-- Dynamic provider: called on each keystroke
owlry.provider.register({
name = "my-search",
display_name = "My Search",
prefix = "?my",
query = function(q)
if q == "" then return {} end
return {
{ id = "result", name = "Result for: " .. q, command = "echo " .. q },
}
end,
})
```
- [ ] **Step 6: Commit**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
git add docs/PLUGIN_DEVELOPMENT.md
git commit -m "docs: update plugin development guide for main.lua/rn defaults, register API, hot-reload"
```
---
## Task 6: Update hello-test plugin and clean up
**Files:**
- Modify: `~/.config/owlry/plugins/hello-test/plugin.toml`
- Modify: `~/.config/owlry/plugins/hello-test/init.lua` → rename to `main.lua`
This is a local-only task, not committed to either repo.
- [ ] **Step 1: Update hello-test plugin**
```bash
# Rename entry point
mv ~/.config/owlry/plugins/hello-test/init.lua ~/.config/owlry/plugins/hello-test/main.lua
# Update manifest to use entry field
cat > ~/.config/owlry/plugins/hello-test/plugin.toml << 'EOF'
[plugin]
id = "hello-test"
name = "Hello Test"
version = "0.1.0"
description = "Minimal test plugin for verifying Lua runtime loading"
EOF
```
- [ ] **Step 2: End-to-end verification**
```bash
# Rebuild and restart daemon
cargo build -p owlry-core
RUST_LOG=info cargo run -p owlry-core
# Expected log output should include:
# - "Loaded Lua runtime with 1 provider(s)" (hello-test)
# - "Loaded Rune runtime with 1 provider(s)" (hyprshutdown)
# - "Plugin file watcher started for ..."
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,876 @@
# Config Editor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Built-in `:config` provider that lets users browse and modify settings, toggle providers, select themes/engines, and manage profiles — all from within the launcher.
**Architecture:** The config editor is a `DynamicProvider` that interprets the query text as a navigation path. `:config providers` shows toggles, `:config theme` lists themes, `:config profile dev modes` shows a mode checklist. Actions (toggling, setting values) use the existing `PluginAction` IPC flow which keeps the window open and re-queries, giving instant visual feedback. Config changes are persisted to `config.toml` via `Config::save()`.
**Tech Stack:** Rust, owlry-core providers, toml serialization
---
## Key Design Decision: Query-as-Navigation
Instead of submenus, the `:config` prefix scopes the search bar as navigation:
```
:config → category list
:config providers → provider toggles
:config theme → theme selection
:config engine → search engine selection
:config frecency → frecency toggle + weight
:config profiles → profile list
:config profile dev → profile actions (edit modes, rename, delete)
:config profile dev modes → mode checklist for profile
:config profile create myname → create profile action
:config fontsize 16 → set font size action
:config width 900 → set width action
```
Actions use `CONFIG:*` commands dispatched via `execute_plugin_action`. Since this returns `false` for `should_close`, the window stays open and re-queries — the user sees updated state immediately.
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `crates/owlry-core/src/providers/config_editor.rs` | Create | ConfigProvider: query parsing, result generation, action execution |
| `crates/owlry-core/src/providers/mod.rs` | Modify | Register ConfigProvider, extend action dispatch |
| `crates/owlry-core/src/config/mod.rs` | Modify | Add helper methods for config mutation |
---
### Task 1: Create ConfigProvider skeleton and register it
**Files:**
- Create: `crates/owlry-core/src/providers/config_editor.rs`
- Modify: `crates/owlry-core/src/providers/mod.rs`
- [ ] **Step 1: Add module declaration**
In `crates/owlry-core/src/providers/mod.rs`, add with the other module declarations:
```rust
pub(crate) mod config_editor;
```
- [ ] **Step 2: Create config_editor.rs with top-level categories**
Create `crates/owlry-core/src/providers/config_editor.rs`:
```rust
//! Built-in config editor provider.
//!
//! Lets users browse and modify settings from within the launcher.
//! Uses `:config` prefix with query-as-navigation pattern.
use std::sync::{Arc, RwLock};
use crate::config::Config;
use super::{DynamicProvider, LaunchItem, ProviderType};
const PROVIDER_TYPE_ID: &str = "config";
const PROVIDER_ICON: &str = "preferences-system-symbolic";
pub struct ConfigProvider {
config: Arc<RwLock<Config>>,
}
impl ConfigProvider {
pub fn new(config: Arc<RwLock<Config>>) -> Self {
Self { config }
}
/// Execute a CONFIG:* action command. Returns true if handled.
pub fn execute_action(&self, command: &str) -> bool {
let Some(action) = command.strip_prefix("CONFIG:") else {
return false;
};
let mut config = match self.config.write() {
Ok(c) => c,
Err(_) => return false,
};
let handled = self.handle_action(action, &mut config);
if handled {
if let Err(e) = config.save() {
log::warn!("Failed to save config: {}", e);
}
}
handled
}
fn handle_action(&self, action: &str, config: &mut Config) -> bool {
if let Some(key) = action.strip_prefix("toggle:") {
return self.toggle_bool(key, config);
}
if let Some(rest) = action.strip_prefix("set:") {
return self.set_value(rest, config);
}
if let Some(rest) = action.strip_prefix("profile:") {
return self.handle_profile_action(rest, config);
}
false
}
fn toggle_bool(&self, key: &str, config: &mut Config) -> bool {
match key {
"providers.applications" => { config.providers.applications = !config.providers.applications; true }
"providers.commands" => { config.providers.commands = !config.providers.commands; true }
"providers.calculator" => { config.providers.calculator = !config.providers.calculator; true }
"providers.converter" => { config.providers.converter = !config.providers.converter; true }
"providers.system" => { config.providers.system = !config.providers.system; true }
"providers.websearch" => { config.providers.websearch = !config.providers.websearch; true }
"providers.ssh" => { config.providers.ssh = !config.providers.ssh; true }
"providers.clipboard" => { config.providers.clipboard = !config.providers.clipboard; true }
"providers.bookmarks" => { config.providers.bookmarks = !config.providers.bookmarks; true }
"providers.emoji" => { config.providers.emoji = !config.providers.emoji; true }
"providers.scripts" => { config.providers.scripts = !config.providers.scripts; true }
"providers.files" => { config.providers.files = !config.providers.files; true }
"providers.uuctl" => { config.providers.uuctl = !config.providers.uuctl; true }
"providers.media" => { config.providers.media = !config.providers.media; true }
"providers.weather" => { config.providers.weather = !config.providers.weather; true }
"providers.pomodoro" => { config.providers.pomodoro = !config.providers.pomodoro; true }
"providers.frecency" => { config.providers.frecency = !config.providers.frecency; true }
_ => false,
}
}
fn set_value(&self, rest: &str, config: &mut Config) -> bool {
let Some((key, value)) = rest.split_once(':') else { return false };
match key {
"appearance.theme" => { config.appearance.theme = Some(value.to_string()); true }
"appearance.font_size" => {
if let Ok(v) = value.parse::<i32>() {
config.appearance.font_size = v;
true
} else { false }
}
"appearance.width" => {
if let Ok(v) = value.parse::<i32>() {
config.appearance.width = v;
true
} else { false }
}
"appearance.height" => {
if let Ok(v) = value.parse::<i32>() {
config.appearance.height = v;
true
} else { false }
}
"appearance.border_radius" => {
if let Ok(v) = value.parse::<i32>() {
config.appearance.border_radius = v;
true
} else { false }
}
"providers.search_engine" => { config.providers.search_engine = value.to_string(); true }
"providers.frecency_weight" => {
if let Ok(v) = value.parse::<f64>() {
config.providers.frecency_weight = v.clamp(0.0, 1.0);
true
} else { false }
}
_ => false,
}
}
fn handle_profile_action(&self, rest: &str, config: &mut Config) -> bool {
if let Some(name) = rest.strip_prefix("create:") {
config.profiles.entry(name.to_string()).or_insert_with(|| {
crate::config::ProfileConfig { modes: vec![] }
});
return true;
}
if let Some(name) = rest.strip_prefix("delete:") {
config.profiles.remove(name);
return true;
}
if let Some(rest) = rest.strip_prefix("rename:") {
if let Some((old, new)) = rest.split_once(':') {
if let Some(profile) = config.profiles.remove(old) {
config.profiles.insert(new.to_string(), profile);
return true;
}
}
return false;
}
if let Some(rest) = rest.strip_prefix("mode:") {
// format: profile_name:toggle:mode_name
let parts: Vec<&str> = rest.splitn(3, ':').collect();
if parts.len() == 3 && parts[1] == "toggle" {
let profile_name = parts[0];
let mode = parts[2];
if let Some(profile) = config.profiles.get_mut(profile_name) {
if let Some(pos) = profile.modes.iter().position(|m| m == mode) {
profile.modes.remove(pos);
} else {
profile.modes.push(mode.to_string());
}
return true;
}
}
return false;
}
false
}
}
impl DynamicProvider for ConfigProvider {
fn name(&self) -> &str {
"Config"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(PROVIDER_TYPE_ID.into())
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
let config = match self.config.read() {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let path = query.trim();
self.generate_items(path, &config)
}
fn priority(&self) -> u32 {
8_000
}
}
```
- [ ] **Step 3: Implement generate_items — the query router**
Add to `ConfigProvider`:
```rust
fn generate_items(&self, path: &str, config: &Config) -> Vec<LaunchItem> {
// Top-level categories
if path.is_empty() {
return self.top_level_items();
}
let (section, rest) = match path.split_once(' ') {
Some((s, r)) => (s, r.trim()),
None => (path, ""),
};
match section {
"providers" => self.provider_items(config),
"theme" => self.theme_items(config, rest),
"engine" => self.engine_items(config),
"frecency" => self.frecency_items(config, rest),
"fontsize" => self.numeric_item("Font Size", "appearance.font_size", config.appearance.font_size, rest),
"width" => self.numeric_item("Width", "appearance.width", config.appearance.width, rest),
"height" => self.numeric_item("Height", "appearance.height", config.appearance.height, rest),
"radius" => self.numeric_item("Border Radius", "appearance.border_radius", config.appearance.border_radius, rest),
"profiles" => self.profile_items(config, rest),
"profile" => self.profile_detail_items(config, rest),
_ => self.top_level_items(),
}
}
fn top_level_items(&self) -> Vec<LaunchItem> {
vec![
self.make_item("config:providers", "Providers", "Toggle providers on/off", ""),
self.make_item("config:theme", "Theme", "Select color theme", ""),
self.make_item("config:engine", "Search Engine", "Select web search engine", ""),
self.make_item("config:frecency", "Frecency", "Frecency ranking settings", ""),
self.make_item("config:fontsize", "Font Size", "Set UI font size", ""),
self.make_item("config:width", "Width", "Set window width", ""),
self.make_item("config:height", "Height", "Set window height", ""),
self.make_item("config:radius", "Border Radius", "Set border radius", ""),
self.make_item("config:profiles", "Profiles", "Manage named mode profiles", ""),
]
}
fn make_item(&self, id: &str, name: &str, description: &str, command: &str) -> LaunchItem {
LaunchItem {
id: id.to_string(),
name: name.to_string(),
description: Some(description.to_string()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: command.to_string(),
terminal: false,
tags: vec!["config".into(), "settings".into()],
}
}
fn toggle_item(&self, id: &str, name: &str, enabled: bool, key: &str) -> LaunchItem {
let prefix = if enabled { "" } else { "" };
LaunchItem {
id: id.to_string(),
name: format!("{} {}", prefix, name),
description: Some(format!("{} (click to toggle)", if enabled { "Enabled" } else { "Disabled" })),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:toggle:{}", key),
terminal: false,
tags: vec!["config".into()],
}
}
```
- [ ] **Step 4: Implement provider_items**
```rust
fn provider_items(&self, config: &Config) -> Vec<LaunchItem> {
vec![
self.toggle_item("config:prov:app", "Applications", config.providers.applications, "providers.applications"),
self.toggle_item("config:prov:cmd", "Commands", config.providers.commands, "providers.commands"),
self.toggle_item("config:prov:calc", "Calculator", config.providers.calculator, "providers.calculator"),
self.toggle_item("config:prov:conv", "Converter", config.providers.converter, "providers.converter"),
self.toggle_item("config:prov:sys", "System", config.providers.system, "providers.system"),
self.toggle_item("config:prov:web", "Web Search", config.providers.websearch, "providers.websearch"),
self.toggle_item("config:prov:ssh", "SSH", config.providers.ssh, "providers.ssh"),
self.toggle_item("config:prov:clip", "Clipboard", config.providers.clipboard, "providers.clipboard"),
self.toggle_item("config:prov:bm", "Bookmarks", config.providers.bookmarks, "providers.bookmarks"),
self.toggle_item("config:prov:emoji", "Emoji", config.providers.emoji, "providers.emoji"),
self.toggle_item("config:prov:scripts", "Scripts", config.providers.scripts, "providers.scripts"),
self.toggle_item("config:prov:files", "File Search", config.providers.files, "providers.files"),
self.toggle_item("config:prov:uuctl", "systemd Units", config.providers.uuctl, "providers.uuctl"),
self.toggle_item("config:prov:media", "Media", config.providers.media, "providers.media"),
self.toggle_item("config:prov:weather", "Weather", config.providers.weather, "providers.weather"),
self.toggle_item("config:prov:pomo", "Pomodoro", config.providers.pomodoro, "providers.pomodoro"),
]
}
```
- [ ] **Step 5: Implement theme_items and engine_items**
```rust
fn theme_items(&self, config: &Config, filter: &str) -> Vec<LaunchItem> {
let current = config.appearance.theme.as_deref().unwrap_or("(default)");
let themes = [
"owl", "catppuccin-mocha", "nord", "rose-pine", "dracula",
"gruvbox-dark", "tokyo-night", "solarized-dark", "one-dark", "apex-neon",
];
themes.iter()
.filter(|t| filter.is_empty() || t.contains(filter))
.map(|t| {
let mark = if *t == current { "" } else { " " };
LaunchItem {
id: format!("config:theme:{}", t),
name: format!("{}{}", mark, t),
description: Some(if *t == current { "Current theme".into() } else { "Select this theme".into() }),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:set:appearance.theme:{}", t),
terminal: false,
tags: vec!["config".into()],
}
})
.collect()
}
fn engine_items(&self, config: &Config) -> Vec<LaunchItem> {
let current = &config.providers.search_engine;
let engines = [
"duckduckgo", "google", "bing", "startpage", "brave", "ecosia",
];
engines.iter()
.map(|e| {
let mark = if *e == current.as_str() { "" } else { " " };
LaunchItem {
id: format!("config:engine:{}", e),
name: format!("{}{}", mark, e),
description: Some(if *e == current.as_str() { "Current engine".into() } else { "Select this engine".into() }),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:set:providers.search_engine:{}", e),
terminal: false,
tags: vec!["config".into()],
}
})
.collect()
}
```
- [ ] **Step 6: Implement frecency_items and numeric_item**
```rust
fn frecency_items(&self, config: &Config, rest: &str) -> Vec<LaunchItem> {
let mut items = vec![
self.toggle_item("config:frecency:toggle", "Frecency Ranking", config.providers.frecency, "providers.frecency"),
];
// If user typed a weight value, show a set action
if !rest.is_empty() {
if let Ok(v) = rest.parse::<f64>() {
let clamped = v.clamp(0.0, 1.0);
items.push(LaunchItem {
id: "config:frecency:set".into(),
name: format!("Set weight to {:.1}", clamped),
description: Some(format!("Current: {:.1}", config.providers.frecency_weight)),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:set:providers.frecency_weight:{}", clamped),
terminal: false,
tags: vec!["config".into()],
});
}
} else {
items.push(LaunchItem {
id: "config:frecency:weight".into(),
name: format!("Weight: {:.1}", config.providers.frecency_weight),
description: Some("Type a value (0.01.0) after :config frecency".into()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(),
terminal: false,
tags: vec!["config".into()],
});
}
items
}
fn numeric_item(&self, label: &str, key: &str, current: i32, input: &str) -> Vec<LaunchItem> {
if !input.is_empty() {
if let Ok(v) = input.parse::<i32>() {
return vec![LaunchItem {
id: format!("config:set:{}", key),
name: format!("Set {} to {}", label, v),
description: Some(format!("Current: {} (restart to apply)", current)),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:set:{}:{}", key, v),
terminal: false,
tags: vec!["config".into()],
}];
}
}
vec![LaunchItem {
id: format!("config:show:{}", key),
name: format!("{}: {}", label, current),
description: Some(format!("Type a number after :config {} to change (restart to apply)", key.rsplit('.').next().unwrap_or(key))),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(),
terminal: false,
tags: vec!["config".into()],
}]
}
```
- [ ] **Step 7: Implement profile_items and profile_detail_items**
```rust
fn profile_items(&self, config: &Config, filter: &str) -> Vec<LaunchItem> {
let mut items: Vec<LaunchItem> = config.profiles.iter()
.filter(|(name, _)| filter.is_empty() || name.contains(filter))
.map(|(name, profile)| {
let modes = profile.modes.join(", ");
LaunchItem {
id: format!("config:profile:{}", name),
name: name.clone(),
description: Some(if modes.is_empty() { "(no modes)".into() } else { modes }),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(), // navigate deeper by typing :config profile <name>
terminal: false,
tags: vec!["config".into(), "profile".into()],
}
})
.collect();
// "Create" action — user types :config profile create <name>
items.push(LaunchItem {
id: "config:profile:create_hint".into(),
name: " Create New Profile".into(),
description: Some("Type: :config profile create <name>".into()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(),
terminal: false,
tags: vec!["config".into()],
});
items
}
fn profile_detail_items(&self, config: &Config, rest: &str) -> Vec<LaunchItem> {
let (profile_name, sub) = match rest.split_once(' ') {
Some((n, s)) => (n, s.trim()),
None => (rest, ""),
};
// Handle "profile create <name>"
if profile_name == "create" && !sub.is_empty() {
return vec![LaunchItem {
id: format!("config:profile:create:{}", sub),
name: format!("Create profile '{}'", sub),
description: Some("Press Enter to create".into()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:profile:create:{}", sub),
terminal: false,
tags: vec!["config".into()],
}];
}
let profile = match config.profiles.get(profile_name) {
Some(p) => p,
None => return vec![],
};
if sub == "modes" || sub.starts_with("modes") {
// Mode checklist
let all_modes = [
"app", "cmd", "calc", "conv", "sys", "web", "ssh", "clip",
"bm", "emoji", "scripts", "file", "uuctl", "media", "weather", "pomo",
];
return all_modes.iter()
.map(|mode| {
let enabled = profile.modes.iter().any(|m| m == mode);
let prefix = if enabled { "" } else { "" };
LaunchItem {
id: format!("config:profile:{}:mode:{}", profile_name, mode),
name: format!("{} {}", prefix, mode),
description: Some(format!("{} in profile '{}'", if enabled { "Enabled" } else { "Disabled" }, profile_name)),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:profile:mode:{}:toggle:{}", profile_name, mode),
terminal: false,
tags: vec!["config".into()],
}
})
.collect();
}
// Profile actions
vec![
LaunchItem {
id: format!("config:profile:{}:modes", profile_name),
name: "Edit Modes".into(),
description: Some(format!("Current: {}", profile.modes.join(", "))),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(), // navigate with :config profile <name> modes
terminal: false,
tags: vec!["config".into()],
},
LaunchItem {
id: format!("config:profile:{}:delete", profile_name),
name: format!("Delete profile '{}'", profile_name),
description: Some("Remove this profile".into()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:profile:delete:{}", profile_name),
terminal: false,
tags: vec!["config".into()],
},
]
}
```
- [ ] **Step 8: Write tests**
Add at the end of `config_editor.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
fn make_config() -> Arc<RwLock<Config>> {
Arc::new(RwLock::new(Config::default()))
}
#[test]
fn test_top_level_categories() {
let p = ConfigProvider::new(make_config());
let items = p.query("");
assert!(items.len() >= 8);
assert!(items.iter().any(|i| i.name == "Providers"));
assert!(items.iter().any(|i| i.name == "Theme"));
assert!(items.iter().any(|i| i.name == "Profiles"));
}
#[test]
fn test_provider_toggles() {
let p = ConfigProvider::new(make_config());
let items = p.query("providers");
assert!(items.len() >= 10);
assert!(items.iter().any(|i| i.name.contains("Calculator")));
}
#[test]
fn test_toggle_action() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(config.read().unwrap().providers.calculator);
assert!(p.execute_action("CONFIG:toggle:providers.calculator"));
assert!(!config.read().unwrap().providers.calculator);
assert!(p.execute_action("CONFIG:toggle:providers.calculator"));
assert!(config.read().unwrap().providers.calculator);
}
#[test]
fn test_set_theme() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:set:appearance.theme:nord"));
assert_eq!(config.read().unwrap().appearance.theme, Some("nord".into()));
}
#[test]
fn test_set_numeric() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:set:appearance.font_size:18"));
assert_eq!(config.read().unwrap().appearance.font_size, 18);
}
#[test]
fn test_frecency_weight_clamped() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:set:providers.frecency_weight:2.0"));
assert_eq!(config.read().unwrap().providers.frecency_weight, 1.0);
}
#[test]
fn test_invalid_action() {
let p = ConfigProvider::new(make_config());
assert!(!p.execute_action("INVALID:something"));
assert!(!p.execute_action("CONFIG:toggle:nonexistent.key"));
}
#[test]
fn test_theme_items_show_current() {
let config = make_config();
{
config.write().unwrap().appearance.theme = Some("nord".into());
}
let p = ConfigProvider::new(config);
let items = p.query("theme");
let nord = items.iter().find(|i| i.name.contains("nord")).unwrap();
assert!(nord.name.starts_with(""));
}
#[test]
fn test_numeric_input_generates_set_action() {
let p = ConfigProvider::new(make_config());
let items = p.query("fontsize 18");
assert_eq!(items.len(), 1);
assert!(items[0].name.contains("Set Font Size to 18"));
assert_eq!(items[0].command, "CONFIG:set:appearance.font_size:18");
}
#[test]
fn test_profile_create() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:profile:create:myprofile"));
assert!(config.read().unwrap().profiles.contains_key("myprofile"));
}
#[test]
fn test_profile_delete() {
let config = make_config();
{
config.write().unwrap().profiles.insert("test".into(), crate::config::ProfileConfig { modes: vec!["app".into()] });
}
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:profile:delete:test"));
assert!(!config.read().unwrap().profiles.contains_key("test"));
}
#[test]
fn test_profile_mode_toggle() {
let config = make_config();
{
config.write().unwrap().profiles.insert("dev".into(), crate::config::ProfileConfig { modes: vec!["app".into()] });
}
let p = ConfigProvider::new(Arc::clone(&config));
// Add ssh
assert!(p.execute_action("CONFIG:profile:mode:dev:toggle:ssh"));
assert!(config.read().unwrap().profiles["dev"].modes.contains(&"ssh".into()));
// Remove app
assert!(p.execute_action("CONFIG:profile:mode:dev:toggle:app"));
assert!(!config.read().unwrap().profiles["dev"].modes.contains(&"app".into()));
}
#[test]
fn test_provider_type() {
let p = ConfigProvider::new(make_config());
assert_eq!(p.provider_type(), ProviderType::Plugin("config".into()));
}
#[test]
fn test_profile_create_query() {
let p = ConfigProvider::new(make_config());
let items = p.query("profile create myname");
assert_eq!(items.len(), 1);
assert!(items[0].name.contains("myname"));
assert_eq!(items[0].command, "CONFIG:profile:create:myname");
}
}
```
- [ ] **Step 9: Verify compilation and tests**
Run: `cargo test -p owlry-core config_editor`
Note: tests that call `execute_action` will try `config.save()` which writes to disk. The save will fail gracefully (warns) in test environment since there's no XDG config dir — the toggle/set still returns true. If tests fail due to save, add `#[allow(dead_code)]` or mock the save path. Alternatively, since `Config::save()` returns a Result and the provider logs but ignores errors, this should be fine.
Expected: All tests pass.
- [ ] **Step 10: Commit**
```bash
git add crates/owlry-core/src/providers/config_editor.rs crates/owlry-core/src/providers/mod.rs
git commit -m "feat(core): add built-in config editor provider
Interactive :config prefix for browsing and modifying settings.
Supports provider toggles, theme/engine selection, numeric input,
and profile CRUD. Uses CONFIG:* action commands that persist to
config.toml via Config::save()."
```
---
### Task 2: Wire ConfigProvider into ProviderManager
**Files:**
- Modify: `crates/owlry-core/src/providers/mod.rs`
- Modify: `crates/owlry-core/src/config/mod.rs` (if ProfileConfig is not public)
The ConfigProvider needs to be:
1. Registered as a built-in dynamic provider
2. Its `execute_action` called from `execute_plugin_action`
- [ ] **Step 1: Make Config wrap in Arc<RwLock> for shared ownership**
The ConfigProvider needs mutable access to config. Currently `new_with_config` takes `&Config`. Change the daemon startup to wrap Config in `Arc<RwLock<Config>>` and pass it to both the ConfigProvider and the server.
In `crates/owlry-core/src/providers/mod.rs`, in `new_with_config()`, after creating the config provider:
```rust
// Config editor — needs shared mutable access to config
let config_arc = std::sync::Arc::new(std::sync::RwLock::new(config.clone()));
builtin_dynamic.push(Box::new(config_editor::ConfigProvider::new(config_arc)));
info!("Registered built-in config editor provider");
```
- [ ] **Step 2: Extend execute_plugin_action for built-in providers**
In `execute_plugin_action`, after the existing native provider check, add:
```rust
// Check built-in config editor
if command.starts_with("CONFIG:") {
for provider in &self.builtin_dynamic {
if let ProviderType::Plugin(ref id) = provider.provider_type() {
if id == "config" {
// Downcast to ConfigProvider to call execute_action
// Since we can't downcast trait objects easily, add an
// execute_action method to DynamicProvider with default impl
return provider.execute_action(command);
}
}
}
}
```
For this to work, add `execute_action` to the `DynamicProvider` trait with a default no-op:
```rust
pub(crate) trait DynamicProvider: Send + Sync {
fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType;
fn query(&self, query: &str) -> Vec<LaunchItem>;
fn priority(&self) -> u32;
/// Handle a plugin action command. Returns true if handled.
fn execute_action(&self, _command: &str) -> bool {
false
}
}
```
The ConfigProvider already has `execute_action` as an inherent method — just also implement it via the trait.
- [ ] **Step 3: Ensure ProfileConfig is accessible**
Check if `crate::config::ProfileConfig` is public. If not, add `pub` to its definition in `config/mod.rs`. The ConfigProvider needs to construct it for profile creation.
- [ ] **Step 4: Run tests**
Run: `cargo test -p owlry-core --lib`
Expected: All tests pass (128+ existing + new config editor tests).
- [ ] **Step 5: Commit**
```bash
git add crates/owlry-core/src/providers/mod.rs crates/owlry-core/src/config/mod.rs
git commit -m "feat(core): wire config editor into ProviderManager
Register ConfigProvider as built-in dynamic provider. Extend
execute_plugin_action to dispatch CONFIG:* commands. Add
execute_action method to DynamicProvider trait."
```
---
### Task 3: Update CLAUDE.md and README with config editor docs
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Add config editor section to README**
In the README, in the Usage section (after Keyboard Shortcuts), add:
```markdown
### Settings Editor
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 theme` | Select color theme |
| `:config engine` | Select web search engine |
| `:config frecency` | Toggle frecency, set weight |
| `:config fontsize 16` | Set font size (restart to apply) |
| `:config profiles` | List profiles |
| `:config profile create dev` | Create a new profile |
| `:config profile dev modes` | Edit which modes a profile includes |
Changes are saved to `config.toml` immediately. Some settings (theme, frecency) take effect on the next search. Others (font size, dimensions) require a restart.
```
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: add config editor usage to README"
```
---
## Execution Notes
### Task dependency order
Task 1 is the bulk of the implementation. Task 2 wires it in. Task 3 is docs.
**Order:** 1 → 2 → 3
### What's NOT in this plan
- **Hot-apply for theme** — would need the UI to re-trigger CSS loading after a CONFIG action. Can be added later by emitting a signal from the daemon or having the UI check a flag after `execute_plugin_action` returns.
- **Profile rename via text input** — the current design supports `:config profile create <name>` but rename would need a two-step flow. Can be added later.
- **Config file watching** — if the user edits `config.toml` externally, the ConfigProvider's cached `Arc<RwLock<Config>>` becomes stale. A file watcher could reload it. Deferred.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,922 @@
# Performance Optimization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix an unsound `unsafe` block, eliminate per-keystroke clone avalanches in the search path, and apply targeted I/O and algorithmic optimizations across both the `owlry` and `owlry-plugins` repos.
**Architecture:** Nine tasks across two repos. Phase 1 removes unsound `unsafe` code. Phase 2 restructures the hot search path to score by reference and clone only the final top-N results, combined with partial-sort (`select_nth_unstable_by`) for O(n) selection. Phase 3 removes unnecessary blocking I/O and simplifies GTK list updates. Phase 4 applies minor algorithmic fixes. Phase 5 covers plugin-repo fixes (separate repo, separate branch).
**Tech Stack:** Rust 1.90+, GTK4 4.12+, owlry-core, owlry-plugins
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `crates/owlry-core/src/providers/native_provider.rs` | Modify | Remove `RwLock<Vec<LaunchItem>>`, eliminate `unsafe` block |
| `crates/owlry-core/src/providers/mod.rs` | Modify | Score-by-reference in `search_with_frecency`, partial sort, clone only top N |
| `crates/owlry-core/src/data/frecency.rs` | Modify | Remove auto-save in `record_launch` |
| `crates/owlry/src/ui/main_window.rs` | Modify | Replace child-removal loop with `remove_all()` |
| `crates/owlry-core/src/providers/application.rs` | Modify | Single-pass double-space cleanup |
| `crates/owlry-core/src/filter.rs` | Modify | Merge full and partial prefix arrays into single-pass lookup |
---
## Phase 1: Safety & Correctness
### Task 1: Remove unsound `unsafe` in NativeProvider::items()
The `items()` implementation creates a raw pointer from an `RwLockReadGuard`, then drops the guard while returning a slice backed by that pointer. This is UB waiting to happen. The inner `RwLock` is unnecessary — `refresh()` takes `&mut self` (exclusive access guaranteed by the outer `Arc<RwLock<ProviderManager>>`), and `items()` takes `&self`. Replace the `RwLock<Vec<LaunchItem>>` with a plain `Vec<LaunchItem>`.
**Files:**
- Modify: `crates/owlry-core/src/providers/native_provider.rs`
- Test: `crates/owlry-core/src/providers/native_provider.rs` (existing tests)
- [ ] **Step 1: Run existing tests to establish baseline**
Run: `cargo test -p owlry-core`
Expected: All tests PASS.
- [ ] **Step 2: Replace `RwLock<Vec<LaunchItem>>` with `Vec<LaunchItem>` in the struct**
In `crates/owlry-core/src/providers/native_provider.rs`, change the struct definition:
```rust
pub struct NativeProvider {
plugin: Arc<NativePlugin>,
info: ProviderInfo,
handle: ProviderHandle,
items: Vec<LaunchItem>,
}
```
Update `new()` to initialize without RwLock:
```rust
Self {
plugin,
info,
handle,
items: Vec::new(),
}
```
- [ ] **Step 3: Remove the `unsafe` block from `items()` — return `&self.items` directly**
Replace the entire `Provider::items()` impl:
```rust
fn items(&self) -> &[LaunchItem] {
&self.items
}
```
- [ ] **Step 4: Update `refresh()` to write directly to `self.items`**
Replace the RwLock write in `refresh()`:
```rust
// Was: *self.items.write().unwrap() = items;
self.items = items;
```
- [ ] **Step 5: Update `query()` to read from `self.items` directly**
In the `query()` method, replace the RwLock read:
```rust
// Was: return self.items.read().unwrap().clone();
return self.items.clone();
```
- [ ] **Step 6: Remove `use std::sync::RwLock;` (no longer needed, `Arc` still used for plugin)**
Remove `RwLock` from the `use std::sync::{Arc, RwLock};` import:
```rust
use std::sync::Arc;
```
- [ ] **Step 7: Run tests and check**
Run: `cargo test -p owlry-core && cargo check -p owlry-core`
Expected: All tests PASS, no warnings about unused imports.
- [ ] **Step 8: Compile full workspace to verify no downstream breakage**
Run: `cargo check --workspace`
Expected: Clean compilation.
- [ ] **Step 9: Commit**
```bash
git add crates/owlry-core/src/providers/native_provider.rs
git commit -m "fix(native-provider): remove unsound unsafe in items()
Replace RwLock<Vec<LaunchItem>> with plain Vec. The inner RwLock
was unnecessary — refresh() takes &mut self (exclusive access
guaranteed by the outer Arc<RwLock<ProviderManager>>). The unsafe
block in items() dropped the RwLockReadGuard while returning a
slice backed by the raw pointer, creating a dangling reference."
```
---
## Phase 2: Hot Path Optimization
### Task 2: Eliminate clone avalanche in search_with_frecency
Currently every matching `LaunchItem` (5 Strings + Vec) is cloned during scoring, then the Vec is sorted and truncated to ~15 results — discarding 95%+ of clones. Refactor to score items by reference, partial-sort the lightweight `(&LaunchItem, i64)` tuples with `select_nth_unstable_by` (O(n) average), and clone only the top-N survivors.
**Files:**
- Modify: `crates/owlry-core/src/providers/mod.rs``search_with_frecency` method (~lines 652-875)
- Test: existing tests in `crates/owlry-core/src/providers/mod.rs`
- [ ] **Step 1: Run existing search tests to establish baseline**
Run: `cargo test -p owlry-core search`
Expected: All search-related tests PASS.
- [ ] **Step 2: Refactor `score_item` closure to return `Option<i64>` instead of `Option<(LaunchItem, i64)>`**
In `search_with_frecency`, change the closure at ~line 780 from:
```rust
let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> {
```
To:
```rust
let score_item = |item: &LaunchItem| -> Option<i64> {
```
And change the return from:
```rust
base_score.map(|s| {
let frecency_score = frecency.get_score_at(&item.id, now);
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
let exact_match_boost = if item.name.eq_ignore_ascii_case(query) {
match &item.provider {
ProviderType::Application => 50_000,
_ => 30_000,
}
} else {
0
};
(item.clone(), s + frecency_boost + exact_match_boost)
})
```
To:
```rust
base_score.map(|s| {
let frecency_score = frecency.get_score_at(&item.id, now);
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
let exact_match_boost = if item.name.eq_ignore_ascii_case(query) {
match &item.provider {
ProviderType::Application => 50_000,
_ => 30_000,
}
} else {
0
};
s + frecency_boost + exact_match_boost
})
```
- [ ] **Step 3: Replace the static-item scoring loops to collect references**
Replace the scoring loops at ~lines 831-853:
```rust
// Was:
// for provider in &self.providers { ... results.push(scored); }
// for provider in &self.static_native_providers { ... results.push(scored); }
// Score static items by reference (no cloning)
let mut scored_refs: Vec<(&LaunchItem, i64)> = Vec::new();
for provider in &self.providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(score) = score_item(item) {
scored_refs.push((item, score));
}
}
}
for provider in &self.static_native_providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(score) = score_item(item) {
scored_refs.push((item, score));
}
}
}
// Partial sort: O(n) average to find top max_results, then O(k log k) to order them
if scored_refs.len() > max_results {
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
scored_refs.truncate(max_results);
}
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
// Clone only the survivors
results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score)));
```
- [ ] **Step 4: Add final merge-sort for dynamic + static results**
After extending results, add the final sort (dynamic results from earlier in the function + the newly added static results need unified ordering):
```rust
// Final sort merges dynamic results with static top-N
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
```
This replaces the existing `results.sort_by(...)` and `results.truncate(...)` lines at ~854-855 — the logic is the same, just confirming it's still present after the refactor.
- [ ] **Step 5: Optimize the empty-query path to score by reference too**
Replace the empty-query block at ~lines 739-776. Change:
```rust
let core_items = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
let native_items = self
.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
let items: Vec<(LaunchItem, i64)> = core_items
.chain(native_items)
.filter(|item| {
if let Some(tag) = tag_filter {
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
} else {
true
}
})
.map(|item| {
let frecency_score = frecency.get_score_at(&item.id, now);
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
(item, boosted)
})
.collect();
results.extend(items);
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
return results;
```
With:
```rust
let mut scored_refs: Vec<(&LaunchItem, i64)> = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter())
.chain(
self.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter()),
)
.filter(|item| {
if let Some(tag) = tag_filter {
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
} else {
true
}
})
.map(|item| {
let frecency_score = frecency.get_score_at(&item.id, now);
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
(item, boosted)
})
.collect();
if scored_refs.len() > max_results {
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
scored_refs.truncate(max_results);
}
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score)));
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
return results;
```
- [ ] **Step 6: Run tests**
Run: `cargo test -p owlry-core`
Expected: All tests PASS. Search results are identical (same items, same ordering).
- [ ] **Step 7: Compile full workspace**
Run: `cargo check --workspace`
Expected: Clean compilation.
- [ ] **Step 8: Commit**
```bash
git add crates/owlry-core/src/providers/mod.rs
git commit -m "perf(search): score by reference, clone only top-N results
Refactor search_with_frecency to score static provider items by
reference (&LaunchItem, i64) instead of cloning every match.
Use select_nth_unstable_by for O(n) partial sort, then clone
only the max_results survivors. Reduces clones from O(total_matches)
to O(max_results) — typically from hundreds to ~15."
```
---
## Phase 3: I/O Optimization
### Task 3: Remove frecency auto-save on every launch
`record_launch` calls `self.save()` synchronously — serializing JSON and writing to disk on every item launch. The `Drop` impl already saves on shutdown. Mark dirty and let the caller (or shutdown) handle persistence.
**Files:**
- Modify: `crates/owlry-core/src/data/frecency.rs:98-123`
- Test: `crates/owlry-core/src/data/frecency.rs` (existing + new)
- [ ] **Step 1: Write test verifying record_launch sets dirty without saving**
Add to the `#[cfg(test)]` block in `frecency.rs`:
```rust
#[test]
fn record_launch_sets_dirty_without_saving() {
let mut store = FrecencyStore {
data: FrecencyData::default(),
path: PathBuf::from("/dev/null"),
dirty: false,
};
store.record_launch("test-item");
assert!(store.dirty, "record_launch should set dirty flag");
assert_eq!(store.data.entries["test-item"].launch_count, 1);
}
```
- [ ] **Step 2: Run test to verify it fails (current code auto-saves, clearing dirty)**
Run: `cargo test -p owlry-core record_launch_sets_dirty`
Expected: FAIL — `store.dirty` is `false` because `save()` clears it.
- [ ] **Step 3: Remove the auto-save from `record_launch`**
In `crates/owlry-core/src/data/frecency.rs`, remove lines 119-122 from `record_launch`:
```rust
// Remove this block:
// Auto-save after recording
if let Err(e) = self.save() {
warn!("Failed to save frecency data: {}", e);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p owlry-core record_launch_sets_dirty`
Expected: PASS.
- [ ] **Step 5: Run all frecency tests**
Run: `cargo test -p owlry-core frecency`
Expected: All PASS.
- [ ] **Step 6: Commit**
```bash
git add crates/owlry-core/src/data/frecency.rs
git commit -m "perf(frecency): remove blocking auto-save on every launch
record_launch no longer calls save() synchronously. The dirty flag
is set and the Drop impl flushes on shutdown. Removes a JSON
serialize + fs::write from the hot launch path."
```
---
### Task 4: GTK ListBox — replace child-removal loop with `remove_all()`
The current code removes children one-by-one in a `while` loop, triggering layout invalidation per removal. GTK 4.12 provides `remove_all()` which batches the operation.
**Files:**
- Modify: `crates/owlry/src/ui/main_window.rs` — two locations (~lines 705-707 and ~742-744)
- [ ] **Step 1: Replace daemon-mode child removal loop**
In the `spawn_future_local` async block (~line 705), replace:
```rust
while let Some(child) = results_list_cb.first_child() {
results_list_cb.remove(&child);
}
```
With:
```rust
results_list_cb.remove_all();
```
- [ ] **Step 2: Replace local-mode (dmenu) child removal loop**
In the synchronous (local/dmenu) branch (~line 742), replace:
```rust
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
```
With:
```rust
results_list.remove_all();
```
- [ ] **Step 3: Verify compilation**
Run: `cargo check -p owlry`
Expected: Clean compilation. `remove_all()` is available in gtk4 4.12+.
- [ ] **Step 4: Commit**
```bash
git add crates/owlry/src/ui/main_window.rs
git commit -m "perf(ui): use ListBox::remove_all() instead of per-child loop
Replaces two while-loop child removal patterns with the batched
remove_all() method available since GTK 4.12. Avoids per-removal
layout invalidation."
```
---
## Phase 4: Minor Optimizations
### Task 5: Single-pass double-space cleanup in application.rs
The `clean_desktop_exec_field` function uses a `while contains(" ") { replace(" ", " ") }` loop — O(n²) on pathological input with repeated allocations. Replace with a single-pass char iterator.
**Files:**
- Modify: `crates/owlry-core/src/providers/application.rs:60-64`
- Test: existing tests in `crates/owlry-core/src/providers/application.rs`
- [ ] **Step 1: Add test for pathological input**
Add to the existing `#[cfg(test)]` block:
```rust
#[test]
fn test_clean_desktop_exec_collapses_spaces() {
assert_eq!(clean_desktop_exec_field("app --flag arg"), "app --flag arg");
// Pathological: many consecutive spaces
let input = format!("app{}arg", " ".repeat(100));
assert_eq!(clean_desktop_exec_field(&input), "app arg");
}
```
- [ ] **Step 2: Run test to verify it passes with current implementation**
Run: `cargo test -p owlry-core clean_desktop_exec`
Expected: All PASS (current implementation works, just inefficiently).
- [ ] **Step 3: Replace the while-loop with a single-pass approach**
Replace lines 60-64 of `application.rs`:
```rust
// Was:
// let mut cleaned = result.trim().to_string();
// while cleaned.contains(" ") {
// cleaned = cleaned.replace(" ", " ");
// }
// cleaned
let trimmed = result.trim();
let mut cleaned = String::with_capacity(trimmed.len());
let mut prev_space = false;
for c in trimmed.chars() {
if c == ' ' {
if !prev_space {
cleaned.push(' ');
}
prev_space = true;
} else {
cleaned.push(c);
prev_space = false;
}
}
cleaned
```
- [ ] **Step 4: Run all application tests**
Run: `cargo test -p owlry-core clean_desktop_exec`
Expected: All PASS including the new pathological test.
- [ ] **Step 5: Commit**
```bash
git add crates/owlry-core/src/providers/application.rs
git commit -m "perf(application): single-pass double-space collapse
Replace while-contains-replace loop with a single-pass char
iterator. Eliminates O(n²) behavior and repeated allocations
on pathological desktop file Exec values."
```
---
### Task 6: Consolidate parse_query prefix matching into single pass
`parse_query` maintains four separate arrays (core_prefixes, plugin_prefixes, partial_core, partial_plugin) with duplicated prefix strings, iterating them in sequence. Merge full and partial matching into a single array and a single loop per category.
**Files:**
- Modify: `crates/owlry-core/src/filter.rs:202-406`
- Test: existing tests in `crates/owlry-core/src/filter.rs`
- [ ] **Step 1: Run existing filter tests to establish baseline**
Run: `cargo test -p owlry-core filter`
Expected: All PASS.
- [ ] **Step 2: Define a unified prefix entry struct and static arrays**
At the top of `parse_query`, replace the four separate arrays with two unified arrays:
```rust
pub fn parse_query(query: &str) -> ParsedQuery {
let trimmed = query.trim_start();
// Tag filter: ":tag:XXX query" — check first (unchanged)
if let Some(rest) = trimmed.strip_prefix(":tag:") {
if let Some(space_idx) = rest.find(' ') {
let tag = rest[..space_idx].to_lowercase();
let query_part = rest[space_idx + 1..].to_string();
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> tag={:?}, query={:?}",
query, tag, query_part
);
return ParsedQuery {
prefix: None,
tag_filter: Some(tag),
query: query_part,
};
} else {
let tag = rest.to_lowercase();
return ParsedQuery {
prefix: None,
tag_filter: Some(tag),
query: String::new(),
};
}
}
// Core prefixes — each entry is tried as ":prefix " (full) and ":prefix" (partial)
const CORE_PREFIXES: &[(&str, fn() -> ProviderType)] = &[
("app", || ProviderType::Application),
("apps", || ProviderType::Application),
("cmd", || ProviderType::Command),
("command", || ProviderType::Command),
];
// Plugin prefixes — each entry maps to a plugin type_id
const PLUGIN_PREFIXES: &[(&str, &str)] = &[
("bm", "bookmarks"),
("bookmark", "bookmarks"),
("bookmarks", "bookmarks"),
("calc", "calc"),
("calculator", "calc"),
("clip", "clipboard"),
("clipboard", "clipboard"),
("emoji", "emoji"),
("emojis", "emoji"),
("file", "filesearch"),
("files", "filesearch"),
("find", "filesearch"),
("script", "scripts"),
("scripts", "scripts"),
("ssh", "ssh"),
("sys", "system"),
("system", "system"),
("power", "system"),
("uuctl", "uuctl"),
("systemd", "uuctl"),
("web", "websearch"),
("search", "websearch"),
("config", "config"),
("settings", "config"),
("conv", "conv"),
("converter", "conv"),
];
// Single-pass: try each core prefix as both full (":prefix query") and partial (":prefix")
for (name, make_provider) in CORE_PREFIXES {
let with_space = format!(":{} ", name);
if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
let provider = make_provider();
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
query, provider, rest
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: rest.to_string(),
};
}
let exact = format!(":{}", name);
if trimmed == exact {
let provider = make_provider();
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> partial prefix {:?}",
query, provider
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: String::new(),
};
}
}
// Single-pass: try each plugin prefix as both full and partial
for (name, type_id) in PLUGIN_PREFIXES {
let with_space = format!(":{} ", name);
if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
query, provider, rest
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: rest.to_string(),
};
}
let exact = format!(":{}", name);
if trimmed == exact {
let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> partial prefix {:?}",
query, provider
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: String::new(),
};
}
}
// Dynamic plugin prefix fallback (unchanged)
if let Some(rest) = trimmed.strip_prefix(':') {
if let Some(space_idx) = rest.find(' ') {
let prefix_word = &rest[..space_idx];
if !prefix_word.is_empty()
&& prefix_word
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return ParsedQuery {
prefix: Some(ProviderType::Plugin(prefix_word.to_string())),
tag_filter: None,
query: rest[space_idx + 1..].to_string(),
};
}
} else if !rest.is_empty()
&& rest
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return ParsedQuery {
prefix: Some(ProviderType::Plugin(rest.to_string())),
tag_filter: None,
query: String::new(),
};
}
}
let result = ParsedQuery {
prefix: None,
tag_filter: None,
query: query.to_string(),
};
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}",
query, result.prefix, result.tag_filter, result.query
);
result
}
```
> **Note:** `CORE_PREFIXES` uses function pointers (`fn() -> ProviderType`) because `ProviderType::Application` and `ProviderType::Command` are fieldless variants that can be constructed in a const context via a trivial function. `PLUGIN_PREFIXES` stays as `(&str, &str)` because the `to_string()` allocation only happens once a prefix actually matches.
- [ ] **Step 3: Run all filter tests**
Run: `cargo test -p owlry-core filter`
Expected: All existing tests PASS (behavior unchanged).
- [ ] **Step 4: Run full check**
Run: `cargo check -p owlry-core`
Expected: Clean compilation.
- [ ] **Step 5: Commit**
```bash
git add crates/owlry-core/src/filter.rs
git commit -m "refactor(filter): consolidate parse_query prefix arrays
Merge four separate prefix arrays (core full, plugin full, core
partial, plugin partial) into two arrays with a single loop each
that checks both full and partial match. Halves the data and
eliminates the duplicate iteration."
```
---
## Phase 5: Plugin Repo Fixes
> **These tasks target `somegit.dev/Owlibou/owlry-plugins` — a separate repository.**
> Clone and branch separately. They can be done independently of Phases 1-4.
### Task 7: Filesearch — add minimum query length threshold
The filesearch plugin spawns an `fd` subprocess on every keystroke (after debounce). For short queries this is wasteful and returns too many results. Add a 3-character minimum before spawning.
**Files:**
- Modify: `owlry-plugin-filesearch/src/lib.rs``query()` method
- Test: existing or new tests
- [ ] **Step 1: Add early return in the `query` method**
At the top of the `query` function (which receives the search text), add:
```rust
fn query(&self, query: &str) -> Vec<PluginItem> {
// Don't spawn fd for very short queries — too many results, too slow
if query.len() < 3 {
return Vec::new();
}
// ... existing code ...
}
```
Find the correct method — it may be the `ProviderVTable::provider_query` path or a helper like `search_with_fd`. The guard should be placed at the earliest point before `Command::new("fd")` is invoked.
- [ ] **Step 2: Verify compilation and test**
Run: `cargo check -p owlry-plugin-filesearch`
Expected: Clean compilation.
- [ ] **Step 3: Commit**
```bash
git add owlry-plugin-filesearch/src/lib.rs
git commit -m "perf(filesearch): skip fd subprocess for queries under 3 chars
Avoids spawning a subprocess per keystroke when the user has
only typed 1-2 characters. Short queries return too many results
from fd and block the daemon's read lock."
```
---
### Task 8: Emoji plugin — avoid double clone on refresh
The emoji provider's `refresh()` returns `state.items.to_vec().into()` which clones all ~400 `PluginItem` structs. The core's `NativeProvider::refresh()` then converts each to `LaunchItem` (another set of allocations). If the plugin API supports transferring ownership instead of cloning, use that. Otherwise, this is an API-level limitation.
**Files:**
- Modify: `owlry-plugin-emoji/src/lib.rs``provider_refresh` function
- [ ] **Step 1: Check if items can be drained instead of cloned**
If `state.items` is a `Vec<PluginItem>` that gets rebuilt on each refresh anyway, drain it:
```rust
// Was: state.items.to_vec().into()
// If items are rebuilt each refresh:
std::mem::take(&mut state.items).into()
```
If `state.items` must be preserved between refreshes (because refresh is called multiple times and the items don't change), then the clone is necessary and this task is a no-op. Check the `refresh()` implementation to determine which case applies.
- [ ] **Step 2: Verify and commit if applicable**
Run: `cargo check -p owlry-plugin-emoji`
---
### Task 9: Clipboard plugin — add caching for `cliphist list`
The clipboard provider calls `cliphist list` synchronously on every refresh. If the daemon's periodic refresh timer triggers this, it blocks the RwLock. Add a simple staleness check — only re-run `cliphist list` if more than N seconds have elapsed since the last successful fetch.
**Files:**
- Modify: `owlry-plugin-clipboard/src/lib.rs`
- [ ] **Step 1: Add a `last_refresh` timestamp to the provider state**
```rust
use std::time::Instant;
struct ClipboardState {
items: Vec<PluginItem>,
last_refresh: Option<Instant>,
}
```
- [ ] **Step 2: Guard the subprocess call with a staleness check**
In the `refresh()` path:
```rust
const REFRESH_INTERVAL: Duration = Duration::from_secs(5);
if let Some(last) = state.last_refresh {
if last.elapsed() < REFRESH_INTERVAL {
return state.items.clone().into();
}
}
// ... existing cliphist list call ...
state.last_refresh = Some(Instant::now());
```
- [ ] **Step 3: Verify and commit**
Run: `cargo check -p owlry-plugin-clipboard`
```bash
git commit -m "perf(clipboard): cache cliphist results for 5 seconds
Avoids re-spawning cliphist list on every refresh cycle when the
previous results are still fresh."
```
---
## Summary
| Task | Impact | Repo | Risk |
|------|--------|------|------|
| 1. Remove unsafe in NativeProvider | CRITICAL (soundness) | owlry | Low — drops unnecessary RwLock |
| 2. Score-by-ref + partial sort | HIGH (keystroke perf) | owlry | Medium — touches hot path, verify with tests |
| 3. Remove frecency auto-save | MEDIUM (launch perf) | owlry | Low — Drop impl already saves |
| 4. ListBox remove_all() | MEDIUM (UI smoothness) | owlry | Low — direct GTK API replacement |
| 5. Single-pass space collapse | LOW (startup) | owlry | Low — purely algorithmic |
| 6. Consolidate parse_query | LOW (keystroke) | owlry | Low — existing tests cover behavior |
| 7. Filesearch min query length | HIGH (keystroke perf) | plugins | Low — early return guard |
| 8. Emoji refresh optimization | MEDIUM (startup) | plugins | Low — depends on API check |
| 9. Clipboard caching | MEDIUM (refresh perf) | plugins | Low — simple staleness check |

View File

@@ -0,0 +1,142 @@
# Codebase Hardening: owlry + owlry-plugins
**Date:** 2026-03-26
**Scope:** 15 fixes across 2 repositories, organized in 5 severity tiers
**Approach:** Severity-ordered tiers, one commit per tier, core repo first
---
## Tier 1: Critical / Soundness (owlry core)
### 1a. Replace `static mut HOST_API` with `OnceLock`
**File:** `crates/owlry-plugin-api/src/lib.rs`
**Problem:** `static mut` is unsound — concurrent reads during initialization are UB.
**Fix:** Replace with `std::sync::OnceLock<&'static HostAPI>`. `init_host_api()` calls `HOST_API.set(api)`, `host_api()` calls `HOST_API.get().copied()`. No public API changes — convenience wrappers (`notify()`, `log_info()`, etc.) keep working. No ABI impact since `HOST_API` is internal.
### 1b. Add IPC message size limit
**File:** `crates/owlry-core/src/server.rs`
**Problem:** `BufReader::lines()` reads unbounded lines. A malicious/buggy client can OOM the daemon.
**Fix:** Replace the `lines()` iterator with a manual `read_line()` loop enforcing a 1 MB max. Lines exceeding the limit get an error response and the connection is dropped. Constant: `const MAX_REQUEST_SIZE: usize = 1_048_576`.
### 1c. Handle mutex poisoning gracefully
**File:** `crates/owlry-core/src/server.rs`
**Problem:** All `lock().unwrap()` calls panic on poisoned mutex, crashing handler threads.
**Fix:** Replace with `lock().unwrap_or_else(|e| e.into_inner())`. The ProviderManager and FrecencyStore don't have invariants that require abort-on-poison.
---
## Tier 2: Security (owlry core)
### 2a. Set socket permissions after bind
**File:** `crates/owlry-core/src/server.rs`
**Problem:** Socket inherits process umask, may be readable by other local users.
**Fix:** After `UnixListener::bind()`, call `std::fs::set_permissions(socket_path, Permissions::from_mode(0o600))`. Uses `std::os::unix::fs::PermissionsExt`.
### 2b. Log signal handler failure
**File:** `crates/owlry-core/src/main.rs`
**Problem:** `ctrlc::set_handler(...).ok()` silently swallows errors. Failed handler means no socket cleanup on SIGINT.
**Fix:** Replace `.ok()` with `if let Err(e) = ... { warn!("...") }`.
### 2c. Add client read timeout
**File:** `crates/owlry-core/src/server.rs`
**Problem:** A client that connects but never sends data blocks a thread forever.
**Fix:** Set `stream.set_read_timeout(Some(Duration::from_secs(30)))` on accepted connections before entering the read loop.
---
## Tier 3: Robustness / Quality (owlry core)
### 3a. Log malformed JSON requests
**File:** `crates/owlry-core/src/server.rs`
**Problem:** JSON parse errors only sent as response to client, not visible in daemon logs.
**Fix:** Add `warn!("Malformed request from client: {}", e)` before sending the error response.
### 3b. Replace Mutex with RwLock for concurrent reads
**File:** `crates/owlry-core/src/server.rs`
**Problem:** `Mutex<ProviderManager>` blocks all concurrent queries even though they're read-only.
**Fix:** Replace both `Arc<Mutex<ProviderManager>>` and `Arc<Mutex<FrecencyStore>>` with `Arc<RwLock<...>>`.
Lock usage per request type:
| Request | ProviderManager | FrecencyStore |
|---------|----------------|---------------|
| Query | `read()` | `read()` |
| Launch | — | `write()` |
| Providers | `read()` | — |
| Refresh | `write()` | — |
| Toggle | — | — |
| Submenu | `read()` | — |
| PluginAction | `read()` | — |
Poisoning recovery: `.unwrap_or_else(|e| e.into_inner())` applies to RwLock the same way.
---
## Tier 4: Critical fixes (owlry-plugins)
### 4a. Fix `Box::leak` memory leak in converter
**File:** `owlry-plugins/crates/owlry-plugin-converter/src/units.rs`
**Problem:** `Box::leak(code.into_boxed_str())` leaks memory on every keystroke for currency queries.
**Fix:** Currency codes are already `&'static str` in `CURRENCY_ALIASES`. Change `resolve_currency_code()` return type from `Option<String>` to `Option<&'static str>` so it returns the static str directly. This eliminates the `Box::leak`. Callers in `units.rs` (`find_unit`, `convert_currency`, `convert_currency_common`) and `currency.rs` (`is_currency_alias`) must be updated to work with `&'static str` — mostly removing `.to_string()` calls or adding them at the boundary where `String` is needed (e.g., HashMap lookups that need owned keys).
### 4b. Fix bookmarks temp file race condition
**File:** `owlry-plugins/crates/owlry-plugin-bookmarks/src/lib.rs`
**Problem:** Predictable `/tmp/owlry_places_temp.sqlite` path — concurrent instances clobber, symlink attacks possible.
**Fix:** Append PID and monotonic counter to filename: `owlry_places_{pid}.sqlite`. Uses `std::process::id()`. Each profile copy gets its own name via index. Cleanup on exit remains the same.
### 4c. Fix bookmarks background refresh never updating state
**File:** `owlry-plugins/crates/owlry-plugin-bookmarks/src/lib.rs`
**Problem:** Background thread loads items and saves cache but never writes back to `self.items`. Current session keeps stale data.
**Fix:** Replace `items: Vec<PluginItem>` with `items: Arc<Mutex<Vec<PluginItem>>>`. Background thread writes to the shared vec after completing. `provider_refresh` reads from it. The `loading` AtomicBool already prevents concurrent loads.
---
## Tier 5: Quality fixes (owlry-plugins)
### 5a. SSH plugin: read terminal from config
**File:** `owlry-plugins/crates/owlry-plugin-ssh/src/lib.rs`
**Problem:** Hardcoded `kitty` as terminal fallback. Core already detects terminals.
**Fix:** Read `terminal` from `[plugins.ssh]` in `~/.config/owlry/config.toml`. Fall back to `$TERMINAL` env var, then `xdg-terminal-exec`. Same config pattern as weather/pomodoro plugins.
### 5b. WebSearch plugin: read engine from config
**File:** `owlry-plugins/crates/owlry-plugin-websearch/src/lib.rs`
**Problem:** TODO comment for config reading, never implemented. Engine is always duckduckgo.
**Fix:** Read `engine` from `[plugins.websearch]` in config.toml. Supports named engines (`google`, `duckduckgo`, etc.) or custom URL templates with `{query}`. Falls back to duckduckgo.
### 5c. Emoji plugin: build items once at init
**File:** `owlry-plugins/crates/owlry-plugin-emoji/src/lib.rs`
**Problem:** `load_emojis()` clears and rebuilds ~370 items on every `refresh()` call.
**Fix:** Call `load_emojis()` in `EmojiState::new()`. `provider_refresh` returns `self.items.clone()` without rebuilding.
### 5d. Calculator/Converter: safer shell commands
**Files:** `owlry-plugin-calculator/src/lib.rs`, `owlry-plugin-converter/src/lib.rs`
**Problem:** `sh -c 'echo -n "..."'` pattern with double-quote interpolation. Theoretically breakable by unexpected result formatting.
**Fix:** Use `printf '%s' '...' | wl-copy` with single-quote escaping (`replace('\'', "'\\''")`) — the same safe pattern already used by bookmarks and clipboard plugins.
---
## Out of scope
These were identified but deferred:
- **Hardcoded emoji list** — replacing with a crate/data file is a feature, not a fix
- **Plugin vtable-level tests** — valuable but a separate testing initiative
- **IPC protocol versioning** — protocol change, not a bug fix
- **Plugin sandbox enforcement** — large feature, not a point fix
- **Desktop Exec field sanitization** — deep rabbit hole, needs separate design
- **Config validation** — separate concern, deserves its own pass

View File

@@ -0,0 +1,161 @@
# Script Runtime Integration for owlry-core Daemon
**Date:** 2026-03-26
**Scope:** Wire up Lua/Rune script runtime loading in the daemon, fix ABI mismatch, add filesystem-watching hot-reload, update plugin documentation
**Repos:** owlry (core), owlry-plugins (docs only)
---
## Problem
The daemon (`owlry-core`) only loads native plugins from `/usr/lib/owlry/plugins/`. User script plugins in `~/.config/owlry/plugins/` are never discovered because `ProviderManager::new_with_config()` never calls the `LoadedRuntime` infrastructure that already exists in `runtime_loader.rs`. Both Lua and Rune runtimes are installed at `/usr/lib/owlry/runtimes/` and functional, but never invoked.
Additionally, the Lua runtime's `RuntimeInfo` struct has 5 fields while the core expects 2, causing a SIGSEGV on cleanup.
---
## 1. Fix Lua RuntimeInfo ABI mismatch
**File:** `owlry/crates/owlry-lua/src/lib.rs`
Shrink Lua's `RuntimeInfo` from 5 fields to 2, matching core and Rune:
```rust
// Before (5 fields — ABI mismatch with core):
pub struct RuntimeInfo {
pub id: RString,
pub name: RString,
pub version: RString,
pub description: RString,
pub api_version: u32,
}
// After (2 fields — matches core/Rune):
pub struct RuntimeInfo {
pub name: RString,
pub version: RString,
}
```
Update `runtime_info()` to return only 2 fields. Remove the `LUA_RUNTIME_API_VERSION` constant and `LuaRuntimeVTable` (use the core's `ScriptRuntimeVTable` layout — both already match). The extra metadata (`id`, `description`) was never consumed by the core.
### Vtable `init` signature change
Change the `init` function in the vtable to accept the owlry version as a second parameter:
```rust
// Before:
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
// After:
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
```
This applies to:
- `owlry-core/src/plugins/runtime_loader.rs``ScriptRuntimeVTable.init`
- `owlry-lua/src/lib.rs``LuaRuntimeVTable.init` and `runtime_init()` implementation
- `owlry-rune/src/lib.rs``RuneRuntimeVTable.init` and `runtime_init()` implementation
The core passes its version (`env!("CARGO_PKG_VERSION")` from `owlry-core`) when calling `(vtable.init)(plugins_dir, version)`. Runtimes forward it to `discover_and_load()` instead of hardcoding a version string. This keeps compatibility checks future-proof — no code changes needed on version bumps.
---
## 2. Change default entry points to `main`
**Files:**
- `owlry/crates/owlry-lua/src/manifest.rs` — change `default_entry()` from `"init.lua"` to `"main.lua"`
- `owlry/crates/owlry-rune/src/manifest.rs` — change `default_entry()` from `"init.rn"` to `"main.rn"`
Add `#[serde(alias = "entry_point")]` to the `entry` field in both manifests so existing `plugin.toml` files using `entry_point` continue to work.
---
## 3. Wire runtime loading into ProviderManager
**File:** `owlry/crates/owlry-core/src/providers/mod.rs`
In `ProviderManager::new_with_config()`, after native plugin loading:
1. Get user plugins directory from `paths::plugins_dir()`
2. Get owlry version: `env!("CARGO_PKG_VERSION")`
3. Try `LoadedRuntime::load_lua(&plugins_dir, version)` — log at `info!` if unavailable, not error
4. Try `LoadedRuntime::load_rune(&plugins_dir, version)` — same
5. Call `create_providers()` on each loaded runtime
6. Feed runtime providers into existing categorization (static/dynamic/widget)
`LoadedRuntime::load_lua`, `load_rune`, and `load_from_path` all gain an `owlry_version: &str` parameter, which is passed to `(vtable.init)(plugins_dir, owlry_version)`.
Store `LoadedRuntime` instances on `ProviderManager` in a new field `runtimes: Vec<LoadedRuntime>`. These must stay alive for the daemon's lifetime (they own the `Library` handle via `Arc`).
Remove `#![allow(dead_code)]` from `runtime_loader.rs` since it's now used.
---
## 4. Filesystem watcher for automatic hot-reload
**New file:** `owlry/crates/owlry-core/src/plugins/watcher.rs`
**Modified:** `owlry/crates/owlry-core/src/providers/mod.rs`, `Cargo.toml`
### Dependencies
Add to `owlry-core/Cargo.toml`:
```toml
notify = "7"
notify-debouncer-mini = "0.5"
```
### Watcher design
After initializing runtimes, spawn a background watcher thread:
1. Watch `~/.config/owlry/plugins/` recursively using `notify-debouncer-mini` with 500ms debounce
2. On debounced event (any file create/modify/delete):
- Acquire write lock on `ProviderManager`
- Remove all runtime-backed providers from the provider vecs
- Drop old `LoadedRuntime` instances
- Re-load runtimes from `/usr/lib/owlry/runtimes/` with fresh plugin discovery
- Add new runtime providers to provider vecs
- Refresh the new providers
- Release write lock
### Provider tracking
`ProviderManager` needs to distinguish runtime providers from native/core providers for selective removal during reload. Options:
- **Tag-based:** Runtime providers already use `ProviderType::Plugin(type_id)`. Keep a `HashSet<String>` of type_ids that came from runtimes. On reload, remove providers whose type_id is in the set.
- **Separate storage:** Store runtime providers in their own vec, separate from native providers. Query merges results from both.
**Chosen: Tag-based.** Simpler — runtime type_ids are tracked in a `runtime_type_ids: HashSet<String>` on `ProviderManager`. Reload clears the set, removes matching providers, then re-adds.
### Thread communication
The watcher thread needs access to `Arc<RwLock<ProviderManager>>`. The `Server` already holds this Arc. Pass a clone to the watcher thread at startup. The watcher acquires `write()` only during reload (~10ms), so read contention is minimal.
### Watcher lifecycle
- Started in `Server::run()` (or `Server::bind()`) before the accept loop
- Runs until the daemon exits (watcher thread is detached or joined on drop)
- Errors in the watcher (e.g., inotify limit exceeded) are logged and the watcher stops — daemon continues without hot-reload
---
## 5. Plugin development documentation
**File:** `owlry-plugins/docs/PLUGIN_DEVELOPMENT.md`
Cover:
- **Plugin directory structure** — `~/.config/owlry/plugins/<name>/plugin.toml` + `main.lua`/`main.rn`
- **Manifest reference** — all `plugin.toml` fields (`id`, `name`, `version`, `description`, `entry`/`entry_point`, `owlry_version`, `[[providers]]` section, `[permissions]` section)
- **Lua plugin guide** — `owlry.provider.register()` API with `refresh` and `query` callbacks, item table format (`id`, `name`, `command`, `description`, `icon`, `terminal`, `tags`)
- **Rune plugin guide** — `pub fn refresh()` and `pub fn query(q)` signatures, `Item::new()` builder, `use owlry::Item`
- **Hot-reload** — changes are picked up automatically, no daemon restart needed
- **Examples** — complete working examples for both Lua and Rune
---
## Out of scope
- Config-gated runtime loading (runtimes self-skip if `.so` not installed)
- Per-plugin selective reload (full runtime reload is fast enough)
- Plugin registry/installation (already exists in the CLI)
- Sandbox enforcement (separate concern, deferred from hardening spec)

View File

@@ -0,0 +1,116 @@
# Built-in Providers Migration — Design Spec
## Goal
Move calculator, converter, and system from external `.so` plugins (owlry-plugins repo) to native providers compiled into `owlry-core`. Remove 3 plugin AUR packages (transitional), 4 meta AUR packages (already deleted). Update READMEs for both repos.
## Architecture
The 3 plugins currently use the FFI plugin API (`PluginVTable`, `PluginItem`, etc.) and are loaded as `.so` files by `NativePluginLoader`. As built-in providers, they become native Rust modules inside `owlry-core/src/providers/` implementing the existing `Provider` trait — same as `ApplicationProvider` and `CommandProvider`.
No changes to the plugin system itself. External plugins continue to work via `.so` loading.
## Components
### New modules in owlry-core
- `providers/calculator.rs` — port of owlry-plugin-calculator (231 lines, depends on `meval`)
- `providers/converter/mod.rs` — port of owlry-plugin-converter entry point
- `providers/converter/parser.rs` — query parsing (235 lines, no new deps)
- `providers/converter/units.rs` — unit definitions + conversion (944 lines, no new deps)
- `providers/converter/currency.rs` — ECB rate fetching (313 lines, depends on `reqwest` blocking + `dirs` + `serde`)
- `providers/system.rs` — port of owlry-plugin-system (257 lines, no new deps)
### New owlry-core dependencies
- `meval` — math expression evaluation (currently optional behind `lua` feature, make required)
- `reqwest` with `blocking` feature — ECB currency rate fetching (currently optional behind `lua`, make required)
- `dirs` — already a dependency
- `serde`/`serde_json` — already dependencies
### Modified files
- `owlry-core/src/providers/mod.rs` — register the 3 new providers in `ProviderManager`, honor config toggles, classify calculator+converter as dynamic providers
- `owlry-core/Cargo.toml` — move `meval` and `reqwest` from optional to required
- `owlry-core/src/config/mod.rs` — add `converter` config toggle (calculator and system already exist)
### Provider classification
- Calculator → dynamic (queried per-keystroke via `query()`)
- Converter → dynamic (queried per-keystroke via `query()`)
- System → static (populated at `refresh()`, returns fixed list of actions)
## Provider Type IDs
Built-in providers use `ProviderType::Plugin(String)` with fixed IDs to maintain backward compatibility with the UI highlighting and filter system:
- Calculator: `ProviderType::Plugin("calc".into())`
- Converter: `ProviderType::Plugin("conv".into())`
- System: `ProviderType::Plugin("sys".into())`
This ensures the UI's highlighting logic (`matches!(id.as_str(), "calc" | "conv")`) and CSS badge classes (`.owlry-badge-calc`, `.owlry-badge-sys`) continue to work without changes.
## Config
Existing toggles in `[providers]`:
```toml
[providers]
calculator = true # already exists
system = true # already exists
converter = true # new — add with default true
```
When a toggle is false, the provider is not registered in `ProviderManager` at startup.
## Currency Conversion
The converter's currency feature uses `reqwest` (blocking) to fetch ECB exchange rates with a 24-hour file cache at `~/.cache/owlry/ecb_rates.json`. If the HTTP fetch fails (no network, timeout), currency conversion silently returns no results — unit conversion still works. This matches current plugin behavior.
## AUR Changes
### Main repo (owlry)
- `aur/owlry-core/PKGBUILD` — bump version
- Remove `aur/owlry-meta-*` directories (4 dirs, already deleted from AUR)
### Plugins repo (owlry-plugins)
- Remove crates: `owlry-plugin-calculator`, `owlry-plugin-converter`, `owlry-plugin-system`
- Remove AUR dirs: `aur/owlry-plugin-calculator`, `aur/owlry-plugin-converter`, `aur/owlry-plugin-system` from tracked files
- Push transitional PKGBUILDs to the 3 AUR repos:
```bash
pkgname=owlry-plugin-calculator # (and converter, system)
pkgver=<last_version>
pkgrel=99
pkgdesc="Transitional package — calculator is now built into owlry-core"
arch=('any')
depends=('owlry-core>=<new_version>')
replaces=('owlry-plugin-calculator')
# No source, no build, no package body
```
### Conflict prevention
When owlry-core gains built-in calculator/converter/system, users who have the old `.so` plugins installed will have both the built-in provider AND the `.so` plugin active — duplicate results. The daemon should detect this: if a built-in provider ID matches a loaded native plugin ID, skip the native plugin. Add this check in `ProviderManager` when registering native plugins.
## README Updates
### Main repo README
- Package table: remove separate plugin entries for calculator, converter, system — note them as built-in to owlry-core
- Remove meta package section entirely
- Update install examples (no need to install calculator/converter/system separately)
### Plugins repo README
- Remove calculator, converter, system from plugin listing
- Add note that these 3 are built into owlry-core
## Testing
- Port existing plugin tests directly — they test provider logic, not FFI wrappers
- `cargo test -p owlry-core --lib` covers all 3 new providers
- Add conflict detection test (built-in provider ID vs native plugin ID)
- Manual verification: `= 5+3` (calc), `20F` (conv), `20 euro to dollar` (currency), system actions

View File

@@ -0,0 +1,187 @@
# Config Editor — Design Spec
## Goal
A built-in provider in owlry-core that lets users browse and modify their configuration directly from the launcher UI, without opening a text editor.
## Scope
### Editable settings (curated)
**Provider toggles** (boolean):
- applications, commands, calculator, converter, system
- websearch, ssh, clipboard, bookmarks, emoji, scripts, files
- media, weather, pomodoro
- uuctl (systemd user units)
**Appearance** (text input + selection):
- theme (selection from available themes)
- font_size (numeric input)
- width, height (numeric input)
- border_radius (numeric input)
**Search** (text input + selection):
- search_engine (selection: google, duckduckgo, bing, startpage, brave, ecosia)
- frecency (boolean toggle)
- frecency_weight (numeric input, 0.01.0)
**Profiles** (CRUD):
- List existing profiles
- Create new profile (name input + mode checklist)
- Edit profile (rename, edit modes, delete)
### Not in scope
- Weather API key / location (sensitive, better in config file)
- Pomodoro durations (niche, config file)
- Plugin disabled list (covered by provider toggles)
- use_uwsm / terminal_command (advanced, config file)
## UX Flow
### Entry point
Type `:config` or select the "Settings" item that appears for queries like "settings", "config", "preferences".
### Top-level categories
```
:config →
┌─ Providers Toggle providers on/off
├─ Appearance Theme, font size, dimensions
├─ Search Search engine, frecency
└─ Profiles Manage named mode sets
```
Each category is a submenu item. Selecting one opens its submenu.
### Provider toggles
```
Providers →
┌─ ✓ Applications [toggle]
├─ ✓ Commands [toggle]
├─ ✓ Calculator [toggle]
├─ ✓ Converter [toggle]
├─ ✓ System [toggle]
├─ ✗ Weather [toggle]
├─ ...
```
Selecting a row toggles it. The ✓/✗ prefix updates immediately. Change is written to `config.toml` and hot-applied where possible.
### Appearance settings
```
Appearance →
┌─ Theme: owl [select]
├─ Font Size: 14 [edit]
├─ Width: 850 [edit]
├─ Height: 650 [edit]
└─ Border Radius: 12 [edit]
```
**Selection fields** (theme): Selecting opens a submenu with available options. Current value is marked with ✓.
**Text/numeric fields** (font size, width, etc.): Selecting a row enters edit mode — the search bar clears and shows a placeholder like "Font Size (current: 14)". User types a new value and presses Enter. The value is validated (numeric, within reasonable range), written to config, and the submenu re-displays with the updated value.
### Search settings
```
Search →
┌─ Search Engine: duckduckgo [select]
├─ Frecency: enabled [toggle]
└─ Frecency Weight: 0.3 [edit]
```
Same patterns — selection for engine, toggle for frecency, text input for weight.
### Profile management
```
Profiles →
┌─ dev (app, cmd, ssh) [submenu]
├─ media (media, emoji) [submenu]
└─ Create New Profile [action]
```
**Select existing profile** → submenu:
```
Profile: dev →
┌─ Edit Modes [submenu → checklist]
├─ Rename [text input]
└─ Delete [confirm action]
```
**Edit Modes** → checklist (same as provider toggles but for the profile's mode list):
```
Edit Modes: dev →
┌─ ✓ app
├─ ✓ cmd
├─ ✗ calc
├─ ✗ conv
├─ ✓ ssh
├─ ...
```
Toggle to include/exclude. Changes saved on submenu exit (Escape).
**Create New Profile**:
1. Search bar becomes name input (placeholder: "Profile name...")
2. User types name, presses Enter
3. Opens mode checklist (all unchecked)
4. Toggle desired modes, press Escape to save
**Delete**: Selecting "Delete" removes the profile from config and returns to the profiles list.
## Architecture
### Provider type
Built-in static provider in owlry-core. Uses `ProviderType::Plugin("config")` with prefix `:config`.
### Provider classification
**Static** — the top-level items (Providers, Appearance, Search, Profiles) are populated at refresh time. But it also needs **submenu support** — each category opens a submenu with actions.
This means the config provider needs to handle `?SUBMENU:` queries to generate submenu items dynamically, and `!ACTION:` commands to execute changes.
### Command protocol
Actions use the existing plugin action system (`PluginAction` IPC request):
- `CONFIG:toggle:providers.calculator` — toggle a boolean
- `CONFIG:set:appearance.font_size:16` — set a value
- `CONFIG:set:providers.search_engine:google` — set a string
- `CONFIG:profile:create:dev` — create a profile
- `CONFIG:profile:delete:dev` — delete a profile
- `CONFIG:profile:rename:dev:development` — rename
- `CONFIG:profile:mode:dev:toggle:ssh` — toggle a mode in a profile
### Config persistence
All changes write to `~/.config/owlry/config.toml` via the existing `Config::save()` method.
### Hot-apply behavior
| Setting | Hot-apply | Notes |
|---------|-----------|-------|
| Provider toggles | Yes | Daemon re-reads config, enables/disables providers |
| Theme | Yes | UI reloads CSS |
| Frecency toggle/weight | Yes | Next search uses new value |
| Search engine | Yes | Next web search uses new engine |
| Font size | Restart | CSS variable, needs reload |
| Width/Height | Restart | GTK window geometry set at construction |
| Border radius | Restart | CSS variable, needs reload |
| Profiles | Yes | Config file update, available on next `--profile` launch |
Settings that require restart show a "(restart to apply)" hint in the description.
### Submenu integration
The config provider uses the existing submenu system:
- Top-level items have `SUBMENU:config:{category}` commands
- Categories return action items via `?SUBMENU:{category}`
- Actions execute via `CONFIG:*` commands through `execute_plugin_action`
This keeps the implementation within the existing provider/submenu architecture without new IPC message types.

580
justfile
View File

@@ -1,65 +1,57 @@
# Owlry build and release automation # Owlry build and release automation
# Default recipe
default: default:
@just --list @just --list
# Build debug (all workspace members) # === Build ===
build: build:
cargo build --workspace cargo build --workspace
# Build UI binary only
build-ui: build-ui:
cargo build -p owlry cargo build -p owlry
# Build core daemon only
build-daemon: build-daemon:
cargo build -p owlry-core cargo build -p owlry-core
# Build core daemon release
release-daemon:
cargo build -p owlry-core --release
# Run core daemon
run-daemon *ARGS:
cargo run -p owlry-core -- {{ARGS}}
# Build release
release: release:
cargo build --workspace --release cargo build --workspace --release
# Run in debug mode release-daemon:
cargo build -p owlry-core --release
# === Run ===
run *ARGS: run *ARGS:
cargo run -p owlry -- {{ARGS}} cargo run -p owlry -- {{ARGS}}
# Run tests run-daemon *ARGS:
cargo run -p owlry-core -- {{ARGS}}
# === Quality ===
test: test:
cargo test --workspace cargo test --workspace
# Check code
check: check:
cargo check --workspace cargo check --workspace
cargo clippy --workspace cargo clippy --workspace
# Format code
fmt: fmt:
cargo fmt --all cargo fmt --all
# Clean build artifacts
clean: clean:
cargo clean cargo clean
# Install locally (core + runtimes) # === Install ===
install-local: install-local:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
echo "Building release..." echo "Building release..."
# Build UI without embedded Lua (smaller binary)
cargo build -p owlry --release --no-default-features cargo build -p owlry --release --no-default-features
# Build core daemon
cargo build -p owlry-core --release cargo build -p owlry-core --release
# Build runtimes
cargo build -p owlry-lua -p owlry-rune --release cargo build -p owlry-lua -p owlry-rune --release
echo "Creating directories..." echo "Creating directories..."
@@ -68,58 +60,24 @@ install-local:
echo "Installing binaries..." echo "Installing binaries..."
sudo install -Dm755 target/release/owlry /usr/bin/owlry sudo install -Dm755 target/release/owlry /usr/bin/owlry
sudo install -Dm755 target/release/owlry-core /usr/bin/owlry-core sudo install -Dm755 target/release/owlryd /usr/bin/owlryd
echo "Installing runtimes..." echo "Installing runtimes..."
if [ -f "target/release/libowlry_lua.so" ]; then [ -f target/release/libowlry_lua.so ] && sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so
sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so [ -f target/release/libowlry_rune.so ] && sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so
echo " → liblua.so"
fi
if [ -f "target/release/libowlry_rune.so" ]; then
sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so
echo " → librune.so"
fi
echo "Installing systemd service files..." echo "Installing systemd service files..."
if [ -f "systemd/owlry-core.service" ]; then [ -f systemd/owlryd.service ] && sudo install -Dm644 systemd/owlryd.service /usr/lib/systemd/user/owlryd.service
sudo install -Dm644 systemd/owlry-core.service /usr/lib/systemd/user/owlry-core.service [ -f systemd/owlryd.socket ] && sudo install -Dm644 systemd/owlryd.socket /usr/lib/systemd/user/owlryd.socket
echo " → owlry-core.service"
fi
if [ -f "systemd/owlry-core.socket" ]; then
sudo install -Dm644 systemd/owlry-core.socket /usr/lib/systemd/user/owlry-core.socket
echo " → owlry-core.socket"
fi
echo "" echo "Done. Start daemon: systemctl --user enable --now owlryd.service"
echo "Installation complete!"
echo " - /usr/bin/owlry (UI)"
echo " - /usr/bin/owlry-core (daemon)"
echo " - $(ls /usr/lib/owlry/runtimes/*.so 2>/dev/null | wc -l) runtimes"
echo " - systemd: owlry-core.service, owlry-core.socket"
echo ""
echo "To start the daemon:"
echo " systemctl --user enable --now owlry-core.service"
echo " OR add 'exec-once = owlry-core' to your compositor config"
echo ""
echo "Note: Install plugins separately from the owlry-plugins repo."
# === Release Management === # === Version Management ===
# AUR package directories (relative to project root)
aur_core_dir := "aur/owlry"
# Get current version from core crate
version := `grep '^version' crates/owlry/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'`
# Show current version
show-version:
@echo "Current version: {{version}}"
# Show all crate versions
show-versions: show-versions:
#!/usr/bin/env bash #!/usr/bin/env bash
echo "=== Crate Versions ===" echo "=== Crate Versions ==="
for toml in Cargo.toml crates/*/Cargo.toml; do for toml in crates/*/Cargo.toml; do
name=$(grep '^name' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') name=$(grep '^name' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
ver=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') ver=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
printf " %-30s %s\n" "$name" "$ver" printf " %-30s %s\n" "$name" "$ver"
@@ -129,20 +87,16 @@ show-versions:
crate-version crate: crate-version crate:
@grep '^version' crates/{{crate}}/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/' @grep '^version' crates/{{crate}}/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'
# Bump a specific crate version (usage: just bump-crate owlry-core 0.2.0) # Bump a single crate version, update Cargo.lock, commit
bump-crate crate new_version: bump-crate crate new_version:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
toml="crates/{{crate}}/Cargo.toml" toml="crates/{{crate}}/Cargo.toml"
if [ ! -f "$toml" ]; then [ -f "$toml" ] || { echo "Error: $toml not found"; exit 1; }
echo "Error: $toml not found"
exit 1
fi
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" = "{{new_version}}" ]; then [ "$old" = "{{new_version}}" ] && { echo "{{crate}} already at {{new_version}}"; exit 0; }
echo "{{crate}} is already at {{new_version}}, skipping"
exit 0
fi
echo "Bumping {{crate}} from $old to {{new_version}}" echo "Bumping {{crate}} from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml" sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
cargo check -p {{crate}} cargo check -p {{crate}}
@@ -150,7 +104,215 @@ bump-crate crate new_version:
git commit -m "chore({{crate}}): bump version to {{new_version}}" git commit -m "chore({{crate}}): bump version to {{new_version}}"
echo "{{crate}} bumped to {{new_version}}" echo "{{crate}} bumped to {{new_version}}"
# Bump meta-packages (no crate, just AUR version) # Bump all crates to same version
bump-all new_version:
#!/usr/bin/env bash
set -euo pipefail
for toml in crates/*/Cargo.toml; do
crate=$(basename $(dirname "$toml"))
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
[ "$old" = "{{new_version}}" ] && continue
echo "Bumping $crate from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
done
cargo check --workspace
git add crates/*/Cargo.toml Cargo.lock
git commit -m "chore: bump all crates to {{new_version}}"
echo "All crates bumped to {{new_version}}"
# Bump core UI only
bump new_version:
just bump-crate owlry {{new_version}}
# === Tagging ===
# Tag a specific crate (format: {crate}-v{version})
tag-crate crate:
#!/usr/bin/env bash
set -euo pipefail
ver=$(grep '^version' "crates/{{crate}}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
tag="{{crate}}-v$ver"
if git rev-parse "$tag" >/dev/null 2>&1; then
echo "Tag $tag already exists"
exit 0
fi
git tag -a "$tag" -m "{{crate}} v$ver"
echo "Created tag $tag"
# Push all local tags
push-tags:
git push --tags
# === AUR Package Management ===
# Stage AUR files into the main repo git index.
# AUR subdirs have their own .git (for aur.archlinux.org), which makes
# git treat them as embedded repos. Temporarily hide .git to stage files.
aur-stage pkg:
#!/usr/bin/env bash
set -euo pipefail
dir="aur/{{pkg}}"
[ -d "$dir" ] || { echo "Error: $dir not found"; exit 1; }
# Build list of files to stage
files=("$dir/PKGBUILD" "$dir/.SRCINFO")
for f in "$dir"/*.install; do
[ -f "$f" ] && files+=("$f")
done
if [ -d "$dir/.git" ]; then
mv "$dir/.git" "$dir/.git.bak"
git add "${files[@]}"
mv "$dir/.git.bak" "$dir/.git"
else
git add "${files[@]}"
fi
# Update a specific AUR package PKGBUILD with correct version + checksum
aur-update-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
aur_dir="aur/{{pkg}}"
[ -d "$aur_dir" ] || { echo "Error: $aur_dir not found"; exit 1; }
# Determine version
case "{{pkg}}" in
owlry-meta-*)
ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
echo "Meta-package {{pkg}} at $ver (bump pkgrel manually if needed)"
(cd "$aur_dir" && makepkg --printsrcinfo > .SRCINFO)
exit 0
;;
*)
crate_dir="crates/{{pkg}}"
[ -d "$crate_dir" ] || { echo "Error: $crate_dir not found"; exit 1; }
ver=$(grep '^version' "$crate_dir/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
;;
esac
tag="{{pkg}}-v$ver"
url="https://somegit.dev/Owlibou/owlry/archive/$tag.tar.gz"
echo "Updating {{pkg}} to $ver (tag: $tag)"
sed -i "s/^pkgver=.*/pkgver=$ver/" "$aur_dir/PKGBUILD"
sed -i 's/^pkgrel=.*/pkgrel=1/' "$aur_dir/PKGBUILD"
# Update checksum from the tagged tarball
if grep -q "^source=" "$aur_dir/PKGBUILD"; then
echo "Downloading tarball and computing checksum..."
hash=$(curl -sL "$url" | b2sum | cut -d' ' -f1)
if [ -z "$hash" ] || [ ${#hash} -lt 64 ]; then
echo "Error: failed to download or hash $url"
exit 1
fi
sed -i "s|^b2sums=.*|b2sums=('$hash')|" "$aur_dir/PKGBUILD"
fi
(cd "$aur_dir" && makepkg --printsrcinfo > .SRCINFO)
echo "{{pkg}} PKGBUILD updated to $ver"
# Shortcut: update core UI AUR package
aur-update:
just aur-update-pkg owlry
# Publish a specific AUR package to aur.archlinux.org
aur-publish-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
aur_dir="aur/{{pkg}}"
[ -d "$aur_dir/.git" ] || { echo "Error: $aur_dir has no AUR git repo"; exit 1; }
cd "$aur_dir"
ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//')
git add -A
git commit -m "Update to v$ver" || { echo "Nothing to commit"; exit 0; }
git push origin master
echo "{{pkg}} v$ver published to AUR!"
# Shortcut: publish core UI to AUR
aur-publish:
just aur-publish-pkg owlry
# Update and publish ALL AUR packages
aur-update-all:
#!/usr/bin/env bash
set -euo pipefail
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -f "$dir/PKGBUILD" ] || continue
echo "=== $pkg ==="
just aur-update-pkg "$pkg"
echo ""
done
echo "All updated. Run 'just aur-publish-all' to publish."
aur-publish-all:
#!/usr/bin/env bash
set -euo pipefail
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -d "$dir/.git" ] || continue
[ -f "$dir/PKGBUILD" ] || continue
echo "=== $pkg ==="
just aur-publish-pkg "$pkg"
echo ""
done
echo "All published!"
# Show AUR package status
aur-status:
#!/usr/bin/env bash
echo "=== AUR Package Status ==="
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -f "$dir/PKGBUILD" ] || continue
ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//')
if [ -d "$dir/.git" ]; then
printf " ✓ %-30s %s\n" "$pkg" "$ver"
else
printf " ✗ %-30s %s (no AUR repo)\n" "$pkg" "$ver"
fi
done
# Commit AUR file changes to the main repo (handles embedded .git dirs)
aur-commit msg="chore(aur): update PKGBUILDs":
#!/usr/bin/env bash
set -euo pipefail
for dir in aur/*/; do
pkg=$(basename "$dir")
[ -f "$dir/PKGBUILD" ] || continue
just aur-stage "$pkg"
done
git diff --cached --quiet && { echo "No AUR changes to commit"; exit 0; }
git commit -m "{{msg}}"
# === Release Workflows ===
# Release a single crate: bump → push → tag → update AUR → publish AUR
release-crate crate new_version:
#!/usr/bin/env bash
set -euo pipefail
just bump-crate {{crate}} {{new_version}}
git push
just tag-crate {{crate}}
just push-tags
echo "Waiting for tag to propagate..."
sleep 3
just aur-update-pkg {{crate}}
just aur-commit "chore(aur): update {{crate}} to {{new_version}}"
git push
just aur-publish-pkg {{crate}}
echo ""
echo "{{crate}} v{{new_version}} released and published to AUR!"
# === Meta Package Management ===
# Bump meta-package versions
bump-meta new_version: bump-meta new_version:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -165,271 +327,25 @@ bump-meta new_version:
done done
echo "Meta-packages bumped to {{new_version}}" echo "Meta-packages bumped to {{new_version}}"
# Bump all crates (core UI + daemon + plugin-api + runtimes) to same version # === Testing ===
bump-all new_version:
#!/usr/bin/env bash
set -euo pipefail
for toml in crates/*/Cargo.toml; do
crate=$(basename $(dirname "$toml"))
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" != "{{new_version}}" ]; then
echo "Bumping $crate from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
fi
done
cargo check --workspace
git add crates/*/Cargo.toml Cargo.lock
git commit -m "chore: bump all crates to {{new_version}}"
echo "All crates bumped to {{new_version}}"
# Bump core version (usage: just bump 0.2.0) # Quick local build test (no chroot, uses host deps)
bump new_version:
#!/usr/bin/env bash
set -euo pipefail
if [ "{{version}}" = "{{new_version}}" ]; then
echo "Version is already {{new_version}}, skipping bump"
exit 0
fi
echo "Bumping core version from {{version}} to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' crates/owlry/Cargo.toml
cargo check -p owlry
git add crates/owlry/Cargo.toml Cargo.lock
git commit -m "chore: bump version to {{new_version}}"
echo "Version bumped to {{new_version}}"
# Create and push a release tag
tag:
#!/usr/bin/env bash
set -euo pipefail
if git rev-parse "v{{version}}" >/dev/null 2>&1; then
echo "Tag v{{version}} already exists, skipping"
exit 0
fi
echo "Creating tag v{{version}}"
git tag -a "v{{version}}" -m "Release v{{version}}"
git push origin "v{{version}}"
echo "Tag v{{version}} pushed"
# Update AUR package (core UI)
aur-update:
#!/usr/bin/env bash
set -euo pipefail
cd "{{aur_core_dir}}"
url="https://somegit.dev/Owlibou/owlry"
echo "Updating PKGBUILD to version {{version}}"
sed -i 's/^pkgver=.*/pkgver={{version}}/' PKGBUILD
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
# Update checksums (b2sums)
echo "Updating checksums..."
b2sum=$(curl -sL "$url/archive/v{{version}}.tar.gz" | b2sum | cut -d' ' -f1)
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
# Generate .SRCINFO
echo "Generating .SRCINFO..."
makepkg --printsrcinfo > .SRCINFO
# Show diff
git diff
echo ""
echo "AUR package updated. Review changes above."
echo "Run 'just aur-publish' to commit and push."
# Publish AUR package (core UI)
aur-publish:
#!/usr/bin/env bash
set -euo pipefail
cd "{{aur_core_dir}}"
git add PKGBUILD .SRCINFO
git commit -m "Update to v{{version}}"
git push
echo "AUR package v{{version}} published!"
# Test AUR package build locally (core UI)
aur-test:
#!/usr/bin/env bash
set -euo pipefail
cd "{{aur_core_dir}}"
echo "Testing PKGBUILD..."
makepkg -sf
echo ""
echo "Package built successfully!"
ls -lh *.pkg.tar.zst
# === AUR Package Management (individual packages) ===
# Update a specific AUR package (usage: just aur-update-pkg owlry-core)
aur-update-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
aur_dir="aur/{{pkg}}"
if [ ! -d "$aur_dir" ]; then
echo "Error: $aur_dir not found"
exit 1
fi
url="https://somegit.dev/Owlibou/owlry"
# Determine crate version
case "{{pkg}}" in
owlry-meta-essentials|owlry-meta-tools|owlry-meta-widgets|owlry-meta-full)
# Meta-packages use static versioning (1.0.0), only bump pkgrel for dep changes
crate_ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
;;
*)
# Get version from crate
crate_dir="crates/{{pkg}}"
if [ ! -d "$crate_dir" ]; then
echo "Error: $crate_dir not found"
exit 1
fi
crate_ver=$(grep '^version' "$crate_dir/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
;;
esac
cd "$aur_dir"
echo "Updating {{pkg}} PKGBUILD:"
echo " pkgver=$crate_ver"
sed -i "s/^pkgver=.*/pkgver=$crate_ver/" PKGBUILD
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
# Update checksums
if grep -q "^source=" PKGBUILD; then
echo "Updating checksums..."
b2sum=$(curl -sL "$url/archive/v$crate_ver.tar.gz" | b2sum | cut -d' ' -f1)
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
fi
# Generate .SRCINFO
echo "Generating .SRCINFO..."
makepkg --printsrcinfo > .SRCINFO
git diff --stat
echo ""
echo "{{pkg}} updated. Run 'just aur-publish-pkg {{pkg}}' to publish."
# Publish a specific AUR package
aur-publish-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
aur_dir="aur/{{pkg}}"
if [ ! -d "$aur_dir" ]; then
echo "Error: $aur_dir not found"
exit 1
fi
cd "$aur_dir"
ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//')
git add PKGBUILD .SRCINFO
git commit -m "Update to v$ver"
git push origin master
echo "{{pkg}} v$ver published!"
# Test a specific AUR package build locally
aur-test-pkg pkg: aur-test-pkg pkg:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd "aur/{{pkg}}" cd "aur/{{pkg}}"
echo "Testing {{pkg}} PKGBUILD..." echo "Testing {{pkg}} PKGBUILD..."
makepkg -sf makepkg -sf
echo ""
echo "Package built successfully!" echo "Package built successfully!"
ls -lh *.pkg.tar.zst ls -lh *.pkg.tar.zst
# List all AUR packages with their versions # Build AUR packages from the local working tree in a clean chroot.
aur-status: # Packages current source (incl. uncommitted changes), patches PKGBUILD,
#!/usr/bin/env bash # builds in dep order, injects local artifacts, restores PKGBUILD on exit.
echo "=== AUR Package Status ===" #
for dir in aur/*/; do # Examples:
pkg=$(basename "$dir") # just aur-local-test owlry-core
if [ -f "$dir/PKGBUILD" ]; then # just aur-local-test -c owlry-core owlry-rune
ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//') # just aur-local-test --all --reset
if [ -d "$dir/.git" ]; then aur-local-test *args:
status="✓" scripts/aur-local-test {{args}}
else
status="✗ (not initialized)"
fi
printf " %s %-30s %s\n" "$status" "$pkg" "$ver"
fi
done
# Update ALL AUR packages (core + daemon + runtimes + meta)
aur-update-all:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Updating core UI ==="
just aur-update
echo ""
echo "=== Updating core daemon ==="
just aur-update-pkg owlry-core
echo ""
echo "=== Updating runtimes ==="
just aur-update-pkg owlry-lua
just aur-update-pkg owlry-rune
echo ""
echo "=== Updating meta-packages ==="
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
echo "--- $pkg ---"
(cd "aur/$pkg" && makepkg --printsrcinfo > .SRCINFO)
done
echo ""
echo "All AUR packages updated. Run 'just aur-publish-all' to publish."
# Publish ALL AUR packages
aur-publish-all:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Publishing core UI ==="
just aur-publish
echo ""
echo "=== Publishing core daemon ==="
just aur-publish-pkg owlry-core
echo ""
echo "=== Publishing runtimes ==="
just aur-publish-pkg owlry-lua
just aur-publish-pkg owlry-rune
echo ""
echo "=== Publishing meta-packages ==="
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
echo "--- $pkg ---"
just aur-publish-pkg "$pkg"
done
echo ""
echo "All AUR packages published!"
# Full release workflow for core only (bump + tag + aur)
release-core new_version: (bump new_version)
#!/usr/bin/env bash
set -euo pipefail
# Push version bump
git push
# Create and push tag
just tag
# Wait for tag to be available
echo "Waiting for tag to propagate..."
sleep 2
# Update AUR
just aur-update
echo ""
echo "Core release v{{new_version}} prepared!"
echo "Review AUR changes, then run 'just aur-publish'"

373
scripts/aur-local-test Executable file
View File

@@ -0,0 +1,373 @@
#!/usr/bin/env bash
# scripts/aur-local-test
#
# Build AUR packages from the local working tree in a clean extra chroot.
#
# Packages the current working tree (including uncommitted changes) into a
# tarball, temporarily patches each PKGBUILD to use it, runs
# extra-x86_64-build, then restores the PKGBUILD on exit regardless of
# success or failure.
#
# Packages with local AUR deps (e.g. owlry-rune depends on owlry-core) are
# built in topological order and their artifacts injected automatically.
#
# Usage: scripts/aur-local-test [OPTIONS] [PKG...]
# See --help for details.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
REPO_NAME="$(basename "$REPO_ROOT")"
AUR_DIR="$REPO_ROOT/aur"
# State tracked for cleanup
TMP_TARBALL=""
declare -a PKGBUILD_BACKUPS=()
declare -a PLACED_FILES=()
# Build config
RESET_CHROOT=0
declare -a INPUT_PKGS=()
declare -a EXTRA_INJECT=() # --inject paths (external AUR deps)
# ─── Output helpers ──────────────────────────────────────────────────────────
die() { echo "error: $*" >&2; exit 1; }
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m ->\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m !\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31mFAIL\033[0m %s\n' "$*" >&2; }
# ─── Cleanup ─────────────────────────────────────────────────────────────────
cleanup() {
local code=$?
local f pkgbuild
# Remove tarballs placed in aur/ dirs
for f in "${PLACED_FILES[@]+"${PLACED_FILES[@]}"}"; do
[[ -f "$f" ]] && rm -f "$f"
done
# Restore patched PKGBUILDs from backups
for f in "${PKGBUILD_BACKUPS[@]+"${PKGBUILD_BACKUPS[@]}"}"; do
pkgbuild="${f%.bak}"
[[ -f "$f" ]] && mv "$f" "$pkgbuild"
done
[[ -n "$TMP_TARBALL" && -f "$TMP_TARBALL" ]] && rm -f "$TMP_TARBALL"
exit "$code"
}
trap cleanup EXIT INT TERM
# ─── Usage ───────────────────────────────────────────────────────────────────
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") [OPTIONS] [PKG...]
Build AUR packages from the local working tree in a clean chroot.
Packages current working tree (incl. uncommitted changes), patches PKGBUILD
source + checksum, runs extra-x86_64-build, then restores on exit.
Packages with local AUR deps are built in topological order and their
.pkg.tar.zst artifacts are injected into dependent builds automatically.
OPTIONS
-c, --reset Reset chroot matrix (passes -c to extra-x86_64-build).
Only applied to the first package; subsequent builds
reuse the already-fresh chroot.
-a, --all Build all packages in aur/ (respects dep order).
-I, --inject FILE Inject FILE (.pkg.tar.zst) into the chroot before
building. For AUR deps not in the official repos
(e.g. owlry-core when testing owlry-plugins).
Can be repeated.
-h, --help Show this help.
EXAMPLES
# Single package
$(basename "$0") owlry-core
# Multiple packages with chroot reset
$(basename "$0") -c owlry-core owlry-rune
# All packages in dependency order
$(basename "$0") --all --reset
# owlry-plugins: inject owlry-core from sibling repo
$(basename "$0") -I ../owlry/aur/owlry-core/owlry-core-*.pkg.tar.zst --all
EOF
exit 1
}
# ─── Argument parsing ────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-c|--reset)
RESET_CHROOT=1
shift ;;
-a|--all)
for dir in "$AUR_DIR"/*/; do
pkg=$(basename "$dir")
[[ -f "$dir/PKGBUILD" ]] && INPUT_PKGS+=("$pkg")
done
shift ;;
-I|--inject)
[[ $# -ge 2 ]] || die "--inject requires an argument"
[[ -f "$2" ]] || die "inject file not found: $2"
EXTRA_INJECT+=("$(realpath "$2")")
shift 2 ;;
-h|--help) usage ;;
-*) die "unknown option: $1" ;;
*)
if [[ "$1" == *.pkg.tar.zst ]]; then
[[ -f "$1" ]] || die "inject file not found: $1"
EXTRA_INJECT+=("$(realpath "$1")")
else
INPUT_PKGS+=("$1")
fi
shift ;;
esac
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.
local_deps_of() {
local pkg="$1"
local pkgbuild="$AUR_DIR/$pkg/PKGBUILD"
[[ -f "$pkgbuild" ]] || return 0
local dep_line bare
dep_line=$(grep '^depends=' "$pkgbuild" 2>/dev/null | head -1 || true)
[[ -z "$dep_line" ]] && return 0
# Strip depends=, parens, and quotes; split on whitespace
echo "$dep_line" \
| sed "s/^depends=//; s/[()\"']/ /g" \
| tr ' ' '\n' \
| while IFS= read -r dep; do
[[ -z "$dep" ]] && continue
bare="${dep%%[><=]*}" # strip version constraints
[[ -d "$AUR_DIR/$bare" ]] && echo "$bare"
done
}
# Topological sort (DFS) — deps before dependents.
declare -A TOPO_VISITED=()
declare -a TOPO_ORDER=()
topo_visit() {
local pkg="$1"
[[ -v "TOPO_VISITED[$pkg]" ]] && return 0
TOPO_VISITED[$pkg]=1
local dep
while IFS= read -r dep; do
topo_visit "$dep"
done < <(local_deps_of "$pkg")
TOPO_ORDER+=("$pkg")
}
resolve_order() {
TOPO_VISITED=()
TOPO_ORDER=()
local pkg
for pkg in "$@"; do
topo_visit "$pkg"
done
}
# ─── Tarball creation ────────────────────────────────────────────────────────
make_tarball() {
TMP_TARBALL=$(mktemp /tmp/aur-local-XXXXXX.tar.gz)
info "Packaging ${REPO_NAME} working tree (incl. uncommitted changes)..."
tar czf "$TMP_TARBALL" \
--exclude='.git' \
--exclude='target' \
--transform "s|^\.|${REPO_NAME}|" \
-C "$REPO_ROOT" .
ok "Tarball ready: $(du -b "$TMP_TARBALL" | cut -f1 | numfmt --to=iec 2>/dev/null || wc -c < "$TMP_TARBALL") bytes"
}
# ─── PKGBUILD patching ───────────────────────────────────────────────────────
# Patch a package's PKGBUILD to use the local tarball.
# Backs up the original; cleanup() restores it on exit.
patch_pkgbuild() {
local pkg="$1"
local pkgbuild="$AUR_DIR/$pkg/PKGBUILD"
local pkgdir="$AUR_DIR/$pkg"
# Skip packages with no remote source (meta/group packages)
if ! grep -q '^source=' "$pkgbuild" || grep -qE '^source=\(\s*\)' "$pkgbuild"; then
ok "No source URL to patch — skipping tarball injection for $pkg"
return 0
fi
local pkgname pkgver filename hash
pkgname=$(grep '^pkgname=' "$pkgbuild" | cut -d= -f2- | tr -d "\"'")
pkgver=$(grep '^pkgver=' "$pkgbuild" | cut -d= -f2- | tr -d "\"'")
filename="${pkgname}-${pkgver}.tar.gz"
hash=$(b2sum "$TMP_TARBALL" | cut -d' ' -f1)
# Backup original PKGBUILD
cp "$pkgbuild" "${pkgbuild}.bak"
PKGBUILD_BACKUPS+=("${pkgbuild}.bak")
# Place local tarball where makepkg looks for it
cp "$TMP_TARBALL" "$pkgdir/$filename"
PLACED_FILES+=("$pkgdir/$filename")
# Patch source and checksum lines in-place
sed -i "s|^source=.*|source=(\"${filename}\")|" "$pkgbuild"
sed -i "s|^b2sums=.*|b2sums=('${hash}')|" "$pkgbuild"
ok "Patched PKGBUILD: source=${filename}, b2sum=${hash:0:12}…"
}
# ─── Build ───────────────────────────────────────────────────────────────────
# built_artifacts[pkg] = path to the .pkg.tar.zst produced in this run.
# Used to inject pkg artifacts into dependent builds.
declare -A BUILT_ARTIFACTS=()
find_artifact() {
local pkg="$1"
local pkgver
# pkgver is the same in patched and original PKGBUILD
pkgver=$(grep '^pkgver=' "$AUR_DIR/$pkg/PKGBUILD" | cut -d= -f2- | tr -d "\"'" \
|| grep '^pkgver=' "$AUR_DIR/$pkg/PKGBUILD.bak" | cut -d= -f2- | tr -d "\"'")
ls "$AUR_DIR/$pkg/${pkg}-${pkgver}-"*".pkg.tar.zst" 2>/dev/null \
| grep -v -- '-debug-' | sort -V | tail -1 || true
}
build_one() {
local pkg="$1"
local pkgdir="$AUR_DIR/$pkg"
info "[$pkg] Patching PKGBUILD..."
patch_pkgbuild "$pkg"
# Collect inject args: extra (external) + artifacts of local deps built earlier
local inject=()
for f in "${EXTRA_INJECT[@]+"${EXTRA_INJECT[@]}"}"; do
inject+=("-I" "$f")
done
while IFS= read -r dep; do
if [[ -v "BUILT_ARTIFACTS[$dep]" ]]; then
inject+=("-I" "${BUILT_ARTIFACTS[$dep]}")
else
warn "$pkg depends on $dep (local AUR) which was not built in this run"
warn " → Build $dep first or pass: -I path/to/${dep}-*.pkg.tar.zst"
fi
done < <(local_deps_of "$pkg")
# Build args: -c only on the first package, then cleared
local build_args=()
if [[ $RESET_CHROOT -eq 1 ]]; then
build_args+=("-c")
RESET_CHROOT=0
fi
info "[$pkg] Running extra-x86_64-build..."
(
cd "$pkgdir"
if [[ ${#inject[@]} -gt 0 ]]; then
extra-x86_64-build "${build_args[@]+"${build_args[@]}"}" -- "${inject[@]}"
else
extra-x86_64-build "${build_args[@]+"${build_args[@]}"}"
fi
)
# Record artifact for potential injection into dependents
local artifact
artifact=$(find_artifact "$pkg")
if [[ -n "$artifact" ]]; then
BUILT_ARTIFACTS[$pkg]="$artifact"
ok "[$pkg] artifact: $(basename "$artifact")"
fi
}
# ─── 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" ]] \
|| die "package not found: aur/$pkg/PKGBUILD"
done
# Sort into build order (deps before dependents)
resolve_order "${INPUT_PKGS[@]}"
# Create one tarball, reused for all packages in this run
make_tarball
declare -a FAILED=()
for pkg in "${TOPO_ORDER[@]}"; do
echo ""
if build_one "$pkg"; then
:
else
fail "[$pkg]"
FAILED+=("$pkg")
fi
done
echo ""
if [[ ${#FAILED[@]} -gt 0 ]]; then
fail "packages failed: ${FAILED[*]}"
exit 1
fi
info "All packages built successfully!"

View File

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