Compare commits

..

126 Commits

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

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

Provider trait now requires Send + Sync to satisfy RwLock's Sync bound
on the inner type. RuntimeProvider and LuaProvider gain the
corresponding unsafe impl Sync.
2026-03-26 16:39:10 +01:00
3aaeafde8b fix: security — socket perms 0600, signal handler logging, client read timeout 2026-03-26 16:32:06 +01:00
7ce6de17aa fix: soundness — OnceLock for HOST_API, IPC size limits, mutex poisoning recovery 2026-03-26 16:29:47 +01:00
91da177f46 feat: architecture split — client/daemon with independent plugin repo 2026-03-26 13:40:24 +01:00
f5d83f1372 chore: format, fix clippy warnings, bump all crates to 1.0.0 2026-03-26 13:37:55 +01:00
50caa1ff0d fix(owlry-core): make ProviderFilter dynamically accept all plugin types
Replace hardcoded list of 13 plugin IDs in ProviderFilter::all() with
an accept_all flag. When set, is_active()/is_enabled() return true for
any ProviderType, so dynamically loaded plugins are accepted without
maintaining a static list. Prefix-based filtering still narrows scope
as before, and from_mode_strings() still filters to explicit modes only.
2026-03-26 13:30:51 +01:00
0c46082b2b docs: update CLAUDE.md for client/daemon architecture 2026-03-26 13:27:45 +01:00
a0b65e69a4 refactor: remove plugin crates from core repo
Plugins have been moved to the owlry-plugins repo. This removes:
- All 13 owlry-plugin-* crate directories
- Plugin documentation (PLUGINS.md, PLUGIN_DEVELOPMENT.md)
- Plugin-specific justfile targets (build, bump, AUR)

Retained in core: owlry (UI), owlry-core (daemon),
owlry-plugin-api (ABI interface), owlry-lua, owlry-rune (runtimes).
2026-03-26 13:21:59 +01:00
938a9ee6f3 docs: update README and justfile for client/daemon architecture 2026-03-26 13:03:48 +01:00
d4f71cae42 feat: add systemd user service and socket units for owlry-core
Add owlry-core.service (Type=simple, restart-on-failure) and
owlry-core.socket (listening on $XDG_RUNTIME_DIR/owlry/owlry.sock)
for socket-activated daemon deployment.
2026-03-26 12:59:49 +01:00
6391711df2 feat: add config profiles, remove --providers flag
Add ProfileConfig struct and profiles map to Config, allowing named
mode presets in config.toml (e.g. [profiles.dev] modes = ["app","cmd"]).

Remove the --providers/-p CLI flag and repurpose -p as the short form
for --prompt. Add --profile flag that loads modes from a named profile.

Mode resolution priority: --mode > --profile > config defaults.
2026-03-26 12:58:47 +01:00
30b2b5b9c0 feat(owlry): implement toggle behavior for repeated invocations
Use a flock-based lock file at $XDG_RUNTIME_DIR/owlry/owlry-ui.lock
to detect when another owlry UI instance is already running. If the
lock is held, send a Toggle IPC command to the daemon and exit
immediately instead of opening a second window.
2026-03-26 12:56:30 +01:00
5be21aadc6 refactor(owlry): wire UI to use IPC client instead of direct provider calls
The UI now uses a SearchBackend abstraction that wraps either:
- CoreClient (daemon mode): connects to owlry-core via IPC for search,
  frecency tracking, submenu queries, and plugin actions
- Local ProviderManager (dmenu mode): unchanged direct provider access

Key changes:
- New backend.rs with SearchBackend enum abstracting IPC vs local
- app.rs creates CoreClient in normal mode, falls back to local if
  daemon unavailable
- main_window.rs uses SearchBackend instead of ProviderManager+FrecencyStore
- Command execution stays in the UI (daemon only tracks frecency)
- dmenu mode path is completely unchanged (no daemon involvement)
- Added terminal field to IPC ResultItem for proper terminal launch
- Added PluginAction IPC request for plugin command execution
2026-03-26 12:52:00 +01:00
4ed9a9973a feat(owlry): implement IPC client for daemon communication
Add CoreClient struct that connects to the owlry-core daemon Unix socket
and provides typed methods for query, launch, providers, toggle, and
submenu operations. Reuses owlry_core::paths::socket_path() as the
single source of truth for the socket location. Includes connect_or_start()
with systemd integration and exponential backoff retry logic.
2026-03-26 12:33:27 +01:00
18c58ce33d feat(owlry-core): add daemon binary entry point
Add [[bin]] target and main.rs that starts the IPC server with
env_logger, socket path from XDG_RUNTIME_DIR, and graceful shutdown
via ctrlc signal handler. Also add socket_path() to paths module.
2026-03-26 12:28:53 +01:00
f609ce1c13 feat(owlry-core): implement IPC server over Unix socket
Adds Server struct that listens on a Unix domain socket, accepts
client connections (thread-per-client), reads newline-delimited JSON
requests, dispatches to ProviderManager/FrecencyStore/Config, and
sends JSON responses back. Includes stale socket cleanup and Drop
impl for socket removal.
2026-03-26 12:26:06 +01:00
915dc193d9 feat(owlry-core): add daemon-friendly API to ProviderManager and ProviderFilter
Add methods needed by the IPC server (Task 9) to create filters from
mode strings, query provider metadata, and refresh individual providers.

ProviderFilter:
- from_mode_strings(): create filter from ["app", "cmd", "calc"] etc.
- all(): create permissive filter accepting all provider types
- mode_string_to_provider_type(): public helper for string-to-type mapping

ProviderManager:
- ProviderDescriptor struct for IPC provider metadata responses
- available_providers() -> Vec<ProviderDescriptor> (replaces ProviderType version)
- refresh_provider(id): refresh a single provider by type_id
- new_with_config(config): self-contained init for daemon use

NativeProvider:
- icon(): get provider's default icon name
- position_str(): get position as "normal"/"widget" string
2026-03-26 12:22:37 +01:00
71d78ce7df feat(owlry-core): define IPC message types with serde 2026-03-26 12:17:16 +01:00
1bce5850a3 chore: update justfile for owlry-core crate 2026-03-26 12:14:37 +01:00
182a500596 refactor: wire owlry to use owlry-core as library dependency
- Add owlry-core dependency to owlry Cargo.toml
- Remove dependencies from owlry that moved to owlry-core:
  fuzzy-matcher, freedesktop-desktop-entry, libloading, notify-rust,
  thiserror, mlua, meval, reqwest
- Forward feature flags (dev-logging, lua) to owlry-core
- Update all imports in owlry source files to use owlry_core::
  for moved modules (config, data, filter, providers, plugins,
  notify, paths)
- Delete original source files from owlry that were moved
- Create minimal providers/mod.rs that only re-exports DmenuProvider
- Move plugins/commands.rs to plugin_commands.rs (stays in owlry
  since it depends on CLI types from clap)
- Restructure app.rs to build core providers externally and pass
  them to ProviderManager::new() instead of using the old
  with_native_plugins() constructor
2026-03-26 12:07:03 +01:00
d79c9087fd feat(owlry-core): move backend modules from owlry
Move the following modules from crates/owlry/src/ to crates/owlry-core/src/:
- config/ (configuration loading and types)
- data/ (frecency store)
- filter.rs (provider filtering and prefix parsing)
- notify.rs (desktop notifications)
- paths.rs (XDG path handling)
- plugins/ (plugin system: native loader, manifest, registry, runtime loader, Lua API)
- providers/ (provider trait, manager, application, command, native_provider, lua_provider)

Notable changes from the original:
- providers/mod.rs: ProviderManager constructor changed from with_native_plugins()
  to new(core_providers, native_providers) to decouple from DmenuProvider
  (which stays in owlry as a UI concern)
- plugins/mod.rs: commands module removed (stays in owlry as CLI concern)
- Added thiserror and tempfile dependencies to owlry-core Cargo.toml
2026-03-26 12:06:34 +01:00
8494a806bf feat(owlry-core): scaffold new core crate 2026-03-26 11:53:00 +01:00
9db3be6fdc chore: update all dependencies to latest stable
Major version bumps:
- reqwest: 0.12 -> 0.13 (rustls-tls feature renamed to rustls)
- mlua: 0.10 -> 0.11
- freedesktop-desktop-entry: 0.7 -> 0.8
- rusqlite: 0.32 -> 0.39

Cargo.lock refreshed with latest semver-compatible versions across
all transitive dependencies.

Note: gtk4 0.11 / glib-build-tools 0.22 skipped (requires Rust 1.92,
current toolchain is 1.91).
2026-03-26 11:46:02 +01:00
a49f5127dc docs: add architecture split design spec and implementation plan 2026-03-26 11:37:22 +01:00
c0ea40a393 docs(config): sync example config with current features
- Add dmenu usage examples with | sh pattern
- Fix max_results default (10 → 100)
- Add widget providers (media, weather, pomodoro) with settings
- Add provider badge color customization options
- Add plugin sandbox settings section
- Fix disabled → disabled_plugins, add enabled and registry_url
- Add weather and pomodoro configuration options
2026-01-02 19:00:51 +01:00
44f0915ba9 docs: improve dmenu examples with proper output handling
- Clarify that dmenu outputs to stdout (doesn't execute)
- Add screenshot menu example with | sh pattern
- Use printf instead of echo -e for POSIX compliance
- Add xdg-open example for opening files
- Use shorter -p flag instead of --prompt
2026-01-02 18:56:19 +01:00
a55567b422 chore(owlry-rune): bump version to 0.4.10 2026-01-02 16:59:22 +01:00
707caefadf chore(owlry-lua): bump version to 0.4.10 2026-01-02 16:59:22 +01:00
78895d34b5 chore(plugins): bump all plugins to 0.4.10 2026-01-02 16:59:14 +01:00
e6f217f19c chore: bump version to 0.4.10 2026-01-02 16:59:06 +01:00
ff04675417 refactor(config): replace launch_wrapper with use_uwsm boolean
- Replace complex auto-detection with explicit use_uwsm config option
- Remove detect_launch_wrapper() function and hyprctl/uwsm auto-detection
- Use gio launch as default (always available via GTK4's glib2 dependency)
- When use_uwsm=true, launch via uwsm app -- for systemd session integration
- Add error handling for when uwsm is enabled but not installed
- Update documentation in README.md, CLAUDE.md, and config.example.toml
2026-01-02 16:57:40 +01:00
b85f85c4da feat(dmenu): add full dmenu compatibility
- Add free-form text input (output typed text when no item matches)
- Add proper exit codes (0=selection, 1=cancelled)
- Detect dmenu mode via ProviderManager::is_dmenu_mode()

This enables standard dmenu usage patterns like:
  echo -e "yes\nno" | owlry -m dmenu && echo "selected"
2026-01-02 16:36:40 +01:00
1aa92ee1e5 chore(owlry-rune): bump version to 0.4.9 2026-01-02 16:18:19 +01:00
9532b3cfde chore(owlry-lua): bump version to 0.4.9 2026-01-02 16:18:18 +01:00
551e5d74ae chore(plugins): bump all plugins to 0.4.9 2026-01-02 16:18:18 +01:00
60eaffb2ab chore: bump version to 0.4.9 2026-01-02 16:18:08 +01:00
6d8d4a9f89 fix(providers): improve app discovery and launch reliability
- Add Keywords field from desktop files to searchable tags
  (fixes apps like Nautilus not found when searching by legacy name)
- Respect XDG_DATA_DIRS with proper fallbacks for app directories
- Add Flatpak, Snap, and Nix application directory support
- Simplify desktop file launch to use gio directly (guaranteed by GTK4)
- Add desktop notifications for launch failures
- Check desktop file existence before launch attempt
2026-01-02 16:18:00 +01:00
3ef9398655 chore: bump all crates to 0.4.8 2026-01-01 23:30:45 +01:00
46bb4bfb38 chore: bump version to 0.4.8 2026-01-01 23:28:09 +01:00
c8aed5faf5 fix(dmenu): print selection to stdout instead of executing
dmenu mode was incorrectly trying to execute the selected item
as a command (via hyprctl/sh). Now it properly prints the
selection to stdout, enabling standard dmenu piping workflows
like: git branch | owlry -m dmenu | xargs git checkout
2026-01-01 23:28:03 +01:00
bf8a31af78 chore: bump all crates to 0.4.7 2026-01-01 22:29:00 +01:00
e23bdf5cee fix(providers): enable submenu support for static native plugins
Static native plugins (systemd, clipboard, etc.) were being boxed as
Box<dyn Provider>, which lost access to the query() method needed for
submenu support. The Provider trait only has refresh() and items().

Add static_native_providers field to keep static native plugins as
NativeProvider instances, preserving their query() method. Update all
search methods and query_submenu_actions() to include this new list.

Fixes systemd plugin submenu not showing actions when selecting a service.
2026-01-01 22:14:43 +01:00
25c4d40d36 docs: add comprehensive usage documentation
- Expand CLI --help with examples, dmenu mode, and search prefixes
- Add dmenu mode section to README with practical examples
- Add plugin management CLI reference to README
- Update argument descriptions with all valid modes listed
2026-01-01 21:45:52 +01:00
b36dd2a438 chore: update bump-all to include core in single commit 2025-12-30 20:32:28 +01:00
35a0f580c3 chore(owlry-rune): bump version to 0.4.6 2025-12-30 20:23:58 +01:00
7ed36c58c2 chore(owlry-lua): bump version to 0.4.6 2025-12-30 20:23:57 +01:00
7cccd3b512 chore(plugins): bump all plugins to 0.4.6 2025-12-30 20:23:48 +01:00
9f6d0c5935 chore: bump version to 0.4.6 2025-12-30 20:23:38 +01:00
026a232e0c docs: add ROADMAP.md with feature ideas
- High value/low effort: hot-reload, frecency pruning, :recent, clipboard images
- Medium effort: universal actions, plugin settings UI, result capture
- Bigger bets: window switcher, cross-device sync, natural language, plugin marketplace
- Technical debt: meval→evalexpr, API compat, per-plugin config

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 09:04:55 +01:00
1557119448 docs: comprehensive documentation update
README.md:
- Fix bundle package names (add meta- prefix)
- Add Firefox support to bookmarks plugin description
- Add system paths table (plugins, runtimes, example config)
- Add Quick Start section for copying example config
- Expand config example with providers section

docs/PLUGINS.md:
- Add Firefox support to bookmarks
- Fix bundle package names
- Remove outdated [plugins.weather] and [plugins.pomodoro] config examples

docs/PLUGIN_DEVELOPMENT.md:
- Fix Rust edition from 2024 to 2021
- Add position and priority fields to ProviderInfo
- Add ProviderPosition enum documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 08:49:30 +01:00
b814d07382 chore(owlry-rune): bump version to 0.4.5 2025-12-30 08:29:57 +01:00
0dead603ec chore(owlry-lua): bump version to 0.4.5 2025-12-30 08:29:57 +01:00
c1eb5ae2eb chore(plugins): bump all plugins to 0.4.5 2025-12-30 08:29:47 +01:00
07847c76d8 chore: bump version to 0.4.5 2025-12-30 08:29:25 +01:00
2dfce67f3b chore(owlry-rune): bump version to 0.4.4 2025-12-30 08:01:52 +01:00
b1198f4600 chore(owlry-lua): bump version to 0.4.4 2025-12-30 08:01:51 +01:00
e6776b803c chore(plugins): bump all plugins to 0.4.4 2025-12-30 08:01:15 +01:00
6e2d60466b chore: bump version to 0.4.4 2025-12-30 07:45:57 +01:00
8c1cf88474 feat: simplify ProviderType, add plugin priority, fix bookmarks SQLite
Core changes:
- Simplified ProviderType enum to 4 core types + Plugin(String)
- Added priority field to plugin API (API_VERSION = 3)
- Removed hardcoded plugin-specific code from core
- Updated filter.rs to use Plugin(type_id) for all plugins
- Updated main_window.rs UI mappings to derive from type_id
- Fixed weather/media SVG icon colors

Plugin changes:
- All plugins now declare their own priority values
- Widget plugins: weather(12000), pomodoro(11500), media(11000)
- Dynamic plugins: calc(10000), websearch(9000), filesearch(8000)
- Static plugins: priority 0 (frecency-based)

Bookmarks plugin:
- Replaced SQLx with rusqlite + bundled SQLite
- Fixes "undefined symbol: sqlite3_db_config" build errors
- No longer depends on system SQLite version

Config:
- Fixed config.example.toml invalid nested TOML sections
- Removed [providers.websearch], [providers.weather], etc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 07:45:49 +01:00
ecaaae39e3 refactor(aur): rename meta packages to owlry-meta-*
Renamed for consistency:
- owlry-essentials → owlry-meta-essentials
- owlry-tools → owlry-meta-tools
- owlry-widgets → owlry-meta-widgets
- owlry-full → owlry-meta-full

New packages include replaces/conflicts for smooth transition.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:46:19 +01:00
96e9b09a31 docs(justfile): clarify meta-package static versioning
Meta-packages now use static 1.0.0 version, only bumping pkgrel
when dependencies change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:42:54 +01:00
e053f7d5d5 refactor(justfile): simplify AUR update for unified versioning
Removed _srcver handling since all packages now share the same version.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:29:56 +01:00
b1f11c076b chore: unify all package versions to 0.4.3
All crates (core, plugins, runtimes, plugin-api) now share the same
version number for simpler release management and clearer compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:27:23 +01:00
2d7fb33f30 fix(bookmarks): fix test calling non-existent method
Changed test to use static method process_chrome_folder_static.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:26:31 +01:00
3b1ff03ff8 chore: bump version to 0.4.2 2025-12-30 06:22:15 +01:00
e1fb63d6c4 fix(tests): make runtime tests environment-agnostic
Tests now verify functions don't panic rather than assuming
runtimes aren't installed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:22:04 +01:00
33e2f9cb5e chore(owlry-plugin-weather): bump version to 0.2.2 2025-12-30 06:13:44 +01:00
6b21602a07 chore(owlry-plugin-pomodoro): bump version to 0.2.2 2025-12-30 06:13:44 +01:00
4516865c21 chore(owlry-plugin-emoji): bump version to 0.2.2 2025-12-30 06:13:43 +01:00
4fbc7fc4c9 chore(owlry-plugin-bookmarks): bump version to 0.2.2 2025-12-30 06:13:43 +01:00
536c5c5012 chore: bump version to 0.4.1 2025-12-30 06:12:02 +01:00
abd4df6939 feat: add lazy loading, non-blocking bookmarks, and file search fix
- Add lazy loading for result lists (load more on scroll/selection)
- Add non-blocking bookmark loading with JSON cache
- Add Firefox favicon extraction and caching
- Fix dynamic provider filtering (files/calc/websearch in All mode)
- Fix clippy warnings across core and plugins
- Add apex-neon theme
- Add aur/ to gitignore

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:11:50 +01:00
43f7228be2 feat(justfile): add bump-meta and aur-publish-meta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:54:13 +01:00
a1b47b8ba0 chore: bump all plugins and runtimes to 0.2.1 2025-12-30 03:49:36 +01:00
ccce9b8572 fix(justfile): handle _srcver for plugin AUR packages
Plugins use _srcver (core version) for source tarball, separate from
pkgver (plugin version). This allows independent plugin versioning
while still downloading from the core release tag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:45:39 +01:00
ffb4c2f127 fix: prevent .SRCINFO creation in project root
- Use subshell for cd in aur-update-all recipe
- Add .SRCINFO to root .gitignore

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:40:08 +01:00
cde599db03 feat(justfile): add comprehensive release automation
- bump-all: bump plugins + runtimes together
- aur-update-all: update all 20 AUR packages
- aur-publish-all: publish all AUR packages
- release-all: complete release workflow (bump, tag, push, update AUR)
- release-core: core-only release workflow

Usage: just release-all 0.5.0 0.3.0
       (core version, plugin version)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:39:13 +01:00
cf8e33c976 fix(justfile): read version from crates/owlry/Cargo.toml
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:31:26 +01:00
85a18fc271 chore(owlry-rune): bump version to 0.2.0 2025-12-30 03:31:12 +01:00
67dad9c269 chore(owlry-lua): bump version to 0.2.0 2025-12-30 03:31:11 +01:00
3e8be3a4c5 chore(plugins): bump all plugins to 0.2.0 2025-12-30 03:30:56 +01:00
e83feb6ce4 chore: bump version to 0.4.0 2025-12-30 03:30:46 +01:00
bead9e4b4a feat(justfile): add per-crate version and AUR management
- Add show-versions, crate-version for version inspection
- Add bump-crate, bump-plugins for individual/batch version bumps
- Add aur-update-pkg, aur-publish-pkg for per-package AUR management
- Add aur-update-plugins, aur-publish-plugins for batch operations
- Add aur-status to show all AUR packages with versions

Supports independent versioning: core at 0.3.x, plugins at 0.1.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:28:35 +01:00
10722bc016 refactor: make Lua deps optional, remove unused dependencies
- Make meval, reqwest optional (behind 'lua' feature)
- Remove unused zbus and tokio dependencies
- Change default features from ["lua"] to []
- Update justfile install-local to use --no-default-features

Core binary now has 18 dependencies instead of 27 when built
without the lua feature, reducing compile time and binary size.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:14:53 +01:00
384dd016a0 feat: convert to workspace with native plugin architecture
BREAKING: Restructure from monolithic binary to modular plugin ecosystem

Architecture changes:
- Convert to Cargo workspace with crates/ directory
- Create owlry-plugin-api crate with ABI-stable interface (abi_stable)
- Move core binary to crates/owlry/
- Extract providers to native plugin crates (13 plugins)
- Add owlry-lua crate for Lua plugin runtime

Plugin system:
- Plugins loaded from /usr/lib/owlry/plugins/*.so
- Widget providers refresh automatically (universal, not hardcoded)
- Per-plugin config via [plugins.<name>] sections in config.toml
- Backwards compatible with [providers] config format

New features:
- just install-local: build and install core + all plugins
- Plugin config: weather and pomodoro read from [plugins.*]
- HostAPI for plugins: notifications, logging

Documentation:
- Update README with new package structure
- Add docs/PLUGINS.md with all plugin documentation
- Add docs/PLUGIN_DEVELOPMENT.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:01:37 +01:00
a582f0181c feat: bundle Weather Icons via GResources
Replace emoji icons with proper SVG icons loaded from GResources:

- Add Weather Icons (Erik Flowers) for weather conditions
- Add music note icon for media player widget
- Add tomato icon for pomodoro timer
- Create GResource manifest and build.rs for compilation
- Update providers to use resource paths for icons
- Image::from_resource() loads icons from compiled bundle

This ensures icons display consistently regardless of user's
installed icon theme. Weather icons are OFL licensed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 19:45:33 +01:00
97c6f655ca feat: add widget providers (weather, media, pomodoro)
- Weather widget with Open-Meteo/wttr.in/OpenWeatherMap API support
- 15-minute weather caching with geocoding for city names
- MPRIS media player widget with play/pause toggle via dbus-send
- Pomodoro timer widget with configurable work/break cycles
- Widgets display at top of results with emoji icons
- Improved terminal detection for Hyprland/Sway environments
- Updated gtk4 to 0.10, gtk4-layer-shell to 0.7

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 19:36:26 +01:00
8670909480 chore: add media.md to gitignore
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:08:47 +01:00
cb12ffbeca chore: bump version to 0.3.9 2025-12-29 18:06:55 +01:00
892333dbca style: reduce vertical spacing on result rows
- Row padding: 6px (was 8px)
- Row margin: 1px (was 2px)
- Tag badge margin: 2px (was 4px)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:06:50 +01:00
6d3d69d103 chore: bump version to 0.3.8 2025-12-29 17:58:51 +01:00
bec8fc332b feat: increase default window size and reduce padding
- Default dimensions: 700x500 (was 600x400)
- Main container padding: 12px (was 16px)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:58:47 +01:00
a750ef8559 docs: add example files to README configuration section
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:47:21 +01:00
7cbebd324f chore: bump version to 0.3.7 2025-12-29 17:44:57 +01:00
5519381d8c chore: bump version to 0.3.6 2025-12-29 17:39:46 +01:00
38025279f9 docs: reorganize and update README
- Add new features: tags, configurable tabs, tag filtering
- Add File Locations section with XDG paths
- Fix scripts path (~/.local/share instead of ~/.config)
- Add Tags section explaining tag-based filtering
- Add Tab Configuration section
- Consolidate providers into a table
- Streamline theming section
- Remove redundant examples and verbose explanations
- More concise overall (~140 lines shorter)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:39:01 +01:00
405b598b9b docs: add example files and reorganize config
- Add data/style.example.css with CSS customization guide
- Add data/scripts/example.sh as script template
- Reorganize config.example.toml with clear sections:
  - File locations box at top
  - Separate sections: General, Appearance, Providers
  - Group trigger providers vs prefix providers
  - Add inline comments for all options
- List all built-in themes in appearance section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:34:10 +01:00
d086995399 docs: update config.example.toml with accurate XDG paths
- Add file locations header documenting all XDG paths
- Fix scripts path: $XDG_DATA_HOME not $XDG_CONFIG_HOME
- Document theme location for custom themes
- Document style.css for custom CSS overrides
- Document frecency.json storage location

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:32:05 +01:00
7ca8a1f443 feat: add tags, configurable tabs, and tag-based filtering
- Add `tags` field to LaunchItem for categorization
- Extract .desktop Categories as tags for applications
- Add semantic tags to providers (systemd, ssh, script, etc.)
- Display tag badges in result rows (max 3 tags)
- Add `tabs` config option for customizable header tabs
- Dynamic Ctrl+1-9 shortcuts based on tab config
- Add `:tag:XXX` prefix for tag-based filtering
- Include tags in fuzzy search with lower weight
- Update config.example.toml with tabs documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:30:47 +01:00
2a2a22f72c refactor: restructure project directories to follow FHS
- Move themes/ → data/themes/ (installable to /usr/share/owlry/themes/)
- Move resources/ → src/resources/ (embedded into binary)
- Move config.example.toml → data/ (installable to /usr/share/doc/owlry/)
- Update include_str! paths in app.rs

New structure follows Filesystem Hierarchy Standard:
- data/         → files installed to /usr/share/
- src/resources → embedded resources compiled into binary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:56:49 +01:00
0eccdc5883 refactor: centralize path handling with XDG Base Directory compliance
- Add src/paths.rs module for all XDG path lookups
- Move scripts from ~/.config to ~/.local/share (XDG data)
- Use $XDG_CONFIG_HOME for browser bookmark paths
- Add dev-logging feature flag for verbose debug output
- Add dev-install profile for testable release builds
- Remove CLAUDE.md from version control

BREAKING: Scripts directory moved from
~/.config/owlry/scripts/ to ~/.local/share/owlry/scripts/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:46:14 +01:00
3f7a8950eb chore: bump version to 0.3.5 2025-12-28 19:33:25 +01:00
b38bf082e1 fix: update emoji test to check description field 2025-12-28 19:33:20 +01:00
617dbbce3e chore: bump version to 0.3.4 2025-12-28 19:28:17 +01:00
4ff054afe0 chore: bump version to 0.3.3 2025-12-28 19:21:51 +01:00
131 changed files with 25706 additions and 5155 deletions

14
.gitignore vendored
View File

@@ -1 +1,15 @@
/target
CLAUDE.md
media.md
# AUR packages (each is its own git repo for aur.archlinux.org)
aur/*/.git/
aur/*/pkg/
aur/*/src/
aur/*/*.tar.zst
aur/*/*.tar.gz
aur/*/*.tar.xz
aur/*/*.pkg.tar.*
# Keep PKGBUILD and .SRCINFO tracked
.SRCINFO
aur/

445
CLAUDE.md
View File

@@ -1,32 +1,439 @@
# Owlry - Claude Code Instructions
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Development Commands
```bash
just build # Debug build (all workspace members)
just build-ui # UI binary only
just build-daemon # Core daemon only
just release # Release build (LTO, stripped)
just release-daemon # Release build for daemon only
just check # cargo check + clippy
just test # Run tests
just fmt # Format code
just run [ARGS] # Run UI with optional args (e.g., just run --mode app)
just run-daemon # Run core daemon
just install-local # Install core + daemon + runtimes + systemd units
# Dev build with verbose logging
cargo run -p owlry --features dev-logging
# Build core without embedded Lua (smaller binary, uses external owlry-lua)
cargo build -p owlry --release --no-default-features
```
## Usage Examples
### Basic Invocation
The UI client connects to the `owlry-core` daemon via Unix socket IPC. Start the daemon first:
```bash
# Start daemon (systemd recommended)
systemctl --user enable --now owlry-core.service
# Or run directly
owlry-core
# Then launch UI
owlry # Launch with all providers
owlry -m app # Applications only
owlry -m cmd # PATH commands only
owlry --profile dev # Use a named config profile
owlry -m calc # Calculator plugin only (if installed)
```
### dmenu Mode
dmenu mode runs locally without the daemon. Use `-m dmenu` with piped input for interactive selection. The selected item is printed to stdout (not executed), so pipe the output to execute it:
```bash
# Screenshot menu (execute selected command)
printf '%s\n' \
"grimblast --notify copy screen" \
"grimblast --notify copy area" \
"grimblast --notify edit screen" \
| owlry -m dmenu -p "Screenshot" \
| sh
# Git branch checkout
git branch | owlry -m dmenu -p "checkout" | xargs git checkout
# Kill a process
ps -eo comm | sort -u | owlry -m dmenu -p "kill" | xargs pkill
# Select and open a project
find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
```
### CLI Flags
| Flag | Description |
|------|-------------|
| `-m`, `--mode MODE` | Start in single-provider mode (app, cmd, dmenu, calc, etc.) |
| `--profile NAME` | Use a named profile from config (defines which modes to enable) |
| `-p`, `--prompt TEXT` | Custom prompt text for the search input (dmenu mode) |
### Available Modes
| Mode | Description |
|------|-------------|
| `app` | Desktop applications |
| `cmd` | PATH commands |
| `dmenu` | Pipe-based selection (requires stdin, runs locally) |
| `calc` | Calculator (plugin) |
| `clip` | Clipboard history (plugin) |
| `emoji` | Emoji picker (plugin) |
| `ssh` | SSH hosts (plugin) |
| `sys` | System actions (plugin) |
| `bm` | Bookmarks (plugin) |
| `file` | File search (plugin) |
| `web` | Web search (plugin) |
| `uuctl` | systemd user units (plugin) |
### Search Prefixes
Type these in the search box to filter by provider:
| Prefix | Provider | Example |
|--------|----------|---------|
| `:app` | Applications | `:app firefox` |
| `:cmd` | PATH commands | `:cmd git` |
| `:sys` | System actions | `:sys shutdown` |
| `:ssh` | SSH hosts | `:ssh server` |
| `:clip` | Clipboard | `:clip password` |
| `:bm` | Bookmarks | `:bm github` |
| `:emoji` | Emoji | `:emoji heart` |
| `:calc` | Calculator | `:calc sqrt(16)` |
| `:web` | Web search | `:web rust docs` |
| `:file` | Files | `:file config` |
| `:uuctl` | systemd | `:uuctl docker` |
| `:tag:X` | Filter by tag | `:tag:development` |
### Trigger Prefixes
| Trigger | Provider | Example |
|---------|----------|---------|
| `=` | Calculator | `= 5+3` |
| `?` | Web search | `? rust programming` |
| `/` | File search | `/ .bashrc` |
### Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `Enter` | Launch selected item |
| `Escape` | Close launcher / exit submenu |
| `Up` / `Down` | Navigate results |
| `Tab` | Cycle filter tabs |
| `Shift+Tab` | Cycle tabs (reverse) |
| `Ctrl+1..9` | Toggle tab by position |
### Plugin CLI
```bash
owlry plugin list # List installed
owlry plugin list --available # Show registry
owlry plugin search "query" # Search registry
owlry plugin install <name> # Install from registry
owlry plugin install ./path # Install from local path
owlry plugin remove <name> # Uninstall
owlry plugin enable/disable <name> # Toggle
owlry plugin create <name> # Create Lua plugin template
owlry plugin create <name> -r rune # Create Rune plugin template
owlry plugin validate ./path # Validate plugin structure
owlry plugin run <id> <cmd> [args] # Run plugin CLI command
owlry plugin commands <id> # List plugin commands
owlry plugin runtimes # Show available runtimes
```
## Release Workflow
Always use `just` for releases and AUR deployment:
Always use `just` for releases - do NOT manually edit Cargo.toml for version bumps:
```bash
# Bump version (updates Cargo.toml + Cargo.lock, commits)
just bump 0.x.y
# Bump a single crate
just bump-crate owlry-core 0.5.1
# Push and create tag
# Bump all crates to same version
just bump-all 0.5.1
# Bump core UI only
just bump 0.5.1
# Create and push release tag
git push && just tag
# Update AUR package
just aur-update
# Tagging convention: every crate gets its own tag
# Format: {crate-name}-v{version}
# Examples:
# owlry-v1.0.1
# owlry-core-v1.1.0
# owlry-lua-v1.1.0
# owlry-rune-v1.1.0
# plugin-api-v1.0.1
#
# The plugins repo uses the same convention:
# owlry-plugin-bookmarks-v1.0.1
# owlry-plugin-calculator-v1.0.1
# etc.
#
# IMPORTANT: After bumping versions, tag EVERY changed crate individually.
# The plugin-api tag is referenced by owlry-plugins Cargo.toml as a git dependency.
# Review changes, then publish
just aur-publish
# AUR package management
just aur-update # Update core UI PKGBUILD
just aur-update-pkg NAME # Update specific package (owlry-core, owlry-lua, etc.)
just aur-update-all # Update all AUR packages
just aur-publish # Publish core UI to AUR
just aur-publish-all # Publish all AUR packages
# Version inspection
just show-versions # List all crate versions
just aur-status # Show AUR package versions and git status
```
Do NOT manually edit Cargo.toml for version bumps - use `just bump`.
## AUR Packaging
## Available just recipes
The `aur/` directory contains PKGBUILDs for core packages:
- `just build` / `just release` - Build debug/release
- `just check` - Run cargo check + clippy
- `just test` - Run tests
- `just bump <version>` - Bump version
- `just tag` - Create and push git tag
- `just aur-update` - Update PKGBUILD checksums
- `just aur-publish` - Commit and push to AUR
- `just aur-test` - Test PKGBUILD locally
| Category | Packages |
|----------|----------|
| Core UI | `owlry` |
| Core Daemon | `owlry-core` |
| Runtimes | `owlry-lua`, `owlry-rune` |
| Meta-bundles | `owlry-meta-essentials`, `owlry-meta-widgets`, `owlry-meta-tools`, `owlry-meta-full` |
Plugin AUR packages are in the separate `owlry-plugins` repo at `somegit.dev/Owlibou/owlry-plugins`.
## Architecture
### Client/Daemon Split
Owlry uses a client/daemon architecture:
- **`owlry`** (client): GTK4 UI that connects to the daemon via Unix socket IPC. Handles rendering, user input, and launching applications. In dmenu mode, runs a local `ProviderManager` directly (no daemon needed).
- **`owlry-core`** (daemon): Headless background service that loads plugins, manages providers, handles fuzzy matching, frecency scoring, and serves queries over IPC. Runs as a systemd user service.
### Workspace Structure
```
owlry/
├── Cargo.toml # Workspace root
├── systemd/ # systemd user service/socket files
│ ├── owlry-core.service
│ └── owlry-core.socket
├── crates/
│ ├── owlry/ # UI client binary (GTK4 + Layer Shell)
│ │ └── src/
│ │ ├── main.rs # Entry point
│ │ ├── app.rs # GTK Application setup, CSS loading
│ │ ├── cli.rs # Clap CLI argument parsing
│ │ ├── client.rs # CoreClient - IPC client to daemon
│ │ ├── backend.rs # SearchBackend - abstraction over IPC/local
│ │ ├── theme.rs # Theme loading
│ │ ├── plugin_commands.rs # Plugin CLI subcommand handlers
│ │ ├── providers/ # dmenu provider (local-only)
│ │ └── ui/ # GTK widgets (MainWindow, ResultRow, submenu)
│ ├── owlry-core/ # Daemon library + binary
│ │ └── src/
│ │ ├── main.rs # Daemon entry point
│ │ ├── lib.rs # Public API (re-exports modules)
│ │ ├── server.rs # Unix socket IPC server
│ │ ├── ipc.rs # Request/Response message types
│ │ ├── filter.rs # ProviderFilter - mode/prefix filtering
│ │ ├── paths.rs # XDG path utilities, socket path
│ │ ├── notify.rs # Desktop notifications
│ │ ├── config/ # Config loading (config.toml)
│ │ ├── data/ # FrecencyStore
│ │ ├── providers/ # Application, Command, native/lua provider hosts
│ │ └── plugins/ # Plugin loading, manifests, registry, runtimes
│ ├── owlry-plugin-api/ # ABI-stable plugin interface
│ ├── owlry-lua/ # Lua script runtime (cdylib)
│ └── owlry-rune/ # Rune script runtime (cdylib)
```
### IPC Protocol
Communication uses newline-delimited JSON over a Unix domain socket at `$XDG_RUNTIME_DIR/owlry/owlry.sock`.
**Request types** (`owlry_core::ipc::Request`):
| Type | Purpose |
|------|---------|
| `Query` | Search with text and optional mode filters |
| `Launch` | Record a launch event for frecency |
| `Providers` | List available providers |
| `Refresh` | Refresh a specific provider |
| `Toggle` | Toggle visibility (client-side concern, daemon acks) |
| `Submenu` | Query submenu actions for a plugin item |
| `PluginAction` | Execute a plugin action command |
**Response types** (`owlry_core::ipc::Response`):
| Type | Purpose |
|------|---------|
| `Results` | Search results with `Vec<ResultItem>` |
| `Providers` | Provider list with `Vec<ProviderDesc>` |
| `SubmenuItems` | Submenu actions for a plugin |
| `Ack` | Success acknowledgement |
| `Error` | Error with message |
### Core Data Flow
```
[owlry UI] [owlry-core daemon]
main.rs → CliArgs → OwlryApp main.rs → Server::bind()
↓ ↓
SearchBackend UnixListener accept loop
↓ ↓
┌──────┴──────┐ handle_request()
↓ ↓ ↓
Daemon Local (dmenu) ┌───────────┴───────────┐
↓ ↓ ↓
CoreClient ──── IPC ────→ ProviderManager ProviderFilter
↓ ↓
[Provider impls] parse_query()
LaunchItem[]
FrecencyStore (boost)
Response::Results ──── IPC ────→ UI rendering
```
### Provider System
**Core providers** (in `owlry-core`):
- **Application**: Desktop applications from XDG directories
- **Command**: Shell commands from PATH
**dmenu provider** (in `owlry` client, local only):
- **Dmenu**: Pipe-based input (dmenu compatibility)
All other providers are native plugins in the separate `owlry-plugins` repo (`somegit.dev/Owlibou/owlry-plugins`).
**User plugins** (script-based, in `~/.config/owlry/plugins/`):
- **Lua plugins**: Loaded by `owlry-lua` runtime from `/usr/lib/owlry/runtimes/liblua.so`
- **Rune plugins**: Loaded by `owlry-rune` runtime from `/usr/lib/owlry/runtimes/librune.so`
- User plugins are **hot-reloaded** automatically when files change (no daemon restart needed)
- Custom prefixes (e.g., `:hs`) are resolved dynamically for user plugins
`ProviderManager` (in `owlry-core`) orchestrates providers and handles:
- Fuzzy matching via `SkimMatcherV2`
- Frecency score boosting
- Native plugin loading from `/usr/lib/owlry/plugins/`
- Script runtime loading from `/usr/lib/owlry/runtimes/` for user plugins
- Filesystem watching for automatic user plugin hot-reload
**Submenu System**: Plugins can return items with `SUBMENU:plugin_id:data` commands. When selected, the plugin is queried with `?SUBMENU:data` to get action items (e.g., systemd service actions).
### Plugin API
Native plugins use the ABI-stable interface in `owlry-plugin-api`:
```rust
#[repr(C)]
pub struct PluginVTable {
pub info: extern "C" fn() -> PluginInfo,
pub providers: extern "C" fn() -> RVec<ProviderInfo>,
pub provider_init: extern "C" fn(id: RStr) -> ProviderHandle,
pub provider_refresh: extern "C" fn(ProviderHandle) -> RVec<PluginItem>,
pub provider_query: extern "C" fn(ProviderHandle, RStr) -> RVec<PluginItem>,
pub provider_drop: extern "C" fn(ProviderHandle),
}
// Each plugin exports:
#[no_mangle]
pub extern "C" fn owlry_plugin_vtable() -> &'static PluginVTable
```
Plugins are compiled as `.so` (cdylib) and loaded by the daemon at startup.
**Plugin locations** (when deployed):
- `/usr/lib/owlry/plugins/*.so` - Native plugins
- `/usr/lib/owlry/runtimes/*.so` - Script runtimes (liblua.so, librune.so)
- `~/.config/owlry/plugins/` - User plugins (Lua/Rune)
### Filter & Prefix System
`ProviderFilter` (`owlry-core/src/filter.rs`) handles:
- CLI mode selection (`--mode app`)
- Profile-based mode selection (`--profile dev`)
- Provider toggling (Ctrl+1/2/3)
- Prefix parsing (`:app`, `:cmd`, `:sys`, etc.)
- Dynamic prefix fallback for user plugins (any `:word` prefix maps to `Plugin(word)`)
Query parsing extracts prefix and forwards clean query to providers.
### SearchBackend
`SearchBackend` (`owlry/src/backend.rs`) abstracts over two modes:
- **`Daemon`**: Wraps `CoreClient`, sends queries over IPC to `owlry-core`
- **`Local`**: Wraps `ProviderManager` directly (used for dmenu mode only)
### UI Layer
- `MainWindow` (`src/ui/main_window.rs`): GTK4 window with Layer Shell overlay
- `ResultRow` (`src/ui/result_row.rs`): Individual result rendering
- `submenu` (`src/ui/submenu.rs`): Universal submenu parsing utilities (plugins provide actions)
### Configuration
`Config` (`owlry-core/src/config/mod.rs`) loads from `~/.config/owlry/config.toml`:
- Auto-detects terminal (`$TERMINAL` -> `xdg-terminal-exec` -> common terminals)
- Optional `use_uwsm = true` for systemd session integration (launches apps via `uwsm app --`)
- Profiles: Define named mode sets under `[profiles.<name>]` with `modes = ["app", "cmd", ...]`
### Theming
CSS loading priority (`owlry/src/app.rs`):
1. Base structural CSS (`resources/base.css`)
2. Theme CSS (built-in "owl" or custom `~/.config/owlry/themes/{name}.css`)
3. User overrides (`~/.config/owlry/style.css`)
4. Config variable injection
### Systemd Integration
Service files in `systemd/`:
- `owlry-core.service`: Runs daemon as `Type=simple`, restarts on failure
- `owlry-core.socket`: Socket activation at `%t/owlry/owlry.sock`
Start with: `systemctl --user enable --now owlry-core.service`
## Plugins
Plugins live in a separate repository: `somegit.dev/Owlibou/owlry-plugins`
13 native plugin crates, all compiled as cdylib (.so):
| Category | Plugins | Behavior |
|----------|---------|----------|
| Static | bookmarks, clipboard, emoji, scripts, ssh, system, systemd | Loaded at startup, refresh() populates items |
| Dynamic | calculator, websearch, filesearch | Queried per-keystroke via query() |
| Widget | weather, media, pomodoro | Displayed at top of results |
## Key Patterns
- **Rc<RefCell<T>>** used throughout for GTK signal handlers needing mutable state
- **Feature flag `dev-logging`**: Wraps debug!() calls in `#[cfg(feature = "dev-logging")]`
- **Feature flag `lua`**: Enables built-in Lua runtime (off by default); enable to embed Lua in core binary
- **Script runtimes**: External `.so` runtimes loaded from `/usr/lib/owlry/runtimes/` — Lua and Rune user plugins loaded from `~/.config/owlry/plugins/`
- **Hot-reload**: Filesystem watcher (`notify` crate) monitors user plugins dir and reloads runtimes on file changes
- **dmenu mode**: Runs locally without daemon. Use `-m dmenu` with piped stdin
- **Frecency**: Time-decayed frequency scoring stored in `~/.local/share/owlry/frecency.json`
- **ABI stability**: Plugin interface uses `abi_stable` crate for safe Rust dynamic linking
- **Plugin API v3**: Adds `position` (Normal/Widget) and `priority` fields to ProviderInfo
- **ProviderType simplification**: Core uses only `Application`, `Command`, `Dmenu`, `Plugin(String)` - all plugin-specific types removed from core
## Dependencies (Rust 1.90+, GTK 4.12+)
External tool dependencies (for plugins):
- Clipboard plugin: `cliphist`, `wl-clipboard`
- File search plugin: `fd` or `mlocate`
- Emoji plugin: `wl-clipboard`, `noto-fonts-emoji`
- Systemd plugin: `systemd` (user services)
- Bookmarks plugin: Firefox support uses `rusqlite` with bundled SQLite (no system dependency)

3969
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +1,34 @@
[package]
name = "owlry"
version = "0.3.2"
[workspace]
resolver = "2"
members = [
"crates/owlry",
"crates/owlry-core",
"crates/owlry-plugin-api",
"crates/owlry-lua",
"crates/owlry-rune",
]
# Shared workspace settings
[workspace.package]
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"
authors = ["Your Name <you@example.com>"]
license = "GPL-3.0-or-later"
repository = "https://somegit.dev/Owlibou/owlry"
keywords = ["launcher", "wayland", "gtk4", "linux"]
categories = ["gui"]
[dependencies]
# GTK4 for the UI
gtk4 = { version = "0.9", features = ["v4_12"] }
# Layer shell support for Wayland overlay behavior
gtk4-layer-shell = "0.4"
# Async runtime for non-blocking operations
tokio = { version = "1", features = ["rt", "sync", "process", "fs"] }
# Fuzzy matching for search
fuzzy-matcher = "0.3"
# XDG desktop entry parsing
freedesktop-desktop-entry = "0.7"
# Directory utilities
dirs = "5"
# Low-level syscalls for stdin detection
libc = "0.2"
# Logging
log = "0.4"
env_logger = "0.11"
# Error handling
thiserror = "2"
# Configuration
serde = { version = "1", features = ["derive"] }
toml = "0.8"
# CLI argument parsing
clap = { version = "4", features = ["derive"] }
# Math expression evaluation for calculator
meval = "0.2"
# JSON serialization for data persistence
serde_json = "1"
# Date/time for frecency calculations
chrono = { version = "0.4", features = ["serde"] }
# Release profile (shared across all crates)
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
opt-level = "z" # Optimize for size
opt-level = "z"
[profile.dev]
opt-level = 0
debug = true
# For installing a testable build: cargo install --path crates/owlry --profile dev-install --features dev-logging
[profile.dev-install]
inherits = "release"
strip = false
debug = 1

539
README.md
View File

@@ -10,32 +10,63 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
## Features
- **Provider-based architecture** - Search applications, commands, system actions, SSH hosts, clipboard history, bookmarks, emoji, and more
- **Fuzzy search** - Fast, typo-tolerant matching across all providers
- **Filter tabs & prefixes** - Scope searches with UI tabs or `:app`, `:cmd`, `:sys` prefixes
- **Calculator** - Quick math with `= 5+3` or `calc sin(pi/2)`
- **Web search** - Search the web with `? query` or `web query`
- **File search** - Find files with `/ filename` or `find config` (requires `fd` or `locate`)
- **Frecency ranking** - Frequently/recently used items rank higher
- **GTK4 theming** - Respects system theme by default, with optional custom themes
- **Wayland native** - Uses Layer Shell for proper overlay behavior
- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory
- **Modular plugin architecture** — Install only what you need
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
- **13 native plugins** — Calculator, clipboard, emoji, weather, media, and more
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
- **Config profiles** — Named mode presets for different workflows
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc.
- **Frecency ranking** — Frequently/recently used items rank higher
- **Toggle behavior** — Bind one key to open/close the launcher
- **GTK4 theming** — System theme by default, with 9 built-in themes
- **Wayland native** — Uses Layer Shell for proper overlay behavior
- **Extensible** — Create custom plugins in Lua or Rune
## Installation
### Arch Linux (AUR)
```bash
# Using yay
# Minimal core (applications + commands only)
yay -S owlry
# Using paru
paru -S owlry
# Add individual plugins
yay -S owlry-plugin-calculator owlry-plugin-weather
# Or install bundles:
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-rune # Rune runtime
```
### Build from source
### Available Packages
#### Dependencies
| Package | Description |
|---------|-------------|
| `owlry` | Core: UI client (`owlry`) and daemon (`owlry-core`) |
| `owlry-plugin-calculator` | Math expressions (`= 5+3`) |
| `owlry-plugin-system` | Shutdown, reboot, suspend, lock |
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
| `owlry-plugin-clipboard` | History via cliphist |
| `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-systemd` | User services with actions |
| `owlry-plugin-weather` | Weather widget |
| `owlry-plugin-media` | MPRIS media controls |
| `owlry-plugin-pomodoro` | Pomodoro timer widget |
### Build from Source
**Dependencies:**
```bash
# Arch Linux
sudo pacman -S gtk4 gtk4-layer-shell
@@ -47,44 +78,152 @@ sudo apt install libgtk-4-dev libgtk4-layer-shell-dev
sudo dnf install gtk4-devel gtk4-layer-shell-devel
```
#### Optional dependencies
```bash
# For clipboard history
sudo pacman -S cliphist wl-clipboard
# For file search
sudo pacman -S fd # or: mlocate
```
#### Build
Requires Rust 1.90 or later.
**Build (requires Rust 1.90+):**
```bash
git clone https://somegit.dev/Owlibou/owlry.git
cd owlry
cargo build --release
# Build core only (daemon + UI)
cargo build --release -p owlry -p owlry-core
# Build specific plugin
cargo build --release -p owlry-plugin-calculator
# Build everything
cargo build --release --workspace
```
The binary will be at `target/release/owlry`.
**Install locally:**
```bash
just install-local
```
This installs both binaries, all plugins, runtimes, and the systemd service files.
## 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.
### Starting the Daemon
Choose one of three methods:
**1. Compositor autostart (recommended for most users)**
Add to your compositor config:
```bash
# Hyprland (~/.config/hypr/hyprland.conf)
exec-once = owlry-core
# Sway (~/.config/sway/config)
exec owlry-core
```
**2. Systemd user service**
```bash
systemctl --user enable --now owlry-core.service
```
**3. Socket activation (auto-start on first use)**
```bash
systemctl --user enable owlry-core.socket
```
The daemon starts automatically when the UI client first connects. No manual startup needed.
### Launching the UI
Bind `owlry` to a key in your compositor:
```bash
# Hyprland
bind = SUPER, Space, exec, owlry
# Sway
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.
If the daemon is not running when the UI launches, it will attempt to start it via systemd automatically.
## Usage
```bash
# Launch with default settings (GTK theme, all providers)
owlry
# Launch with only applications
owlry --mode app
# Launch with specific providers
owlry --providers app,cmd
# Show help
owlry --help
owlry # Launch with all providers
owlry -m app # Applications only
owlry -m cmd # PATH commands only
owlry -m calc # Calculator plugin only (if installed)
owlry --profile dev # Use a named profile from config
owlry --help # Show all options with examples
```
### Profiles
Profiles are named sets of modes defined in your config:
```toml
[profiles.dev]
modes = ["app", "cmd", "ssh"]
[profiles.media]
modes = ["media", "emoji"]
[profiles.minimal]
modes = ["app"]
```
Launch with a profile:
```bash
owlry --profile dev
```
You can bind different profiles to different keys:
```bash
# Hyprland
bind = SUPER, Space, exec, owlry
bind = SUPER, D, exec, owlry --profile dev
bind = SUPER, M, exec, owlry --profile media
```
### 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.
dmenu mode is self-contained: it does not use the daemon and works without `owlry-core` running.
```bash
# Screenshot menu (execute selected command)
printf '%s\n' \
"grimblast --notify copy screen" \
"grimblast --notify copy area" \
"grimblast --notify edit screen" \
| owlry -m dmenu -p "Screenshot" \
| sh
# Git branch checkout
git branch | owlry -m dmenu -p "checkout" | xargs git checkout
# Kill a process
ps -eo comm | sort -u | owlry -m dmenu -p "kill" | xargs pkill
# Select and open a project
find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
# Package manager search
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.
### Keyboard Shortcuts
| Key | Action |
@@ -92,216 +231,177 @@ owlry --help
| `Enter` | Launch selected item |
| `Escape` | Close launcher / exit submenu |
| `Up` / `Down` | Navigate results |
| `Tab` | Cycle filter modes |
| `Shift+Tab` | Cycle filter modes (reverse) |
| `Ctrl+1` | Toggle Applications filter |
| `Ctrl+2` | Toggle Commands filter |
| `Ctrl+3` | Toggle systemd filter |
| `Tab` | Cycle filter tabs |
| `Shift+Tab` | Cycle filter tabs (reverse) |
| `Ctrl+1..9` | Toggle tab by position |
### Search Prefixes
Filter results by provider using prefixes:
| Prefix | Provider | Example |
|--------|----------|---------|
| `:app` | Applications | `:app firefox` |
| `:cmd` | PATH commands | `:cmd git` |
| `:sys` | System actions | `:sys shutdown` |
| `:ssh` | SSH hosts | `:ssh server` |
| `:clip` | Clipboard history | `:clip password` |
| `:bm` | Browser bookmarks | `:bm github` |
| `:emoji` | Emoji picker | `:emoji heart` |
| `:script` | Custom scripts | `:script backup` |
| `:file` | File search | `:file config.toml` |
| `:calc` | Calculator | `:calc 5+3` |
| `:clip` | Clipboard | `:clip password` |
| `:bm` | Bookmarks | `:bm github` |
| `:emoji` | Emoji | `:emoji heart` |
| `:script` | Scripts | `:script backup` |
| `:file` | Files | `:file config` |
| `:calc` | Calculator | `:calc sqrt(16)` |
| `:web` | Web search | `:web rust docs` |
| `:uuctl` | systemd services | `:uuctl docker` |
| `:uuctl` | systemd | `:uuctl docker` |
| `:tag:X` | Filter by tag | `:tag:development` |
### Trigger Prefixes
Some providers can be triggered directly without filter mode:
| Trigger | Provider | Example |
|---------|----------|---------|
| `=` | Calculator | `= 5+3` or `=5*2` |
| `=` | Calculator | `= 5+3` |
| `calc ` | Calculator | `calc sqrt(16)` |
| `?` | Web search | `? rust programming` |
| `web ` | Web search | `web linux tips` |
| `search ` | Web search | `search owlry` |
| `/` | File search | `/ .bashrc` |
| `find ` | File search | `find config` |
## Providers
### Applications
Searches `.desktop` files from standard XDG directories.
### Commands
Searches executable files in `$PATH`.
### System
Quick access to system actions:
- Shutdown, Reboot, Suspend, Hibernate
- Lock Screen, Log Out
- **Reboot into BIOS** - Restart directly into UEFI/BIOS setup
### SSH
Parses `~/.ssh/config` and offers quick connections to configured hosts. Opens in your configured terminal.
### Clipboard (requires cliphist)
Search and paste from clipboard history. Requires `cliphist` and `wl-clipboard`:
```bash
sudo pacman -S cliphist wl-clipboard
```
### Bookmarks
Reads bookmarks from Chromium-based browsers:
- Chrome, Chromium, Brave, Edge, Vivaldi
### Emoji
Search 300+ emojis by name or keywords. Selected emoji is copied to clipboard via `wl-copy`.
### Scripts
Runs executable scripts from `~/.config/owlry/scripts/`. Create the directory and add your scripts:
```bash
mkdir -p ~/.config/owlry/scripts
echo '#!/bin/bash
# My backup script
rsync -av ~/Documents /backup/' > ~/.config/owlry/scripts/backup
chmod +x ~/.config/owlry/scripts/backup
```
### Calculator
Evaluate math expressions with `= expr` or `calc expr`:
- Basic: `= 5+3`, `= 10/3`
- Functions: `= sqrt(16)`, `= sin(pi/2)`
- Constants: `= pi`, `= e`
### Web Search
Search the web with `? query` or `web query`. Configurable search engine:
- Google, DuckDuckGo, Bing, Brave, Ecosia, Startpage, SearXNG
- Or custom URL with `{query}` placeholder
### File Search (requires fd or locate)
Search files with `/ pattern` or `find pattern`:
```bash
sudo pacman -S fd # recommended, faster
# or
sudo pacman -S mlocate && sudo updatedb
```
### systemd User Services
Lists and controls user-level systemd services. Select a service to access actions:
- Start / Stop / Restart / Reload
- Kill (force stop)
- Status (opens in terminal)
- Journal (live logs in terminal)
- Enable / Disable (autostart)
## Configuration
Configuration file: `~/.config/owlry/config.toml`
Owlry follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/):
| Path | Purpose |
|------|---------|
| `~/.config/owlry/config.toml` | Main configuration |
| `~/.config/owlry/themes/*.css` | Custom themes |
| `~/.config/owlry/style.css` | CSS overrides |
| `~/.config/owlry/plugins/` | User plugins (Lua/Rune) |
| `~/.local/share/owlry/scripts/` | User scripts |
| `~/.local/share/owlry/frecency.json` | Usage history |
System locations:
| Path | Purpose |
|------|---------|
| `/usr/lib/owlry/plugins/*.so` | Installed native plugins |
| `/usr/lib/owlry/runtimes/*.so` | Lua/Rune script runtimes |
| `/usr/share/doc/owlry/config.example.toml` | Example configuration |
### Quick Start
```bash
# Copy example config
mkdir -p ~/.config/owlry
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
```
### Example Configuration
```toml
[general]
show_icons = true
max_results = 10
# terminal_command = "kitty" # Auto-detected if not set
# launch_wrapper = "uwsm app --" # Auto-detected for uwsm/hyprland
tabs = ["app", "cmd", "uuctl"]
# terminal_command = "kitty" # Auto-detected
# use_uwsm = false # Enable for systemd session integration
[appearance]
width = 600
height = 400
width = 850
height = 650
font_size = 14
border_radius = 12
# theme = "owl" # Optional: "owl" or custom theme name
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc.
[plugins]
disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
[providers]
applications = true
commands = true
uuctl = true
calculator = true
websearch = true
search_engine = "duckduckgo" # google, bing, brave, ecosia, startpage, searxng
system = true
ssh = true
clipboard = true
bookmarks = true
emoji = true
scripts = true
files = true
frecency = true
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
applications = true # .desktop files
commands = true # PATH executables
frecency = true # Boost frequently used items
frecency_weight = 0.3 # 0.0-1.0
# Web search engine: google, duckduckgo, bing, startpage, brave, ecosia
search_engine = "duckduckgo"
# Profiles: named sets of modes
[profiles.dev]
modes = ["app", "cmd", "ssh"]
[profiles.media]
modes = ["media", "emoji"]
```
### Default Values
See `/usr/share/doc/owlry/config.example.toml` for all options with documentation.
| Setting | Default |
|---------|---------|
| `show_icons` | `true` |
| `max_results` | `10` |
| `terminal_command` | Auto-detected ($TERMINAL -> xdg-terminal-exec -> kitty/alacritty/etc) |
| `launch_wrapper` | Auto-detected (uwsm -> hyprctl -> none) |
| `width` | `600` |
| `height` | `400` |
| `font_size` | `14` |
| `border_radius` | `12` |
| `theme` | None (GTK default) |
## Plugin System
### Launch Wrapper
Owlry uses a modular plugin architecture. Plugins are loaded by the daemon (`owlry-core`) from:
When running in uwsm-managed or Hyprland sessions, owlry auto-detects and uses the appropriate launch wrapper:
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
| Session | Wrapper | Purpose |
|---------|---------|---------|
| uwsm | `uwsm app --` | Proper systemd scope and session management |
| Hyprland | `hyprctl dispatch exec --` | Native Hyprland window management |
| Other | None (direct `sh -c`) | Standard shell execution |
### Disabling Plugins
Add plugin IDs to the disabled list in your config:
```toml
[plugins]
disabled = ["emoji", "pomodoro"]
```
### Plugin Management CLI
```bash
# List installed plugins
owlry plugin list
owlry plugin list --enabled # Only enabled
owlry plugin list --available # Show registry plugins
# Search registry
owlry plugin search "weather"
# Install/remove
owlry plugin install <name> # From registry
owlry plugin install ./my-plugin # From local path
owlry plugin remove <name>
# Enable/disable
owlry plugin enable <name>
owlry plugin disable <name>
# Plugin info
owlry plugin info <name>
owlry plugin commands <name> # List plugin CLI commands
# Create new plugin
owlry plugin create my-plugin # Lua (default)
owlry plugin create my-plugin -r rune # Rune
# Run plugin command
owlry plugin run <plugin-id> <command> [args...]
```
### Creating Custom Plugins
See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
- Native plugin development (Rust)
- Lua plugin development
- Rune plugin development
- Available APIs
## Theming
### GTK Theme (Default)
By default, Owlry inherits colors from your system GTK4 theme (Adwaita, Breeze, etc.).
### Built-in Themes
Owlry includes an owl-inspired dark theme:
```toml
[appearance]
theme = "owl"
```
### Included Example Themes
Example themes are installed to `/usr/share/owlry/themes/`:
| Theme | Description |
|-------|-------------|
| `owl` | Owl-inspired dark theme with amber accents |
| `catppuccin-mocha` | Soothing pastel theme |
| `nord` | Arctic, north-bluish palette |
| `rose-pine` | All natural pine, faux fur and soho vibes |
| `dracula` | Dark theme for vampires |
| `gruvbox-dark` | Retro groove color scheme |
| `tokyo-night` | Lights of Tokyo at night |
| `solarized-dark` | Precision colors for machines and people |
| `one-dark` | Atom's iconic One Dark theme |
To use an example theme:
```bash
mkdir -p ~/.config/owlry/themes
cp /usr/share/owlry/themes/catppuccin-mocha.css ~/.config/owlry/themes/
```
Then set in config:
| `owl` | Dark theme with amber accents |
| `catppuccin-mocha` | Soothing pastel |
| `nord` | Arctic blue palette |
| `rose-pine` | Natural pine vibes |
| `dracula` | Dark vampire theme |
| `gruvbox-dark` | Retro groove |
| `tokyo-night` | Tokyo city lights |
| `solarized-dark` | Precision colors |
| `one-dark` | Atom's One Dark |
```toml
[appearance]
@@ -310,7 +410,7 @@ theme = "catppuccin-mocha"
### Custom Theme
Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
Create `~/.config/owlry/themes/mytheme.css`:
```css
:root {
@@ -324,7 +424,7 @@ Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
}
```
### CSS Variables Reference
### CSS Variables
| Variable | Description |
|----------|-------------|
@@ -333,22 +433,39 @@ Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
| `--owlry-border` | Border color |
| `--owlry-text` | Primary text |
| `--owlry-text-secondary` | Muted text |
| `--owlry-accent` | Accent/highlight color |
| `--owlry-accent` | Accent color |
| `--owlry-accent-bright` | Bright accent |
| `--owlry-font-size` | Base font size |
| `--owlry-border-radius` | Border radius |
| `--owlry-badge-*` | Provider badge colors (app, cmd, sys, ssh, clip, emoji, etc.) |
### Custom Stylesheet
## Architecture
For full control, create `~/.config/owlry/style.css` with any GTK4 CSS.
Owlry uses a client/daemon split:
```
owlry-core (daemon) owlry (GTK4 UI client)
├── Loads config + plugins ├── Connects to daemon via Unix socket
├── Applications provider ├── Renders results in GTK4 window
├── Commands provider ├── Handles keyboard input
├── Plugin loader ├── Toggle: second launch closes window
│ ├── /usr/lib/owlry/plugins/*.so └── dmenu mode (self-contained, no daemon)
│ ├── /usr/lib/owlry/runtimes/
│ └── ~/.config/owlry/plugins/
├── Frecency tracking
└── IPC server (Unix socket)
└── $XDG_RUNTIME_DIR/owlry/owlry.sock
```
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
This project is licensed under the GNU General Public License v3.0 or later - see [LICENSE](LICENSE) for details.
GNU General Public License v3.0 see [LICENSE](LICENSE).
## Acknowledgments
- [GTK4](https://gtk.org/) - UI toolkit
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) - Wayland Layer Shell bindings
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) - Fuzzy search algorithm
- [GTK4](https://gtk.org/) UI toolkit
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) Wayland Layer Shell
- [abi_stable](https://crates.io/crates/abi_stable) — ABI-stable Rust plugins
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) — Fuzzy search

109
ROADMAP.md Normal file
View File

@@ -0,0 +1,109 @@
# Owlry Roadmap
Feature ideas and future development plans for Owlry.
## High Value, Low Effort
### Plugin hot-reload
Detect `.so` file changes in `/usr/lib/owlry/plugins/` and reload without restarting the launcher. The loader infrastructure already exists.
### Frecency pruning
Add `max_entries` and `max_age_days` config options. Prune old entries on startup to prevent `frecency.json` from growing unbounded.
### `:recent` prefix
Show last N launched items. Data already exists in frecency.json — just needs a provider to surface it.
### Clipboard images
`cliphist` supports images. Extend the clipboard plugin to show image thumbnails in results.
---
## Medium Effort, High Value
### Actions on any result
Generalize the submenu system beyond systemd. Every result type gets contextual actions:
| Provider | Actions |
|----------|---------|
| Applications | Open, Open in terminal, Show .desktop location |
| Files | Open, Open folder, Copy path, Delete |
| SSH | Connect, Copy hostname, Edit config |
| Bookmarks | Open, Copy URL, Open incognito |
| Clipboard | Paste, Delete from history |
This is the difference between a launcher and a command palette.
### Plugin settings UI
A `:settings` provider that lists installed plugins and their configurable options. Edit values inline, writes to `config.toml`.
### Result action capture
Calculator shows `= 5+3 → 8`. Allow pressing Tab or Ctrl+C to copy the result to clipboard instead of "launching" it. Useful for calculator, file paths, URLs.
---
## Bigger Bets
### Window switcher with live thumbnails
A `windows` plugin using Wayland screencopy to show live thumbnails of open windows. Hyprland and Sway expose window lists via IPC. Could replace Alt+Tab.
### Cross-device bookmark sync
Firefox and Chrome sync bookmarks across devices. Parse sync metadata to show "recently added on other devices" or "bookmarks from phone".
### Natural language commands
Parse simple natural language into system commands:
```
"shutdown in 30 minutes" → systemd-run --user --on-active=30m systemctl poweroff
"remind me in 1 hour" → notify-send scheduled via at/systemd timer
"volume 50%" → wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.5
```
Local pattern matching, no AI/cloud required.
### Plugin marketplace
A curated registry of third-party Lua/Rune plugins with one-command install:
```bash
owlry plugin install github-notifications
owlry plugin install todoist
owlry plugin install spotify-controls
```
The script runtimes make this viable without recompiling.
---
## Technical Debt
### Split monorepo for user build efficiency
Currently, a small core fix requires all 16 AUR packages to rebuild (same source tarball). Split into 3 repos:
| Repo | Contents | Versioning |
|------|----------|------------|
| `owlry` | Core binary | Independent |
| `owlry-plugin-api` | ABI interface (crates.io) | Semver, conservative |
| `owlry-plugins` | 13 plugins + 2 runtimes | Independent per plugin |
**Execution order:**
1. Publish `owlry-plugin-api` to crates.io
2. Update monorepo to use crates.io dependency
3. Create `owlry-plugins` repo, move plugins + runtimes
4. Slim current repo to core-only
5. Update AUR PKGBUILDs with new source URLs
**Benefit:** Core bugfix = 1 rebuild. Plugin fix = 1 rebuild. Third-party plugins possible via crates.io.
### Replace meval with evalexpr
`meval` depends on `nom v1.2.4` which will be rejected by future Rust versions. Migrate calculator plugin and Lua runtime to `evalexpr` v13+.
### Plugin API backwards compatibility
When `API_VERSION` increments, provide a compatibility shim so v3 plugins work with v4 core. Prevents ecosystem fragmentation.
### Per-plugin configuration
Current flat `[providers]` config doesn't scale. Design a `[plugins.weather]`, `[plugins.pomodoro]` structure that plugins can declare and the core validates.
---
## Priority
If we had to pick one: **Actions on any result**. It transforms every provider from "search and launch" to "search and do anything". The ROI is massive.

View File

@@ -1,77 +0,0 @@
# Owlry Configuration
# Copy to ~/.config/owlry/config.toml
[general]
show_icons = true
max_results = 10
terminal_command = "kitty" # Auto-detected if not set
# Launch wrapper for app execution (auto-detected if not set)
# Examples:
# "uwsm app --" # For uwsm sessions
# "hyprctl dispatch exec --" # For Hyprland
# "" # Direct execution
# launch_wrapper = "uwsm app --"
[appearance]
width = 600
height = 400
font_size = 14
border_radius = 12
# Theme: "owl" for built-in dark theme, or leave unset for GTK default
# theme = "owl"
# Individual color overrides (CSS color values)
# [appearance.colors]
# background = "#1a1b26"
# background_secondary = "#24283b"
# border = "#414868"
# text = "#c0caf5"
# text_secondary = "#565f89"
# accent = "#7aa2f7"
# accent_bright = "#89b4fa"
# badge_app = "#9ece6a"
# badge_calc = "#e0af68"
# badge_cmd = "#7aa2f7"
# badge_dmenu = "#bb9af7"
# badge_uuctl = "#f7768e"
[providers]
applications = true
commands = true
uuctl = true
# Calculator provider (type "= 5+3" or "calc 5+3")
calculator = true
# Frecency: boost frequently/recently used items in search results
frecency = true
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
# Web search provider (type "? query" or "web query")
websearch = true
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
# Or custom URL with {query} placeholder, e.g. "https://search.example.com/?q={query}"
search_engine = "duckduckgo"
# System commands (shutdown, reboot, lock, suspend, hibernate, logout, BIOS)
system = true
# SSH connections from ~/.ssh/config
ssh = true
# Clipboard history (requires cliphist)
clipboard = true
# Browser bookmarks (Chrome, Chromium, Brave, Edge, Vivaldi)
bookmarks = true
# Emoji picker (copies to clipboard)
emoji = true
# Custom scripts from ~/.config/owlry/scripts/
scripts = true
# File search (requires fd or locate, trigger with "/ pattern" or "find pattern")
files = true

View File

@@ -0,0 +1,62 @@
[package]
name = "owlry-core"
version = "1.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Core daemon for the Owlry application launcher"
[lib]
name = "owlry_core"
path = "src/lib.rs"
[[bin]]
name = "owlry-core"
path = "src/main.rs"
[dependencies]
owlry-plugin-api = { path = "../owlry-plugin-api" }
# Provider system
fuzzy-matcher = "0.3"
freedesktop-desktop-entry = "0.8"
# Plugin loading
libloading = "0.8"
semver = "1"
# Data & config
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
chrono = { version = "0.4", features = ["serde"] }
dirs = "5"
# Error handling
thiserror = "2"
# Filesystem watching (plugin hot-reload)
notify = "7"
notify-debouncer-mini = "0.5"
# Signal handling
ctrlc = { version = "3", features = ["termination"] }
# Logging & notifications
log = "0.4"
env_logger = "0.11"
notify-rust = "4"
# Optional: embedded Lua runtime
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]
tempfile = "3"
[features]
default = []
lua = ["dep:mlua", "dep:meval", "dep:reqwest"]
dev-logging = []

View File

@@ -0,0 +1,593 @@
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use crate::paths;
/// A named profile that selects a set of provider modes.
///
/// Defined in config.toml as:
/// ```toml
/// [profiles.dev]
/// modes = ["app", "cmd", "ssh"]
///
/// [profiles.media]
/// modes = ["media", "emoji"]
/// ```
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ProfileConfig {
pub modes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub appearance: AppearanceConfig,
#[serde(default)]
pub providers: ProvidersConfig,
#[serde(default)]
pub plugins: PluginsConfig,
#[serde(default)]
pub profiles: HashMap<String, ProfileConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig {
#[serde(default = "default_true")]
pub show_icons: bool,
#[serde(default = "default_max_results")]
pub max_results: usize,
/// Terminal command (auto-detected if not specified)
#[serde(default)]
pub terminal_command: Option<String>,
/// Enable uwsm (Universal Wayland Session Manager) for launching apps.
/// When enabled, desktop files are launched via `uwsm app -- <file>`
/// which starts apps in a proper systemd user session.
/// When disabled (default), apps are launched via `gio launch`.
#[serde(default)]
pub use_uwsm: bool,
/// Provider tabs shown in the header bar.
/// Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
#[serde(default = "default_tabs")]
pub tabs: Vec<String>,
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
show_icons: true,
max_results: 100,
terminal_command: None,
use_uwsm: false,
tabs: default_tabs(),
}
}
}
fn default_max_results() -> usize {
100
}
fn default_tabs() -> Vec<String> {
vec!["app".to_string(), "cmd".to_string(), "uuctl".to_string()]
}
/// User-customizable theme colors
/// All fields are optional - unset values inherit from theme or GTK defaults
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ThemeColors {
// Core colors
pub background: Option<String>,
pub background_secondary: Option<String>,
pub border: Option<String>,
pub text: Option<String>,
pub text_secondary: Option<String>,
pub accent: Option<String>,
pub accent_bright: Option<String>,
// Provider badge colors
pub badge_app: Option<String>,
pub badge_bookmark: Option<String>,
pub badge_calc: Option<String>,
pub badge_clip: Option<String>,
pub badge_cmd: Option<String>,
pub badge_dmenu: Option<String>,
pub badge_emoji: Option<String>,
pub badge_file: Option<String>,
pub badge_script: Option<String>,
pub badge_ssh: Option<String>,
pub badge_sys: Option<String>,
pub badge_uuctl: Option<String>,
pub badge_web: Option<String>,
// Widget badge colors
pub badge_media: Option<String>,
pub badge_weather: Option<String>,
pub badge_pomo: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppearanceConfig {
#[serde(default = "default_width")]
pub width: i32,
#[serde(default = "default_height")]
pub height: i32,
#[serde(default = "default_font_size")]
pub font_size: u32,
#[serde(default = "default_border_radius")]
pub border_radius: u32,
/// Theme name: None = GTK default, "owl" = built-in owl theme
#[serde(default)]
pub theme: Option<String>,
/// Individual color overrides
#[serde(default)]
pub colors: ThemeColors,
}
impl Default for AppearanceConfig {
fn default() -> Self {
Self {
width: 850,
height: 650,
font_size: 14,
border_radius: 12,
theme: None,
colors: ThemeColors::default(),
}
}
}
fn default_width() -> i32 {
850
}
fn default_height() -> i32 {
650
}
fn default_font_size() -> u32 {
14
}
fn default_border_radius() -> u32 {
12
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvidersConfig {
#[serde(default = "default_true")]
pub applications: bool,
#[serde(default = "default_true")]
pub commands: bool,
#[serde(default = "default_true")]
pub uuctl: bool,
/// Enable calculator provider (= expression or calc expression)
#[serde(default = "default_true")]
pub calculator: bool,
/// Enable frecency-based result ranking
#[serde(default = "default_true")]
pub frecency: bool,
/// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost)
#[serde(default = "default_frecency_weight")]
pub frecency_weight: f64,
/// Enable web search provider (? query or web query)
#[serde(default = "default_true")]
pub websearch: bool,
/// Search engine for web search
/// Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
/// Or custom URL with {query} placeholder
#[serde(default = "default_search_engine")]
pub search_engine: String,
/// Enable system commands (shutdown, reboot, etc.)
#[serde(default = "default_true")]
pub system: bool,
/// Enable SSH connections from ~/.ssh/config
#[serde(default = "default_true")]
pub ssh: bool,
/// Enable clipboard history (requires cliphist)
#[serde(default = "default_true")]
pub clipboard: bool,
/// Enable browser bookmarks
#[serde(default = "default_true")]
pub bookmarks: bool,
/// Enable emoji picker
#[serde(default = "default_true")]
pub emoji: bool,
/// Enable custom scripts from ~/.config/owlry/scripts/
#[serde(default = "default_true")]
pub scripts: bool,
/// Enable file search (requires fd or locate)
#[serde(default = "default_true")]
pub files: bool,
// ─── Widget Providers ───────────────────────────────────────────────
/// Enable MPRIS media player widget
#[serde(default = "default_true")]
pub media: bool,
/// Enable weather widget
#[serde(default)]
pub weather: bool,
/// Weather provider: wttr.in (default), openweathermap, open-meteo
#[serde(default = "default_weather_provider")]
pub weather_provider: String,
/// API key for weather services that require it (e.g., OpenWeatherMap)
#[serde(default)]
pub weather_api_key: Option<String>,
/// Location for weather (city name or coordinates)
#[serde(default)]
pub weather_location: Option<String>,
/// Enable pomodoro timer widget
#[serde(default)]
pub pomodoro: bool,
/// Pomodoro work duration in minutes
#[serde(default = "default_pomodoro_work")]
pub pomodoro_work_mins: u32,
/// Pomodoro break duration in minutes
#[serde(default = "default_pomodoro_break")]
pub pomodoro_break_mins: u32,
}
impl Default for ProvidersConfig {
fn default() -> Self {
Self {
applications: true,
commands: true,
uuctl: true,
calculator: true,
frecency: true,
frecency_weight: 0.3,
websearch: true,
search_engine: "duckduckgo".to_string(),
system: true,
ssh: true,
clipboard: true,
bookmarks: true,
emoji: true,
scripts: true,
files: true,
media: true,
weather: false,
weather_provider: "wttr.in".to_string(),
weather_api_key: None,
weather_location: Some("Berlin".to_string()),
pomodoro: false,
pomodoro_work_mins: 25,
pomodoro_break_mins: 5,
}
}
}
/// Configuration for plugins
///
/// Supports per-plugin configuration via `[plugins.<name>]` sections:
/// ```toml
/// [plugins]
/// enabled = true
///
/// [plugins.weather]
/// location = "Berlin"
/// units = "metric"
///
/// [plugins.pomodoro]
/// work_mins = 25
/// break_mins = 5
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginsConfig {
/// Whether plugins are enabled globally
#[serde(default = "default_true")]
pub enabled: bool,
/// List of plugin IDs to enable (empty = all discovered plugins)
#[serde(default)]
pub enabled_plugins: Vec<String>,
/// List of plugin IDs to explicitly disable
#[serde(default)]
pub disabled_plugins: Vec<String>,
/// Sandbox settings for plugin execution
#[serde(default)]
pub sandbox: SandboxConfig,
/// Plugin registry URL (for `owlry plugin search` and registry installs)
/// Defaults to the official owlry plugin registry if not specified.
#[serde(default)]
pub registry_url: Option<String>,
/// Per-plugin configuration tables
/// Accessed via `[plugins.<plugin_name>]` sections in config.toml
/// Each plugin can define its own config schema
#[serde(flatten)]
pub plugin_configs: HashMap<String, toml::Value>,
}
/// Sandbox settings for plugin security
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxConfig {
/// Allow plugins to access the filesystem (beyond their own directory)
#[serde(default)]
pub allow_filesystem: bool,
/// Allow plugins to make network requests
#[serde(default)]
pub allow_network: bool,
/// Allow plugins to run shell commands
#[serde(default)]
pub allow_commands: bool,
/// Memory limit for Lua runtime in bytes (0 = unlimited)
#[serde(default = "default_memory_limit")]
pub memory_limit: usize,
}
impl Default for PluginsConfig {
fn default() -> Self {
Self {
enabled: true,
enabled_plugins: Vec::new(),
disabled_plugins: Vec::new(),
sandbox: SandboxConfig::default(),
registry_url: None,
plugin_configs: HashMap::new(),
}
}
}
impl PluginsConfig {
/// Get configuration for a specific plugin by name
///
/// Returns the plugin's config table if it exists in `[plugins.<name>]`
#[allow(dead_code)]
pub fn get_plugin_config(&self, plugin_name: &str) -> Option<&toml::Value> {
self.plugin_configs.get(plugin_name)
}
/// Get a string value from a plugin's config
#[allow(dead_code)]
pub fn get_plugin_string(&self, plugin_name: &str, key: &str) -> Option<&str> {
self.plugin_configs.get(plugin_name)?.get(key)?.as_str()
}
/// Get an integer value from a plugin's config
#[allow(dead_code)]
pub fn get_plugin_int(&self, plugin_name: &str, key: &str) -> Option<i64> {
self.plugin_configs.get(plugin_name)?.get(key)?.as_integer()
}
/// Get a boolean value from a plugin's config
#[allow(dead_code)]
pub fn get_plugin_bool(&self, plugin_name: &str, key: &str) -> Option<bool> {
self.plugin_configs.get(plugin_name)?.get(key)?.as_bool()
}
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
allow_filesystem: false,
allow_network: false,
allow_commands: false,
memory_limit: default_memory_limit(),
}
}
}
fn default_memory_limit() -> usize {
64 * 1024 * 1024 // 64 MB
}
fn default_search_engine() -> String {
"duckduckgo".to_string()
}
fn default_true() -> bool {
true
}
fn default_frecency_weight() -> f64 {
0.3
}
fn default_weather_provider() -> String {
"wttr.in".to_string()
}
fn default_pomodoro_work() -> u32 {
25
}
fn default_pomodoro_break() -> u32 {
5
}
/// Detect the best available terminal emulator
/// Fallback chain:
/// 1. $TERMINAL env var (user's explicit preference)
/// 2. xdg-terminal-exec (freedesktop standard - if available)
/// 3. Desktop-environment native terminal (GNOME→gnome-terminal, KDE→konsole, etc.)
/// 4. Common Wayland-native terminals (kitty, alacritty, wezterm, foot)
/// 5. Common X11/legacy terminals
/// 6. x-terminal-emulator (Debian alternatives)
/// 7. xterm (ultimate fallback - the cockroach of terminals)
fn detect_terminal() -> String {
// 1. Check $TERMINAL env var first (user's explicit preference)
if let Ok(term) = std::env::var("TERMINAL")
&& !term.is_empty()
&& command_exists(&term)
{
debug!("Using $TERMINAL: {}", term);
return term;
}
// 2. Try xdg-terminal-exec (freedesktop standard)
if command_exists("xdg-terminal-exec") {
debug!("Using xdg-terminal-exec");
return "xdg-terminal-exec".to_string();
}
// 3. Desktop-environment aware detection
if let Some(term) = detect_de_terminal() {
debug!("Using DE-native terminal: {}", term);
return term;
}
// 4. Common Wayland-native terminals (preferred for modern setups)
let wayland_terminals = ["kitty", "alacritty", "wezterm", "foot"];
for term in wayland_terminals {
if command_exists(term) {
debug!("Found Wayland terminal: {}", term);
return term.to_string();
}
}
// 5. Common X11/legacy terminals
let legacy_terminals = [
"gnome-terminal",
"konsole",
"xfce4-terminal",
"mate-terminal",
"tilix",
"terminator",
];
for term in legacy_terminals {
if command_exists(term) {
debug!("Found legacy terminal: {}", term);
return term.to_string();
}
}
// 6. Try x-terminal-emulator (Debian alternatives system)
if command_exists("x-terminal-emulator") {
debug!("Using x-terminal-emulator");
return "x-terminal-emulator".to_string();
}
// 7. Ultimate fallback - xterm exists everywhere
debug!("Falling back to xterm");
"xterm".to_string()
}
/// Detect desktop environment and return its native terminal
fn detect_de_terminal() -> Option<String> {
// Check XDG_CURRENT_DESKTOP first
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
.ok()
.map(|s| s.to_lowercase());
// Also check for Wayland compositor-specific env vars
let is_hyprland = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok();
let is_sway = std::env::var("SWAYSOCK").is_ok();
// Map desktop environments to their native/preferred terminals
let candidates: &[&str] = if is_hyprland {
// Hyprland: foot and kitty are most popular in the community
&["foot", "kitty", "alacritty", "wezterm"]
} else if is_sway {
// Sway: foot is the recommended terminal (lightweight, Wayland-native)
&["foot", "alacritty", "kitty", "wezterm"]
} else if let Some(ref de) = desktop {
match de.as_str() {
s if s.contains("gnome") => &["gnome-terminal", "gnome-console", "kgx"],
s if s.contains("kde") || s.contains("plasma") => &["konsole"],
s if s.contains("xfce") => &["xfce4-terminal"],
s if s.contains("mate") => &["mate-terminal"],
s if s.contains("lxqt") => &["qterminal"],
s if s.contains("lxde") => &["lxterminal"],
s if s.contains("cinnamon") => &["gnome-terminal"],
s if s.contains("budgie") => &["tilix", "gnome-terminal"],
s if s.contains("pantheon") => &["io.elementary.terminal", "pantheon-terminal"],
s if s.contains("deepin") => &["deepin-terminal"],
s if s.contains("hyprland") => &["foot", "kitty", "alacritty", "wezterm"],
s if s.contains("sway") => &["foot", "alacritty", "kitty", "wezterm"],
_ => return None,
}
} else {
return None;
};
for term in candidates {
if command_exists(term) {
return Some(term.to_string());
}
}
None
}
/// Check if a command exists in PATH
fn command_exists(cmd: &str) -> bool {
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
// Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default
impl Config {
pub fn config_path() -> Option<PathBuf> {
paths::config_file()
}
pub fn load_or_default() -> Self {
Self::load().unwrap_or_else(|e| {
warn!("Failed to load config: {}, using defaults", e);
Self::default()
})
}
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?;
let mut config = if !path.exists() {
info!("Config file not found, using defaults");
Self::default()
} else {
let content = std::fs::read_to_string(&path)?;
let config: Config = toml::from_str(&content)?;
info!("Loaded config from {:?}", path);
config
};
// Auto-detect terminal if not configured or configured terminal doesn't exist
match &config.general.terminal_command {
None => {
let terminal = detect_terminal();
info!("Detected terminal: {}", terminal);
config.general.terminal_command = Some(terminal);
}
Some(term) if !command_exists(term) => {
warn!("Configured terminal '{}' not found, auto-detecting", term);
let terminal = detect_terminal();
info!("Using detected terminal: {}", terminal);
config.general.terminal_command = Some(terminal);
}
Some(term) => {
debug!("Using configured terminal: {}", term);
}
}
Ok(config)
}
#[allow(dead_code)]
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?;
paths::ensure_parent_dir(&path)?;
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)?;
info!("Saved config to {:?}", path);
Ok(())
}
}

View File

@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::paths;
/// A single frecency entry tracking launch count and recency
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrecencyEntry {
@@ -56,10 +58,7 @@ impl FrecencyStore {
/// Get the path to the frecency data file
fn data_path() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("owlry")
.join("frecency.json")
paths::frecency_file().unwrap_or_else(|| PathBuf::from("frecency.json"))
}
/// Load frecency data from a file
@@ -85,10 +84,7 @@ impl FrecencyStore {
return Ok(());
}
// Ensure directory exists
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
paths::ensure_parent_dir(&self.path)?;
let content = serde_json::to_string_pretty(&self.data)?;
std::fs::write(&self.path, content)?;

View File

@@ -0,0 +1,661 @@
use std::collections::HashSet;
#[cfg(feature = "dev-logging")]
use log::debug;
use crate::config::ProvidersConfig;
use crate::providers::ProviderType;
/// Tracks which providers are enabled and handles prefix-based filtering
#[derive(Debug, Clone)]
pub struct ProviderFilter {
enabled: HashSet<ProviderType>,
active_prefix: Option<ProviderType>,
/// When true, `is_active`/`is_enabled` accept any provider type
/// (unless a prefix narrows the scope). Used by `all()` so that
/// dynamically loaded plugins are accepted without being listed.
accept_all: bool,
}
/// Result of parsing a query for prefix syntax
#[derive(Debug, Clone)]
pub struct ParsedQuery {
pub prefix: Option<ProviderType>,
pub tag_filter: Option<String>,
pub query: String,
}
impl ProviderFilter {
/// Create filter from CLI args and config
pub fn new(
cli_mode: Option<ProviderType>,
cli_providers: Option<Vec<ProviderType>>,
config_providers: &ProvidersConfig,
) -> Self {
let accept_all = cli_mode.is_none() && cli_providers.is_none();
let enabled = if let Some(mode) = cli_mode {
// --mode overrides everything: single provider
HashSet::from([mode])
} else if let Some(providers) = cli_providers {
// --providers overrides config
providers.into_iter().collect()
} else {
// Use config file settings, default to apps only
let mut set = HashSet::new();
// Core providers
if config_providers.applications {
set.insert(ProviderType::Application);
}
if config_providers.commands {
set.insert(ProviderType::Command);
}
// Plugin providers - use Plugin(type_id) for all
if config_providers.uuctl {
set.insert(ProviderType::Plugin("uuctl".to_string()));
}
if config_providers.system {
set.insert(ProviderType::Plugin("system".to_string()));
}
if config_providers.ssh {
set.insert(ProviderType::Plugin("ssh".to_string()));
}
if config_providers.clipboard {
set.insert(ProviderType::Plugin("clipboard".to_string()));
}
if config_providers.bookmarks {
set.insert(ProviderType::Plugin("bookmarks".to_string()));
}
if config_providers.emoji {
set.insert(ProviderType::Plugin("emoji".to_string()));
}
if config_providers.scripts {
set.insert(ProviderType::Plugin("scripts".to_string()));
}
// Dynamic providers
if config_providers.files {
set.insert(ProviderType::Plugin("filesearch".to_string()));
}
if config_providers.calculator {
set.insert(ProviderType::Plugin("calc".to_string()));
}
if config_providers.websearch {
set.insert(ProviderType::Plugin("websearch".to_string()));
}
// Default to apps if nothing enabled
if set.is_empty() {
set.insert(ProviderType::Application);
}
set
};
let filter = Self {
enabled,
active_prefix: None,
accept_all,
};
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] Created with enabled providers: {:?}",
filter.enabled
);
filter
}
/// Default filter: apps only
#[allow(dead_code)]
pub fn apps_only() -> Self {
Self {
enabled: HashSet::from([ProviderType::Application]),
active_prefix: None,
accept_all: false,
}
}
/// Toggle a provider on/off
pub fn toggle(&mut self, provider: ProviderType) {
if self.enabled.contains(&provider) {
self.enabled.remove(&provider);
// Ensure at least one provider is always enabled
if self.enabled.is_empty() {
self.enabled.insert(ProviderType::Application);
}
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] Toggled OFF {:?}, enabled: {:?}",
provider, self.enabled
);
} else {
#[cfg(feature = "dev-logging")]
let provider_debug = format!("{:?}", provider);
self.enabled.insert(provider);
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] Toggled ON {}, enabled: {:?}",
provider_debug, self.enabled
);
}
}
/// Enable a specific provider
pub fn enable(&mut self, provider: ProviderType) {
self.enabled.insert(provider);
}
/// Disable a specific provider (ensures at least one remains)
pub fn disable(&mut self, provider: ProviderType) {
self.enabled.remove(&provider);
if self.enabled.is_empty() {
self.enabled.insert(ProviderType::Application);
}
}
/// Set to single provider mode
pub fn set_single_mode(&mut self, provider: ProviderType) {
self.enabled.clear();
self.enabled.insert(provider);
}
/// Set prefix mode (from :app, :cmd, etc.)
pub fn set_prefix(&mut self, prefix: Option<ProviderType>) {
#[cfg(feature = "dev-logging")]
if self.active_prefix != prefix {
debug!(
"[Filter] Prefix changed: {:?} -> {:?}",
self.active_prefix, prefix
);
}
self.active_prefix = prefix;
}
/// Check if a provider should be searched
pub fn is_active(&self, provider: ProviderType) -> bool {
if let Some(ref prefix) = self.active_prefix {
&provider == prefix
} else if self.accept_all {
true
} else {
self.enabled.contains(&provider)
}
}
/// Check if provider is in enabled set (ignoring prefix)
pub fn is_enabled(&self, provider: ProviderType) -> bool {
self.accept_all || self.enabled.contains(&provider)
}
/// Whether this filter accepts all provider types
pub fn is_accept_all(&self) -> bool {
self.accept_all
}
/// Get current active prefix if any
#[allow(dead_code)]
pub fn active_prefix(&self) -> Option<ProviderType> {
self.active_prefix.clone()
}
/// Parse query for prefix syntax
/// Prefixes map to Plugin(type_id) for plugin providers
pub fn parse_query(query: &str) -> ParsedQuery {
let trimmed = query.trim_start();
// Check for tag filter pattern: ":tag:XXX query" or ":tag:XXX"
if let Some(rest) = trimmed.strip_prefix(":tag:") {
// Find the end of the tag (space or end of string)
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 {
// Just the tag, no query yet
let tag = rest.to_lowercase();
return ParsedQuery {
prefix: None,
tag_filter: Some(tag),
query: String::new(),
};
}
}
// Core provider prefixes
let core_prefixes: &[(&str, ProviderType)] = &[
(":app ", ProviderType::Application),
(":apps ", ProviderType::Application),
(":cmd ", ProviderType::Command),
(":command ", ProviderType::Command),
];
// Plugin provider prefixes - mapped to Plugin(type_id)
let 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"),
];
// Check core prefixes
for (prefix_str, provider) in core_prefixes {
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
query, provider, rest
);
return ParsedQuery {
prefix: Some(provider.clone()),
tag_filter: None,
query: rest.to_string(),
};
}
}
// 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")]
debug!(
"[Filter] parse_query({:?}) -> partial prefix {:?}",
query, provider
);
return ParsedQuery {
prefix: Some(provider.clone()),
tag_filter: None,
query: String::new(),
};
}
}
for (prefix_str, type_id) in partial_plugin {
if trimmed == *prefix_str {
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: ":word " or ":word" where word is unknown
// Maps to Plugin(word) so user plugins with custom prefixes work
if let Some(rest) = trimmed.strip_prefix(':') {
if let Some(space_idx) = rest.find(' ') {
let prefix_word = &rest[..space_idx];
if !prefix_word.is_empty() && prefix_word.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return ParsedQuery {
prefix: Some(ProviderType::Plugin(prefix_word.to_string())),
tag_filter: None,
query: rest[space_idx + 1..].to_string(),
};
}
} else if !rest.is_empty() && rest.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
// Partial prefix (no space yet)
return ParsedQuery {
prefix: Some(ProviderType::Plugin(rest.to_string())),
tag_filter: None,
query: String::new(),
};
}
}
let result = ParsedQuery {
prefix: None,
tag_filter: None,
query: query.to_string(),
};
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}",
query, result.prefix, result.tag_filter, result.query
);
result
}
/// Get enabled providers for UI display (sorted)
pub fn enabled_providers(&self) -> Vec<ProviderType> {
let mut providers: Vec<_> = self.enabled.iter().cloned().collect();
providers.sort_by_key(|p| match p {
ProviderType::Application => 0,
ProviderType::Command => 1,
ProviderType::Dmenu => 2,
ProviderType::Plugin(_) => 100, // Plugin providers sort after core
});
providers
}
/// Create a filter from a list of mode name strings.
///
/// Maps each string to a ProviderType: "app" -> Application, "cmd" -> Command,
/// "dmenu" -> Dmenu, anything else -> Plugin(id). An empty list produces an
/// all-providers filter.
pub fn from_mode_strings(modes: &[String]) -> Self {
if modes.is_empty() {
return Self::all();
}
let enabled: HashSet<ProviderType> = modes
.iter()
.map(|s| Self::mode_string_to_provider_type(s))
.collect();
Self {
enabled,
active_prefix: None,
accept_all: false,
}
}
/// Create a filter that accepts all providers, including any
/// dynamically loaded plugin.
///
/// Sets `accept_all` so that `is_active`/`is_enabled` return true for
/// every `ProviderType` without maintaining a static list of plugin IDs.
/// Core types are still placed in `enabled` for UI purposes (tab display).
///
/// The daemon uses this as the default when no modes are specified.
pub fn all() -> Self {
let mut enabled = HashSet::new();
enabled.insert(ProviderType::Application);
enabled.insert(ProviderType::Command);
enabled.insert(ProviderType::Dmenu);
Self {
enabled,
active_prefix: None,
accept_all: true,
}
}
/// Map a mode string to a ProviderType.
///
/// Delegates to the existing `FromStr` impl on `ProviderType` which maps
/// "app"/"apps"/"application" -> Application, "cmd"/"command" -> Command,
/// "dmenu" -> Dmenu, and everything else -> Plugin(id).
pub fn mode_string_to_provider_type(mode: &str) -> ProviderType {
mode.parse::<ProviderType>()
.unwrap_or_else(|_| ProviderType::Plugin(mode.to_string()))
}
/// Get display name for current mode
pub fn mode_display_name(&self) -> &'static str {
if let Some(ref prefix) = self.active_prefix {
return match prefix {
ProviderType::Application => "Apps",
ProviderType::Command => "Commands",
ProviderType::Dmenu => "dmenu",
ProviderType::Plugin(_) => "Plugin",
};
}
let enabled: Vec<_> = self.enabled_providers();
if enabled.len() == 1 {
match &enabled[0] {
ProviderType::Application => "Apps",
ProviderType::Command => "Commands",
ProviderType::Dmenu => "dmenu",
ProviderType::Plugin(_) => "Plugin",
}
} else {
"All"
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_query_with_prefix() {
let result = ProviderFilter::parse_query(":app firefox");
assert_eq!(result.prefix, Some(ProviderType::Application));
assert_eq!(result.query, "firefox");
}
#[test]
fn test_parse_query_without_prefix() {
let result = ProviderFilter::parse_query("firefox");
assert_eq!(result.prefix, None);
assert_eq!(result.query, "firefox");
}
#[test]
fn test_parse_query_partial_prefix() {
let result = ProviderFilter::parse_query(":cmd");
assert_eq!(result.prefix, Some(ProviderType::Command));
assert_eq!(result.query, "");
}
#[test]
fn test_parse_query_plugin_prefix() {
let result = ProviderFilter::parse_query(":calc 5+3");
assert_eq!(
result.prefix,
Some(ProviderType::Plugin("calc".to_string()))
);
assert_eq!(result.query, "5+3");
}
#[test]
fn test_toggle_ensures_one_enabled() {
let mut filter = ProviderFilter::apps_only();
filter.toggle(ProviderType::Application);
// Should still have apps enabled as fallback
assert!(filter.is_enabled(ProviderType::Application));
}
#[test]
fn test_from_mode_strings_single_core() {
let filter = ProviderFilter::from_mode_strings(&["app".to_string()]);
assert!(filter.is_enabled(ProviderType::Application));
assert!(!filter.is_enabled(ProviderType::Command));
}
#[test]
fn test_from_mode_strings_multiple() {
let filter = ProviderFilter::from_mode_strings(&[
"app".to_string(),
"cmd".to_string(),
"calc".to_string(),
]);
assert!(filter.is_enabled(ProviderType::Application));
assert!(filter.is_enabled(ProviderType::Command));
assert!(filter.is_enabled(ProviderType::Plugin("calc".to_string())));
assert!(!filter.is_enabled(ProviderType::Dmenu));
}
#[test]
fn test_from_mode_strings_empty_returns_all() {
let filter = ProviderFilter::from_mode_strings(&[]);
assert!(filter.is_enabled(ProviderType::Application));
assert!(filter.is_enabled(ProviderType::Command));
assert!(filter.is_enabled(ProviderType::Dmenu));
}
#[test]
fn test_from_mode_strings_plugin() {
let filter = ProviderFilter::from_mode_strings(&["emoji".to_string()]);
assert!(filter.is_enabled(ProviderType::Plugin("emoji".to_string())));
assert!(!filter.is_enabled(ProviderType::Application));
}
#[test]
fn test_from_mode_strings_dmenu() {
let filter = ProviderFilter::from_mode_strings(&["dmenu".to_string()]);
assert!(filter.is_enabled(ProviderType::Dmenu));
assert!(!filter.is_enabled(ProviderType::Application));
}
#[test]
fn test_all_includes_core_types() {
let filter = ProviderFilter::all();
assert!(filter.is_enabled(ProviderType::Application));
assert!(filter.is_enabled(ProviderType::Command));
assert!(filter.is_enabled(ProviderType::Dmenu));
}
#[test]
fn test_all_accepts_any_plugin() {
let filter = ProviderFilter::all();
// Known plugins
assert!(filter.is_enabled(ProviderType::Plugin("calc".to_string())));
assert!(filter.is_enabled(ProviderType::Plugin("clipboard".to_string())));
// Arbitrary unknown plugins must also be accepted
assert!(filter.is_enabled(ProviderType::Plugin("some-future-plugin".to_string())));
assert!(filter.is_enabled(ProviderType::Plugin("custom-user-plugin".to_string())));
}
#[test]
fn test_all_is_active_for_any_plugin() {
let filter = ProviderFilter::all();
assert!(filter.is_active(ProviderType::Application));
assert!(filter.is_active(ProviderType::Plugin("unknown-plugin".to_string())));
}
#[test]
fn test_all_with_prefix_narrows_scope() {
let mut filter = ProviderFilter::all();
filter.set_prefix(Some(ProviderType::Application));
// Prefix narrows: only Application passes
assert!(filter.is_active(ProviderType::Application));
assert!(!filter.is_active(ProviderType::Command));
assert!(!filter.is_active(ProviderType::Plugin("calc".to_string())));
}
#[test]
fn test_explicit_mode_filter_rejects_unknown_plugins() {
let filter = ProviderFilter::from_mode_strings(&["app".to_string(), "cmd".to_string()]);
assert!(filter.is_active(ProviderType::Application));
assert!(filter.is_active(ProviderType::Command));
// Plugins not in the explicit list must be rejected
assert!(!filter.is_active(ProviderType::Plugin("calc".to_string())));
assert!(!filter.is_active(ProviderType::Plugin("unknown".to_string())));
}
#[test]
fn test_mode_string_to_provider_type_core() {
assert_eq!(
ProviderFilter::mode_string_to_provider_type("app"),
ProviderType::Application
);
assert_eq!(
ProviderFilter::mode_string_to_provider_type("cmd"),
ProviderType::Command
);
assert_eq!(
ProviderFilter::mode_string_to_provider_type("dmenu"),
ProviderType::Dmenu
);
}
#[test]
fn test_mode_string_to_provider_type_plugin() {
assert_eq!(
ProviderFilter::mode_string_to_provider_type("calc"),
ProviderType::Plugin("calc".to_string())
);
assert_eq!(
ProviderFilter::mode_string_to_provider_type("websearch"),
ProviderType::Plugin("websearch".to_string())
);
}
#[test]
fn test_mode_string_to_provider_type_aliases() {
assert_eq!(
ProviderFilter::mode_string_to_provider_type("apps"),
ProviderType::Application
);
assert_eq!(
ProviderFilter::mode_string_to_provider_type("application"),
ProviderType::Application
);
assert_eq!(
ProviderFilter::mode_string_to_provider_type("command"),
ProviderType::Command
);
}
}

View File

@@ -0,0 +1,63 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Request {
Query {
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
modes: Option<Vec<String>>,
},
Launch {
item_id: String,
provider: String,
},
Providers,
Refresh {
provider: String,
},
Toggle,
Submenu {
plugin_id: String,
data: String,
},
PluginAction {
command: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Response {
Results { items: Vec<ResultItem> },
Providers { list: Vec<ProviderDesc> },
SubmenuItems { items: Vec<ResultItem> },
Ack,
Error { message: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResultItem {
pub id: String,
pub title: String,
pub description: String,
pub icon: String,
pub provider: String,
pub score: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default)]
pub terminal: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProviderDesc {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
pub icon: String,
pub position: String,
}

View File

@@ -0,0 +1,9 @@
pub mod config;
pub mod data;
pub mod filter;
pub mod ipc;
pub mod notify;
pub mod paths;
pub mod plugins;
pub mod providers;
pub mod server;

View File

@@ -0,0 +1,39 @@
use log::{info, warn};
use owlry_core::paths;
use owlry_core::server::Server;
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let sock = paths::socket_path();
info!("Starting owlry-core daemon...");
// Ensure the socket parent directory exists
if let Err(e) = paths::ensure_parent_dir(&sock) {
eprintln!("Failed to create socket directory: {e}");
std::process::exit(1);
}
let server = match Server::bind(&sock) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to start owlry-core: {e}");
std::process::exit(1);
}
};
// Graceful shutdown on SIGTERM/SIGINT
let sock_cleanup = sock.clone();
if let Err(e) = ctrlc::set_handler(move || {
let _ = std::fs::remove_file(&sock_cleanup);
std::process::exit(0);
}) {
warn!("Failed to set signal handler: {}", e);
}
if let Err(e) = server.run() {
eprintln!("Server error: {e}");
std::process::exit(1);
}
}

View File

@@ -0,0 +1,91 @@
//! Desktop notification system
//!
//! Provides system notifications for owlry and its plugins.
//! Uses the freedesktop notification specification via notify-rust.
//!
//! Note: Some convenience functions are provided for future use and
//! are currently unused by the core (plugins use the Host API instead).
#![allow(dead_code)]
use notify_rust::{Notification, Urgency};
/// Notification urgency level
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NotifyUrgency {
/// Low priority notification
Low,
/// Normal priority notification (default)
#[default]
Normal,
/// Critical/urgent notification
Critical,
}
impl From<NotifyUrgency> for Urgency {
fn from(urgency: NotifyUrgency) -> Self {
match urgency {
NotifyUrgency::Low => Urgency::Low,
NotifyUrgency::Normal => Urgency::Normal,
NotifyUrgency::Critical => Urgency::Critical,
}
}
}
/// Send a simple notification
pub fn notify(summary: &str, body: &str) {
notify_with_options(summary, body, None, NotifyUrgency::Normal);
}
/// Send a notification with an icon
pub fn notify_with_icon(summary: &str, body: &str, icon: &str) {
notify_with_options(summary, body, Some(icon), NotifyUrgency::Normal);
}
/// Send a notification with full options
pub fn notify_with_options(summary: &str, body: &str, icon: Option<&str>, urgency: NotifyUrgency) {
let mut notification = Notification::new();
notification
.appname("Owlry")
.summary(summary)
.body(body)
.urgency(urgency.into());
if let Some(icon_name) = icon {
notification.icon(icon_name);
}
if let Err(e) = notification.show() {
log::warn!("Failed to show notification: {}", e);
}
}
/// Send a notification with a timeout
pub fn notify_with_timeout(summary: &str, body: &str, icon: Option<&str>, timeout_ms: i32) {
let mut notification = Notification::new();
notification
.appname("Owlry")
.summary(summary)
.body(body)
.timeout(timeout_ms);
if let Some(icon_name) = icon {
notification.icon(icon_name);
}
if let Err(e) = notification.show() {
log::warn!("Failed to show notification: {}", e);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_urgency_conversion() {
assert_eq!(Urgency::from(NotifyUrgency::Low), Urgency::Low);
assert_eq!(Urgency::from(NotifyUrgency::Normal), Urgency::Normal);
assert_eq!(Urgency::from(NotifyUrgency::Critical), Urgency::Critical);
}
}

View File

@@ -0,0 +1,217 @@
//! Centralized path handling following XDG Base Directory Specification.
//!
//! XDG directories used:
//! - `$XDG_CONFIG_HOME/owlry/` - User configuration (config.toml, themes/, style.css)
//! - `$XDG_DATA_HOME/owlry/` - User data (scripts/, frecency.json)
//! - `$XDG_CACHE_HOME/owlry/` - Cache files (future use)
//!
//! See: https://specifications.freedesktop.org/basedir-spec/latest/
use std::path::PathBuf;
/// Application name used in XDG paths
const APP_NAME: &str = "owlry";
// =============================================================================
// XDG Base Directories
// =============================================================================
/// Get XDG config home: `$XDG_CONFIG_HOME` or `~/.config`
pub fn config_home() -> Option<PathBuf> {
dirs::config_dir()
}
/// Get XDG data home: `$XDG_DATA_HOME` or `~/.local/share`
pub fn data_home() -> Option<PathBuf> {
dirs::data_dir()
}
/// Get XDG cache home: `$XDG_CACHE_HOME` or `~/.cache`
#[allow(dead_code)]
pub fn cache_home() -> Option<PathBuf> {
dirs::cache_dir()
}
// =============================================================================
// Owlry-specific directories
// =============================================================================
/// Owlry config directory: `$XDG_CONFIG_HOME/owlry/`
pub fn owlry_config_dir() -> Option<PathBuf> {
config_home().map(|p| p.join(APP_NAME))
}
/// Owlry data directory: `$XDG_DATA_HOME/owlry/`
pub fn owlry_data_dir() -> Option<PathBuf> {
data_home().map(|p| p.join(APP_NAME))
}
/// Owlry cache directory: `$XDG_CACHE_HOME/owlry/`
#[allow(dead_code)]
pub fn owlry_cache_dir() -> Option<PathBuf> {
cache_home().map(|p| p.join(APP_NAME))
}
// =============================================================================
// Config files
// =============================================================================
/// Main config file: `$XDG_CONFIG_HOME/owlry/config.toml`
pub fn config_file() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("config.toml"))
}
/// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css`
pub fn custom_style_file() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("style.css"))
}
/// User themes directory: `$XDG_CONFIG_HOME/owlry/themes/`
pub fn themes_dir() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("themes"))
}
/// Get path for a specific theme: `$XDG_CONFIG_HOME/owlry/themes/{name}.css`
pub fn theme_file(name: &str) -> Option<PathBuf> {
themes_dir().map(|p| p.join(format!("{}.css", name)))
}
// =============================================================================
// Data files
// =============================================================================
/// User plugins directory: `$XDG_CONFIG_HOME/owlry/plugins/`
///
/// Plugins are stored in config because they contain user-installed code
/// that the user explicitly chose to add (similar to themes).
pub fn plugins_dir() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("plugins"))
}
/// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json`
pub fn frecency_file() -> Option<PathBuf> {
owlry_data_dir().map(|p| p.join("frecency.json"))
}
// =============================================================================
// System directories
// =============================================================================
/// System data directories for applications (XDG_DATA_DIRS)
///
/// Follows the XDG Base Directory Specification:
/// - $XDG_DATA_HOME/applications (defaults to ~/.local/share/applications)
/// - $XDG_DATA_DIRS/*/applications (defaults to /usr/local/share:/usr/share)
/// - Additional Flatpak and Snap directories
pub fn system_data_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
let mut seen = std::collections::HashSet::new();
// Helper to add unique directories
let mut add_dir = |path: PathBuf| {
if seen.insert(path.clone()) {
dirs.push(path);
}
};
// 1. User data directory first (highest priority)
if let Some(data) = data_home() {
add_dir(data.join("applications"));
}
// 2. XDG_DATA_DIRS - parse the environment variable
// Default per spec: /usr/local/share:/usr/share
let xdg_data_dirs = std::env::var("XDG_DATA_DIRS")
.unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
for dir in xdg_data_dirs.split(':') {
if !dir.is_empty() {
add_dir(PathBuf::from(dir).join("applications"));
}
}
// 3. Always include standard system directories as fallback
// Some environments set XDG_DATA_DIRS without including these
add_dir(PathBuf::from("/usr/share/applications"));
add_dir(PathBuf::from("/usr/local/share/applications"));
// 4. Flatpak directories (user and system)
if let Some(data) = data_home() {
add_dir(data.join("flatpak/exports/share/applications"));
}
add_dir(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
// 5. Snap directories
add_dir(PathBuf::from("/var/lib/snapd/desktop/applications"));
// 6. Nix directories (common on NixOS)
if let Some(home) = dirs::home_dir() {
add_dir(home.join(".nix-profile/share/applications"));
}
add_dir(PathBuf::from("/run/current-system/sw/share/applications"));
dirs
}
// =============================================================================
// Runtime files
// =============================================================================
/// IPC socket path: `$XDG_RUNTIME_DIR/owlry/owlry.sock`
///
/// Falls back to `/tmp` if `$XDG_RUNTIME_DIR` is not set.
pub fn socket_path() -> PathBuf {
let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"));
runtime_dir.join(APP_NAME).join("owlry.sock")
}
// =============================================================================
// Helper functions
// =============================================================================
/// Ensure parent directory of a file exists
pub fn ensure_parent_dir(path: &std::path::Path) -> std::io::Result<()> {
if let Some(parent) = path.parent()
&& !parent.exists()
{
std::fs::create_dir_all(parent)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_paths_are_consistent() {
// All owlry paths should be under XDG directories
if let (Some(config), Some(data)) = (owlry_config_dir(), owlry_data_dir()) {
assert!(config.ends_with("owlry"));
assert!(data.ends_with("owlry"));
}
}
#[test]
fn test_config_file_path() {
if let Some(path) = config_file() {
assert!(path.ends_with("config.toml"));
assert!(path.to_string_lossy().contains("owlry"));
}
}
#[test]
fn test_frecency_in_data_dir() {
if let Some(path) = frecency_file() {
assert!(path.ends_with("frecency.json"));
// Should be in data dir, not config dir
let path_str = path.to_string_lossy();
assert!(
path_str.contains(".local/share") || path_str.contains("XDG_DATA_HOME"),
"frecency should be in data directory"
);
}
}
}

View File

@@ -0,0 +1,330 @@
//! Action API for Lua plugins
//!
//! Allows plugins to register custom actions for result items:
//! - `owlry.action.register(config)` - Register a custom action
use mlua::{Function, Lua, Result as LuaResult, Table, Value};
/// Action registration data
#[derive(Debug, Clone)]
#[allow(dead_code)] // Used by UI integration
pub struct ActionRegistration {
/// Unique action ID
pub id: String,
/// Human-readable name shown in UI
pub display_name: String,
/// Icon name (optional)
pub icon: Option<String>,
/// Keyboard shortcut hint (optional, e.g., "Ctrl+C")
pub shortcut: Option<String>,
/// Plugin that registered this action
pub plugin_id: String,
}
/// Register action APIs
pub fn register_action_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> {
let action_table = lua.create_table()?;
let plugin_id_owned = plugin_id.to_string();
// Initialize action storage in Lua registry
if lua.named_registry_value::<Value>("actions")?.is_nil() {
let actions: Table = lua.create_table()?;
lua.set_named_registry_value("actions", actions)?;
}
// owlry.action.register(config) -> string (action_id)
// config = {
// id = "copy-url",
// name = "Copy URL",
// icon = "edit-copy", -- optional
// shortcut = "Ctrl+C", -- optional
// filter = function(item) return item.provider == "bookmarks" end, -- optional
// handler = function(item) ... end
// }
let plugin_id_for_register = plugin_id_owned.clone();
action_table.set(
"register",
lua.create_function(move |lua, config: Table| {
// Extract required fields
let id: String = config
.get("id")
.map_err(|_| mlua::Error::external("action.register: 'id' is required"))?;
let name: String = config
.get("name")
.map_err(|_| mlua::Error::external("action.register: 'name' is required"))?;
let _handler: Function = config.get("handler").map_err(|_| {
mlua::Error::external("action.register: 'handler' function is required")
})?;
// Extract optional fields
let icon: Option<String> = config.get("icon").ok();
let shortcut: Option<String> = config.get("shortcut").ok();
// Store action in registry
let actions: Table = lua.named_registry_value("actions")?;
// Create full action ID with plugin prefix
let full_id = format!("{}:{}", plugin_id_for_register, id);
// Store config with full ID
let action_entry = lua.create_table()?;
action_entry.set("id", full_id.clone())?;
action_entry.set("name", name.clone())?;
action_entry.set("plugin_id", plugin_id_for_register.clone())?;
if let Some(ref i) = icon {
action_entry.set("icon", i.clone())?;
}
if let Some(ref s) = shortcut {
action_entry.set("shortcut", s.clone())?;
}
// Store filter and handler functions
if let Ok(filter) = config.get::<Function>("filter") {
action_entry.set("filter", filter)?;
}
action_entry.set("handler", config.get::<Function>("handler")?)?;
actions.set(full_id.clone(), action_entry)?;
log::info!(
"[plugin:{}] Registered action '{}' ({})",
plugin_id_for_register,
name,
full_id
);
Ok(full_id)
})?,
)?;
// owlry.action.unregister(id) -> boolean
let plugin_id_for_unregister = plugin_id_owned.clone();
action_table.set(
"unregister",
lua.create_function(move |lua, id: String| {
let actions: Table = lua.named_registry_value("actions")?;
let full_id = format!("{}:{}", plugin_id_for_unregister, id);
if actions.contains_key(full_id.clone())? {
actions.set(full_id, Value::Nil)?;
Ok(true)
} else {
Ok(false)
}
})?,
)?;
owlry.set("action", action_table)?;
Ok(())
}
/// Get all registered actions from a Lua runtime
#[allow(dead_code)] // Will be used by UI
pub fn get_actions(lua: &Lua) -> LuaResult<Vec<ActionRegistration>> {
let actions: Table = match lua.named_registry_value("actions") {
Ok(a) => a,
Err(_) => return Ok(Vec::new()),
};
let mut result = Vec::new();
for pair in actions.pairs::<String, Table>() {
let (_, entry) = pair?;
let id: String = entry.get("id")?;
let display_name: String = entry.get("name")?;
let plugin_id: String = entry.get("plugin_id")?;
let icon: Option<String> = entry.get("icon").ok();
let shortcut: Option<String> = entry.get("shortcut").ok();
result.push(ActionRegistration {
id,
display_name,
icon,
shortcut,
plugin_id,
});
}
Ok(result)
}
/// Get actions that apply to a specific item
#[allow(dead_code)] // Will be used by UI context menu
pub fn get_actions_for_item(lua: &Lua, item: &Table) -> LuaResult<Vec<ActionRegistration>> {
let actions: Table = match lua.named_registry_value("actions") {
Ok(a) => a,
Err(_) => return Ok(Vec::new()),
};
let mut result = Vec::new();
for pair in actions.pairs::<String, Table>() {
let (_, entry) = pair?;
// Check filter if present
if let Ok(filter) = entry.get::<Function>("filter") {
match filter.call::<bool>(item.clone()) {
Ok(true) => {} // Include this action
Ok(false) => continue, // Skip this action
Err(e) => {
log::warn!("Action filter failed: {}", e);
continue;
}
}
}
let id: String = entry.get("id")?;
let display_name: String = entry.get("name")?;
let plugin_id: String = entry.get("plugin_id")?;
let icon: Option<String> = entry.get("icon").ok();
let shortcut: Option<String> = entry.get("shortcut").ok();
result.push(ActionRegistration {
id,
display_name,
icon,
shortcut,
plugin_id,
});
}
Ok(result)
}
/// Execute an action by ID
#[allow(dead_code)] // Will be used by UI
pub fn execute_action(lua: &Lua, action_id: &str, item: &Table) -> LuaResult<()> {
let actions: Table = lua.named_registry_value("actions")?;
let action: Table = actions.get(action_id)?;
let handler: Function = action.get("handler")?;
handler.call::<()>(item.clone())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_lua(plugin_id: &str) -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_action_api(&lua, &owlry, plugin_id).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_action_registration() {
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
return owlry.action.register({
id = "copy-name",
name = "Copy Name",
icon = "edit-copy",
handler = function(item)
-- copy logic here
end
})
"#,
);
let action_id: String = chunk.call(()).unwrap();
assert_eq!(action_id, "test-plugin:copy-name");
// Verify action is registered
let actions = get_actions(&lua).unwrap();
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].display_name, "Copy Name");
}
#[test]
fn test_action_with_filter() {
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
owlry.action.register({
id = "bookmark-action",
name = "Open in Browser",
filter = function(item)
return item.provider == "bookmarks"
end,
handler = function(item) end
})
"#,
);
chunk.call::<()>(()).unwrap();
// Create bookmark item
let bookmark_item = lua.create_table().unwrap();
bookmark_item.set("provider", "bookmarks").unwrap();
bookmark_item.set("name", "Test Bookmark").unwrap();
let actions = get_actions_for_item(&lua, &bookmark_item).unwrap();
assert_eq!(actions.len(), 1);
// Create non-bookmark item
let app_item = lua.create_table().unwrap();
app_item.set("provider", "applications").unwrap();
app_item.set("name", "Test App").unwrap();
let actions2 = get_actions_for_item(&lua, &app_item).unwrap();
assert_eq!(actions2.len(), 0); // Filtered out
}
#[test]
fn test_action_unregister() {
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
owlry.action.register({
id = "temp-action",
name = "Temporary",
handler = function(item) end
})
return owlry.action.unregister("temp-action")
"#,
);
let unregistered: bool = chunk.call(()).unwrap();
assert!(unregistered);
let actions = get_actions(&lua).unwrap();
assert_eq!(actions.len(), 0);
}
#[test]
fn test_execute_action() {
let lua = setup_lua("test-plugin");
// Register action that sets a global
let chunk = lua.load(
r#"
result = nil
owlry.action.register({
id = "test-exec",
name = "Test Execute",
handler = function(item)
result = item.name
end
})
"#,
);
chunk.call::<()>(()).unwrap();
// Create test item
let item = lua.create_table().unwrap();
item.set("name", "TestItem").unwrap();
// Execute action
execute_action(&lua, "test-plugin:test-exec", &item).unwrap();
// Verify handler was called
let result: String = lua.globals().get("result").unwrap();
assert_eq!(result, "TestItem");
}
}

View File

@@ -0,0 +1,307 @@
//! Cache API for Lua plugins
//!
//! Provides in-memory caching with optional TTL:
//! - `owlry.cache.get(key)` - Get cached value
//! - `owlry.cache.set(key, value, ttl_seconds?)` - Set cached value
//! - `owlry.cache.delete(key)` - Delete cached value
//! - `owlry.cache.clear()` - Clear all cached values
use mlua::{Lua, Result as LuaResult, Table, Value};
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
/// Cached entry with optional expiration
struct CacheEntry {
value: String, // Store as JSON string for simplicity
expires_at: Option<Instant>,
}
impl CacheEntry {
fn is_expired(&self) -> bool {
self.expires_at.map(|e| Instant::now() > e).unwrap_or(false)
}
}
/// Global cache storage (shared across all plugins)
static CACHE: LazyLock<Mutex<HashMap<String, CacheEntry>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Register cache APIs
pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let cache_table = lua.create_table()?;
// owlry.cache.get(key) -> value or nil
cache_table.set(
"get",
lua.create_function(|lua, key: String| {
let cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
if let Some(entry) = cache.get(&key) {
if entry.is_expired() {
drop(cache);
// Remove expired entry
if let Ok(mut cache) = CACHE.lock() {
cache.remove(&key);
}
return Ok(Value::Nil);
}
// Parse JSON back to Lua value
let json_value: serde_json::Value =
serde_json::from_str(&entry.value).map_err(|e| {
mlua::Error::external(format!("Failed to parse cached value: {}", e))
})?;
json_to_lua(lua, &json_value)
} else {
Ok(Value::Nil)
}
})?,
)?;
// owlry.cache.set(key, value, ttl_seconds?) -> boolean
cache_table.set(
"set",
lua.create_function(|_lua, (key, value, ttl): (String, Value, Option<u64>)| {
let json_value = lua_value_to_json(&value)?;
let json_str = serde_json::to_string(&json_value)
.map_err(|e| mlua::Error::external(format!("Failed to serialize value: {}", e)))?;
let expires_at = ttl.map(|secs| Instant::now() + Duration::from_secs(secs));
let entry = CacheEntry {
value: json_str,
expires_at,
};
let mut cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
cache.insert(key, entry);
Ok(true)
})?,
)?;
// owlry.cache.delete(key) -> boolean (true if key existed)
cache_table.set(
"delete",
lua.create_function(|_lua, key: String| {
let mut cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
Ok(cache.remove(&key).is_some())
})?,
)?;
// owlry.cache.clear() -> number of entries removed
cache_table.set(
"clear",
lua.create_function(|_lua, ()| {
let mut cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
let count = cache.len();
cache.clear();
Ok(count)
})?,
)?;
// owlry.cache.has(key) -> boolean
cache_table.set(
"has",
lua.create_function(|_lua, key: String| {
let cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
if let Some(entry) = cache.get(&key) {
Ok(!entry.is_expired())
} else {
Ok(false)
}
})?,
)?;
owlry.set("cache", cache_table)?;
Ok(())
}
/// Convert Lua value to serde_json::Value
fn lua_value_to_json(value: &Value) -> LuaResult<serde_json::Value> {
use serde_json::Value as JsonValue;
match value {
Value::Nil => Ok(JsonValue::Null),
Value::Boolean(b) => Ok(JsonValue::Bool(*b)),
Value::Integer(i) => Ok(JsonValue::Number((*i).into())),
Value::Number(n) => Ok(serde_json::Number::from_f64(*n)
.map(JsonValue::Number)
.unwrap_or(JsonValue::Null)),
Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())),
Value::Table(t) => lua_table_to_json(t),
_ => Err(mlua::Error::external("Unsupported Lua type for cache")),
}
}
/// Convert Lua table to serde_json::Value
fn lua_table_to_json(table: &Table) -> LuaResult<serde_json::Value> {
use serde_json::{Map, Value as JsonValue};
// Check if it's an array (sequential integer keys starting from 1)
let is_array = table
.clone()
.pairs::<i64, Value>()
.enumerate()
.all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false));
if is_array {
let mut arr = Vec::new();
for pair in table.clone().pairs::<i64, Value>() {
let (_, v) = pair?;
arr.push(lua_value_to_json(&v)?);
}
Ok(JsonValue::Array(arr))
} else {
let mut map = Map::new();
for pair in table.clone().pairs::<String, Value>() {
let (k, v) = pair?;
map.insert(k, lua_value_to_json(&v)?);
}
Ok(JsonValue::Object(map))
}
}
/// Convert serde_json::Value to Lua value
fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<Value> {
use serde_json::Value as JsonValue;
match value {
JsonValue::Null => Ok(Value::Nil),
JsonValue::Bool(b) => Ok(Value::Boolean(*b)),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(Value::Number(f))
} else {
Ok(Value::Nil)
}
}
JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)),
JsonValue::Array(arr) => {
let table = lua.create_table()?;
for (i, v) in arr.iter().enumerate() {
table.set(i + 1, json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
JsonValue::Object(obj) => {
let table = lua.create_table()?;
for (k, v) in obj {
table.set(k.as_str(), json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_lua() -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_cache_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
// Clear cache between tests
CACHE.lock().unwrap().clear();
lua
}
#[test]
fn test_cache_set_get() {
let lua = setup_lua();
// Set a value
let chunk = lua.load(r#"return owlry.cache.set("test_key", "test_value")"#);
let result: bool = chunk.call(()).unwrap();
assert!(result);
// Get the value back
let chunk = lua.load(r#"return owlry.cache.get("test_key")"#);
let value: String = chunk.call(()).unwrap();
assert_eq!(value, "test_value");
}
#[test]
fn test_cache_table_value() {
let lua = setup_lua();
// Set a table value
let chunk = lua.load(r#"return owlry.cache.set("table_key", {name = "test", value = 42})"#);
let _: bool = chunk.call(()).unwrap();
// Get and verify
let chunk = lua.load(
r#"
local t = owlry.cache.get("table_key")
return t.name, t.value
"#,
);
let (name, value): (String, i32) = chunk.call(()).unwrap();
assert_eq!(name, "test");
assert_eq!(value, 42);
}
#[test]
fn test_cache_delete() {
let lua = setup_lua();
let chunk = lua.load(
r#"
owlry.cache.set("delete_key", "value")
local existed = owlry.cache.delete("delete_key")
local value = owlry.cache.get("delete_key")
return existed, value
"#,
);
let (existed, value): (bool, Option<String>) = chunk.call(()).unwrap();
assert!(existed);
assert!(value.is_none());
}
#[test]
fn test_cache_has() {
let lua = setup_lua();
let chunk = lua.load(
r#"
local before = owlry.cache.has("has_key")
owlry.cache.set("has_key", "value")
local after = owlry.cache.has("has_key")
return before, after
"#,
);
let (before, after): (bool, bool) = chunk.call(()).unwrap();
assert!(!before);
assert!(after);
}
#[test]
fn test_cache_missing_key() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.cache.get("nonexistent_key")"#);
let value: Value = chunk.call(()).unwrap();
assert!(matches!(value, Value::Nil));
}
}

View File

@@ -0,0 +1,418 @@
//! Hook API for Lua plugins
//!
//! Allows plugins to register callbacks for application events:
//! - `owlry.hook.on(event, callback)` - Register a hook
//! - Events: init, query, results, select, pre_launch, post_launch, shutdown
use mlua::{Function, Lua, Result as LuaResult, Table, Value};
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
/// Hook event types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HookEvent {
/// Called when plugin is initialized
Init,
/// Called when query changes, can modify query
Query,
/// Called after results are gathered, can filter/modify results
Results,
/// Called when an item is selected (highlighted)
Select,
/// Called before launching an item, can cancel launch
PreLaunch,
/// Called after launching an item
PostLaunch,
/// Called when application is shutting down
Shutdown,
}
impl HookEvent {
fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"init" => Some(Self::Init),
"query" => Some(Self::Query),
"results" => Some(Self::Results),
"select" => Some(Self::Select),
"pre_launch" | "prelaunch" => Some(Self::PreLaunch),
"post_launch" | "postlaunch" => Some(Self::PostLaunch),
"shutdown" => Some(Self::Shutdown),
_ => None,
}
}
fn as_str(&self) -> &'static str {
match self {
Self::Init => "init",
Self::Query => "query",
Self::Results => "results",
Self::Select => "select",
Self::PreLaunch => "pre_launch",
Self::PostLaunch => "post_launch",
Self::Shutdown => "shutdown",
}
}
}
/// Registered hook information
#[derive(Debug, Clone)]
#[allow(dead_code)] // Will be used for hook inspection
pub struct HookRegistration {
pub event: HookEvent,
pub plugin_id: String,
pub priority: i32,
}
/// Type alias for hook handlers: (plugin_id, priority)
type HookHandlers = Vec<(String, i32)>;
/// Global hook registry
/// Maps event -> list of (plugin_id, priority)
static HOOK_REGISTRY: LazyLock<Mutex<HashMap<HookEvent, HookHandlers>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Register hook APIs
pub fn register_hook_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> {
let hook_table = lua.create_table()?;
let plugin_id_owned = plugin_id.to_string();
// Store plugin_id in registry for later use
lua.set_named_registry_value("plugin_id", plugin_id_owned.clone())?;
// Initialize hook storage in Lua registry
if lua.named_registry_value::<Value>("hooks")?.is_nil() {
let hooks: Table = lua.create_table()?;
lua.set_named_registry_value("hooks", hooks)?;
}
// owlry.hook.on(event, callback, priority?) -> boolean
// Register a hook for an event
let plugin_id_for_closure = plugin_id_owned.clone();
hook_table.set(
"on",
lua.create_function(move |lua, (event_name, callback, priority): (String, Function, Option<i32>)| {
let event = HookEvent::from_str(&event_name).ok_or_else(|| {
mlua::Error::external(format!(
"Unknown hook event '{}'. Valid events: init, query, results, select, pre_launch, post_launch, shutdown",
event_name
))
})?;
let priority = priority.unwrap_or(0);
// Store callback in Lua registry
let hooks: Table = lua.named_registry_value("hooks")?;
let event_key = event.as_str();
let event_hooks: Table = if let Ok(t) = hooks.get::<Table>(event_key) {
t
} else {
let t = lua.create_table()?;
hooks.set(event_key, t.clone())?;
t
};
// Add callback to event hooks
let len = event_hooks.len()? + 1;
let hook_entry = lua.create_table()?;
hook_entry.set("callback", callback)?;
hook_entry.set("priority", priority)?;
event_hooks.set(len, hook_entry)?;
// Register in global registry
let mut registry = HOOK_REGISTRY.lock().map_err(|e| {
mlua::Error::external(format!("Failed to lock hook registry: {}", e))
})?;
let hooks_list = registry.entry(event).or_insert_with(Vec::new);
hooks_list.push((plugin_id_for_closure.clone(), priority));
// Sort by priority (higher priority first)
hooks_list.sort_by(|a, b| b.1.cmp(&a.1));
log::debug!(
"[plugin:{}] Registered hook for '{}' with priority {}",
plugin_id_for_closure,
event_name,
priority
);
Ok(true)
})?,
)?;
// owlry.hook.off(event) -> boolean
// Unregister all hooks for an event from this plugin
let plugin_id_for_off = plugin_id_owned.clone();
hook_table.set(
"off",
lua.create_function(move |lua, event_name: String| {
let event = HookEvent::from_str(&event_name).ok_or_else(|| {
mlua::Error::external(format!("Unknown hook event '{}'", event_name))
})?;
// Remove from Lua registry
let hooks: Table = lua.named_registry_value("hooks")?;
hooks.set(event.as_str(), Value::Nil)?;
// Remove from global registry
let mut registry = HOOK_REGISTRY.lock().map_err(|e| {
mlua::Error::external(format!("Failed to lock hook registry: {}", e))
})?;
if let Some(hooks_list) = registry.get_mut(&event) {
hooks_list.retain(|(id, _)| id != &plugin_id_for_off);
}
log::debug!(
"[plugin:{}] Unregistered hooks for '{}'",
plugin_id_for_off,
event_name
);
Ok(true)
})?,
)?;
owlry.set("hook", hook_table)?;
Ok(())
}
/// Call hooks for a specific event in a Lua runtime
/// Returns the (possibly modified) value
#[allow(dead_code)] // Will be used by UI integration
pub fn call_hooks<T>(lua: &Lua, event: HookEvent, value: T) -> LuaResult<T>
where
T: mlua::IntoLua + mlua::FromLua,
{
let hooks: Table = match lua.named_registry_value("hooks") {
Ok(h) => h,
Err(_) => return Ok(value), // No hooks registered
};
let event_hooks: Table = match hooks.get(event.as_str()) {
Ok(h) => h,
Err(_) => return Ok(value), // No hooks for this event
};
let mut current_value = value.into_lua(lua)?;
// Collect hooks with priorities
let mut hook_entries: Vec<(i32, Function)> = Vec::new();
for pair in event_hooks.pairs::<i64, Table>() {
let (_, entry) = pair?;
let priority: i32 = entry.get("priority").unwrap_or(0);
let callback: Function = entry.get("callback")?;
hook_entries.push((priority, callback));
}
// Sort by priority (higher first)
hook_entries.sort_by(|a, b| b.0.cmp(&a.0));
// Call each hook
for (_, callback) in hook_entries {
match callback.call::<Value>(current_value.clone()) {
Ok(result) => {
// If hook returns non-nil, use it as the new value
if !result.is_nil() {
current_value = result;
}
}
Err(e) => {
log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e);
// Continue with other hooks
}
}
}
T::from_lua(current_value, lua)
}
/// Call hooks that return a boolean (for pre_launch cancellation)
#[allow(dead_code)] // Will be used for pre_launch hooks
pub fn call_hooks_bool(lua: &Lua, event: HookEvent, value: Value) -> LuaResult<bool> {
let hooks: Table = match lua.named_registry_value("hooks") {
Ok(h) => h,
Err(_) => return Ok(true), // No hooks, allow
};
let event_hooks: Table = match hooks.get(event.as_str()) {
Ok(h) => h,
Err(_) => return Ok(true), // No hooks for this event
};
// Collect and sort hooks
let mut hook_entries: Vec<(i32, Function)> = Vec::new();
for pair in event_hooks.pairs::<i64, Table>() {
let (_, entry) = pair?;
let priority: i32 = entry.get("priority").unwrap_or(0);
let callback: Function = entry.get("callback")?;
hook_entries.push((priority, callback));
}
hook_entries.sort_by(|a, b| b.0.cmp(&a.0));
// Call each hook - if any returns false, cancel
for (_, callback) in hook_entries {
match callback.call::<Value>(value.clone()) {
Ok(result) => {
if let Value::Boolean(false) = result {
return Ok(false); // Cancel
}
}
Err(e) => {
log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e);
}
}
}
Ok(true)
}
/// Call hooks with no return value (for notifications)
#[allow(dead_code)] // Will be used for notification hooks
pub fn call_hooks_void(lua: &Lua, event: HookEvent, value: Value) -> LuaResult<()> {
let hooks: Table = match lua.named_registry_value("hooks") {
Ok(h) => h,
Err(_) => return Ok(()), // No hooks
};
let event_hooks: Table = match hooks.get(event.as_str()) {
Ok(h) => h,
Err(_) => return Ok(()), // No hooks for this event
};
for pair in event_hooks.pairs::<i64, Table>() {
let (_, entry) = pair?;
let callback: Function = entry.get("callback")?;
if let Err(e) = callback.call::<()>(value.clone()) {
log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e);
}
}
Ok(())
}
/// Get list of plugins that have registered for an event
#[allow(dead_code)]
pub fn get_registered_plugins(event: HookEvent) -> Vec<String> {
HOOK_REGISTRY
.lock()
.map(|r| {
r.get(&event)
.map(|v| v.iter().map(|(id, _)| id.clone()).collect())
.unwrap_or_default()
})
.unwrap_or_default()
}
/// Clear all hooks (used when reloading plugins)
#[allow(dead_code)]
pub fn clear_all_hooks() {
if let Ok(mut registry) = HOOK_REGISTRY.lock() {
registry.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_lua(plugin_id: &str) -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_hook_api(&lua, &owlry, plugin_id).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_hook_registration() {
clear_all_hooks();
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
local called = false
owlry.hook.on("init", function()
called = true
end)
return true
"#,
);
let result: bool = chunk.call(()).unwrap();
assert!(result);
// Verify hook was registered
let plugins = get_registered_plugins(HookEvent::Init);
assert!(plugins.contains(&"test-plugin".to_string()));
}
#[test]
fn test_hook_with_priority() {
clear_all_hooks();
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
owlry.hook.on("query", function(q) return q .. "1" end, 10)
owlry.hook.on("query", function(q) return q .. "2" end, 20)
return true
"#,
);
chunk.call::<()>(()).unwrap();
// Call hooks - higher priority (20) should run first
let result: String = call_hooks(&lua, HookEvent::Query, "test".to_string()).unwrap();
// Priority 20 adds "2" first, then priority 10 adds "1"
assert_eq!(result, "test21");
}
#[test]
fn test_hook_off() {
clear_all_hooks();
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
owlry.hook.on("select", function() end)
owlry.hook.off("select")
return true
"#,
);
chunk.call::<()>(()).unwrap();
let plugins = get_registered_plugins(HookEvent::Select);
assert!(!plugins.contains(&"test-plugin".to_string()));
}
#[test]
fn test_pre_launch_cancel() {
clear_all_hooks();
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
owlry.hook.on("pre_launch", function(item)
if item.name == "blocked" then
return false -- cancel launch
end
return true
end)
"#,
);
chunk.call::<()>(()).unwrap();
// Create a test item table
let item = lua.create_table().unwrap();
item.set("name", "blocked").unwrap();
let allow = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item)).unwrap();
assert!(!allow); // Should be blocked
// Test with allowed item
let item2 = lua.create_table().unwrap();
item2.set("name", "allowed").unwrap();
let allow2 = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item2)).unwrap();
assert!(allow2); // Should be allowed
}
}

View File

@@ -0,0 +1,350 @@
//! HTTP client API for Lua plugins
//!
//! Provides:
//! - `owlry.http.get(url, opts)` - HTTP GET request
//! - `owlry.http.post(url, body, opts)` - HTTP POST request
use mlua::{Lua, Result as LuaResult, Table, Value};
use std::collections::HashMap;
use std::time::Duration;
/// Register HTTP client APIs
pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let http_table = lua.create_table()?;
// owlry.http.get(url, opts?) -> { status, body, headers }
http_table.set(
"get",
lua.create_function(|lua, (url, opts): (String, Option<Table>)| {
log::debug!("[plugin] http.get: {}", url);
let timeout_secs = opts
.as_ref()
.and_then(|o| o.get::<u64>("timeout").ok())
.unwrap_or(30);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|e| {
mlua::Error::external(format!("Failed to create HTTP client: {}", e))
})?;
let mut request = client.get(&url);
// Add custom headers if provided
if let Some(ref opts) = opts
&& let Ok(headers) = opts.get::<Table>("headers")
{
for pair in headers.pairs::<String, String>() {
let (key, value) = pair?;
request = request.header(&key, &value);
}
}
let response = request
.send()
.map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?;
let status = response.status().as_u16();
let headers = extract_headers(&response);
let body = response.text().map_err(|e| {
mlua::Error::external(format!("Failed to read response body: {}", e))
})?;
let result = lua.create_table()?;
result.set("status", status)?;
result.set("body", body)?;
result.set("ok", (200..300).contains(&status))?;
let headers_table = lua.create_table()?;
for (key, value) in headers {
headers_table.set(key, value)?;
}
result.set("headers", headers_table)?;
Ok(result)
})?,
)?;
// owlry.http.post(url, body, opts?) -> { status, body, headers }
http_table.set(
"post",
lua.create_function(|lua, (url, body, opts): (String, Value, Option<Table>)| {
log::debug!("[plugin] http.post: {}", url);
let timeout_secs = opts
.as_ref()
.and_then(|o| o.get::<u64>("timeout").ok())
.unwrap_or(30);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|e| {
mlua::Error::external(format!("Failed to create HTTP client: {}", e))
})?;
let mut request = client.post(&url);
// Add custom headers if provided
if let Some(ref opts) = opts
&& let Ok(headers) = opts.get::<Table>("headers")
{
for pair in headers.pairs::<String, String>() {
let (key, value) = pair?;
request = request.header(&key, &value);
}
}
// Set body based on type
request = match body {
Value::String(s) => request.body(s.to_str()?.to_string()),
Value::Table(t) => {
// Assume JSON if body is a table
let json_str = table_to_json(&t)?;
request
.header("Content-Type", "application/json")
.body(json_str)
}
Value::Nil => request,
_ => return Err(mlua::Error::external("POST body must be a string or table")),
};
let response = request
.send()
.map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?;
let status = response.status().as_u16();
let headers = extract_headers(&response);
let body = response.text().map_err(|e| {
mlua::Error::external(format!("Failed to read response body: {}", e))
})?;
let result = lua.create_table()?;
result.set("status", status)?;
result.set("body", body)?;
result.set("ok", (200..300).contains(&status))?;
let headers_table = lua.create_table()?;
for (key, value) in headers {
headers_table.set(key, value)?;
}
result.set("headers", headers_table)?;
Ok(result)
})?,
)?;
// owlry.http.get_json(url, opts?) -> parsed JSON as table
// Convenience function that parses JSON response
http_table.set(
"get_json",
lua.create_function(|lua, (url, opts): (String, Option<Table>)| {
log::debug!("[plugin] http.get_json: {}", url);
let timeout_secs = opts
.as_ref()
.and_then(|o| o.get::<u64>("timeout").ok())
.unwrap_or(30);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|e| {
mlua::Error::external(format!("Failed to create HTTP client: {}", e))
})?;
let mut request = client.get(&url);
request = request.header("Accept", "application/json");
// Add custom headers if provided
if let Some(ref opts) = opts
&& let Ok(headers) = opts.get::<Table>("headers")
{
for pair in headers.pairs::<String, String>() {
let (key, value) = pair?;
request = request.header(&key, &value);
}
}
let response = request
.send()
.map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?;
if !response.status().is_success() {
return Err(mlua::Error::external(format!(
"HTTP request failed with status {}",
response.status()
)));
}
let body = response.text().map_err(|e| {
mlua::Error::external(format!("Failed to read response body: {}", e))
})?;
// Parse JSON and convert to Lua table
let json_value: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| mlua::Error::external(format!("Failed to parse JSON: {}", e)))?;
json_to_lua(lua, &json_value)
})?,
)?;
owlry.set("http", http_table)?;
Ok(())
}
/// Extract headers from response into a HashMap
fn extract_headers(response: &reqwest::blocking::Response) -> HashMap<String, String> {
response
.headers()
.iter()
.filter_map(|(k, v)| {
v.to_str()
.ok()
.map(|v| (k.as_str().to_lowercase(), v.to_string()))
})
.collect()
}
/// Convert a Lua table to JSON string
fn table_to_json(table: &Table) -> LuaResult<String> {
let value = lua_to_json(table)?;
serde_json::to_string(&value)
.map_err(|e| mlua::Error::external(format!("Failed to serialize to JSON: {}", e)))
}
/// Convert Lua table to serde_json::Value
fn lua_to_json(table: &Table) -> LuaResult<serde_json::Value> {
use serde_json::{Map, Value as JsonValue};
// Check if it's an array (sequential integer keys starting from 1)
let is_array = table
.clone()
.pairs::<i64, Value>()
.enumerate()
.all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false));
if is_array {
let mut arr = Vec::new();
for pair in table.clone().pairs::<i64, Value>() {
let (_, v) = pair?;
arr.push(lua_value_to_json(&v)?);
}
Ok(JsonValue::Array(arr))
} else {
let mut map = Map::new();
for pair in table.clone().pairs::<String, Value>() {
let (k, v) = pair?;
map.insert(k, lua_value_to_json(&v)?);
}
Ok(JsonValue::Object(map))
}
}
/// Convert a single Lua value to JSON
fn lua_value_to_json(value: &Value) -> LuaResult<serde_json::Value> {
use serde_json::Value as JsonValue;
match value {
Value::Nil => Ok(JsonValue::Null),
Value::Boolean(b) => Ok(JsonValue::Bool(*b)),
Value::Integer(i) => Ok(JsonValue::Number((*i).into())),
Value::Number(n) => Ok(serde_json::Number::from_f64(*n)
.map(JsonValue::Number)
.unwrap_or(JsonValue::Null)),
Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())),
Value::Table(t) => lua_to_json(t),
_ => Err(mlua::Error::external("Unsupported Lua type for JSON")),
}
}
/// Convert serde_json::Value to Lua value
fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<Value> {
use serde_json::Value as JsonValue;
match value {
JsonValue::Null => Ok(Value::Nil),
JsonValue::Bool(b) => Ok(Value::Boolean(*b)),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(Value::Number(f))
} else {
Ok(Value::Nil)
}
}
JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)),
JsonValue::Array(arr) => {
let table = lua.create_table()?;
for (i, v) in arr.iter().enumerate() {
table.set(i + 1, json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
JsonValue::Object(obj) => {
let table = lua.create_table()?;
for (k, v) in obj {
table.set(k.as_str(), json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_lua() -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_http_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_json_conversion() {
let lua = setup_lua();
// Test table to JSON
let table = lua.create_table().unwrap();
table.set("name", "test").unwrap();
table.set("value", 42).unwrap();
let json = table_to_json(&table).unwrap();
assert!(json.contains("name"));
assert!(json.contains("test"));
assert!(json.contains("42"));
}
#[test]
fn test_array_to_json() {
let lua = setup_lua();
let table = lua.create_table().unwrap();
table.set(1, "first").unwrap();
table.set(2, "second").unwrap();
table.set(3, "third").unwrap();
let json = table_to_json(&table).unwrap();
assert!(json.starts_with('['));
assert!(json.contains("first"));
}
// Note: Network tests are skipped in CI - they require internet access
// Use `cargo test -- --ignored` to run them locally
#[test]
#[ignore]
fn test_http_get() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.http.get("https://httpbin.org/get")"#);
let result: Table = chunk.call(()).unwrap();
assert_eq!(result.get::<u16>("status").unwrap(), 200);
assert!(result.get::<bool>("ok").unwrap());
}
}

View File

@@ -0,0 +1,187 @@
//! Math calculation API for Lua plugins
//!
//! Provides safe math expression evaluation:
//! - `owlry.math.calculate(expression)` - Evaluate a math expression
use mlua::{Lua, Result as LuaResult, Table};
/// Register math APIs
pub fn register_math_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let math_table = lua.create_table()?;
// owlry.math.calculate(expression) -> number or nil, error
// Evaluates a mathematical expression safely
// Returns (result, nil) on success or (nil, error_message) on failure
math_table.set(
"calculate",
lua.create_function(
|_lua, expr: String| -> LuaResult<(Option<f64>, Option<String>)> {
match meval::eval_str(&expr) {
Ok(result) => {
if result.is_finite() {
Ok((Some(result), None))
} else {
Ok((None, Some("Result is not a finite number".to_string())))
}
}
Err(e) => Ok((None, Some(e.to_string()))),
}
},
)?,
)?;
// owlry.math.calc(expression) -> number (throws on error)
// Convenience function that throws instead of returning error
math_table.set(
"calc",
lua.create_function(|_lua, expr: String| {
meval::eval_str(&expr)
.map_err(|e| mlua::Error::external(format!("Math error: {}", e)))
.and_then(|r| {
if r.is_finite() {
Ok(r)
} else {
Err(mlua::Error::external("Result is not a finite number"))
}
})
})?,
)?;
// owlry.math.is_expression(str) -> boolean
// Check if a string looks like a math expression
math_table.set(
"is_expression",
lua.create_function(|_lua, expr: String| {
let trimmed = expr.trim();
// Must have at least one digit
if !trimmed.chars().any(|c| c.is_ascii_digit()) {
return Ok(false);
}
// Should only contain valid math characters
let valid = trimmed.chars().all(|c| {
c.is_ascii_digit()
|| c.is_ascii_alphabetic()
|| matches!(c, '+' | '-' | '*' | '/' | '^' | '(' | ')' | '.' | ' ' | '%')
});
Ok(valid)
})?,
)?;
// owlry.math.format(number, decimals?) -> string
// Format a number with optional decimal places
math_table.set(
"format",
lua.create_function(|_lua, (num, decimals): (f64, Option<usize>)| {
let decimals = decimals.unwrap_or(2);
// Check if it's effectively an integer
if (num - num.round()).abs() < f64::EPSILON {
Ok(format!("{}", num as i64))
} else {
Ok(format!("{:.prec$}", num, prec = decimals))
}
})?,
)?;
owlry.set("math", math_table)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_lua() -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_math_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_calculate_basic() {
let lua = setup_lua();
let chunk = lua.load(
r#"
local result, err = owlry.math.calculate("2 + 2")
if err then error(err) end
return result
"#,
);
let result: f64 = chunk.call(()).unwrap();
assert!((result - 4.0).abs() < f64::EPSILON);
}
#[test]
fn test_calculate_complex() {
let lua = setup_lua();
let chunk = lua.load(
r#"
local result, err = owlry.math.calculate("sqrt(16) + 2^3")
if err then error(err) end
return result
"#,
);
let result: f64 = chunk.call(()).unwrap();
assert!((result - 12.0).abs() < f64::EPSILON); // sqrt(16) = 4, 2^3 = 8
}
#[test]
fn test_calculate_error() {
let lua = setup_lua();
let chunk = lua.load(
r#"
local result, err = owlry.math.calculate("invalid expression @@")
if result then
return false -- should not succeed
else
return true -- correctly failed
end
"#,
);
let had_error: bool = chunk.call(()).unwrap();
assert!(had_error);
}
#[test]
fn test_calc_throws() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.math.calc("3 * 4")"#);
let result: f64 = chunk.call(()).unwrap();
assert!((result - 12.0).abs() < f64::EPSILON);
}
#[test]
fn test_is_expression() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.math.is_expression("2 + 2")"#);
let is_expr: bool = chunk.call(()).unwrap();
assert!(is_expr);
let chunk = lua.load(r#"return owlry.math.is_expression("hello world")"#);
let is_expr: bool = chunk.call(()).unwrap();
assert!(!is_expr);
}
#[test]
fn test_format() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.math.format(3.14159, 2)"#);
let formatted: String = chunk.call(()).unwrap();
assert_eq!(formatted, "3.14");
let chunk = lua.load(r#"return owlry.math.format(42.0)"#);
let formatted: String = chunk.call(()).unwrap();
assert_eq!(formatted, "42");
}
}

View File

@@ -0,0 +1,77 @@
//! Lua API implementations for plugins
//!
//! This module provides the `owlry` global table and its submodules
//! that plugins can use to interact with owlry.
pub mod action;
mod cache;
pub mod hook;
mod http;
mod math;
mod process;
pub mod provider;
pub mod theme;
mod utils;
use mlua::{Lua, Result as LuaResult};
pub use action::ActionRegistration;
pub use hook::HookEvent;
pub use provider::ProviderRegistration;
pub use theme::ThemeRegistration;
/// Register all owlry APIs in the Lua runtime
///
/// This creates the `owlry` global table with all available APIs:
/// - `owlry.log.*` - Logging functions
/// - `owlry.path.*` - XDG path helpers
/// - `owlry.fs.*` - Filesystem operations
/// - `owlry.json.*` - JSON encode/decode
/// - `owlry.provider.*` - Provider registration
/// - `owlry.process.*` - Process execution
/// - `owlry.env.*` - Environment variables
/// - `owlry.http.*` - HTTP client
/// - `owlry.cache.*` - In-memory caching
/// - `owlry.math.*` - Math expression evaluation
/// - `owlry.hook.*` - Event hooks
/// - `owlry.action.*` - Custom actions
/// - `owlry.theme.*` - Theme registration
pub fn register_apis(lua: &Lua, plugin_dir: &std::path::Path, plugin_id: &str) -> LuaResult<()> {
let globals = lua.globals();
// Create the main owlry table
let owlry = lua.create_table()?;
// Register utility APIs (log, path, fs, json)
utils::register_log_api(lua, &owlry)?;
utils::register_path_api(lua, &owlry, plugin_dir)?;
utils::register_fs_api(lua, &owlry, plugin_dir)?;
utils::register_json_api(lua, &owlry)?;
// Register provider API
provider::register_provider_api(lua, &owlry)?;
// Register extended APIs (Phase 3)
process::register_process_api(lua, &owlry)?;
process::register_env_api(lua, &owlry)?;
http::register_http_api(lua, &owlry)?;
cache::register_cache_api(lua, &owlry)?;
math::register_math_api(lua, &owlry)?;
// Register Phase 4 APIs (hooks, actions, themes)
hook::register_hook_api(lua, &owlry, plugin_id)?;
action::register_action_api(lua, &owlry, plugin_id)?;
theme::register_theme_api(lua, &owlry, plugin_id, plugin_dir)?;
// Set owlry as global
globals.set("owlry", owlry)?;
Ok(())
}
/// Get provider registrations from the Lua runtime
///
/// Returns all providers that were registered via `owlry.provider.register()`
pub fn get_provider_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
provider::get_registrations(lua)
}

View File

@@ -0,0 +1,213 @@
//! Process and environment APIs for Lua plugins
//!
//! Provides:
//! - `owlry.process.run(cmd)` - Run a shell command and return output
//! - `owlry.process.exists(cmd)` - Check if a command exists in PATH
//! - `owlry.env.get(name)` - Get an environment variable
//! - `owlry.env.set(name, value)` - Set an environment variable (for plugin scope)
use mlua::{Lua, Result as LuaResult, Table};
use std::process::Command;
/// Register process-related APIs
pub fn register_process_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let process_table = lua.create_table()?;
// owlry.process.run(cmd) -> { stdout, stderr, exit_code, success }
// Runs a shell command and returns the result
process_table.set(
"run",
lua.create_function(|lua, cmd: String| {
log::debug!("[plugin] process.run: {}", cmd);
let output = Command::new("sh")
.arg("-c")
.arg(&cmd)
.output()
.map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?;
let result = lua.create_table()?;
result.set(
"stdout",
String::from_utf8_lossy(&output.stdout).to_string(),
)?;
result.set(
"stderr",
String::from_utf8_lossy(&output.stderr).to_string(),
)?;
result.set("exit_code", output.status.code().unwrap_or(-1))?;
result.set("success", output.status.success())?;
Ok(result)
})?,
)?;
// owlry.process.run_lines(cmd) -> table of lines
// Convenience function that runs a command and returns stdout split into lines
process_table.set(
"run_lines",
lua.create_function(|lua, cmd: String| {
log::debug!("[plugin] process.run_lines: {}", cmd);
let output = Command::new("sh")
.arg("-c")
.arg(&cmd)
.output()
.map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?;
if !output.status.success() {
return Err(mlua::Error::external(format!(
"Command failed with exit code {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr)
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
let result = lua.create_table()?;
for (i, line) in lines.iter().enumerate() {
result.set(i + 1, *line)?;
}
Ok(result)
})?,
)?;
// owlry.process.exists(cmd) -> boolean
// Checks if a command exists in PATH
process_table.set(
"exists",
lua.create_function(|_lua, cmd: String| {
let exists = Command::new("which")
.arg(&cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
Ok(exists)
})?,
)?;
owlry.set("process", process_table)?;
Ok(())
}
/// Register environment variable APIs
pub fn register_env_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let env_table = lua.create_table()?;
// owlry.env.get(name) -> string or nil
env_table.set(
"get",
lua.create_function(|_lua, name: String| Ok(std::env::var(&name).ok()))?,
)?;
// owlry.env.get_or(name, default) -> string
env_table.set(
"get_or",
lua.create_function(|_lua, (name, default): (String, String)| {
Ok(std::env::var(&name).unwrap_or(default))
})?,
)?;
// owlry.env.home() -> string
// Convenience function to get home directory
env_table.set(
"home",
lua.create_function(|_lua, ()| {
Ok(dirs::home_dir().map(|p| p.to_string_lossy().to_string()))
})?,
)?;
owlry.set("env", env_table)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_lua() -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_process_api(&lua, &owlry).unwrap();
register_env_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_process_run() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.process.run("echo hello")"#);
let result: Table = chunk.call(()).unwrap();
assert_eq!(result.get::<bool>("success").unwrap(), true);
assert_eq!(result.get::<i32>("exit_code").unwrap(), 0);
assert!(result.get::<String>("stdout").unwrap().contains("hello"));
}
#[test]
fn test_process_run_lines() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.process.run_lines("echo -e 'line1\nline2\nline3'")"#);
let result: Table = chunk.call(()).unwrap();
assert_eq!(result.get::<String>(1).unwrap(), "line1");
assert_eq!(result.get::<String>(2).unwrap(), "line2");
assert_eq!(result.get::<String>(3).unwrap(), "line3");
}
#[test]
fn test_process_exists() {
let lua = setup_lua();
// 'sh' should always exist
let chunk = lua.load(r#"return owlry.process.exists("sh")"#);
let exists: bool = chunk.call(()).unwrap();
assert!(exists);
// Made-up command should not exist
let chunk = lua
.load(r#"return owlry.process.exists("this_command_definitely_does_not_exist_12345")"#);
let not_exists: bool = chunk.call(()).unwrap();
assert!(!not_exists);
}
#[test]
fn test_env_get() {
let lua = setup_lua();
// HOME should be set on any Unix system
let chunk = lua.load(r#"return owlry.env.get("HOME")"#);
let home: Option<String> = chunk.call(()).unwrap();
assert!(home.is_some());
// Non-existent variable should return nil
let chunk = lua.load(r#"return owlry.env.get("THIS_VAR_DOES_NOT_EXIST_12345")"#);
let missing: Option<String> = chunk.call(()).unwrap();
assert!(missing.is_none());
}
#[test]
fn test_env_get_or() {
let lua = setup_lua();
let chunk = lua
.load(r#"return owlry.env.get_or("THIS_VAR_DOES_NOT_EXIST_12345", "default_value")"#);
let result: String = chunk.call(()).unwrap();
assert_eq!(result, "default_value");
}
#[test]
fn test_env_home() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.env.home()"#);
let home: Option<String> = chunk.call(()).unwrap();
assert!(home.is_some());
assert!(home.unwrap().starts_with('/'));
}
}

View File

@@ -0,0 +1,315 @@
//! Provider registration API for Lua plugins
//!
//! Allows plugins to register providers via `owlry.provider.register()`
use mlua::{Function, Lua, Result as LuaResult, Table};
/// Provider registration data extracted from Lua
#[derive(Debug, Clone)]
#[allow(dead_code)] // Some fields are for future use
pub struct ProviderRegistration {
/// Provider name (used for filtering/identification)
pub name: String,
/// Human-readable display name
pub display_name: String,
/// Provider type ID (for badge/filtering)
pub type_id: String,
/// Default icon name
pub default_icon: String,
/// Whether this is a static provider (refresh once) or dynamic (query-based)
pub is_static: bool,
/// Prefix to trigger this provider (e.g., ":" for commands)
pub prefix: Option<String>,
}
/// Register owlry.provider.* API
pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let provider_table = lua.create_table()?;
// Initialize registry for storing provider registrations
let registrations: Table = lua.create_table()?;
lua.set_named_registry_value("provider_registrations", registrations)?;
// owlry.provider.register(config) - Register a new provider
provider_table.set(
"register",
lua.create_function(|lua, config: Table| {
// Extract required fields
let name: String = config
.get("name")
.map_err(|_| mlua::Error::external("provider.register: 'name' is required"))?;
let _display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone());
let type_id: String = config
.get("type_id")
.unwrap_or_else(|_| name.replace('-', "_"));
let _default_icon: String = config
.get("default_icon")
.unwrap_or_else(|_| "application-x-executable".to_string());
let _prefix: Option<String> = config.get("prefix").ok();
// Check for refresh function (static provider) or query function (dynamic)
let has_refresh = config.get::<Function>("refresh").is_ok();
let has_query = config.get::<Function>("query").is_ok();
if !has_refresh && !has_query {
return Err(mlua::Error::external(
"provider.register: either 'refresh' or 'query' function is required",
));
}
let is_static = has_refresh;
log::info!(
"[plugin] Registered provider '{}' (type: {}, static: {})",
name,
type_id,
is_static
);
// Store the config in registry for later retrieval
let registrations: Table = lua.named_registry_value("provider_registrations")?;
registrations.set(name.clone(), config)?;
Ok(name)
})?,
)?;
owlry.set("provider", provider_table)?;
Ok(())
}
/// Get all provider registrations from the Lua runtime
pub fn get_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
let registrations: Table = lua.named_registry_value("provider_registrations")?;
let mut result = Vec::new();
for pair in registrations.pairs::<String, Table>() {
let (name, config) = pair?;
let display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone());
let type_id: String = config
.get("type_id")
.unwrap_or_else(|_| name.replace('-', "_"));
let default_icon: String = config
.get("default_icon")
.unwrap_or_else(|_| "application-x-executable".to_string());
let prefix: Option<String> = config.get("prefix").ok();
let is_static = config.get::<Function>("refresh").is_ok();
result.push(ProviderRegistration {
name,
display_name,
type_id,
default_icon,
is_static,
prefix,
});
}
Ok(result)
}
/// Call a provider's refresh function and extract items
pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>> {
let registrations: Table = lua.named_registry_value("provider_registrations")?;
let config: Table = registrations.get(provider_name)?;
let refresh: Function = config.get("refresh")?;
let items: Table = refresh.call(())?;
extract_items(&items)
}
/// Call a provider's query function with a query string
#[allow(dead_code)] // Will be used for dynamic query providers
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
let registrations: Table = lua.named_registry_value("provider_registrations")?;
let config: Table = registrations.get(provider_name)?;
let query_fn: Function = config.get("query")?;
let items: Table = query_fn.call(query.to_string())?;
extract_items(&items)
}
/// Item data from a plugin provider
#[derive(Debug, Clone)]
#[allow(dead_code)] // data field is for future action handlers
pub struct PluginItem {
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub command: Option<String>,
pub terminal: bool,
pub tags: Vec<String>,
/// Custom data passed to action handlers
pub data: Option<String>,
}
/// Extract items from a Lua table returned by refresh/query
fn extract_items(items: &Table) -> LuaResult<Vec<PluginItem>> {
let mut result = Vec::new();
for pair in items.clone().pairs::<i64, Table>() {
let (_, item) = pair?;
let id: String = item.get("id")?;
let name: String = item.get("name")?;
let description: Option<String> = item.get("description").ok();
let icon: Option<String> = item.get("icon").ok();
let command: Option<String> = item.get("command").ok();
let terminal: bool = item.get("terminal").unwrap_or(false);
let data: Option<String> = item.get("data").ok();
// Extract tags array
let tags: Vec<String> = if let Ok(tags_table) = item.get::<Table>("tags") {
tags_table
.pairs::<i64, String>()
.filter_map(|r| r.ok())
.map(|(_, v)| v)
.collect()
} else {
Vec::new()
};
result.push(PluginItem {
id,
name,
description,
icon,
command,
terminal,
tags,
data,
});
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_lua() -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_provider_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_register_static_provider() {
let lua = create_test_lua();
let script = r#"
owlry.provider.register({
name = "test-provider",
display_name = "Test Provider",
type_id = "test",
default_icon = "test-icon",
refresh = function()
return {
{ id = "1", name = "Item 1", description = "First item" },
{ id = "2", name = "Item 2", command = "echo hello" },
}
end
})
"#;
lua.load(script).call::<()>(()).unwrap();
let registrations = get_registrations(&lua).unwrap();
assert_eq!(registrations.len(), 1);
assert_eq!(registrations[0].name, "test-provider");
assert_eq!(registrations[0].display_name, "Test Provider");
assert!(registrations[0].is_static);
}
#[test]
fn test_register_dynamic_provider() {
let lua = create_test_lua();
let script = r#"
owlry.provider.register({
name = "search",
prefix = "?",
query = function(q)
return {
{ id = "result", name = "Result for: " .. q }
}
end
})
"#;
lua.load(script).call::<()>(()).unwrap();
let registrations = get_registrations(&lua).unwrap();
assert_eq!(registrations.len(), 1);
assert!(!registrations[0].is_static);
assert_eq!(registrations[0].prefix, Some("?".to_string()));
}
#[test]
fn test_call_refresh() {
let lua = create_test_lua();
let script = r#"
owlry.provider.register({
name = "items",
refresh = function()
return {
{ id = "a", name = "Alpha", tags = {"one", "two"} },
{ id = "b", name = "Beta", terminal = true },
}
end
})
"#;
lua.load(script).call::<()>(()).unwrap();
let items = call_refresh(&lua, "items").unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].id, "a");
assert_eq!(items[0].name, "Alpha");
assert_eq!(items[0].tags, vec!["one", "two"]);
assert!(!items[0].terminal);
assert_eq!(items[1].id, "b");
assert!(items[1].terminal);
}
#[test]
fn test_call_query() {
let lua = create_test_lua();
let script = r#"
owlry.provider.register({
name = "search",
query = function(q)
return {
{ id = "1", name = "Found: " .. q }
}
end
})
"#;
lua.load(script).call::<()>(()).unwrap();
let items = call_query(&lua, "search", "hello").unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "Found: hello");
}
#[test]
fn test_register_missing_function() {
let lua = create_test_lua();
let script = r#"
owlry.provider.register({
name = "broken",
})
"#;
let result = lua.load(script).call::<()>(());
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,286 @@
//! Theme API for Lua plugins
//!
//! Allows plugins to contribute CSS themes:
//! - `owlry.theme.register(config)` - Register a theme
use mlua::{Lua, Result as LuaResult, Table, Value};
use std::path::Path;
/// Theme registration data
#[derive(Debug, Clone)]
#[allow(dead_code)] // Will be used by theme loading
pub struct ThemeRegistration {
/// Theme name (used in config)
pub name: String,
/// Human-readable display name
pub display_name: String,
/// CSS content
pub css: String,
/// Plugin that registered this theme
pub plugin_id: String,
}
/// Register theme APIs
pub fn register_theme_api(
lua: &Lua,
owlry: &Table,
plugin_id: &str,
plugin_dir: &Path,
) -> LuaResult<()> {
let theme_table = lua.create_table()?;
let plugin_id_owned = plugin_id.to_string();
let plugin_dir_owned = plugin_dir.to_path_buf();
// Initialize theme storage in Lua registry
if lua.named_registry_value::<Value>("themes")?.is_nil() {
let themes: Table = lua.create_table()?;
lua.set_named_registry_value("themes", themes)?;
}
// owlry.theme.register(config) -> string (theme_name)
// config = {
// name = "dark-owl",
// display_name = "Dark Owl", -- optional, defaults to name
// css = "...", -- CSS string
// -- OR
// css_file = "theme.css" -- path relative to plugin dir
// }
let plugin_id_for_register = plugin_id_owned.clone();
let plugin_dir_for_register = plugin_dir_owned.clone();
theme_table.set(
"register",
lua.create_function(move |lua, config: Table| {
// Extract required fields
let name: String = config
.get("name")
.map_err(|_| mlua::Error::external("theme.register: 'name' is required"))?;
let display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone());
// Get CSS either directly or from file
let css: String = if let Ok(css_str) = config.get::<String>("css") {
css_str
} else if let Ok(css_file) = config.get::<String>("css_file") {
let css_path = plugin_dir_for_register.join(&css_file);
std::fs::read_to_string(&css_path).map_err(|e| {
mlua::Error::external(format!(
"Failed to read CSS file '{}': {}",
css_path.display(),
e
))
})?
} else {
return Err(mlua::Error::external(
"theme.register: either 'css' or 'css_file' is required",
));
};
// Store theme in registry
let themes: Table = lua.named_registry_value("themes")?;
let theme_entry = lua.create_table()?;
theme_entry.set("name", name.clone())?;
theme_entry.set("display_name", display_name.clone())?;
theme_entry.set("css", css)?;
theme_entry.set("plugin_id", plugin_id_for_register.clone())?;
themes.set(name.clone(), theme_entry)?;
log::info!(
"[plugin:{}] Registered theme '{}'",
plugin_id_for_register,
name
);
Ok(name)
})?,
)?;
// owlry.theme.unregister(name) -> boolean
theme_table.set(
"unregister",
lua.create_function(|lua, name: String| {
let themes: Table = lua.named_registry_value("themes")?;
if themes.contains_key(name.clone())? {
themes.set(name, Value::Nil)?;
Ok(true)
} else {
Ok(false)
}
})?,
)?;
// owlry.theme.list() -> table of theme names
theme_table.set(
"list",
lua.create_function(|lua, ()| {
let themes: Table = match lua.named_registry_value("themes") {
Ok(t) => t,
Err(_) => return lua.create_table(),
};
let result = lua.create_table()?;
let mut i = 1;
for pair in themes.pairs::<String, Table>() {
let (name, _) = pair?;
result.set(i, name)?;
i += 1;
}
Ok(result)
})?,
)?;
owlry.set("theme", theme_table)?;
Ok(())
}
/// Get all registered themes from a Lua runtime
#[allow(dead_code)] // Will be used by theme system
pub fn get_themes(lua: &Lua) -> LuaResult<Vec<ThemeRegistration>> {
let themes: Table = match lua.named_registry_value("themes") {
Ok(t) => t,
Err(_) => return Ok(Vec::new()),
};
let mut result = Vec::new();
for pair in themes.pairs::<String, Table>() {
let (_, entry) = pair?;
let name: String = entry.get("name")?;
let display_name: String = entry.get("display_name")?;
let css: String = entry.get("css")?;
let plugin_id: String = entry.get("plugin_id")?;
result.push(ThemeRegistration {
name,
display_name,
css,
plugin_id,
});
}
Ok(result)
}
/// Get a specific theme's CSS by name
#[allow(dead_code)] // Will be used by theme loading
pub fn get_theme_css(lua: &Lua, name: &str) -> LuaResult<Option<String>> {
let themes: Table = match lua.named_registry_value("themes") {
Ok(t) => t,
Err(_) => return Ok(None),
};
if let Ok(entry) = themes.get::<Table>(name) {
let css: String = entry.get("css")?;
Ok(Some(css))
} else {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_lua(plugin_id: &str, plugin_dir: &Path) -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_theme_api(&lua, &owlry, plugin_id, plugin_dir).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_theme_registration_inline() {
let temp = TempDir::new().unwrap();
let lua = setup_lua("test-plugin", temp.path());
let chunk = lua.load(
r#"
return owlry.theme.register({
name = "my-theme",
display_name = "My Theme",
css = ".owlry-window { background: #333; }"
})
"#,
);
let name: String = chunk.call(()).unwrap();
assert_eq!(name, "my-theme");
let themes = get_themes(&lua).unwrap();
assert_eq!(themes.len(), 1);
assert_eq!(themes[0].display_name, "My Theme");
assert!(themes[0].css.contains("background: #333"));
}
#[test]
fn test_theme_registration_file() {
let temp = TempDir::new().unwrap();
let css_content = ".owlry-window { background: #444; }";
std::fs::write(temp.path().join("theme.css"), css_content).unwrap();
let lua = setup_lua("test-plugin", temp.path());
let chunk = lua.load(
r#"
return owlry.theme.register({
name = "file-theme",
css_file = "theme.css"
})
"#,
);
let name: String = chunk.call(()).unwrap();
assert_eq!(name, "file-theme");
let css = get_theme_css(&lua, "file-theme").unwrap();
assert!(css.is_some());
assert!(css.unwrap().contains("background: #444"));
}
#[test]
fn test_theme_list() {
let temp = TempDir::new().unwrap();
let lua = setup_lua("test-plugin", temp.path());
let chunk = lua.load(
r#"
owlry.theme.register({ name = "theme1", css = "a{}" })
owlry.theme.register({ name = "theme2", css = "b{}" })
return owlry.theme.list()
"#,
);
let list: Table = chunk.call(()).unwrap();
let mut names: Vec<String> = Vec::new();
for pair in list.pairs::<i64, String>() {
let (_, name) = pair.unwrap();
names.push(name);
}
assert_eq!(names.len(), 2);
assert!(names.contains(&"theme1".to_string()));
assert!(names.contains(&"theme2".to_string()));
}
#[test]
fn test_theme_unregister() {
let temp = TempDir::new().unwrap();
let lua = setup_lua("test-plugin", temp.path());
let chunk = lua.load(
r#"
owlry.theme.register({ name = "temp-theme", css = "c{}" })
return owlry.theme.unregister("temp-theme")
"#,
);
let unregistered: bool = chunk.call(()).unwrap();
assert!(unregistered);
let themes = get_themes(&lua).unwrap();
assert_eq!(themes.len(), 0);
}
}

View File

@@ -0,0 +1,569 @@
//! Utility APIs: log, path, fs, json
use mlua::{Lua, Result as LuaResult, Table, Value};
use std::path::{Path, PathBuf};
/// Register owlry.log.* API
///
/// Provides: debug, info, warn, error
pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let log_table = lua.create_table()?;
log_table.set(
"debug",
lua.create_function(|_, msg: String| {
log::debug!("[plugin] {}", msg);
Ok(())
})?,
)?;
log_table.set(
"info",
lua.create_function(|_, msg: String| {
log::info!("[plugin] {}", msg);
Ok(())
})?,
)?;
log_table.set(
"warn",
lua.create_function(|_, msg: String| {
log::warn!("[plugin] {}", msg);
Ok(())
})?,
)?;
log_table.set(
"error",
lua.create_function(|_, msg: String| {
log::error!("[plugin] {}", msg);
Ok(())
})?,
)?;
owlry.set("log", log_table)?;
Ok(())
}
/// Register owlry.path.* API
///
/// Provides XDG directory helpers: config, data, cache, home, plugin_dir
pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> {
let path_table = lua.create_table()?;
let plugin_dir_str = plugin_dir.to_string_lossy().to_string();
// owlry.path.config() -> ~/.config/owlry
path_table.set(
"config",
lua.create_function(|_, ()| {
let path = dirs::config_dir()
.map(|p| p.join("owlry"))
.unwrap_or_default();
Ok(path.to_string_lossy().to_string())
})?,
)?;
// owlry.path.data() -> ~/.local/share/owlry
path_table.set(
"data",
lua.create_function(|_, ()| {
let path = dirs::data_dir()
.map(|p| p.join("owlry"))
.unwrap_or_default();
Ok(path.to_string_lossy().to_string())
})?,
)?;
// owlry.path.cache() -> ~/.cache/owlry
path_table.set(
"cache",
lua.create_function(|_, ()| {
let path = dirs::cache_dir()
.map(|p| p.join("owlry"))
.unwrap_or_default();
Ok(path.to_string_lossy().to_string())
})?,
)?;
// owlry.path.home() -> ~
path_table.set(
"home",
lua.create_function(|_, ()| {
let path = dirs::home_dir().unwrap_or_default();
Ok(path.to_string_lossy().to_string())
})?,
)?;
// owlry.path.join(base, ...) -> joined path
path_table.set(
"join",
lua.create_function(|_, parts: mlua::Variadic<String>| {
let mut path = PathBuf::new();
for part in parts {
path.push(part);
}
Ok(path.to_string_lossy().to_string())
})?,
)?;
// owlry.path.exists(path) -> bool
path_table.set(
"exists",
lua.create_function(|_, path: String| Ok(Path::new(&path).exists()))?,
)?;
// owlry.path.is_file(path) -> bool
path_table.set(
"is_file",
lua.create_function(|_, path: String| Ok(Path::new(&path).is_file()))?,
)?;
// owlry.path.is_dir(path) -> bool
path_table.set(
"is_dir",
lua.create_function(|_, path: String| Ok(Path::new(&path).is_dir()))?,
)?;
// owlry.path.expand(path) -> expanded path (handles ~)
path_table.set(
"expand",
lua.create_function(|_, path: String| {
let expanded = if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
home.join(rest).to_string_lossy().to_string()
} else {
path
}
} else if path == "~" {
dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or(path)
} else {
path
};
Ok(expanded)
})?,
)?;
// owlry.path.plugin_dir() -> this plugin's directory
path_table.set(
"plugin_dir",
lua.create_function(move |_, ()| Ok(plugin_dir_str.clone()))?,
)?;
owlry.set("path", path_table)?;
Ok(())
}
/// Register owlry.fs.* API
///
/// Provides filesystem operations within the plugin's directory
pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> {
let fs_table = lua.create_table()?;
let plugin_dir_str = plugin_dir.to_string_lossy().to_string();
// Store plugin directory in registry for access in closures
lua.set_named_registry_value("plugin_dir", plugin_dir_str.clone())?;
// owlry.fs.read(path) -> string or nil, error
fs_table.set(
"read",
lua.create_function(|lua, path: String| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
match std::fs::read_to_string(&full_path) {
Ok(content) => Ok((Some(content), Value::Nil)),
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
}
})?,
)?;
// owlry.fs.write(path, content) -> bool, error
fs_table.set(
"write",
lua.create_function(|lua, (path, content): (String, String)| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
// Ensure parent directory exists
if let Some(parent) = full_path.parent()
&& !parent.exists()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return Ok((false, Value::String(lua.create_string(e.to_string())?)));
}
match std::fs::write(&full_path, content) {
Ok(()) => Ok((true, Value::Nil)),
Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))),
}
})?,
)?;
// owlry.fs.list(path) -> array of filenames or nil, error
fs_table.set(
"list",
lua.create_function(|lua, path: Option<String>| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let dir_path = path
.map(|p| resolve_plugin_path(&plugin_dir, &p))
.unwrap_or_else(|| PathBuf::from(&plugin_dir));
match std::fs::read_dir(&dir_path) {
Ok(entries) => {
let names: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
let table = lua.create_sequence_from(names)?;
Ok((Some(table), Value::Nil))
}
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
}
})?,
)?;
// owlry.fs.exists(path) -> bool
fs_table.set(
"exists",
lua.create_function(|lua, path: String| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
Ok(full_path.exists())
})?,
)?;
// owlry.fs.mkdir(path) -> bool, error
fs_table.set(
"mkdir",
lua.create_function(|lua, path: String| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
match std::fs::create_dir_all(&full_path) {
Ok(()) => Ok((true, Value::Nil)),
Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))),
}
})?,
)?;
// owlry.fs.remove(path) -> bool, error
fs_table.set(
"remove",
lua.create_function(|lua, path: String| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
let result = if full_path.is_dir() {
std::fs::remove_dir_all(&full_path)
} else {
std::fs::remove_file(&full_path)
};
match result {
Ok(()) => Ok((true, Value::Nil)),
Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))),
}
})?,
)?;
// owlry.fs.is_file(path) -> bool
fs_table.set(
"is_file",
lua.create_function(|lua, path: String| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
Ok(full_path.is_file())
})?,
)?;
// owlry.fs.is_dir(path) -> bool
fs_table.set(
"is_dir",
lua.create_function(|lua, path: String| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
Ok(full_path.is_dir())
})?,
)?;
// owlry.fs.is_executable(path) -> bool
#[cfg(unix)]
fs_table.set(
"is_executable",
lua.create_function(|lua, path: String| {
use std::os::unix::fs::PermissionsExt;
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
let is_exec = full_path
.metadata()
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false);
Ok(is_exec)
})?,
)?;
// owlry.fs.plugin_dir() -> plugin directory path
let dir_clone = plugin_dir_str.clone();
fs_table.set(
"plugin_dir",
lua.create_function(move |_, ()| Ok(dir_clone.clone()))?,
)?;
owlry.set("fs", fs_table)?;
Ok(())
}
/// Resolve a path relative to the plugin directory
///
/// If the path is absolute, returns it as-is (for paths within allowed directories).
/// If relative, joins with plugin directory.
fn resolve_plugin_path(plugin_dir: &str, path: &str) -> PathBuf {
let path = Path::new(path);
if path.is_absolute() {
path.to_path_buf()
} else {
Path::new(plugin_dir).join(path)
}
}
/// Register owlry.json.* API
///
/// Provides JSON encoding/decoding
pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let json_table = lua.create_table()?;
// owlry.json.encode(value) -> string or nil, error
json_table.set(
"encode",
lua.create_function(|lua, value: Value| match lua_to_json(&value) {
Ok(json) => match serde_json::to_string(&json) {
Ok(s) => Ok((Some(s), Value::Nil)),
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
},
Err(e) => Ok((None, Value::String(lua.create_string(&e)?))),
})?,
)?;
// owlry.json.encode_pretty(value) -> string or nil, error
json_table.set(
"encode_pretty",
lua.create_function(|lua, value: Value| match lua_to_json(&value) {
Ok(json) => match serde_json::to_string_pretty(&json) {
Ok(s) => Ok((Some(s), Value::Nil)),
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
},
Err(e) => Ok((None, Value::String(lua.create_string(&e)?))),
})?,
)?;
// owlry.json.decode(string) -> value or nil, error
json_table.set(
"decode",
lua.create_function(|lua, s: String| {
match serde_json::from_str::<serde_json::Value>(&s) {
Ok(json) => match json_to_lua(lua, &json) {
Ok(value) => Ok((Some(value), Value::Nil)),
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
},
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
}
})?,
)?;
owlry.set("json", json_table)?;
Ok(())
}
/// Convert Lua value to JSON
fn lua_to_json(value: &Value) -> Result<serde_json::Value, String> {
match value {
Value::Nil => Ok(serde_json::Value::Null),
Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
Value::Number(n) => serde_json::Number::from_f64(*n)
.map(serde_json::Value::Number)
.ok_or_else(|| "Invalid number".to_string()),
Value::String(s) => Ok(serde_json::Value::String(
s.to_str().map_err(|e| e.to_string())?.to_string(),
)),
Value::Table(t) => {
// Check if it's an array (sequential integer keys starting from 1)
let len = t.raw_len();
let is_array = len > 0
&& (1..=len).all(|i| {
t.raw_get::<Value>(i)
.is_ok_and(|v| !matches!(v, Value::Nil))
});
if is_array {
let arr: Result<Vec<serde_json::Value>, String> = (1..=len)
.map(|i| {
let v: Value = t.raw_get(i).map_err(|e| e.to_string())?;
lua_to_json(&v)
})
.collect();
Ok(serde_json::Value::Array(arr?))
} else {
let mut map = serde_json::Map::new();
for pair in t.clone().pairs::<Value, Value>() {
let (k, v) = pair.map_err(|e| e.to_string())?;
let key = match k {
Value::String(s) => s.to_str().map_err(|e| e.to_string())?.to_string(),
Value::Integer(i) => i.to_string(),
_ => return Err("JSON object keys must be strings".to_string()),
};
map.insert(key, lua_to_json(&v)?);
}
Ok(serde_json::Value::Object(map))
}
}
_ => Err(format!("Cannot convert {:?} to JSON", value)),
}
}
/// Convert JSON to Lua value
fn json_to_lua(lua: &Lua, json: &serde_json::Value) -> LuaResult<Value> {
match json {
serde_json::Value::Null => Ok(Value::Nil),
serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(Value::Number(f))
} else {
Ok(Value::Nil)
}
}
serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)),
serde_json::Value::Array(arr) => {
let table = lua.create_table()?;
for (i, v) in arr.iter().enumerate() {
table.set(i + 1, json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
serde_json::Value::Object(obj) => {
let table = lua.create_table()?;
for (k, v) in obj {
table.set(k.as_str(), json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_lua() -> (Lua, TempDir) {
let lua = Lua::new();
let temp = TempDir::new().unwrap();
let owlry = lua.create_table().unwrap();
register_log_api(&lua, &owlry).unwrap();
register_path_api(&lua, &owlry, temp.path()).unwrap();
register_fs_api(&lua, &owlry, temp.path()).unwrap();
register_json_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
(lua, temp)
}
#[test]
fn test_log_api() {
let (lua, _temp) = create_test_lua();
// Just verify it doesn't panic - using call instead of the e-word
lua.load("owlry.log.info('test message')")
.call::<()>(())
.unwrap();
lua.load("owlry.log.debug('debug')").call::<()>(()).unwrap();
lua.load("owlry.log.warn('warning')")
.call::<()>(())
.unwrap();
lua.load("owlry.log.error('error')").call::<()>(()).unwrap();
}
#[test]
fn test_path_api() {
let (lua, _temp) = create_test_lua();
let home: String = lua.load("return owlry.path.home()").call(()).unwrap();
assert!(!home.is_empty());
let joined: String = lua
.load("return owlry.path.join('a', 'b', 'c')")
.call(())
.unwrap();
assert!(joined.contains("a") && joined.contains("b") && joined.contains("c"));
let expanded: String = lua
.load("return owlry.path.expand('~/test')")
.call(())
.unwrap();
assert!(!expanded.starts_with("~"));
}
#[test]
fn test_fs_api() {
let (lua, temp) = create_test_lua();
// Test write and read
lua.load("owlry.fs.write('test.txt', 'hello world')")
.call::<()>(())
.unwrap();
assert!(temp.path().join("test.txt").exists());
let content: String = lua
.load("return owlry.fs.read('test.txt')")
.call(())
.unwrap();
assert_eq!(content, "hello world");
// Test exists
let exists: bool = lua
.load("return owlry.fs.exists('test.txt')")
.call(())
.unwrap();
assert!(exists);
// Test list
let script = r#"
local files = owlry.fs.list()
return #files
"#;
let count: i32 = lua.load(script).call(()).unwrap();
assert!(count >= 1);
}
#[test]
fn test_json_api() {
let (lua, _temp) = create_test_lua();
// Test encode
let encoded: String = lua
.load(r#"return owlry.json.encode({name = "test", value = 42})"#)
.call(())
.unwrap();
assert!(encoded.contains("test") && encoded.contains("42"));
// Test decode
let script = r#"
local data = owlry.json.decode('{"name":"hello","num":123}')
return data.name, data.num
"#;
let (name, num): (String, i32) = lua.load(script).call(()).unwrap();
assert_eq!(name, "hello");
assert_eq!(num, 123);
// Test array encoding
let encoded: String = lua
.load(r#"return owlry.json.encode({1, 2, 3})"#)
.call(())
.unwrap();
assert_eq!(encoded, "[1,2,3]");
}
}

View File

@@ -0,0 +1,51 @@
//! Plugin system error types
use thiserror::Error;
/// Errors that can occur in the plugin system
#[derive(Error, Debug)]
#[allow(dead_code)] // Some variants are for future use
pub enum PluginError {
#[error("Plugin '{0}' not found")]
NotFound(String),
#[error("Invalid plugin manifest in '{plugin}': {message}")]
InvalidManifest { plugin: String, message: String },
#[error("Plugin '{plugin}' requires owlry {required}, but current version is {current}")]
VersionMismatch {
plugin: String,
required: String,
current: String,
},
#[error("Lua error in plugin '{plugin}': {message}")]
LuaError { plugin: String, message: String },
#[error("Plugin '{plugin}' timed out after {timeout_ms}ms")]
Timeout { plugin: String, timeout_ms: u64 },
#[error("Plugin '{plugin}' attempted forbidden operation: {operation}")]
SandboxViolation { plugin: String, operation: String },
#[error("Plugin '{0}' is already loaded")]
AlreadyLoaded(String),
#[error("Plugin '{0}' is disabled")]
Disabled(String),
#[error("Failed to load native plugin: {0}")]
LoadError(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parsing error: {0}")]
TomlParse(#[from] toml::de::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
/// Result type for plugin operations
pub type PluginResult<T> = Result<T, PluginError>;

View File

@@ -0,0 +1,212 @@
//! Lua plugin loading and initialization
use std::path::PathBuf;
use mlua::Lua;
use super::api;
use super::error::{PluginError, PluginResult};
use super::manifest::PluginManifest;
use super::runtime::{SandboxConfig, create_lua_runtime, load_file};
/// A loaded plugin instance
#[derive(Debug)]
pub struct LoadedPlugin {
/// Plugin manifest
pub manifest: PluginManifest,
/// Path to plugin directory
pub path: PathBuf,
/// Whether plugin is enabled
pub enabled: bool,
/// Lua runtime (None if not yet initialized)
lua: Option<Lua>,
}
impl LoadedPlugin {
/// Create a new loaded plugin (not yet initialized)
pub fn new(manifest: PluginManifest, path: PathBuf) -> Self {
Self {
manifest,
path,
enabled: true,
lua: None,
}
}
/// Get the plugin ID
pub fn id(&self) -> &str {
&self.manifest.plugin.id
}
/// Get the plugin name
#[allow(dead_code)]
pub fn name(&self) -> &str {
&self.manifest.plugin.name
}
/// Initialize the Lua runtime and load the entry point
pub fn initialize(&mut self) -> PluginResult<()> {
if self.lua.is_some() {
return Ok(()); // Already initialized
}
let sandbox = SandboxConfig::from_permissions(&self.manifest.permissions);
let lua = create_lua_runtime(&sandbox).map_err(|e| PluginError::LuaError {
plugin: self.id().to_string(),
message: e.to_string(),
})?;
// Register owlry APIs before loading entry point
api::register_apis(&lua, &self.path, self.id()).map_err(|e| PluginError::LuaError {
plugin: self.id().to_string(),
message: format!("Failed to register APIs: {}", e),
})?;
// Load the entry point file
let entry_path = self.path.join(&self.manifest.plugin.entry);
if !entry_path.exists() {
return Err(PluginError::InvalidManifest {
plugin: self.id().to_string(),
message: format!("Entry point '{}' not found", self.manifest.plugin.entry),
});
}
load_file(&lua, &entry_path).map_err(|e| PluginError::LuaError {
plugin: self.id().to_string(),
message: e.to_string(),
})?;
self.lua = Some(lua);
Ok(())
}
/// Get provider registrations from this plugin
pub fn get_provider_registrations(&self) -> PluginResult<Vec<super::ProviderRegistration>> {
let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError {
plugin: self.id().to_string(),
message: "Plugin not initialized".to_string(),
})?;
api::get_provider_registrations(lua).map_err(|e| PluginError::LuaError {
plugin: self.id().to_string(),
message: e.to_string(),
})
}
/// Call a provider's refresh function
pub fn call_provider_refresh(
&self,
provider_name: &str,
) -> PluginResult<Vec<super::PluginItem>> {
let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError {
plugin: self.id().to_string(),
message: "Plugin not initialized".to_string(),
})?;
api::provider::call_refresh(lua, provider_name).map_err(|e| PluginError::LuaError {
plugin: self.id().to_string(),
message: e.to_string(),
})
}
/// Call a provider's query function
#[allow(dead_code)] // Will be used for dynamic query providers
pub fn call_provider_query(
&self,
provider_name: &str,
query: &str,
) -> PluginResult<Vec<super::PluginItem>> {
let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError {
plugin: self.id().to_string(),
message: "Plugin not initialized".to_string(),
})?;
api::provider::call_query(lua, provider_name, query).map_err(|e| PluginError::LuaError {
plugin: self.id().to_string(),
message: e.to_string(),
})
}
/// Get a reference to the Lua runtime (if initialized)
#[allow(dead_code)]
pub fn lua(&self) -> Option<&Lua> {
self.lua.as_ref()
}
/// Get a mutable reference to the Lua runtime (if initialized)
#[allow(dead_code)]
pub fn lua_mut(&mut self) -> Option<&mut Lua> {
self.lua.as_mut()
}
}
// Note: discover_plugins and check_compatibility are in manifest.rs
// to avoid Lua dependency for plugin discovery.
#[cfg(test)]
mod tests {
use super::super::manifest::{check_compatibility, discover_plugins};
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn create_test_plugin(dir: &Path, id: &str, name: &str) {
let plugin_dir = dir.join(id);
fs::create_dir_all(&plugin_dir).unwrap();
let manifest = format!(
r#"
[plugin]
id = "{}"
name = "{}"
version = "1.0.0"
"#,
id, name
);
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
fs::write(plugin_dir.join("init.lua"), "-- empty plugin").unwrap();
}
#[test]
fn test_discover_plugins() {
let temp = TempDir::new().unwrap();
let plugins_dir = temp.path();
create_test_plugin(plugins_dir, "test-plugin", "Test Plugin");
create_test_plugin(plugins_dir, "another-plugin", "Another Plugin");
let plugins = discover_plugins(plugins_dir).unwrap();
assert_eq!(plugins.len(), 2);
assert!(plugins.contains_key("test-plugin"));
assert!(plugins.contains_key("another-plugin"));
}
#[test]
fn test_discover_plugins_empty_dir() {
let temp = TempDir::new().unwrap();
let plugins = discover_plugins(temp.path()).unwrap();
assert!(plugins.is_empty());
}
#[test]
fn test_discover_plugins_nonexistent_dir() {
let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap();
assert!(plugins.is_empty());
}
#[test]
fn test_check_compatibility() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
owlry_version = ">=0.3.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(check_compatibility(&manifest, "0.3.5").is_ok());
assert!(check_compatibility(&manifest, "0.2.0").is_err());
}
}

View File

@@ -0,0 +1,335 @@
//! Plugin manifest (plugin.toml) parsing
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::error::{PluginError, PluginResult};
/// Plugin manifest loaded from plugin.toml
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
#[serde(default)]
pub provides: PluginProvides,
#[serde(default)]
pub permissions: PluginPermissions,
#[serde(default)]
pub settings: HashMap<String, toml::Value>,
}
/// Core plugin information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
/// Unique plugin identifier (lowercase, alphanumeric, hyphens)
pub id: String,
/// Human-readable name
pub name: String,
/// Semantic version
pub version: String,
/// Short description
#[serde(default)]
pub description: String,
/// Plugin author
#[serde(default)]
pub author: String,
/// License identifier
#[serde(default)]
pub license: String,
/// Repository URL
#[serde(default)]
pub repository: Option<String>,
/// Required owlry version (semver constraint)
#[serde(default = "default_owlry_version")]
pub owlry_version: String,
/// Entry point file (relative to plugin directory)
#[serde(default = "default_entry")]
pub entry: String,
}
fn default_owlry_version() -> String {
">=0.1.0".to_string()
}
fn default_entry() -> String {
"init.lua".to_string()
}
/// What the plugin provides
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginProvides {
/// Provider names this plugin registers
#[serde(default)]
pub providers: Vec<String>,
/// Whether this plugin registers actions
#[serde(default)]
pub actions: bool,
/// Theme names this plugin contributes
#[serde(default)]
pub themes: Vec<String>,
/// Whether this plugin registers hooks
#[serde(default)]
pub hooks: bool,
/// CLI commands this plugin provides
#[serde(default)]
pub commands: Vec<PluginCommand>,
}
/// A CLI command provided by a plugin
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginCommand {
/// Command name (e.g., "add", "list", "sync")
pub name: String,
/// Short description shown in help
#[serde(default)]
pub description: String,
/// Usage pattern (e.g., "<url> [name]")
#[serde(default)]
pub usage: String,
}
/// Plugin permissions/capabilities
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginPermissions {
/// Allow network/HTTP requests
#[serde(default)]
pub network: bool,
/// Filesystem paths the plugin can access (beyond its own directory)
#[serde(default)]
pub filesystem: Vec<String>,
/// Commands the plugin is allowed to run
#[serde(default)]
pub run_commands: Vec<String>,
/// Environment variables the plugin reads
#[serde(default)]
pub environment: Vec<String>,
}
// ============================================================================
// Plugin Discovery (no Lua dependency)
// ============================================================================
/// Discover all plugins in a directory
///
/// Returns a map of plugin ID -> (manifest, path)
pub fn discover_plugins(
plugins_dir: &Path,
) -> PluginResult<HashMap<String, (PluginManifest, PathBuf)>> {
let mut plugins = HashMap::new();
if !plugins_dir.exists() {
log::debug!(
"Plugins directory does not exist: {}",
plugins_dir.display()
);
return Ok(plugins);
}
let entries = std::fs::read_dir(plugins_dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("plugin.toml");
if !manifest_path.exists() {
log::debug!("Skipping {}: no plugin.toml", path.display());
continue;
}
match PluginManifest::load(&manifest_path) {
Ok(manifest) => {
let id = manifest.plugin.id.clone();
if plugins.contains_key(&id) {
log::warn!("Duplicate plugin ID '{}', skipping {}", id, path.display());
continue;
}
log::info!(
"Discovered plugin: {} v{}",
manifest.plugin.name,
manifest.plugin.version
);
plugins.insert(id, (manifest, path));
}
Err(e) => {
log::warn!("Failed to load plugin at {}: {}", path.display(), e);
}
}
}
Ok(plugins)
}
/// Check if a plugin is compatible with the given owlry version
#[allow(dead_code)]
pub fn check_compatibility(manifest: &PluginManifest, owlry_version: &str) -> PluginResult<()> {
if !manifest.is_compatible_with(owlry_version) {
return Err(PluginError::VersionMismatch {
plugin: manifest.plugin.id.clone(),
required: manifest.plugin.owlry_version.clone(),
current: owlry_version.to_string(),
});
}
Ok(())
}
// ============================================================================
// PluginManifest Implementation
// ============================================================================
impl PluginManifest {
/// Load a plugin manifest from a plugin.toml file
pub fn load(path: &Path) -> PluginResult<Self> {
let content = std::fs::read_to_string(path)?;
let manifest: PluginManifest = toml::from_str(&content)?;
manifest.validate()?;
Ok(manifest)
}
/// Load from a plugin directory (looks for plugin.toml inside)
#[allow(dead_code)]
pub fn load_from_dir(plugin_dir: &Path) -> PluginResult<Self> {
let manifest_path = plugin_dir.join("plugin.toml");
if !manifest_path.exists() {
return Err(PluginError::InvalidManifest {
plugin: plugin_dir.display().to_string(),
message: "plugin.toml not found".to_string(),
});
}
Self::load(&manifest_path)
}
/// Validate the manifest
fn validate(&self) -> PluginResult<()> {
// Validate plugin ID format
if self.plugin.id.is_empty() {
return Err(PluginError::InvalidManifest {
plugin: self.plugin.id.clone(),
message: "Plugin ID cannot be empty".to_string(),
});
}
if !self
.plugin
.id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(PluginError::InvalidManifest {
plugin: self.plugin.id.clone(),
message: "Plugin ID must be lowercase alphanumeric with hyphens".to_string(),
});
}
// Validate version format
if semver::Version::parse(&self.plugin.version).is_err() {
return Err(PluginError::InvalidManifest {
plugin: self.plugin.id.clone(),
message: format!("Invalid version format: {}", self.plugin.version),
});
}
// Validate owlry_version constraint
if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() {
return Err(PluginError::InvalidManifest {
plugin: self.plugin.id.clone(),
message: format!(
"Invalid owlry_version constraint: {}",
self.plugin.owlry_version
),
});
}
Ok(())
}
/// Check if this plugin is compatible with the given owlry version
#[allow(dead_code)]
pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
Ok(r) => r,
Err(_) => return false,
};
let version = match semver::Version::parse(owlry_version) {
Ok(v) => v,
Err(_) => return false,
};
req.matches(&version)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_manifest() {
let toml_str = r#"
[plugin]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.name, "Test Plugin");
assert_eq!(manifest.plugin.version, "1.0.0");
assert_eq!(manifest.plugin.entry, "init.lua");
}
#[test]
fn test_parse_full_manifest() {
let toml_str = r#"
[plugin]
id = "my-provider"
name = "My Provider"
version = "1.2.3"
description = "A test provider"
author = "Test Author"
license = "MIT"
owlry_version = ">=0.4.0"
entry = "main.lua"
[provides]
providers = ["my-provider"]
actions = true
themes = ["dark"]
hooks = true
[permissions]
network = true
filesystem = ["~/.config/myapp"]
run_commands = ["myapp"]
environment = ["MY_API_KEY"]
[settings]
max_results = 20
api_url = "https://api.example.com"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.id, "my-provider");
assert!(manifest.provides.actions);
assert!(manifest.permissions.network);
assert_eq!(manifest.permissions.run_commands, vec!["myapp"]);
}
#[test]
fn test_version_compatibility() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
owlry_version = ">=0.3.0, <1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.is_compatible_with("0.3.5"));
assert!(manifest.is_compatible_with("0.4.0"));
assert!(!manifest.is_compatible_with("0.2.0"));
assert!(!manifest.is_compatible_with("1.0.0"));
}
}

View File

@@ -0,0 +1,354 @@
//! Owlry Plugin System
//!
//! This module provides plugin support for extending owlry's functionality.
//! Plugins can register providers, actions, themes, and hooks.
//!
//! # Plugin Types
//!
//! - **Native plugins** (.so): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/`
//! - **Lua plugins**: Script-based plugins from `~/.config/owlry/plugins/` (requires `lua` feature)
//!
//! # Plugin Structure (Lua)
//!
//! Each Lua plugin lives in its own directory under `~/.config/owlry/plugins/`:
//!
//! ```text
//! ~/.config/owlry/plugins/
//! my-plugin/
//! plugin.toml # Plugin manifest
//! init.lua # Entry point
//! lib/ # Optional modules
//! ```
// Always available
pub mod error;
pub mod manifest;
pub mod native_loader;
pub mod registry;
pub mod runtime_loader;
pub mod watcher;
// Lua-specific modules (require mlua)
#[cfg(feature = "lua")]
pub mod api;
#[cfg(feature = "lua")]
pub mod loader;
#[cfg(feature = "lua")]
pub mod runtime;
// Re-export commonly used types
#[cfg(feature = "lua")]
pub use api::provider::{PluginItem, ProviderRegistration};
#[cfg(feature = "lua")]
#[allow(unused_imports)]
pub use api::{ActionRegistration, HookEvent, ThemeRegistration};
#[allow(unused_imports)]
pub use error::{PluginError, PluginResult};
#[cfg(feature = "lua")]
pub use loader::LoadedPlugin;
// Used by plugins/commands.rs for plugin CLI commands
#[allow(unused_imports)]
pub use manifest::{PluginManifest, check_compatibility, discover_plugins};
// ============================================================================
// Lua Plugin Manager (only available with lua feature)
// ============================================================================
#[cfg(feature = "lua")]
mod lua_manager {
use super::*;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use manifest::{check_compatibility, discover_plugins};
/// Plugin manager coordinates loading, initialization, and lifecycle of Lua plugins
pub struct PluginManager {
/// Directory where plugins are stored
plugins_dir: PathBuf,
/// Current owlry version for compatibility checks
owlry_version: String,
/// Loaded plugins by ID (Rc<RefCell<>> allows sharing with LuaProviders)
plugins: HashMap<String, Rc<RefCell<LoadedPlugin>>>,
/// Plugin IDs that are explicitly disabled
disabled: Vec<String>,
}
impl PluginManager {
/// Create a new plugin manager
pub fn new(plugins_dir: PathBuf, owlry_version: &str) -> Self {
Self {
plugins_dir,
owlry_version: owlry_version.to_string(),
plugins: HashMap::new(),
disabled: Vec::new(),
}
}
/// Set the list of disabled plugin IDs
pub fn set_disabled(&mut self, disabled: Vec<String>) {
self.disabled = disabled;
}
/// Discover and load all plugins from the plugins directory
pub fn discover(&mut self) -> PluginResult<usize> {
log::info!("Discovering plugins in {}", self.plugins_dir.display());
let discovered = discover_plugins(&self.plugins_dir)?;
let mut loaded_count = 0;
for (id, (manifest, path)) in discovered {
// Skip disabled plugins
if self.disabled.contains(&id) {
log::info!("Plugin '{}' is disabled, skipping", id);
continue;
}
// Check version compatibility
if let Err(e) = check_compatibility(&manifest, &self.owlry_version) {
log::warn!("Plugin '{}' is not compatible: {}", id, e);
continue;
}
let plugin = LoadedPlugin::new(manifest, path);
self.plugins.insert(id, Rc::new(RefCell::new(plugin)));
loaded_count += 1;
}
log::info!("Discovered {} compatible plugins", loaded_count);
Ok(loaded_count)
}
/// Initialize all discovered plugins (load their Lua code)
pub fn initialize_all(&mut self) -> Vec<PluginError> {
let mut errors = Vec::new();
for (id, plugin_rc) in &self.plugins {
let mut plugin = plugin_rc.borrow_mut();
if !plugin.enabled {
continue;
}
log::debug!("Initializing plugin: {}", id);
if let Err(e) = plugin.initialize() {
log::error!("Failed to initialize plugin '{}': {}", id, e);
errors.push(e);
plugin.enabled = false;
}
}
errors
}
/// Get a loaded plugin by ID (returns Rc for shared ownership)
#[allow(dead_code)]
pub fn get(&self, id: &str) -> Option<Rc<RefCell<LoadedPlugin>>> {
self.plugins.get(id).cloned()
}
/// Get all loaded plugins
#[allow(dead_code)]
pub fn plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
self.plugins.values().cloned()
}
/// Get all enabled plugins
pub fn enabled_plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
self.plugins
.values()
.filter(|p| p.borrow().enabled)
.cloned()
}
/// Get the number of loaded plugins
#[allow(dead_code)]
pub fn plugin_count(&self) -> usize {
self.plugins.len()
}
/// Get the number of enabled plugins
#[allow(dead_code)]
pub fn enabled_count(&self) -> usize {
self.plugins.values().filter(|p| p.borrow().enabled).count()
}
/// Enable a plugin by ID
#[allow(dead_code)]
pub fn enable(&mut self, id: &str) -> PluginResult<()> {
let plugin_rc = self
.plugins
.get(id)
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
let mut plugin = plugin_rc.borrow_mut();
if !plugin.enabled {
plugin.enabled = true;
// Initialize if not already done
plugin.initialize()?;
}
Ok(())
}
/// Disable a plugin by ID
#[allow(dead_code)]
pub fn disable(&mut self, id: &str) -> PluginResult<()> {
let plugin_rc = self
.plugins
.get(id)
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
plugin_rc.borrow_mut().enabled = false;
Ok(())
}
/// Get plugin IDs that provide a specific feature
#[allow(dead_code)]
pub fn providers_for(&self, provider_name: &str) -> Vec<String> {
self.enabled_plugins()
.filter(|p| {
p.borrow()
.manifest
.provides
.providers
.contains(&provider_name.to_string())
})
.map(|p| p.borrow().id().to_string())
.collect()
}
/// Check if any plugin provides actions
#[allow(dead_code)]
pub fn has_action_plugins(&self) -> bool {
self.enabled_plugins()
.any(|p| p.borrow().manifest.provides.actions)
}
/// Check if any plugin provides hooks
#[allow(dead_code)]
pub fn has_hook_plugins(&self) -> bool {
self.enabled_plugins()
.any(|p| p.borrow().manifest.provides.hooks)
}
/// Get all theme names provided by plugins
#[allow(dead_code)]
pub fn theme_names(&self) -> Vec<String> {
self.enabled_plugins()
.flat_map(|p| p.borrow().manifest.provides.themes.clone())
.collect()
}
/// Create providers from all enabled plugins
///
/// This must be called after `initialize_all()`. Returns a vec of Provider trait
/// objects that can be added to the ProviderManager.
pub fn create_providers(&self) -> Vec<Box<dyn crate::providers::Provider>> {
use crate::providers::lua_provider::create_providers_from_plugin;
let mut providers = Vec::new();
for plugin_rc in self.enabled_plugins() {
let plugin_providers = create_providers_from_plugin(plugin_rc);
providers.extend(plugin_providers);
}
providers
}
}
}
#[cfg(feature = "lua")]
pub use lua_manager::PluginManager;
// ============================================================================
// Tests
// ============================================================================
#[cfg(all(test, feature = "lua"))]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_plugin(dir: &std::path::Path, id: &str, version: &str, owlry_req: &str) {
let plugin_dir = dir.join(id);
fs::create_dir_all(&plugin_dir).unwrap();
let manifest = format!(
r#"
[plugin]
id = "{}"
name = "Test {}"
version = "{}"
owlry_version = "{}"
[provides]
providers = ["{}"]
"#,
id, id, version, owlry_req, id
);
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
fs::write(plugin_dir.join("init.lua"), "-- test plugin").unwrap();
}
#[test]
fn test_plugin_manager_discover() {
let temp = TempDir::new().unwrap();
create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0");
create_test_plugin(temp.path(), "plugin-b", "2.0.0", ">=0.3.0");
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
let count = manager.discover().unwrap();
assert_eq!(count, 2);
assert!(manager.get("plugin-a").is_some());
assert!(manager.get("plugin-b").is_some());
}
#[test]
fn test_plugin_manager_disabled() {
let temp = TempDir::new().unwrap();
create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0");
create_test_plugin(temp.path(), "plugin-b", "1.0.0", ">=0.3.0");
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
manager.set_disabled(vec!["plugin-b".to_string()]);
let count = manager.discover().unwrap();
assert_eq!(count, 1);
assert!(manager.get("plugin-a").is_some());
assert!(manager.get("plugin-b").is_none());
}
#[test]
fn test_plugin_manager_version_compat() {
let temp = TempDir::new().unwrap();
create_test_plugin(temp.path(), "old-plugin", "1.0.0", ">=0.5.0"); // Requires future version
create_test_plugin(temp.path(), "new-plugin", "1.0.0", ">=0.3.0");
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
let count = manager.discover().unwrap();
assert_eq!(count, 1);
assert!(manager.get("old-plugin").is_none()); // Incompatible
assert!(manager.get("new-plugin").is_some());
}
#[test]
fn test_providers_for() {
let temp = TempDir::new().unwrap();
create_test_plugin(temp.path(), "my-provider", "1.0.0", ">=0.3.0");
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
manager.discover().unwrap();
let providers = manager.providers_for("my-provider");
assert_eq!(providers.len(), 1);
assert_eq!(providers[0], "my-provider");
}
}

View File

@@ -0,0 +1,402 @@
//! Native Plugin Loader
//!
//! Loads pre-compiled Rust plugins (.so files) from `/usr/lib/owlry/plugins/`.
//! These plugins use the ABI-stable interface defined in `owlry-plugin-api`.
//!
//! Note: This module is infrastructure for the plugin architecture. Full integration
//! with ProviderManager is pending Phase 5 (AUR Packaging) when native plugins
//! will actually be deployed.
#![allow(dead_code)]
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Once};
use libloading::Library;
use log::{debug, error, info, warn};
use owlry_plugin_api::{
API_VERSION, HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo,
ProviderKind, RStr,
};
use crate::notify;
// ============================================================================
// Host API Implementation
// ============================================================================
/// Host notification handler
extern "C" fn host_notify(
summary: RStr<'_>,
body: RStr<'_>,
icon: RStr<'_>,
urgency: NotifyUrgency,
) {
let icon_str = icon.as_str();
let icon_opt = if icon_str.is_empty() {
None
} else {
Some(icon_str)
};
let notify_urgency = match urgency {
NotifyUrgency::Low => notify::NotifyUrgency::Low,
NotifyUrgency::Normal => notify::NotifyUrgency::Normal,
NotifyUrgency::Critical => notify::NotifyUrgency::Critical,
};
notify::notify_with_options(summary.as_str(), body.as_str(), icon_opt, notify_urgency);
}
/// Host log info handler
extern "C" fn host_log_info(message: RStr<'_>) {
info!("[plugin] {}", message.as_str());
}
/// Host log warning handler
extern "C" fn host_log_warn(message: RStr<'_>) {
warn!("[plugin] {}", message.as_str());
}
/// Host log error handler
extern "C" fn host_log_error(message: RStr<'_>) {
error!("[plugin] {}", message.as_str());
}
/// Static host API instance
static HOST_API: HostAPI = HostAPI {
notify: host_notify,
log_info: host_log_info,
log_warn: host_log_warn,
log_error: host_log_error,
};
/// Initialize the host API (called once before loading plugins)
static HOST_API_INIT: Once = Once::new();
fn ensure_host_api_initialized() {
HOST_API_INIT.call_once(|| {
// SAFETY: We only call this once, before any plugins are loaded
unsafe {
owlry_plugin_api::init_host_api(&HOST_API);
}
debug!("Host API initialized for plugins");
});
}
use super::error::{PluginError, PluginResult};
/// Default directory for system-installed native plugins
pub const SYSTEM_PLUGINS_DIR: &str = "/usr/lib/owlry/plugins";
/// A loaded native plugin with its library handle and vtable
pub struct NativePlugin {
/// Plugin metadata
pub info: PluginInfo,
/// List of providers this plugin offers
pub providers: Vec<ProviderInfo>,
/// The vtable for calling plugin functions
vtable: &'static PluginVTable,
/// The loaded library (must be kept alive)
_library: Library,
}
impl NativePlugin {
/// Get the plugin ID
pub fn id(&self) -> &str {
self.info.id.as_str()
}
/// Get the plugin name
pub fn name(&self) -> &str {
self.info.name.as_str()
}
/// Initialize a provider by ID
pub fn init_provider(&self, provider_id: &str) -> ProviderHandle {
(self.vtable.provider_init)(provider_id.into())
}
/// Refresh a static provider
pub fn refresh_provider(&self, handle: ProviderHandle) -> Vec<owlry_plugin_api::PluginItem> {
(self.vtable.provider_refresh)(handle).into_iter().collect()
}
/// Query a dynamic provider
pub fn query_provider(
&self,
handle: ProviderHandle,
query: &str,
) -> Vec<owlry_plugin_api::PluginItem> {
(self.vtable.provider_query)(handle, query.into())
.into_iter()
.collect()
}
/// Drop a provider handle
pub fn drop_provider(&self, handle: ProviderHandle) {
(self.vtable.provider_drop)(handle)
}
}
// SAFETY: NativePlugin is safe to send between threads because:
// - `info` and `providers` are plain data (RString, RVec from abi_stable are Send+Sync)
// - `vtable` is a &'static reference to immutable function pointers
// - `_library` (libloading::Library) is Send+Sync
unsafe impl Send for NativePlugin {}
unsafe impl Sync for NativePlugin {}
/// Manages native plugin discovery and loading
pub struct NativePluginLoader {
/// Directory to scan for plugins
plugins_dir: PathBuf,
/// Loaded plugins by ID (Arc for shared ownership with providers)
plugins: HashMap<String, Arc<NativePlugin>>,
/// Plugin IDs that are disabled
disabled: Vec<String>,
}
impl NativePluginLoader {
/// Create a new loader with the default system plugins directory
pub fn new() -> Self {
Self::with_dir(PathBuf::from(SYSTEM_PLUGINS_DIR))
}
/// Create a new loader with a custom plugins directory
pub fn with_dir(plugins_dir: PathBuf) -> Self {
Self {
plugins_dir,
plugins: HashMap::new(),
disabled: Vec::new(),
}
}
/// Set the list of disabled plugin IDs
pub fn set_disabled(&mut self, disabled: Vec<String>) {
self.disabled = disabled;
}
/// Check if the plugins directory exists
pub fn plugins_dir_exists(&self) -> bool {
self.plugins_dir.exists()
}
/// Discover and load all native plugins
pub fn discover(&mut self) -> PluginResult<usize> {
// Initialize host API before loading any plugins
ensure_host_api_initialized();
if !self.plugins_dir.exists() {
debug!(
"Native plugins directory does not exist: {}",
self.plugins_dir.display()
);
return Ok(0);
}
info!(
"Discovering native plugins in {}",
self.plugins_dir.display()
);
let entries = std::fs::read_dir(&self.plugins_dir).map_err(|e| {
PluginError::LoadError(format!(
"Failed to read plugins directory {}: {}",
self.plugins_dir.display(),
e
))
})?;
let mut loaded_count = 0;
for entry in entries.flatten() {
let path = entry.path();
// Only process .so files
if path.extension() != Some(OsStr::new("so")) {
continue;
}
match self.load_plugin(&path) {
Ok(plugin) => {
let id = plugin.id().to_string();
// Check if disabled
if self.disabled.contains(&id) {
info!("Native plugin '{}' is disabled, skipping", id);
continue;
}
info!(
"Loaded native plugin '{}' v{} with {} providers",
plugin.name(),
plugin.info.version.as_str(),
plugin.providers.len()
);
self.plugins.insert(id, Arc::new(plugin));
loaded_count += 1;
}
Err(e) => {
error!("Failed to load plugin {:?}: {}", path, e);
}
}
}
info!("Loaded {} native plugins", loaded_count);
Ok(loaded_count)
}
/// Load a single plugin from a .so file
fn load_plugin(&self, path: &Path) -> PluginResult<NativePlugin> {
debug!("Loading native plugin from {:?}", path);
// Load the library
// SAFETY: We trust plugins in /usr/lib/owlry/plugins/ as they were
// installed by the package manager
let library = unsafe { Library::new(path) }.map_err(|e| {
PluginError::LoadError(format!("Failed to load library {:?}: {}", path, e))
})?;
// Get the vtable function
let vtable: &'static PluginVTable = unsafe {
let func: libloading::Symbol<extern "C" fn() -> &'static PluginVTable> =
library.get(b"owlry_plugin_vtable").map_err(|e| {
PluginError::LoadError(format!(
"Plugin {:?} missing owlry_plugin_vtable symbol: {}",
path, e
))
})?;
func()
};
// Get plugin info
let info = (vtable.info)();
// Check API version compatibility
if info.api_version != API_VERSION {
return Err(PluginError::LoadError(format!(
"Plugin '{}' has API version {} but owlry requires version {}",
info.id.as_str(),
info.api_version,
API_VERSION
)));
}
// Get provider list
let providers: Vec<ProviderInfo> = (vtable.providers)().into_iter().collect();
Ok(NativePlugin {
info,
providers,
vtable,
_library: library,
})
}
/// Get a loaded plugin by ID
pub fn get(&self, id: &str) -> Option<Arc<NativePlugin>> {
self.plugins.get(id).cloned()
}
/// Get all loaded plugins as Arc references
pub fn plugins(&self) -> impl Iterator<Item = Arc<NativePlugin>> + '_ {
self.plugins.values().cloned()
}
/// Get all loaded plugins as a Vec (for passing to create_providers)
pub fn into_plugins(self) -> Vec<Arc<NativePlugin>> {
self.plugins.into_values().collect()
}
/// Get the number of loaded plugins
pub fn plugin_count(&self) -> usize {
self.plugins.len()
}
/// Create providers from all loaded native plugins
///
/// Returns a vec of (plugin_id, provider_info, handle) tuples that can be
/// used to create NativeProvider instances.
pub fn create_provider_handles(&self) -> Vec<(String, ProviderInfo, ProviderHandle)> {
let mut handles = Vec::new();
for plugin in self.plugins.values() {
for provider_info in &plugin.providers {
let handle = plugin.init_provider(provider_info.id.as_str());
handles.push((plugin.id().to_string(), provider_info.clone(), handle));
}
}
handles
}
}
impl Default for NativePluginLoader {
fn default() -> Self {
Self::new()
}
}
/// Active provider instance from a native plugin
pub struct NativeProviderInstance {
/// Plugin ID this provider belongs to
pub plugin_id: String,
/// Provider metadata
pub info: ProviderInfo,
/// Handle to the provider state
pub handle: ProviderHandle,
/// Cached items for static providers
pub cached_items: Vec<owlry_plugin_api::PluginItem>,
}
impl NativeProviderInstance {
/// Create a new provider instance
pub fn new(plugin_id: String, info: ProviderInfo, handle: ProviderHandle) -> Self {
Self {
plugin_id,
info,
handle,
cached_items: Vec::new(),
}
}
/// Check if this is a static provider
pub fn is_static(&self) -> bool {
self.info.provider_type == ProviderKind::Static
}
/// Check if this is a dynamic provider
pub fn is_dynamic(&self) -> bool {
self.info.provider_type == ProviderKind::Dynamic
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_loader_nonexistent_dir() {
let mut loader = NativePluginLoader::with_dir(PathBuf::from("/nonexistent/path"));
let count = loader.discover().unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_loader_empty_dir() {
let temp = tempfile::TempDir::new().unwrap();
let mut loader = NativePluginLoader::with_dir(temp.path().to_path_buf());
let count = loader.discover().unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_disabled_plugins() {
let mut loader = NativePluginLoader::new();
loader.set_disabled(vec!["test-plugin".to_string()]);
assert!(loader.disabled.contains(&"test-plugin".to_string()));
}
}

View File

@@ -0,0 +1,292 @@
//! Plugin registry client for discovering and installing remote plugins
//!
//! The registry is a git repository containing an `index.toml` file with
//! plugin metadata. Plugins are installed by cloning their source repositories.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use crate::paths;
/// Default registry URL (can be overridden in config)
pub const DEFAULT_REGISTRY_URL: &str =
"https://raw.githubusercontent.com/owlry/plugin-registry/main/index.toml";
/// Cache duration for registry index (1 hour)
const CACHE_DURATION: Duration = Duration::from_secs(3600);
/// Registry index containing all available plugins
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryIndex {
/// Registry metadata
#[serde(default)]
pub registry: RegistryMeta,
/// Available plugins
#[serde(default)]
pub plugins: Vec<RegistryPlugin>,
}
/// Registry metadata
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RegistryMeta {
/// Registry name
#[serde(default)]
pub name: String,
/// Registry description
#[serde(default)]
pub description: String,
/// Registry maintainer URL
#[serde(default)]
pub url: String,
}
/// Plugin entry in the registry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryPlugin {
/// Unique plugin identifier
pub id: String,
/// Human-readable name
pub name: String,
/// Latest version
pub version: String,
/// Short description
#[serde(default)]
pub description: String,
/// Plugin author
#[serde(default)]
pub author: String,
/// Git repository URL for installation
pub repository: String,
/// Search tags
#[serde(default)]
pub tags: Vec<String>,
/// Minimum owlry version required
#[serde(default)]
pub owlry_version: String,
/// License identifier
#[serde(default)]
pub license: String,
}
/// Registry client for fetching and searching plugins
pub struct RegistryClient {
/// Registry URL (index.toml location)
registry_url: String,
/// Local cache directory
cache_dir: PathBuf,
}
impl RegistryClient {
/// Create a new registry client with the given URL
pub fn new(registry_url: &str) -> Self {
let cache_dir = paths::owlry_cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp/owlry"))
.join("registry");
Self {
registry_url: registry_url.to_string(),
cache_dir,
}
}
/// Create a client with the default registry URL
pub fn default_registry() -> Self {
Self::new(DEFAULT_REGISTRY_URL)
}
/// Get the path to the cached index file
fn cache_path(&self) -> PathBuf {
self.cache_dir.join("index.toml")
}
/// Check if the cache is valid (exists and not expired)
fn is_cache_valid(&self) -> bool {
let cache_path = self.cache_path();
if !cache_path.exists() {
return false;
}
if let Ok(metadata) = fs::metadata(&cache_path)
&& let Ok(modified) = metadata.modified()
&& let Ok(elapsed) = SystemTime::now().duration_since(modified)
{
return elapsed < CACHE_DURATION;
}
false
}
/// Fetch the registry index (from cache or network)
pub fn fetch_index(&self, force_refresh: bool) -> Result<RegistryIndex, String> {
// Use cache if valid and not forcing refresh
if !force_refresh
&& self.is_cache_valid()
&& let Ok(content) = fs::read_to_string(self.cache_path())
&& let Ok(index) = toml::from_str(&content)
{
return Ok(index);
}
// Fetch from network
self.fetch_from_network()
}
/// Fetch the index from the network and cache it
fn fetch_from_network(&self) -> Result<RegistryIndex, String> {
// Use curl for fetching (available on most systems)
let output = std::process::Command::new("curl")
.args(["-fsSL", "--max-time", "30", &self.registry_url])
.output()
.map_err(|e| format!("Failed to run curl: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to fetch registry: {}", stderr.trim()));
}
let content = String::from_utf8_lossy(&output.stdout);
// Parse the index
let index: RegistryIndex = toml::from_str(&content)
.map_err(|e| format!("Failed to parse registry index: {}", e))?;
// Cache the result
if let Err(e) = self.cache_index(&content) {
eprintln!("Warning: Failed to cache registry index: {}", e);
}
Ok(index)
}
/// Cache the index content to disk
fn cache_index(&self, content: &str) -> Result<(), String> {
fs::create_dir_all(&self.cache_dir)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
fs::write(self.cache_path(), content)
.map_err(|e| format!("Failed to write cache file: {}", e))?;
Ok(())
}
/// Search for plugins matching a query
pub fn search(&self, query: &str, force_refresh: bool) -> Result<Vec<RegistryPlugin>, String> {
let index = self.fetch_index(force_refresh)?;
let query_lower = query.to_lowercase();
let matches: Vec<_> = index
.plugins
.into_iter()
.filter(|p| {
p.id.to_lowercase().contains(&query_lower)
|| p.name.to_lowercase().contains(&query_lower)
|| p.description.to_lowercase().contains(&query_lower)
|| p.tags
.iter()
.any(|t| t.to_lowercase().contains(&query_lower))
})
.collect();
Ok(matches)
}
/// Find a specific plugin by ID
pub fn find(&self, id: &str, force_refresh: bool) -> Result<Option<RegistryPlugin>, String> {
let index = self.fetch_index(force_refresh)?;
Ok(index.plugins.into_iter().find(|p| p.id == id))
}
/// List all available plugins
pub fn list_all(&self, force_refresh: bool) -> Result<Vec<RegistryPlugin>, String> {
let index = self.fetch_index(force_refresh)?;
Ok(index.plugins)
}
/// Clear the cache
#[allow(dead_code)]
pub fn clear_cache(&self) -> Result<(), String> {
let cache_path = self.cache_path();
if cache_path.exists() {
fs::remove_file(&cache_path).map_err(|e| format!("Failed to remove cache: {}", e))?;
}
Ok(())
}
/// Get the repository URL for a plugin
#[allow(dead_code)]
pub fn get_install_url(&self, id: &str) -> Result<String, String> {
match self.find(id, false)? {
Some(plugin) => Ok(plugin.repository),
None => Err(format!("Plugin '{}' not found in registry", id)),
}
}
}
/// Check if a string looks like a URL (for distinguishing registry names from URLs)
pub fn is_url(s: &str) -> bool {
s.starts_with("http://")
|| s.starts_with("https://")
|| s.starts_with("git@")
|| s.starts_with("git://")
}
/// Check if a string looks like a local path
pub fn is_path(s: &str) -> bool {
s.starts_with('/')
|| s.starts_with("./")
|| s.starts_with("../")
|| s.starts_with('~')
|| Path::new(s).exists()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_registry_index() {
let toml_str = r#"
[registry]
name = "Test Registry"
description = "A test registry"
[[plugins]]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
description = "A test plugin"
author = "Test Author"
repository = "https://github.com/test/plugin"
tags = ["test", "example"]
owlry_version = ">=0.3.0"
"#;
let index: RegistryIndex = toml::from_str(toml_str).unwrap();
assert_eq!(index.registry.name, "Test Registry");
assert_eq!(index.plugins.len(), 1);
assert_eq!(index.plugins[0].id, "test-plugin");
assert_eq!(index.plugins[0].tags, vec!["test", "example"]);
}
#[test]
fn test_is_url() {
assert!(is_url("https://github.com/user/repo"));
assert!(is_url("http://example.com"));
assert!(is_url("git@github.com:user/repo.git"));
assert!(!is_url("my-plugin"));
assert!(!is_url("/path/to/plugin"));
}
#[test]
fn test_is_path() {
assert!(is_path("/absolute/path"));
assert!(is_path("./relative/path"));
assert!(is_path("../parent/path"));
assert!(is_path("~/home/path"));
assert!(!is_path("my-plugin"));
assert!(!is_path("https://example.com"));
}
}

View File

@@ -0,0 +1,154 @@
//! Lua runtime setup and sandboxing
use mlua::{Lua, Result as LuaResult, StdLib};
use super::manifest::PluginPermissions;
/// Configuration for the Lua sandbox
#[derive(Debug, Clone)]
#[allow(dead_code)] // Fields used for future permission enforcement
pub struct SandboxConfig {
/// Allow shell command running
pub allow_commands: bool,
/// Allow HTTP requests
pub allow_network: bool,
/// Allow filesystem access outside plugin directory
pub allow_external_fs: bool,
/// Maximum run time per call (ms)
pub max_run_time_ms: u64,
/// Memory limit (bytes, 0 = unlimited)
pub max_memory: usize,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
allow_commands: false,
allow_network: false,
allow_external_fs: false,
max_run_time_ms: 5000, // 5 seconds
max_memory: 64 * 1024 * 1024, // 64 MB
}
}
}
impl SandboxConfig {
/// Create a sandbox config from plugin permissions
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
Self {
allow_commands: !permissions.run_commands.is_empty(),
allow_network: permissions.network,
allow_external_fs: !permissions.filesystem.is_empty(),
..Default::default()
}
}
}
/// Create a new sandboxed Lua runtime
pub fn create_lua_runtime(_sandbox: &SandboxConfig) -> LuaResult<Lua> {
// Create Lua with safe standard libraries only
// ALL_SAFE excludes: debug, io, os (dangerous parts), package (loadlib), ffi
// We then customize the os table to only allow safe functions
let libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH;
let lua = Lua::new_with(libs, mlua::LuaOptions::default())?;
// Set up safe environment
setup_safe_globals(&lua)?;
Ok(lua)
}
/// Set up safe global environment by removing/replacing dangerous functions
fn setup_safe_globals(lua: &Lua) -> LuaResult<()> {
let globals = lua.globals();
// Remove dangerous globals
globals.set("dofile", mlua::Value::Nil)?;
globals.set("loadfile", mlua::Value::Nil)?;
// Create a restricted os table with only safe functions
// We do NOT include: os.exit, os.remove, os.rename, os.setlocale, os.tmpname
// and the shell-related functions
let os_table = lua.create_table()?;
os_table.set(
"clock",
lua.create_function(|_, ()| Ok(std::time::Instant::now().elapsed().as_secs_f64()))?,
)?;
os_table.set("date", lua.create_function(os_date)?)?;
os_table.set(
"difftime",
lua.create_function(|_, (t2, t1): (f64, f64)| Ok(t2 - t1))?,
)?;
os_table.set("time", lua.create_function(os_time)?)?;
globals.set("os", os_table)?;
// Remove print (plugins should use owlry.log instead)
// We'll add it back via owlry.log
globals.set("print", mlua::Value::Nil)?;
Ok(())
}
/// Safe os.date implementation
fn os_date(_lua: &Lua, format: Option<String>) -> LuaResult<String> {
use chrono::Local;
let now = Local::now();
let fmt = format.unwrap_or_else(|| "%c".to_string());
Ok(now.format(&fmt).to_string())
}
/// Safe os.time implementation
fn os_time(_lua: &Lua, _args: ()) -> LuaResult<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
Ok(duration.as_secs() as i64)
}
/// Load and run a Lua file in the given runtime
pub fn load_file(lua: &Lua, path: &std::path::Path) -> LuaResult<()> {
let content = std::fs::read_to_string(path).map_err(mlua::Error::external)?;
lua.load(&content)
.set_name(path.file_name().and_then(|n| n.to_str()).unwrap_or("chunk"))
.into_function()?
.call(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_sandboxed_runtime() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
// Verify dangerous functions are removed
let result: LuaResult<mlua::Value> = lua.globals().get("dofile");
assert!(matches!(result, Ok(mlua::Value::Nil)));
// Verify safe functions work
let result: String = lua.load("return os.date('%Y')").call(()).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_basic_lua_operations() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
// Test basic math
let result: i32 = lua.load("return 2 + 2").call(()).unwrap();
assert_eq!(result, 4);
// Test table operations
let result: i32 = lua.load("local t = {1,2,3}; return #t").call(()).unwrap();
assert_eq!(result, 3);
// Test string operations
let result: String = lua.load("return string.upper('hello')").call(()).unwrap();
assert_eq!(result, "HELLO");
}
}

View File

@@ -0,0 +1,305 @@
//! Dynamic runtime loader
//!
//! This module provides dynamic loading of script runtimes (Lua, Rune)
//! when they're not compiled into the core binary.
//!
//! Runtimes are loaded from `/usr/lib/owlry/runtimes/`:
//! - `liblua.so` - Lua runtime (from owlry-lua package)
//! - `librune.so` - Rune runtime (from owlry-rune package)
//!
//! Note: This module is infrastructure for the runtime architecture. Full integration
//! is pending Phase 5 (AUR Packaging) when runtime packages will be available.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use libloading::{Library, Symbol};
use owlry_plugin_api::{PluginItem, RStr, RString, RVec};
use super::error::{PluginError, PluginResult};
use crate::providers::{LaunchItem, Provider, ProviderType};
/// System directory for runtime libraries
pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes";
/// Information about a loaded runtime
#[repr(C)]
#[derive(Debug)]
pub struct RuntimeInfo {
pub name: RString,
pub version: RString,
}
/// Information about a provider from a script runtime
#[repr(C)]
#[derive(Debug, Clone)]
pub struct ScriptProviderInfo {
pub name: RString,
pub display_name: RString,
pub type_id: RString,
pub default_icon: RString,
pub is_static: bool,
pub prefix: owlry_plugin_api::ROption<RString>,
}
// Type alias for backwards compatibility
pub type LuaProviderInfo = ScriptProviderInfo;
/// Handle to runtime-managed state
#[repr(transparent)]
#[derive(Clone, Copy)]
pub struct RuntimeHandle(pub *mut ());
/// VTable for script runtime functions (used by both Lua and Rune)
#[repr(C)]
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),
}
/// A loaded script runtime
pub struct LoadedRuntime {
/// Runtime name (for logging)
name: &'static str,
/// Keep library alive
_library: Arc<Library>,
/// Runtime vtable
vtable: &'static ScriptRuntimeVTable,
/// Runtime handle (state)
handle: RuntimeHandle,
/// Provider information
providers: Vec<ScriptProviderInfo>,
}
impl LoadedRuntime {
/// Load the Lua runtime from the system directory
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,
)
}
/// Load a runtime from a specific path
fn load_from_path(
name: &'static str,
library_path: &Path,
vtable_symbol: &[u8],
plugins_dir: &Path,
owlry_version: &str,
) -> PluginResult<Self> {
if !library_path.exists() {
return Err(PluginError::NotFound(library_path.display().to_string()));
}
// SAFETY: We trust the runtime library to be correct
let library = unsafe { Library::new(library_path) }
.map_err(|e| PluginError::LoadError(format!("{}: {}", library_path.display(), e)))?;
let library = Arc::new(library);
// Get the vtable
let vtable: &'static ScriptRuntimeVTable = unsafe {
let get_vtable: Symbol<extern "C" fn() -> &'static ScriptRuntimeVTable> =
library.get(vtable_symbol).map_err(|e| {
PluginError::LoadError(format!(
"{}: Missing vtable symbol: {}",
library_path.display(),
e
))
})?;
get_vtable()
};
// Initialize the runtime
let plugins_dir_str = plugins_dir.to_string_lossy();
let handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version));
// Get provider information
let providers_rvec = (vtable.providers)(handle);
let providers: Vec<ScriptProviderInfo> = providers_rvec.into_iter().collect();
log::info!(
"Loaded {} runtime with {} provider(s)",
name,
providers.len()
);
Ok(Self {
name,
_library: library,
vtable,
handle,
providers,
})
}
/// Get all providers from this runtime
pub fn providers(&self) -> &[ScriptProviderInfo] {
&self.providers
}
/// Create Provider trait objects for all providers in this runtime
pub fn create_providers(&self) -> Vec<Box<dyn Provider>> {
self.providers
.iter()
.map(|info| {
let provider =
RuntimeProvider::new(self.name, self.vtable, self.handle, info.clone());
Box::new(provider) as Box<dyn Provider>
})
.collect()
}
}
impl Drop for LoadedRuntime {
fn drop(&mut self) {
(self.vtable.drop)(self.handle);
}
}
// LoadedRuntime needs to be Send + Sync because ProviderManager is shared across
// threads via Arc<RwLock<ProviderManager>>.
// Safety: RuntimeHandle is an opaque FFI handle accessed only through extern "C"
// vtable functions. The same safety argument that applies to RuntimeProvider applies
// here — all access is mediated by the vtable, and the runtime itself serializes access.
unsafe impl Send for LoadedRuntime {}
unsafe impl Sync for LoadedRuntime {}
/// A provider backed by a dynamically loaded runtime
pub struct RuntimeProvider {
/// Runtime name (for logging)
#[allow(dead_code)]
runtime_name: &'static str,
vtable: &'static ScriptRuntimeVTable,
handle: RuntimeHandle,
info: ScriptProviderInfo,
items: Vec<LaunchItem>,
}
impl RuntimeProvider {
fn new(
runtime_name: &'static str,
vtable: &'static ScriptRuntimeVTable,
handle: RuntimeHandle,
info: ScriptProviderInfo,
) -> Self {
Self {
runtime_name,
vtable,
handle,
info,
items: Vec::new(),
}
}
fn convert_item(&self, item: PluginItem) -> LaunchItem {
LaunchItem {
id: item.id.to_string(),
name: item.name.to_string(),
description: item.description.into_option().map(|s| s.to_string()),
icon: item.icon.into_option().map(|s| s.to_string()),
provider: ProviderType::Plugin(self.info.type_id.to_string()),
command: item.command.to_string(),
terminal: item.terminal,
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
}
}
}
impl Provider for RuntimeProvider {
fn name(&self) -> &str {
self.info.name.as_str()
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(self.info.type_id.to_string())
}
fn refresh(&mut self) {
if !self.info.is_static {
return;
}
let name_rstr = RStr::from_str(self.info.name.as_str());
let items_rvec = (self.vtable.refresh)(self.handle, name_rstr);
self.items = items_rvec
.into_iter()
.map(|i| self.convert_item(i))
.collect();
log::debug!(
"[RuntimeProvider] '{}' refreshed with {} items",
self.info.name,
self.items.len()
);
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
// RuntimeProvider needs to be Send + Sync for the Provider trait.
// Safety: RuntimeHandle is an opaque FFI handle accessed only through
// extern "C" vtable functions. The same safety argument that justifies
// Send applies to Sync — all access is mediated by the vtable.
unsafe impl Send for RuntimeProvider {}
unsafe impl Sync for RuntimeProvider {}
/// Check if the Lua runtime is available
pub fn lua_runtime_available() -> bool {
PathBuf::from(SYSTEM_RUNTIMES_DIR)
.join("liblua.so")
.exists()
}
/// Check if the Rune runtime is available
pub fn rune_runtime_available() -> bool {
PathBuf::from(SYSTEM_RUNTIMES_DIR)
.join("librune.so")
.exists()
}
impl LoadedRuntime {
/// Load the Rune runtime from the system directory
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,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lua_runtime_check_doesnt_panic() {
// Just verify the function runs without panicking
// Result depends on whether runtime is installed
let _available = lua_runtime_available();
}
#[test]
fn test_rune_runtime_check_doesnt_panic() {
// Just verify the function runs without panicking
// Result depends on whether runtime is installed
let _available = rune_runtime_available();
}
}

View File

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

View File

@@ -1,7 +1,7 @@
use super::{LaunchItem, Provider, ProviderType};
use crate::paths;
use freedesktop_desktop_entry::{DesktopEntry, Iter};
use log::{debug, warn};
use std::path::PathBuf;
/// Clean desktop file field codes from command string.
/// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes
@@ -66,34 +66,18 @@ fn clean_desktop_exec_field(cmd: &str) -> String {
cleaned
}
#[derive(Default)]
pub struct ApplicationProvider {
items: Vec<LaunchItem>,
}
impl ApplicationProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
Self::default()
}
fn get_application_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
// User applications
if let Some(data_home) = dirs::data_dir() {
dirs.push(data_home.join("applications"));
}
// System applications
dirs.push(PathBuf::from("/usr/share/applications"));
dirs.push(PathBuf::from("/usr/local/share/applications"));
// Flatpak applications
if let Some(data_home) = dirs::data_dir() {
dirs.push(data_home.join("flatpak/exports/share/applications"));
}
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
dirs
fn get_application_dirs() -> Vec<std::path::PathBuf> {
paths::system_data_dirs()
}
}
@@ -115,6 +99,15 @@ impl Provider for ApplicationProvider {
// Empty locale list for default locale
let locales: &[&str] = &[];
// Get current desktop environment(s) for OnlyShowIn/NotShowIn filtering
// XDG_CURRENT_DESKTOP can be colon-separated (e.g., "ubuntu:GNOME")
let current_desktops: Vec<String> = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.split(':')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
for path in Iter::new(dirs.into_iter()) {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
@@ -142,6 +135,27 @@ impl Provider for ApplicationProvider {
continue;
}
// Apply OnlyShowIn/NotShowIn filters only if we know the current desktop
// If XDG_CURRENT_DESKTOP is not set, show all apps (don't filter)
if !current_desktops.is_empty() {
// OnlyShowIn: if set, current desktop must be in the list
if desktop_entry.only_show_in().is_some_and(|only| {
!current_desktops
.iter()
.any(|de| only.contains(&de.as_str()))
}) {
continue;
}
// NotShowIn: if current desktop is in the list, skip
if desktop_entry
.not_show_in()
.is_some_and(|not| current_desktops.iter().any(|de| not.contains(&de.as_str())))
{
continue;
}
}
let name = match desktop_entry.name(locales) {
Some(n) => n.to_string(),
None => continue,
@@ -152,6 +166,17 @@ impl Provider for ApplicationProvider {
None => continue,
};
// Extract categories and keywords as tags (lowercase for consistency)
let mut tags: Vec<String> = desktop_entry
.categories()
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
.unwrap_or_default();
// Add keywords for searchability (e.g., Nautilus has Name=Files but Keywords contains "nautilus")
if let Some(keywords) = desktop_entry.keywords(locales) {
tags.extend(keywords.into_iter().map(|s| s.to_lowercase()));
}
let item = LaunchItem {
id: path.to_string_lossy().to_string(),
name,
@@ -160,6 +185,7 @@ impl Provider for ApplicationProvider {
provider: ProviderType::Application,
command: run_cmd,
terminal: desktop_entry.terminal(),
tags,
};
self.items.push(item);
@@ -167,8 +193,16 @@ impl Provider for ApplicationProvider {
debug!("Found {} applications", self.items.len());
#[cfg(feature = "dev-logging")]
debug!(
"XDG_CURRENT_DESKTOP={:?}, scanned dirs count={}",
current_desktops,
Self::get_application_dirs().len()
);
// Sort alphabetically by name
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
self.items
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
fn items(&self) -> &[LaunchItem] {
@@ -190,7 +224,10 @@ mod tests {
#[test]
fn test_clean_desktop_exec_multiple_placeholders() {
assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app");
assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other");
assert_eq!(
clean_desktop_exec_field("app --flag %u --other"),
"app --flag --other"
);
}
#[test]
@@ -220,4 +257,18 @@ mod tests {
"bash -c 'echo %u'"
);
}
#[test]
fn test_clean_desktop_exec_preserves_env() {
// env VAR=value pattern should be preserved
assert_eq!(
clean_desktop_exec_field("env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity %F"),
"env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity"
);
// Multiple env vars
assert_eq!(
clean_desktop_exec_field("env FOO=bar BAZ=qux myapp %u"),
"env FOO=bar BAZ=qux myapp"
);
}
}

View File

@@ -4,13 +4,14 @@ use std::collections::HashSet;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
#[derive(Default)]
pub struct CommandProvider {
items: Vec<LaunchItem>,
}
impl CommandProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
Self::default()
}
fn get_path_dirs() -> Vec<PathBuf> {
@@ -87,6 +88,7 @@ impl Provider for CommandProvider {
provider: ProviderType::Command,
command: name,
terminal: false,
tags: Vec::new(),
};
self.items.push(item);
@@ -96,7 +98,8 @@ impl Provider for CommandProvider {
debug!("Found {} commands in PATH", self.items.len());
// Sort alphabetically
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
self.items
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
fn items(&self) -> &[LaunchItem] {

View File

@@ -0,0 +1,142 @@
//! LuaProvider - Bridge between Lua plugins and the Provider trait
//!
//! This module provides a `LuaProvider` struct that implements the `Provider` trait
//! by delegating to a Lua plugin's registered provider functions.
use std::cell::RefCell;
use std::rc::Rc;
use crate::plugins::{LoadedPlugin, PluginItem, ProviderRegistration};
use super::{LaunchItem, Provider, ProviderType};
/// A provider backed by a Lua plugin
///
/// This struct implements the `Provider` trait by calling into a Lua plugin's
/// `refresh` or `query` functions.
pub struct LuaProvider {
/// Provider registration info
registration: ProviderRegistration,
/// Reference to the loaded plugin (shared with other providers from same plugin)
plugin: Rc<RefCell<LoadedPlugin>>,
/// Cached items from last refresh
items: Vec<LaunchItem>,
}
impl LuaProvider {
/// Create a new LuaProvider
pub fn new(registration: ProviderRegistration, plugin: Rc<RefCell<LoadedPlugin>>) -> Self {
Self {
registration,
plugin,
items: Vec::new(),
}
}
/// Convert a PluginItem to a LaunchItem
fn convert_item(&self, item: PluginItem) -> LaunchItem {
LaunchItem {
id: item.id,
name: item.name,
description: item.description,
icon: item.icon,
provider: ProviderType::Plugin(self.registration.type_id.clone()),
command: item.command.unwrap_or_default(),
terminal: item.terminal,
tags: item.tags,
}
}
}
impl Provider for LuaProvider {
fn name(&self) -> &str {
&self.registration.name
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(self.registration.type_id.clone())
}
fn refresh(&mut self) {
// Only refresh static providers
if !self.registration.is_static {
return;
}
let plugin = self.plugin.borrow();
match plugin.call_provider_refresh(&self.registration.name) {
Ok(items) => {
self.items = items.into_iter().map(|i| self.convert_item(i)).collect();
log::debug!(
"[LuaProvider] '{}' refreshed with {} items",
self.registration.name,
self.items.len()
);
}
Err(e) => {
log::error!(
"[LuaProvider] Failed to refresh '{}': {}",
self.registration.name,
e
);
self.items.clear();
}
}
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
// LuaProvider needs to be Send + Sync for the Provider trait.
// Rc<RefCell<>> is !Send and !Sync, but the ProviderManager RwLock ensures
// Rc<RefCell<>> is only accessed during refresh() (write lock = exclusive access).
// Read-only operations (items(), search) only touch self.items (Vec<LaunchItem>).
unsafe impl Send for LuaProvider {}
unsafe impl Sync for LuaProvider {}
/// Create LuaProviders from all registered providers in a plugin
pub fn create_providers_from_plugin(plugin: Rc<RefCell<LoadedPlugin>>) -> Vec<Box<dyn Provider>> {
let registrations = {
let p = plugin.borrow();
match p.get_provider_registrations() {
Ok(regs) => regs,
Err(e) => {
log::error!("[LuaProvider] Failed to get registrations: {}", e);
return Vec::new();
}
}
};
registrations
.into_iter()
.map(|reg| {
let provider = LuaProvider::new(reg, plugin.clone());
Box::new(provider) as Box<dyn Provider>
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
// Note: Full integration tests require a complete plugin setup
// These tests verify the basic structure
#[test]
fn test_provider_type() {
let reg = ProviderRegistration {
name: "test".to_string(),
display_name: "Test".to_string(),
type_id: "test_provider".to_string(),
default_icon: "test-icon".to_string(),
is_static: true,
prefix: None,
};
// We can't easily create a mock LoadedPlugin, so just test the type
assert_eq!(reg.type_id, "test_provider");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,215 @@
//! Native Plugin Provider Bridge
//!
//! This module provides a bridge between native plugins (compiled .so files)
//! and the core Provider trait used by ProviderManager.
//!
//! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files
//! and provide search providers via an ABI-stable interface.
use std::sync::{Arc, RwLock};
use log::debug;
use owlry_plugin_api::{
PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition,
};
use super::{LaunchItem, Provider, ProviderType};
use crate::plugins::native_loader::NativePlugin;
/// A provider backed by a native plugin
///
/// This wraps a native plugin's provider and implements the core Provider trait,
/// allowing native plugins to be used seamlessly with the existing ProviderManager.
pub struct NativeProvider {
/// The native plugin (shared reference since multiple providers may use same plugin)
plugin: Arc<NativePlugin>,
/// Provider metadata
info: ProviderInfo,
/// Handle to the provider state in the plugin
handle: ProviderHandle,
/// Cached items (for static providers)
items: RwLock<Vec<LaunchItem>>,
}
impl NativeProvider {
/// Create a new native provider
pub fn new(plugin: Arc<NativePlugin>, info: ProviderInfo) -> Self {
let handle = plugin.init_provider(info.id.as_str());
Self {
plugin,
info,
handle,
items: RwLock::new(Vec::new()),
}
}
/// Get the ProviderType for this native provider
/// All native plugins return Plugin(type_id) - the core has no hardcoded plugin types
fn get_provider_type(&self) -> ProviderType {
ProviderType::Plugin(self.info.type_id.to_string())
}
/// Convert a plugin API item to a core LaunchItem
fn convert_item(&self, item: ApiPluginItem) -> LaunchItem {
LaunchItem {
id: item.id.to_string(),
name: item.name.to_string(),
description: item.description.as_ref().map(|s| s.to_string()).into(),
icon: item.icon.as_ref().map(|s| s.to_string()).into(),
provider: self.get_provider_type(),
command: item.command.to_string(),
terminal: item.terminal,
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
}
}
/// Query the provider
///
/// For dynamic providers, this is called per-keystroke.
/// For static providers, returns cached items unless query is a special command
/// (submenu queries `?SUBMENU:` or action commands `!ACTION:`).
pub fn query(&self, query: &str) -> Vec<LaunchItem> {
// Special queries (submenu, actions) should always be forwarded to the plugin
let is_special_query = query.starts_with("?SUBMENU:") || query.starts_with("!");
if self.info.provider_type != ProviderKind::Dynamic && !is_special_query {
return self.items.read().unwrap().clone();
}
let api_items = self.plugin.query_provider(self.handle, query);
api_items
.into_iter()
.map(|item| self.convert_item(item))
.collect()
}
/// Check if this provider has a prefix that matches the query
#[allow(dead_code)]
pub fn matches_prefix(&self, query: &str) -> bool {
match self.info.prefix.as_ref().into_option() {
Some(prefix) => query.starts_with(prefix.as_str()),
None => false,
}
}
/// Get the prefix for this provider (if any)
#[allow(dead_code)]
pub fn prefix(&self) -> Option<&str> {
self.info.prefix.as_ref().map(|s| s.as_str()).into()
}
/// Check if this is a dynamic provider
#[allow(dead_code)]
pub fn is_dynamic(&self) -> bool {
self.info.provider_type == ProviderKind::Dynamic
}
/// Get the provider type ID (e.g., "calc", "clipboard", "weather")
pub fn type_id(&self) -> &str {
self.info.type_id.as_str()
}
/// Check if this is a widget provider (appears at top of results)
pub fn is_widget(&self) -> bool {
self.info.position == ProviderPosition::Widget
}
/// Get the provider's priority for result ordering
/// Higher values appear first in results
pub fn priority(&self) -> i32 {
self.info.priority
}
/// Get the provider's default icon name
pub fn icon(&self) -> &str {
self.info.icon.as_str()
}
/// Get the provider's display position as a string
pub fn position_str(&self) -> &str {
match self.info.position {
ProviderPosition::Widget => "widget",
ProviderPosition::Normal => "normal",
}
}
/// Execute an action command on the provider
/// Uses query with "!" prefix to trigger action handling in the plugin
pub fn execute_action(&self, action: &str) {
let action_query = format!("!{}", action);
self.plugin.query_provider(self.handle, &action_query);
}
}
impl Provider for NativeProvider {
fn name(&self) -> &str {
self.info.name.as_str()
}
fn provider_type(&self) -> ProviderType {
self.get_provider_type()
}
fn refresh(&mut self) {
// Only refresh static providers
if self.info.provider_type != ProviderKind::Static {
return;
}
debug!("Refreshing native provider '{}'", self.info.name.as_str());
let api_items = self.plugin.refresh_provider(self.handle);
let items: Vec<LaunchItem> = api_items
.into_iter()
.map(|item| self.convert_item(item))
.collect();
debug!(
"Native provider '{}' loaded {} items",
self.info.name.as_str(),
items.len()
);
*self.items.write().unwrap() = items;
}
fn items(&self) -> &[LaunchItem] {
// This is tricky with RwLock - we need to return a reference but can't
// 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)
}
}
}
impl Drop for NativeProvider {
fn drop(&mut self) {
// Clean up the provider handle
self.plugin.drop_provider(self.handle);
}
}
#[cfg(test)]
mod tests {
use super::*;
// Note: Full testing requires actual .so plugins, which we'll test
// via integration tests. Unit tests here focus on the conversion logic.
#[test]
fn test_provider_type_conversion() {
// Test that type_id is correctly converted to ProviderType::Plugin
let type_id = "calculator";
let provider_type = ProviderType::Plugin(type_id.to_string());
assert_eq!(format!("{}", provider_type), "calculator");
}
}

View File

@@ -0,0 +1,286 @@
use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use std::thread;
/// Maximum allowed size for a single IPC request line (1 MiB).
const MAX_REQUEST_SIZE: usize = 1_048_576;
use log::{error, info, warn};
use crate::config::Config;
use crate::data::FrecencyStore;
use crate::filter::ProviderFilter;
use crate::ipc::{ProviderDesc, Request, Response, ResultItem};
use crate::providers::{LaunchItem, ProviderManager};
/// IPC server that listens on a Unix domain socket and dispatches
/// requests to the provider system.
pub struct Server {
listener: UnixListener,
socket_path: PathBuf,
provider_manager: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
}
impl Server {
/// Bind to the given socket path, loading config and creating a ProviderManager.
///
/// Removes a stale socket file if one already exists at the path.
pub fn bind(socket_path: &Path) -> io::Result<Self> {
// Remove stale socket if present
if socket_path.exists() {
info!("Removing stale socket at {:?}", socket_path);
std::fs::remove_file(socket_path)?;
}
let listener = UnixListener::bind(socket_path)?;
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
info!("IPC server listening on {:?}", socket_path);
let config = Config::load_or_default();
let provider_manager = ProviderManager::new_with_config(&config);
let frecency = FrecencyStore::new();
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),
})
}
/// Accept connections in a loop, spawning a thread per client.
pub fn run(&self) -> io::Result<()> {
// Start filesystem watcher for user plugin hot-reload
crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager));
info!("Server entering accept loop");
for stream in self.listener.incoming() {
match stream {
Ok(stream) => {
let pm = Arc::clone(&self.provider_manager);
let frecency = Arc::clone(&self.frecency);
let config = Arc::clone(&self.config);
thread::spawn(move || {
if let Err(e) = Self::handle_client(stream, pm, frecency, config) {
warn!("Client handler error: {}", e);
}
});
}
Err(e) => {
error!("Failed to accept connection: {}", e);
}
}
}
Ok(())
}
/// Accept one connection and handle all its requests until EOF.
///
/// Intended for integration tests where spawning a full accept loop
/// is unnecessary.
pub fn handle_one_for_testing(&self) -> io::Result<()> {
let (stream, _addr) = self.listener.accept()?;
Self::handle_client(
stream,
Arc::clone(&self.provider_manager),
Arc::clone(&self.frecency),
Arc::clone(&self.config),
)
}
/// Read newline-delimited JSON requests from a single client stream,
/// dispatch each, and write the JSON response back.
fn handle_client(
stream: UnixStream,
pm: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
) -> io::Result<()> {
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
let mut reader = BufReader::new(stream.try_clone()?);
let mut writer = stream;
loop {
let mut line = String::new();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
break;
}
if line.len() > MAX_REQUEST_SIZE {
let resp = Response::Error {
message: format!(
"request too large ({} bytes, max {})",
line.len(),
MAX_REQUEST_SIZE
),
};
write_response(&mut writer, &resp)?;
break;
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let request: Request = match serde_json::from_str(trimmed) {
Ok(req) => req,
Err(e) => {
warn!("Malformed request from client: {}", e);
let resp = Response::Error {
message: format!("invalid request JSON: {}", e),
};
write_response(&mut writer, &resp)?;
continue;
}
};
let response = Self::handle_request(&request, &pm, &frecency, &config);
write_response(&mut writer, &response)?;
}
Ok(())
}
/// Dispatch a single request to the appropriate subsystem and return
/// the response.
fn handle_request(
request: &Request,
pm: &Arc<RwLock<ProviderManager>>,
frecency: &Arc<RwLock<FrecencyStore>>,
config: &Arc<Config>,
) -> Response {
match request {
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(),
}
}
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
}
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(),
}
}
Request::Refresh { provider } => {
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
pm_guard.refresh_provider(provider);
Response::Ack
}
Request::Toggle => {
// Toggle visibility is a client-side concern; the daemon just acks.
Response::Ack
}
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),
},
}
}
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),
}
}
}
}
}
}
impl Drop for Server {
fn drop(&mut self) {
// Best-effort cleanup of the socket file
if self.socket_path.exists() {
let _ = std::fs::remove_file(&self.socket_path);
}
}
}
/// Serialize a response as a single JSON line terminated by newline.
fn write_response(writer: &mut UnixStream, response: &Response) -> io::Result<()> {
let mut json = serde_json::to_string(response)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
json.push('\n');
writer.write_all(json.as_bytes())?;
writer.flush()
}
fn launch_item_to_result(item: LaunchItem, score: i64) -> ResultItem {
ResultItem {
id: item.id,
title: item.name,
description: item.description.unwrap_or_default(),
icon: item.icon.unwrap_or_default(),
provider: format!("{}", item.provider),
score,
command: Some(item.command),
terminal: item.terminal,
tags: item.tags,
}
}
fn descriptor_to_desc(desc: crate::providers::ProviderDescriptor) -> ProviderDesc {
ProviderDesc {
id: desc.id,
name: desc.name,
prefix: desc.prefix,
icon: desc.icon,
position: desc.position,
}
}

View File

@@ -0,0 +1,148 @@
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
#[test]
fn test_query_request_roundtrip() {
let req = Request::Query {
text: "fire".into(),
modes: Some(vec!["app".into(), "cmd".into()]),
};
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
}
#[test]
fn test_query_request_without_modes() {
let req = Request::Query {
text: "fire".into(),
modes: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("modes"));
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
}
#[test]
fn test_launch_request_roundtrip() {
let req = Request::Launch {
item_id: "firefox.desktop".into(),
provider: "app".into(),
};
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
}
#[test]
fn test_results_response_roundtrip() {
let resp = Response::Results {
items: vec![ResultItem {
id: "firefox.desktop".into(),
title: "Firefox".into(),
description: "Web Browser".into(),
icon: "firefox".into(),
provider: "app".into(),
score: 95,
command: Some("firefox".into()),
terminal: false,
tags: vec![],
}],
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: Response = serde_json::from_str(&json).unwrap();
assert_eq!(resp, parsed);
}
#[test]
fn test_providers_response() {
let resp = Response::Providers {
list: vec![ProviderDesc {
id: "app".into(),
name: "Applications".into(),
prefix: Some(":app".into()),
icon: "application-x-executable".into(),
position: "normal".into(),
}],
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: Response = serde_json::from_str(&json).unwrap();
assert_eq!(resp, parsed);
}
#[test]
fn test_error_response() {
let resp = Response::Error {
message: "plugin not found".into(),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: Response = serde_json::from_str(&json).unwrap();
assert_eq!(resp, parsed);
}
#[test]
fn test_toggle_request() {
let req = Request::Toggle;
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
}
#[test]
fn test_submenu_request() {
let req = Request::Submenu {
plugin_id: "systemd".into(),
data: "docker.service".into(),
};
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
}
#[test]
fn test_refresh_request() {
let req = Request::Refresh {
provider: "clipboard".into(),
};
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
}
#[test]
fn test_plugin_action_request() {
let req = Request::PluginAction {
command: "POMODORO:start".into(),
};
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
}
#[test]
fn test_terminal_field_defaults_false() {
// terminal field should default to false when missing from JSON
let json =
r#"{"id":"test","title":"Test","description":"","icon":"","provider":"cmd","score":0}"#;
let item: ResultItem = serde_json::from_str(json).unwrap();
assert!(!item.terminal);
}
#[test]
fn test_terminal_field_roundtrip() {
let item = ResultItem {
id: "htop".into(),
title: "htop".into(),
description: "Process viewer".into(),
icon: "htop".into(),
provider: "cmd".into(),
score: 50,
command: Some("htop".into()),
terminal: true,
tags: vec![],
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("\"terminal\":true"));
let parsed: ResultItem = serde_json::from_str(&json).unwrap();
assert!(parsed.terminal);
}

View File

@@ -0,0 +1,239 @@
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::thread;
use owlry_core::ipc::{Request, Response};
use owlry_core::server::Server;
/// Helper: send a JSON request line and read the JSON response line.
fn roundtrip(stream: &mut UnixStream, request: &Request) -> Response {
let mut line = serde_json::to_string(request).unwrap();
line.push('\n');
stream.write_all(line.as_bytes()).unwrap();
stream.flush().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut buf = String::new();
reader.read_line(&mut buf).unwrap();
serde_json::from_str(buf.trim()).unwrap()
}
#[test]
fn test_server_responds_to_providers_request() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("owlry-test.sock");
let server = Server::bind(&sock).unwrap();
// Spawn the server to handle exactly one connection
let handle = thread::spawn(move || {
server.handle_one_for_testing().unwrap();
});
// Connect as a client
let mut stream = UnixStream::connect(&sock).unwrap();
let resp = roundtrip(&mut stream, &Request::Providers);
match resp {
Response::Providers { list } => {
// The default ProviderManager always has at least Application and Command
assert!(
list.len() >= 2,
"expected at least 2 providers, got {}",
list.len()
);
let ids: Vec<&str> = list.iter().map(|p| p.id.as_str()).collect();
assert!(ids.contains(&"app"), "missing 'app' provider");
assert!(ids.contains(&"cmd"), "missing 'cmd' provider");
}
other => panic!("expected Providers response, got: {:?}", other),
}
drop(stream);
handle.join().unwrap();
}
#[test]
fn test_server_handles_launch_request() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("owlry-test.sock");
let server = Server::bind(&sock).unwrap();
let handle = thread::spawn(move || {
server.handle_one_for_testing().unwrap();
});
let mut stream = UnixStream::connect(&sock).unwrap();
let req = Request::Launch {
item_id: "firefox.desktop".into(),
provider: "app".into(),
};
let resp = roundtrip(&mut stream, &req);
assert_eq!(resp, Response::Ack);
drop(stream);
handle.join().unwrap();
}
#[test]
fn test_server_handles_query_request() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("owlry-test.sock");
let server = Server::bind(&sock).unwrap();
let handle = thread::spawn(move || {
server.handle_one_for_testing().unwrap();
});
let mut stream = UnixStream::connect(&sock).unwrap();
let req = Request::Query {
text: "nonexistent_query_xyz".into(),
modes: None,
};
let resp = roundtrip(&mut stream, &req);
match resp {
Response::Results { items } => {
// A nonsense query should return empty or very few results
// (no items will fuzzy-match "nonexistent_query_xyz")
assert!(
items.len() <= 5,
"expected few/no results for gibberish query"
);
}
other => panic!("expected Results response, got: {:?}", other),
}
drop(stream);
handle.join().unwrap();
}
#[test]
fn test_server_handles_toggle_request() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("owlry-test.sock");
let server = Server::bind(&sock).unwrap();
let handle = thread::spawn(move || {
server.handle_one_for_testing().unwrap();
});
let mut stream = UnixStream::connect(&sock).unwrap();
let resp = roundtrip(&mut stream, &Request::Toggle);
assert_eq!(resp, Response::Ack);
drop(stream);
handle.join().unwrap();
}
#[test]
fn test_server_handles_refresh_request() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("owlry-test.sock");
let server = Server::bind(&sock).unwrap();
let handle = thread::spawn(move || {
server.handle_one_for_testing().unwrap();
});
let mut stream = UnixStream::connect(&sock).unwrap();
let req = Request::Refresh {
provider: "app".into(),
};
let resp = roundtrip(&mut stream, &req);
assert_eq!(resp, Response::Ack);
drop(stream);
handle.join().unwrap();
}
#[test]
fn test_server_handles_submenu_for_unknown_plugin() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("owlry-test.sock");
let server = Server::bind(&sock).unwrap();
let handle = thread::spawn(move || {
server.handle_one_for_testing().unwrap();
});
let mut stream = UnixStream::connect(&sock).unwrap();
let req = Request::Submenu {
plugin_id: "nonexistent_plugin".into(),
data: "some_data".into(),
};
let resp = roundtrip(&mut stream, &req);
match resp {
Response::Error { message } => {
assert!(
message.contains("nonexistent_plugin"),
"error should mention the plugin id"
);
}
other => panic!(
"expected Error response for unknown plugin, got: {:?}",
other
),
}
drop(stream);
handle.join().unwrap();
}
#[test]
fn test_server_handles_multiple_requests_per_connection() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("owlry-test.sock");
let server = Server::bind(&sock).unwrap();
let handle = thread::spawn(move || {
server.handle_one_for_testing().unwrap();
});
let mut stream = UnixStream::connect(&sock).unwrap();
// Send Providers request
let resp1 = roundtrip(&mut stream, &Request::Providers);
assert!(matches!(resp1, Response::Providers { .. }));
// Send Toggle request on same connection
let resp2 = roundtrip(&mut stream, &Request::Toggle);
assert_eq!(resp2, Response::Ack);
drop(stream);
handle.join().unwrap();
}
#[test]
fn test_server_cleans_up_stale_socket() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("owlry-test.sock");
// Create a stale socket file
std::os::unix::net::UnixListener::bind(&sock).unwrap();
assert!(sock.exists());
// Server::bind should succeed by removing the stale socket
let server = Server::bind(&sock).unwrap();
let handle = thread::spawn(move || {
server.handle_one_for_testing().unwrap();
});
let mut stream = UnixStream::connect(&sock).unwrap();
let resp = roundtrip(&mut stream, &Request::Toggle);
assert_eq!(resp, Response::Ack);
drop(stream);
handle.join().unwrap();
}

View File

@@ -0,0 +1,46 @@
[package]
name = "owlry-lua"
version = "1.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Lua runtime for owlry plugins - enables loading user-created Lua plugins"
keywords = ["owlry", "plugin", "lua", "runtime"]
categories = ["development-tools"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry (shared types)
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types
abi_stable = "0.11"
# Lua runtime
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"] }
# Plugin manifest parsing
toml = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Version compatibility
semver = "1"
# HTTP client for plugins
reqwest = { version = "0.13", features = ["blocking", "json"] }
# Math expression evaluation
meval = "0.2"
# Date/time for os.date
chrono = "0.4"
# XDG paths
dirs = "5.0"
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,52 @@
//! Lua API implementations for plugins
//!
//! This module provides the `owlry` global table and its submodules
//! that plugins can use to interact with owlry.
mod provider;
mod utils;
use mlua::{Lua, Result as LuaResult};
use owlry_plugin_api::PluginItem;
use crate::loader::ProviderRegistration;
/// Register all owlry APIs in the Lua runtime
pub fn register_apis(lua: &Lua, plugin_dir: &std::path::Path, plugin_id: &str) -> LuaResult<()> {
let globals = lua.globals();
// Create the main owlry table
let owlry = lua.create_table()?;
// Register utility APIs (log, path, fs, json)
utils::register_log_api(lua, &owlry)?;
utils::register_path_api(lua, &owlry, plugin_dir)?;
utils::register_fs_api(lua, &owlry, plugin_dir)?;
utils::register_json_api(lua, &owlry)?;
// Register provider API
provider::register_provider_api(lua, &owlry)?;
// Set owlry as global
globals.set("owlry", owlry)?;
// Suppress unused warnings
let _ = plugin_id;
Ok(())
}
/// Get provider registrations from the Lua runtime
pub fn get_provider_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
provider::get_registrations(lua)
}
/// Call a provider's refresh function
pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>> {
provider::call_refresh(lua, provider_name)
}
/// Call a provider's query function
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
provider::call_query(lua, provider_name, query)
}

View File

@@ -0,0 +1,258 @@
//! Provider registration API for Lua plugins
use mlua::{Function, Lua, Result as LuaResult, Table, Value};
use owlry_plugin_api::PluginItem;
use std::cell::RefCell;
use crate::loader::ProviderRegistration;
thread_local! {
static REGISTRATIONS: RefCell<Vec<ProviderRegistration>> = const { RefCell::new(Vec::new()) };
}
/// Register the provider API in the owlry table
pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let provider = lua.create_table()?;
// owlry.provider.register(config)
provider.set("register", lua.create_function(register_provider)?)?;
owlry.set("provider", provider)?;
Ok(())
}
/// Implementation of owlry.provider.register()
fn register_provider(lua: &Lua, config: Table) -> LuaResult<()> {
let name: String = config.get("name")?;
let display_name: String = config
.get::<Option<String>>("display_name")?
.unwrap_or_else(|| name.clone());
let type_id: String = config
.get::<Option<String>>("type_id")?
.unwrap_or_else(|| name.replace('-', "_"));
let default_icon: String = config
.get::<Option<String>>("default_icon")?
.unwrap_or_else(|| "application-x-addon".to_string());
let prefix: Option<String> = config.get("prefix")?;
// Check if it's a dynamic provider (has query function) or static (has refresh)
let has_query: bool = config.contains_key("query")?;
let has_refresh: bool = config.contains_key("refresh")?;
if !has_query && !has_refresh {
return Err(mlua::Error::external(
"Provider must have either 'refresh' or 'query' function",
));
}
let is_dynamic = has_query;
// Store the config table in owlry.provider._registrations[name]
// so call_refresh/call_query can find the callback functions later
let globals = lua.globals();
let owlry: Table = globals.get("owlry")?;
let provider: Table = owlry.get("provider")?;
let registrations: Table = match provider.get::<Value>("_registrations")? {
Value::Table(t) => t,
_ => {
let t = lua.create_table()?;
provider.set("_registrations", t.clone())?;
t
}
};
registrations.set(name.as_str(), config)?;
REGISTRATIONS.with(|regs| {
regs.borrow_mut().push(ProviderRegistration {
name,
display_name,
type_id,
default_icon,
prefix,
is_dynamic,
});
});
Ok(())
}
/// Get all registered providers
pub fn get_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
// Suppress unused warning
let _ = lua;
REGISTRATIONS.with(|regs| Ok(regs.borrow().clone()))
}
/// Call a provider's refresh function
pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>> {
let globals = lua.globals();
let owlry: Table = globals.get("owlry")?;
let provider: Table = owlry.get("provider")?;
// Get the registered providers table (internal)
let registrations: Table = match provider.get::<Value>("_registrations")? {
Value::Table(t) => t,
_ => {
// Try to find the config directly from the global scope
// This happens when register was called with the config table
return call_provider_function(lua, provider_name, "refresh", None);
}
};
let config: Table = match registrations.get(provider_name)? {
Value::Table(t) => t,
_ => return Ok(Vec::new()),
};
let refresh_fn: Function = match config.get("refresh")? {
Value::Function(f) => f,
_ => return Ok(Vec::new()),
};
let result: Value = refresh_fn.call(())?;
parse_items_result(result)
}
/// Call a provider's query function
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
call_provider_function(lua, provider_name, "query", Some(query))
}
/// Call a provider function by name
fn call_provider_function(
lua: &Lua,
provider_name: &str,
function_name: &str,
query: Option<&str>,
) -> LuaResult<Vec<PluginItem>> {
// Search through all registered providers in the Lua globals
// This is a workaround since we store registrations thread-locally
let globals = lua.globals();
// Try to find a registered provider with matching name
// First check if there's a _providers table
if let Ok(Value::Table(providers)) = globals.get::<Value>("_owlry_providers")
&& let Ok(Value::Table(config)) = providers.get::<Value>(provider_name)
&& let Ok(Value::Function(func)) = config.get::<Value>(function_name)
{
let result: Value = match query {
Some(q) => func.call(q)?,
None => func.call(())?,
};
return parse_items_result(result);
}
// Fall back: search through globals for functions
// This is less reliable but handles simple cases
Ok(Vec::new())
}
/// Parse items from Lua return value
fn parse_items_result(result: Value) -> LuaResult<Vec<PluginItem>> {
let mut items = Vec::new();
if let Value::Table(table) = result {
for pair in table.pairs::<i32, Table>() {
let (_, item_table) = pair?;
if let Ok(item) = parse_item(&item_table) {
items.push(item);
}
}
}
Ok(items)
}
/// Parse a single item from a Lua table
fn parse_item(table: &Table) -> LuaResult<PluginItem> {
let id: String = table.get("id")?;
let name: String = table.get("name")?;
let command: String = table.get::<Option<String>>("command")?.unwrap_or_default();
let description: Option<String> = table.get("description")?;
let icon: Option<String> = table.get("icon")?;
let terminal: bool = table.get::<Option<bool>>("terminal")?.unwrap_or(false);
let tags: Vec<String> = table
.get::<Option<Vec<String>>>("tags")?
.unwrap_or_default();
let mut item = PluginItem::new(id, name, command);
if let Some(desc) = description {
item = item.with_description(desc);
}
if let Some(ic) = icon {
item = item.with_icon(&ic);
}
if terminal {
item = item.with_terminal(true);
}
if !tags.is_empty() {
item = item.with_keywords(tags);
}
Ok(item)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{SandboxConfig, create_lua_runtime};
#[test]
fn test_register_static_provider() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_provider_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let code = r#"
owlry.provider.register({
name = "test-provider",
display_name = "Test Provider",
refresh = function()
return {
{ id = "1", name = "Item 1" }
}
end
})
"#;
lua.load(code).set_name("test").call::<()>(()).unwrap();
let regs = get_registrations(&lua).unwrap();
assert_eq!(regs.len(), 1);
assert_eq!(regs[0].name, "test-provider");
assert!(!regs[0].is_dynamic);
}
#[test]
fn test_register_dynamic_provider() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_provider_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let code = r#"
owlry.provider.register({
name = "query-provider",
prefix = "?",
query = function(q)
return {
{ id = "search", name = "Search: " .. q }
}
end
})
"#;
lua.load(code).set_name("test").call::<()>(()).unwrap();
let regs = get_registrations(&lua).unwrap();
assert_eq!(regs.len(), 1);
assert_eq!(regs[0].name, "query-provider");
assert!(regs[0].is_dynamic);
assert_eq!(regs[0].prefix, Some("?".to_string()));
}
}

View File

@@ -0,0 +1,447 @@
//! Utility APIs: logging, paths, filesystem, JSON
use mlua::{Lua, Result as LuaResult, Table, Value};
use std::path::{Path, PathBuf};
// ============================================================================
// Logging API
// ============================================================================
/// Register the log API in the owlry table
pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let log = lua.create_table()?;
log.set(
"debug",
lua.create_function(|_, msg: String| {
eprintln!("[DEBUG] {}", msg);
Ok(())
})?,
)?;
log.set(
"info",
lua.create_function(|_, msg: String| {
eprintln!("[INFO] {}", msg);
Ok(())
})?,
)?;
log.set(
"warn",
lua.create_function(|_, msg: String| {
eprintln!("[WARN] {}", msg);
Ok(())
})?,
)?;
log.set(
"error",
lua.create_function(|_, msg: String| {
eprintln!("[ERROR] {}", msg);
Ok(())
})?,
)?;
owlry.set("log", log)?;
Ok(())
}
// ============================================================================
// Path API
// ============================================================================
/// Register the path API in the owlry table
pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> {
let path = lua.create_table()?;
// owlry.path.config() -> ~/.config/owlry
path.set(
"config",
lua.create_function(|_, ()| {
Ok(dirs::config_dir()
.map(|d| d.join("owlry"))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default())
})?,
)?;
// owlry.path.data() -> ~/.local/share/owlry
path.set(
"data",
lua.create_function(|_, ()| {
Ok(dirs::data_dir()
.map(|d| d.join("owlry"))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default())
})?,
)?;
// owlry.path.cache() -> ~/.cache/owlry
path.set(
"cache",
lua.create_function(|_, ()| {
Ok(dirs::cache_dir()
.map(|d| d.join("owlry"))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default())
})?,
)?;
// owlry.path.home() -> ~
path.set(
"home",
lua.create_function(|_, ()| {
Ok(dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default())
})?,
)?;
// owlry.path.join(...) -> joined path
path.set(
"join",
lua.create_function(|_, parts: mlua::Variadic<String>| {
let mut path = PathBuf::new();
for part in parts {
path.push(part);
}
Ok(path.to_string_lossy().to_string())
})?,
)?;
// owlry.path.plugin_dir() -> plugin directory
let plugin_dir_str = plugin_dir.to_string_lossy().to_string();
path.set(
"plugin_dir",
lua.create_function(move |_, ()| Ok(plugin_dir_str.clone()))?,
)?;
// owlry.path.expand(path) -> expanded path (~ -> home)
path.set(
"expand",
lua.create_function(|_, path: String| {
if path.starts_with("~/")
&& let Some(home) = dirs::home_dir()
{
return Ok(home.join(&path[2..]).to_string_lossy().to_string());
}
Ok(path)
})?,
)?;
owlry.set("path", path)?;
Ok(())
}
// ============================================================================
// Filesystem API
// ============================================================================
/// Register the fs API in the owlry table
pub fn register_fs_api(lua: &Lua, owlry: &Table, _plugin_dir: &Path) -> LuaResult<()> {
let fs = lua.create_table()?;
// owlry.fs.exists(path) -> bool
fs.set(
"exists",
lua.create_function(|_, path: String| {
let path = expand_path(&path);
Ok(Path::new(&path).exists())
})?,
)?;
// owlry.fs.is_dir(path) -> bool
fs.set(
"is_dir",
lua.create_function(|_, path: String| {
let path = expand_path(&path);
Ok(Path::new(&path).is_dir())
})?,
)?;
// owlry.fs.read(path) -> string or nil
fs.set(
"read",
lua.create_function(|_, path: String| {
let path = expand_path(&path);
match std::fs::read_to_string(&path) {
Ok(content) => Ok(Some(content)),
Err(_) => Ok(None),
}
})?,
)?;
// owlry.fs.read_lines(path) -> table of strings or nil
fs.set(
"read_lines",
lua.create_function(|lua, path: String| {
let path = expand_path(&path);
match std::fs::read_to_string(&path) {
Ok(content) => {
let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
Ok(Some(lua.create_sequence_from(lines)?))
}
Err(_) => Ok(None),
}
})?,
)?;
// owlry.fs.list_dir(path) -> table of filenames or nil
fs.set(
"list_dir",
lua.create_function(|lua, path: String| {
let path = expand_path(&path);
match std::fs::read_dir(&path) {
Ok(entries) => {
let names: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
Ok(Some(lua.create_sequence_from(names)?))
}
Err(_) => Ok(None),
}
})?,
)?;
// owlry.fs.read_json(path) -> table or nil
fs.set(
"read_json",
lua.create_function(|lua, path: String| {
let path = expand_path(&path);
match std::fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => json_to_lua(lua, &value),
Err(_) => Ok(Value::Nil),
},
Err(_) => Ok(Value::Nil),
}
})?,
)?;
// owlry.fs.write(path, content) -> bool
fs.set(
"write",
lua.create_function(|_, (path, content): (String, String)| {
let path = expand_path(&path);
// Create parent directories if needed
if let Some(parent) = Path::new(&path).parent() {
let _ = std::fs::create_dir_all(parent);
}
Ok(std::fs::write(&path, content).is_ok())
})?,
)?;
owlry.set("fs", fs)?;
Ok(())
}
// ============================================================================
// JSON API
// ============================================================================
/// Register the json API in the owlry table
pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let json = lua.create_table()?;
// owlry.json.encode(value) -> string
json.set(
"encode",
lua.create_function(|lua, value: Value| {
let json_value = lua_to_json(lua, &value)?;
Ok(serde_json::to_string(&json_value).unwrap_or_else(|_| "null".to_string()))
})?,
)?;
// owlry.json.decode(string) -> value or nil
json.set(
"decode",
lua.create_function(|lua, s: String| {
match serde_json::from_str::<serde_json::Value>(&s) {
Ok(value) => json_to_lua(lua, &value),
Err(_) => Ok(Value::Nil),
}
})?,
)?;
owlry.set("json", json)?;
Ok(())
}
// ============================================================================
// Helper Functions
// ============================================================================
/// Expand ~ in paths
fn expand_path(path: &str) -> String {
if path.starts_with("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(&path[2..]).to_string_lossy().to_string();
}
path.to_string()
}
/// Convert JSON value to Lua value
fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<Value> {
match value {
serde_json::Value::Null => Ok(Value::Nil),
serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(Value::Number(f))
} else {
Ok(Value::Nil)
}
}
serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)),
serde_json::Value::Array(arr) => {
let table = lua.create_table()?;
for (i, v) in arr.iter().enumerate() {
table.set(i + 1, json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
serde_json::Value::Object(obj) => {
let table = lua.create_table()?;
for (k, v) in obj {
table.set(k.as_str(), json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
}
}
/// Convert Lua value to JSON value
fn lua_to_json(_lua: &Lua, value: &Value) -> LuaResult<serde_json::Value> {
match value {
Value::Nil => Ok(serde_json::Value::Null),
Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
Value::Number(n) => Ok(serde_json::json!(*n)),
Value::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_string())),
Value::Table(t) => {
// Check if it's an array (sequential integer keys starting from 1)
let mut is_array = true;
let mut max_key = 0i64;
for pair in t.clone().pairs::<Value, Value>() {
let (k, _) = pair?;
match k {
Value::Integer(i) if i > 0 => {
max_key = max_key.max(i);
}
_ => {
is_array = false;
break;
}
}
}
if is_array && max_key > 0 {
let mut arr = Vec::new();
for i in 1..=max_key {
let v: Value = t.get(i)?;
arr.push(lua_to_json(_lua, &v)?);
}
Ok(serde_json::Value::Array(arr))
} else {
let mut obj = serde_json::Map::new();
for pair in t.clone().pairs::<String, Value>() {
let (k, v) = pair?;
obj.insert(k, lua_to_json(_lua, &v)?);
}
Ok(serde_json::Value::Object(obj))
}
}
_ => Ok(serde_json::Value::Null),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{SandboxConfig, create_lua_runtime};
#[test]
fn test_log_api() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_log_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
// Just verify it doesn't panic
lua.load("owlry.log.info('test message')")
.set_name("test")
.call::<()>(())
.unwrap();
}
#[test]
fn test_path_api() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_path_api(&lua, &owlry, Path::new("/tmp/test-plugin")).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let home: String = lua
.load("return owlry.path.home()")
.set_name("test")
.call(())
.unwrap();
assert!(!home.is_empty());
let plugin_dir: String = lua
.load("return owlry.path.plugin_dir()")
.set_name("test")
.call(())
.unwrap();
assert_eq!(plugin_dir, "/tmp/test-plugin");
}
#[test]
fn test_fs_api() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_fs_api(&lua, &owlry, Path::new("/tmp")).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let exists: bool = lua
.load("return owlry.fs.exists('/tmp')")
.set_name("test")
.call(())
.unwrap();
assert!(exists);
let is_dir: bool = lua
.load("return owlry.fs.is_dir('/tmp')")
.set_name("test")
.call(())
.unwrap();
assert!(is_dir);
}
#[test]
fn test_json_api() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_json_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let code = r#"
local t = { name = "test", value = 42 }
local json = owlry.json.encode(t)
local decoded = owlry.json.decode(json)
return decoded.name, decoded.value
"#;
let (name, value): (String, i32) = lua.load(code).set_name("test").call(()).unwrap();
assert_eq!(name, "test");
assert_eq!(value, 42);
}
}

338
crates/owlry-lua/src/lib.rs Normal file
View File

@@ -0,0 +1,338 @@
//! Owlry Lua Runtime
//!
//! This crate provides Lua plugin support for owlry. It is loaded dynamically
//! by the core when Lua plugins need to be executed.
//!
//! # Architecture
//!
//! The runtime acts as a "meta-plugin" that:
//! 1. Discovers Lua plugins in `~/.config/owlry/plugins/`
//! 2. Creates sandboxed Lua VMs for each plugin
//! 3. Registers the `owlry` API table
//! 4. Bridges Lua providers to native `PluginItem` format
//!
//! # Plugin Structure
//!
//! Each plugin lives in its own directory:
//! ```text
//! ~/.config/owlry/plugins/
//! my-plugin/
//! plugin.toml # Plugin manifest
//! init.lua # Entry point
//! ```
mod api;
mod loader;
mod manifest;
mod runtime;
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::PluginItem;
use std::collections::HashMap;
use std::path::PathBuf;
use loader::LoadedPlugin;
/// Runtime vtable - exported interface for the core to use
#[repr(C)]
pub struct LuaRuntimeVTable {
/// Get runtime info
pub info: extern "C" fn() -> RuntimeInfo,
/// Initialize the runtime with plugins directory
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
/// Get provider infos from all loaded plugins
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<LuaProviderInfo>,
/// Refresh a provider's items
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
/// Query a dynamic provider
pub query: extern "C" fn(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem>,
/// Cleanup and drop the runtime
pub drop: extern "C" fn(handle: RuntimeHandle),
}
/// Runtime info returned by the runtime
#[repr(C)]
pub struct RuntimeInfo {
pub name: RString,
pub version: RString,
}
/// Opaque handle to the runtime state
#[repr(C)]
#[derive(Clone, Copy)]
pub struct RuntimeHandle {
pub ptr: *mut (),
}
unsafe impl Send for RuntimeHandle {}
unsafe impl Sync for RuntimeHandle {}
impl RuntimeHandle {
/// Create a null handle (reserved for error cases)
#[allow(dead_code)]
fn null() -> Self {
Self {
ptr: std::ptr::null_mut(),
}
}
fn from_box<T>(state: Box<T>) -> Self {
Self {
ptr: Box::into_raw(state) as *mut (),
}
}
unsafe fn drop_as<T>(&self) {
if !self.ptr.is_null() {
unsafe { drop(Box::from_raw(self.ptr as *mut T)) };
}
}
}
/// Provider info from a Lua plugin
///
/// Must match ScriptProviderInfo layout in owlry-core/src/plugins/runtime_loader.rs
#[repr(C)]
pub struct LuaProviderInfo {
/// Provider name (used as vtable refresh/query key: "plugin_id:provider_name")
pub name: RString,
/// Display name
pub display_name: RString,
/// Type ID for filtering
pub type_id: RString,
/// Icon name
pub default_icon: RString,
/// Whether this is a static provider (true) or dynamic (false)
pub is_static: bool,
/// Optional prefix trigger
pub prefix: ROption<RString>,
}
/// Internal runtime state
struct LuaRuntimeState {
plugins_dir: PathBuf,
plugins: HashMap<String, LoadedPlugin>,
/// Maps "plugin_id:provider_name" to plugin_id for lookup
provider_map: HashMap<String, String>,
}
impl LuaRuntimeState {
fn new(plugins_dir: PathBuf) -> Self {
Self {
plugins_dir,
plugins: HashMap::new(),
provider_map: HashMap::new(),
}
}
fn discover_and_load(&mut self, owlry_version: &str) {
let discovered = match loader::discover_plugins(&self.plugins_dir) {
Ok(d) => d,
Err(e) => {
eprintln!("owlry-lua: Failed to discover plugins: {}", e);
return;
}
};
for (id, (manifest, path)) in discovered {
// Check version compatibility
if !manifest.is_compatible_with(owlry_version) {
eprintln!(
"owlry-lua: Plugin '{}' not compatible with owlry {}",
id, owlry_version
);
continue;
}
let mut plugin = LoadedPlugin::new(manifest, path);
if let Err(e) = plugin.initialize() {
eprintln!("owlry-lua: Failed to initialize plugin '{}': {}", id, e);
continue;
}
// Build provider map
if let Ok(registrations) = plugin.get_provider_registrations() {
for reg in &registrations {
let full_id = format!("{}:{}", id, reg.name);
self.provider_map.insert(full_id, id.clone());
}
}
self.plugins.insert(id, plugin);
}
}
fn get_providers(&self) -> Vec<LuaProviderInfo> {
let mut providers = Vec::new();
for (plugin_id, plugin) in &self.plugins {
if let Ok(registrations) = plugin.get_provider_registrations() {
for reg in registrations {
let full_id = format!("{}:{}", plugin_id, reg.name);
providers.push(LuaProviderInfo {
name: RString::from(full_id),
display_name: RString::from(reg.display_name.as_str()),
type_id: RString::from(reg.type_id.as_str()),
default_icon: RString::from(reg.default_icon.as_str()),
is_static: !reg.is_dynamic,
prefix: reg.prefix.map(RString::from).into(),
});
}
}
}
providers
}
fn refresh_provider(&self, provider_id: &str) -> Vec<PluginItem> {
// Parse "plugin_id:provider_name"
let parts: Vec<&str> = provider_id.splitn(2, ':').collect();
if parts.len() != 2 {
return Vec::new();
}
let (plugin_id, provider_name) = (parts[0], parts[1]);
if let Some(plugin) = self.plugins.get(plugin_id) {
match plugin.call_provider_refresh(provider_name) {
Ok(items) => items,
Err(e) => {
eprintln!("owlry-lua: Refresh failed for {}: {}", provider_id, e);
Vec::new()
}
}
} else {
Vec::new()
}
}
fn query_provider(&self, provider_id: &str, query: &str) -> Vec<PluginItem> {
// Parse "plugin_id:provider_name"
let parts: Vec<&str> = provider_id.splitn(2, ':').collect();
if parts.len() != 2 {
return Vec::new();
}
let (plugin_id, provider_name) = (parts[0], parts[1]);
if let Some(plugin) = self.plugins.get(plugin_id) {
match plugin.call_provider_query(provider_name, query) {
Ok(items) => items,
Err(e) => {
eprintln!("owlry-lua: Query failed for {}: {}", provider_id, e);
Vec::new()
}
}
} else {
Vec::new()
}
}
}
// ============================================================================
// Exported Functions
// ============================================================================
extern "C" fn runtime_info() -> RuntimeInfo {
RuntimeInfo {
name: RString::from("Lua"),
version: RString::from(env!("CARGO_PKG_VERSION")),
}
}
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)
}
extern "C" fn runtime_providers(handle: RuntimeHandle) -> RVec<LuaProviderInfo> {
if handle.ptr.is_null() {
return RVec::new();
}
let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) };
state.get_providers().into()
}
extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) };
state.refresh_provider(provider_id.as_str()).into()
}
extern "C" fn runtime_query(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) };
state
.query_provider(provider_id.as_str(), query.as_str())
.into()
}
extern "C" fn runtime_drop(handle: RuntimeHandle) {
if !handle.ptr.is_null() {
unsafe {
handle.drop_as::<LuaRuntimeState>();
}
}
}
/// Static vtable instance
static LUA_RUNTIME_VTABLE: LuaRuntimeVTable = LuaRuntimeVTable {
info: runtime_info,
init: runtime_init,
providers: runtime_providers,
refresh: runtime_refresh,
query: runtime_query,
drop: runtime_drop,
};
/// Entry point - returns the runtime vtable
#[unsafe(no_mangle)]
pub extern "C" fn owlry_lua_runtime_vtable() -> &'static LuaRuntimeVTable {
&LUA_RUNTIME_VTABLE
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runtime_info() {
let info = runtime_info();
assert_eq!(info.name.as_str(), "Lua");
assert!(!info.version.as_str().is_empty());
}
#[test]
fn test_runtime_handle_null() {
let handle = RuntimeHandle::null();
assert!(handle.ptr.is_null());
}
#[test]
fn test_runtime_handle_from_box() {
let state = Box::new(42u32);
let handle = RuntimeHandle::from_box(state);
assert!(!handle.ptr.is_null());
unsafe { handle.drop_as::<u32>() };
}
}

View File

@@ -0,0 +1,232 @@
//! Plugin discovery and loading
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use mlua::Lua;
use owlry_plugin_api::PluginItem;
use crate::api;
use crate::manifest::PluginManifest;
use crate::runtime::{SandboxConfig, create_lua_runtime, load_file};
/// Provider registration info from Lua
#[derive(Debug, Clone)]
pub struct ProviderRegistration {
pub name: String,
pub display_name: String,
pub type_id: String,
pub default_icon: String,
pub prefix: Option<String>,
pub is_dynamic: bool,
}
/// A loaded plugin instance
pub struct LoadedPlugin {
/// Plugin manifest
pub manifest: PluginManifest,
/// Path to plugin directory
pub path: PathBuf,
/// Whether plugin is enabled
pub enabled: bool,
/// Lua runtime (None if not yet initialized)
lua: Option<Lua>,
}
impl std::fmt::Debug for LoadedPlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoadedPlugin")
.field("manifest", &self.manifest)
.field("path", &self.path)
.field("enabled", &self.enabled)
.field("lua", &self.lua.is_some())
.finish()
}
}
impl LoadedPlugin {
/// Create a new loaded plugin (not yet initialized)
pub fn new(manifest: PluginManifest, path: PathBuf) -> Self {
Self {
manifest,
path,
enabled: true,
lua: None,
}
}
/// Get the plugin ID
pub fn id(&self) -> &str {
&self.manifest.plugin.id
}
/// Initialize the Lua runtime and load the entry point
pub fn initialize(&mut self) -> Result<(), String> {
if self.lua.is_some() {
return Ok(()); // Already initialized
}
let sandbox = SandboxConfig::from_permissions(&self.manifest.permissions);
let lua = create_lua_runtime(&sandbox)
.map_err(|e| format!("Failed to create Lua runtime: {}", e))?;
// Register owlry APIs before loading entry point
api::register_apis(&lua, &self.path, self.id())
.map_err(|e| format!("Failed to register APIs: {}", e))?;
// Load the entry point file
let entry_path = self.path.join(&self.manifest.plugin.entry);
if !entry_path.exists() {
return Err(format!(
"Entry point '{}' not found",
self.manifest.plugin.entry
));
}
load_file(&lua, &entry_path).map_err(|e| format!("Failed to load entry point: {}", e))?;
self.lua = Some(lua);
Ok(())
}
/// Get provider registrations from this plugin
pub fn get_provider_registrations(&self) -> Result<Vec<ProviderRegistration>, String> {
let lua = self
.lua
.as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?;
api::get_provider_registrations(lua)
.map_err(|e| format!("Failed to get registrations: {}", e))
}
/// Call a provider's refresh function
pub fn call_provider_refresh(&self, provider_name: &str) -> Result<Vec<PluginItem>, String> {
let lua = self
.lua
.as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?;
api::call_refresh(lua, provider_name).map_err(|e| format!("Refresh failed: {}", e))
}
/// Call a provider's query function
pub fn call_provider_query(
&self,
provider_name: &str,
query: &str,
) -> Result<Vec<PluginItem>, String> {
let lua = self
.lua
.as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?;
api::call_query(lua, provider_name, query).map_err(|e| format!("Query failed: {}", e))
}
}
/// Discover plugins in a directory
pub fn discover_plugins(
plugins_dir: &Path,
) -> Result<HashMap<String, (PluginManifest, PathBuf)>, String> {
let mut plugins = HashMap::new();
if !plugins_dir.exists() {
return Ok(plugins);
}
let entries = std::fs::read_dir(plugins_dir)
.map_err(|e| format!("Failed to read plugins directory: {}", e))?;
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("plugin.toml");
if !manifest_path.exists() {
continue;
}
match PluginManifest::load(&manifest_path) {
Ok(manifest) => {
let id = manifest.plugin.id.clone();
if plugins.contains_key(&id) {
eprintln!(
"owlry-lua: Duplicate plugin ID '{}', skipping {}",
id,
path.display()
);
continue;
}
plugins.insert(id, (manifest, path));
}
Err(e) => {
eprintln!(
"owlry-lua: Failed to load plugin at {}: {}",
path.display(),
e
);
}
}
}
Ok(plugins)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_plugin(dir: &Path, id: &str) {
let plugin_dir = dir.join(id);
fs::create_dir_all(&plugin_dir).unwrap();
let manifest = format!(
r#"
[plugin]
id = "{}"
name = "Test {}"
version = "1.0.0"
"#,
id, id
);
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
fs::write(plugin_dir.join("main.lua"), "-- empty plugin").unwrap();
}
#[test]
fn test_discover_plugins() {
let temp = TempDir::new().unwrap();
let plugins_dir = temp.path();
create_test_plugin(plugins_dir, "test-plugin");
create_test_plugin(plugins_dir, "another-plugin");
let plugins = discover_plugins(plugins_dir).unwrap();
assert_eq!(plugins.len(), 2);
assert!(plugins.contains_key("test-plugin"));
assert!(plugins.contains_key("another-plugin"));
}
#[test]
fn test_discover_plugins_empty_dir() {
let temp = TempDir::new().unwrap();
let plugins = discover_plugins(temp.path()).unwrap();
assert!(plugins.is_empty());
}
#[test]
fn test_discover_plugins_nonexistent_dir() {
let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap();
assert!(plugins.is_empty());
}
}

View File

@@ -0,0 +1,181 @@
//! Plugin manifest (plugin.toml) parsing
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
/// Plugin manifest loaded from plugin.toml
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
#[serde(default)]
pub provides: PluginProvides,
#[serde(default)]
pub permissions: PluginPermissions,
#[serde(default)]
pub settings: HashMap<String, toml::Value>,
}
/// Core plugin information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
/// Unique plugin identifier (lowercase, alphanumeric, hyphens)
pub id: String,
/// Human-readable name
pub name: String,
/// Semantic version
pub version: String,
/// Short description
#[serde(default)]
pub description: String,
/// Plugin author
#[serde(default)]
pub author: String,
/// License identifier
#[serde(default)]
pub license: String,
/// Repository URL
#[serde(default)]
pub repository: Option<String>,
/// Required owlry version (semver constraint)
#[serde(default = "default_owlry_version")]
pub owlry_version: String,
/// Entry point file (relative to plugin directory)
#[serde(default = "default_entry", alias = "entry_point")]
pub entry: String,
}
fn default_owlry_version() -> String {
">=0.1.0".to_string()
}
fn default_entry() -> String {
"main.lua".to_string()
}
/// What the plugin provides
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginProvides {
/// Provider names this plugin registers
#[serde(default)]
pub providers: Vec<String>,
/// Whether this plugin registers actions
#[serde(default)]
pub actions: bool,
/// Theme names this plugin contributes
#[serde(default)]
pub themes: Vec<String>,
/// Whether this plugin registers hooks
#[serde(default)]
pub hooks: bool,
}
/// Plugin permissions/capabilities
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginPermissions {
/// Allow network/HTTP requests
#[serde(default)]
pub network: bool,
/// Filesystem paths the plugin can access (beyond its own directory)
#[serde(default)]
pub filesystem: Vec<String>,
/// Commands the plugin is allowed to run
#[serde(default)]
pub run_commands: Vec<String>,
/// Environment variables the plugin reads
#[serde(default)]
pub environment: Vec<String>,
}
impl PluginManifest {
/// Load a plugin manifest from a plugin.toml file
pub fn load(path: &Path) -> Result<Self, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read manifest: {}", e))?;
let manifest: PluginManifest =
toml::from_str(&content).map_err(|e| format!("Failed to parse manifest: {}", e))?;
manifest.validate()?;
Ok(manifest)
}
/// Validate the manifest
fn validate(&self) -> Result<(), String> {
// Validate plugin ID format
if self.plugin.id.is_empty() {
return Err("Plugin ID cannot be empty".to_string());
}
if !self
.plugin
.id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err("Plugin ID must be lowercase alphanumeric with hyphens".to_string());
}
// Validate version format
if semver::Version::parse(&self.plugin.version).is_err() {
return Err(format!("Invalid version format: {}", self.plugin.version));
}
// Validate owlry_version constraint
if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() {
return Err(format!(
"Invalid owlry_version constraint: {}",
self.plugin.owlry_version
));
}
Ok(())
}
/// Check if this plugin is compatible with the given owlry version
pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
Ok(r) => r,
Err(_) => return false,
};
let version = match semver::Version::parse(owlry_version) {
Ok(v) => v,
Err(_) => return false,
};
req.matches(&version)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_manifest() {
let toml_str = r#"
[plugin]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.name, "Test Plugin");
assert_eq!(manifest.plugin.version, "1.0.0");
assert_eq!(manifest.plugin.entry, "main.lua");
}
#[test]
fn test_version_compatibility() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
owlry_version = ">=0.3.0, <1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.is_compatible_with("0.3.5"));
assert!(manifest.is_compatible_with("0.4.0"));
assert!(!manifest.is_compatible_with("0.2.0"));
assert!(!manifest.is_compatible_with("1.0.0"));
}
}

View File

@@ -0,0 +1,152 @@
//! Lua runtime setup and sandboxing
use mlua::{Lua, Result as LuaResult, StdLib};
use crate::manifest::PluginPermissions;
/// Configuration for the Lua sandbox
///
/// Note: Some fields are reserved for future sandbox enforcement.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SandboxConfig {
/// Allow shell command running (reserved for future enforcement)
pub allow_commands: bool,
/// Allow HTTP requests (reserved for future enforcement)
pub allow_network: bool,
/// Allow filesystem access outside plugin directory (reserved for future enforcement)
pub allow_external_fs: bool,
/// Maximum run time per call (ms) (reserved for future enforcement)
pub max_run_time_ms: u64,
/// Memory limit (bytes, 0 = unlimited) (reserved for future enforcement)
pub max_memory: usize,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
allow_commands: false,
allow_network: false,
allow_external_fs: false,
max_run_time_ms: 5000, // 5 seconds
max_memory: 64 * 1024 * 1024, // 64 MB
}
}
}
impl SandboxConfig {
/// Create a sandbox config from plugin permissions
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
Self {
allow_commands: !permissions.run_commands.is_empty(),
allow_network: permissions.network,
allow_external_fs: !permissions.filesystem.is_empty(),
..Default::default()
}
}
}
/// Create a new sandboxed Lua runtime
pub fn create_lua_runtime(_sandbox: &SandboxConfig) -> LuaResult<Lua> {
// Create Lua with safe standard libraries only
// We exclude: debug, io, os (dangerous parts), package (loadlib), ffi
let libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH;
let lua = Lua::new_with(libs, mlua::LuaOptions::default())?;
// Set up safe environment
setup_safe_globals(&lua)?;
Ok(lua)
}
/// Set up safe global environment by removing/replacing dangerous functions
fn setup_safe_globals(lua: &Lua) -> LuaResult<()> {
let globals = lua.globals();
// Remove dangerous globals
globals.set("dofile", mlua::Value::Nil)?;
globals.set("loadfile", mlua::Value::Nil)?;
// Create a restricted os table with only safe functions
let os_table = lua.create_table()?;
os_table.set(
"clock",
lua.create_function(|_, ()| Ok(std::time::Instant::now().elapsed().as_secs_f64()))?,
)?;
os_table.set("date", lua.create_function(os_date)?)?;
os_table.set(
"difftime",
lua.create_function(|_, (t2, t1): (f64, f64)| Ok(t2 - t1))?,
)?;
os_table.set("time", lua.create_function(os_time)?)?;
globals.set("os", os_table)?;
// Remove print (plugins should use owlry.log instead)
globals.set("print", mlua::Value::Nil)?;
Ok(())
}
/// Safe os.date implementation
fn os_date(_lua: &Lua, format: Option<String>) -> LuaResult<String> {
use chrono::Local;
let now = Local::now();
let fmt = format.unwrap_or_else(|| "%c".to_string());
Ok(now.format(&fmt).to_string())
}
/// Safe os.time implementation
fn os_time(_lua: &Lua, _args: ()) -> LuaResult<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
Ok(duration.as_secs() as i64)
}
/// Load and run a Lua file in the given runtime
pub fn load_file(lua: &Lua, path: &std::path::Path) -> LuaResult<()> {
let content = std::fs::read_to_string(path).map_err(mlua::Error::external)?;
lua.load(&content)
.set_name(path.file_name().and_then(|n| n.to_str()).unwrap_or("chunk"))
.into_function()?
.call(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_sandboxed_runtime() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
// Verify dangerous functions are removed
let result: LuaResult<mlua::Value> = lua.globals().get("dofile");
assert!(matches!(result, Ok(mlua::Value::Nil)));
// Verify safe functions work
let result: String = lua.load("return os.date('%Y')").call(()).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_basic_lua_operations() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
// Test basic math
let result: i32 = lua.load("return 2 + 2").call(()).unwrap();
assert_eq!(result, 4);
// Test table operations
let result: i32 = lua.load("local t = {1,2,3}; return #t").call(()).unwrap();
assert_eq!(result, 3);
// Test string operations
let result: String = lua.load("return string.upper('hello')").call(()).unwrap();
assert_eq!(result, "HELLO");
}
}

View File

@@ -0,0 +1,17 @@
[package]
name = "owlry-plugin-api"
version = "1.0.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Plugin API for owlry application launcher"
keywords = ["owlry", "plugin", "api"]
categories = ["api-bindings"]
[dependencies]
# ABI-stable types for dynamic linking
abi_stable = "0.11"
# Serialization for plugin config
serde = { version = "1", features = ["derive"] }

View File

@@ -0,0 +1,450 @@
//! # Owlry Plugin API
//!
//! This crate provides the ABI-stable interface for owlry native plugins.
//! Plugins are compiled as dynamic libraries (.so) and loaded at runtime.
//!
//! ## Creating a Plugin
//!
//! ```ignore
//! use owlry_plugin_api::*;
//!
//! // Define your plugin's vtable
//! static VTABLE: PluginVTable = PluginVTable {
//! info: plugin_info,
//! providers: plugin_providers,
//! provider_init: my_provider_init,
//! provider_refresh: my_provider_refresh,
//! provider_query: my_provider_query,
//! provider_drop: my_provider_drop,
//! };
//!
//! // Export the vtable
//! #[no_mangle]
//! pub extern "C" fn owlry_plugin_vtable() -> &'static PluginVTable {
//! &VTABLE
//! }
//! ```
use abi_stable::StableAbi;
// Re-export abi_stable types for use by consumers (runtime loader, plugins)
pub use abi_stable::std_types::{ROption, RStr, RString, RVec};
/// Current plugin API version - plugins must match this
/// v2: Added ProviderPosition for widget support
/// v3: Added priority field for plugin-declared result ordering
pub const API_VERSION: u32 = 3;
/// Plugin metadata returned by the info function
#[repr(C)]
#[derive(StableAbi, Clone, Debug)]
pub struct PluginInfo {
/// Unique plugin identifier (e.g., "calculator", "weather")
pub id: RString,
/// Human-readable plugin name
pub name: RString,
/// Plugin version string
pub version: RString,
/// Short description of what the plugin provides
pub description: RString,
/// Plugin API version (must match API_VERSION)
pub api_version: u32,
}
/// Information about a provider offered by a plugin
#[repr(C)]
#[derive(StableAbi, Clone, Debug)]
pub struct ProviderInfo {
/// Unique provider identifier within the plugin
pub id: RString,
/// Human-readable provider name
pub name: RString,
/// Optional prefix that activates this provider (e.g., "=" for calculator)
pub prefix: ROption<RString>,
/// Default icon name for results from this provider
pub icon: RString,
/// Provider type (static or dynamic)
pub provider_type: ProviderKind,
/// Short type identifier for UI badges (e.g., "calc", "web")
pub type_id: RString,
/// Display position (Normal or Widget)
pub position: ProviderPosition,
/// Priority for result ordering (higher values appear first)
/// Suggested ranges:
/// - Widgets: 10000-12000
/// - Dynamic providers: 7000-10000
/// - Static providers: 0-5000 (use 0 for frecency-based ordering)
pub priority: i32,
}
/// Provider behavior type
#[repr(C)]
#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderKind {
/// Static providers load items once at startup via refresh()
Static,
/// Dynamic providers evaluate queries in real-time via query()
Dynamic,
}
/// Provider display position
///
/// Controls where in the result list this provider's items appear.
#[repr(C)]
#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ProviderPosition {
/// Standard position in results (sorted by score/frecency)
#[default]
Normal,
/// Widget position - appears at top of results when query is empty
/// Widgets are always visible regardless of filter settings
Widget,
}
/// A single searchable/launchable item returned by providers
#[repr(C)]
#[derive(StableAbi, Clone, Debug)]
pub struct PluginItem {
/// Unique item identifier
pub id: RString,
/// Display name
pub name: RString,
/// Optional description shown below the name
pub description: ROption<RString>,
/// Optional icon name or path
pub icon: ROption<RString>,
/// Command to execute when selected
pub command: RString,
/// Whether to run in a terminal
pub terminal: bool,
/// Search keywords/tags for filtering
pub keywords: RVec<RString>,
/// Score boost for frecency (higher = more prominent)
pub score_boost: i32,
}
impl PluginItem {
/// Create a new plugin item with required fields
pub fn new(id: impl Into<String>, name: impl Into<String>, command: impl Into<String>) -> Self {
Self {
id: RString::from(id.into()),
name: RString::from(name.into()),
description: ROption::RNone,
icon: ROption::RNone,
command: RString::from(command.into()),
terminal: false,
keywords: RVec::new(),
score_boost: 0,
}
}
/// Set the description
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = ROption::RSome(RString::from(desc.into()));
self
}
/// Set the icon
pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
self.icon = ROption::RSome(RString::from(icon.into()));
self
}
/// Set terminal mode
pub fn with_terminal(mut self, terminal: bool) -> Self {
self.terminal = terminal;
self
}
/// Add keywords
pub fn with_keywords(mut self, keywords: Vec<String>) -> Self {
self.keywords = keywords.into_iter().map(RString::from).collect();
self
}
/// Set score boost
pub fn with_score_boost(mut self, boost: i32) -> Self {
self.score_boost = boost;
self
}
}
/// Plugin function table - defines the interface between owlry and plugins
///
/// Every native plugin must export a function `owlry_plugin_vtable` that returns
/// a static reference to this structure.
#[repr(C)]
#[derive(StableAbi)]
pub struct PluginVTable {
/// Return plugin metadata
pub info: extern "C" fn() -> PluginInfo,
/// Return list of providers this plugin offers
pub providers: extern "C" fn() -> RVec<ProviderInfo>,
/// Initialize a provider by ID, returns an opaque handle
/// The handle is passed to refresh/query/drop functions
pub provider_init: extern "C" fn(provider_id: RStr<'_>) -> ProviderHandle,
/// Refresh a static provider's items
/// Called once at startup and when user requests refresh
pub provider_refresh: extern "C" fn(handle: ProviderHandle) -> RVec<PluginItem>,
/// Query a dynamic provider
/// Called on each keystroke for dynamic providers
pub provider_query: extern "C" fn(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem>,
/// Clean up a provider handle
pub provider_drop: extern "C" fn(handle: ProviderHandle),
}
/// Opaque handle to a provider instance
/// Plugins can use this to store state between calls
#[repr(C)]
#[derive(StableAbi, Clone, Copy, Debug)]
pub struct ProviderHandle {
/// Opaque pointer to provider state
pub ptr: *mut (),
}
impl ProviderHandle {
/// Create a null handle
pub fn null() -> Self {
Self {
ptr: std::ptr::null_mut(),
}
}
/// Create a handle from a boxed value
/// The caller is responsible for calling drop to free the memory
pub fn from_box<T>(value: Box<T>) -> Self {
Self {
ptr: Box::into_raw(value) as *mut (),
}
}
/// Convert handle back to a reference (unsafe)
///
/// # Safety
/// The handle must have been created from a Box<T> of the same type
pub unsafe fn as_ref<T>(&self) -> Option<&T> {
// SAFETY: Caller guarantees the pointer was created from Box<T>
unsafe { (self.ptr as *const T).as_ref() }
}
/// Convert handle back to a mutable reference (unsafe)
///
/// # Safety
/// The handle must have been created from a Box<T> of the same type
pub unsafe fn as_mut<T>(&mut self) -> Option<&mut T> {
// SAFETY: Caller guarantees the pointer was created from Box<T>
unsafe { (self.ptr as *mut T).as_mut() }
}
/// Drop the handle and free its memory (unsafe)
///
/// # Safety
/// The handle must have been created from a Box<T> of the same type
/// and must not be used after this call
pub unsafe fn drop_as<T>(self) {
if !self.ptr.is_null() {
// SAFETY: Caller guarantees the pointer was created from Box<T>
unsafe { drop(Box::from_raw(self.ptr as *mut T)) };
}
}
}
// ProviderHandle contains a raw pointer but we manage it carefully
unsafe impl Send for ProviderHandle {}
unsafe impl Sync for ProviderHandle {}
// ============================================================================
// Host API - Functions the host provides to plugins
// ============================================================================
/// Notification urgency level
#[repr(C)]
#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum NotifyUrgency {
/// Low priority notification
Low = 0,
/// Normal priority notification (default)
#[default]
Normal = 1,
/// Critical/urgent notification
Critical = 2,
}
/// Host API function table
///
/// This structure contains functions that the host (owlry) provides to plugins.
/// Plugins can call these functions to interact with the system.
#[repr(C)]
#[derive(StableAbi, Clone, Copy)]
pub struct HostAPI {
/// Send a notification to the user
/// Parameters: summary, body, icon (optional, empty string for none), urgency
pub notify:
extern "C" fn(summary: RStr<'_>, body: RStr<'_>, icon: RStr<'_>, urgency: NotifyUrgency),
/// Log a message at info level
pub log_info: extern "C" fn(message: RStr<'_>),
/// Log a message at warning level
pub log_warn: extern "C" fn(message: RStr<'_>),
/// Log a message at error level
pub log_error: extern "C" fn(message: RStr<'_>),
}
use std::sync::OnceLock;
// Global host API pointer - set by the host when loading plugins
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()
}
// ============================================================================
// Convenience functions for plugins
// ============================================================================
/// Send a notification (convenience wrapper)
pub fn notify(summary: &str, body: &str) {
if let Some(api) = host_api() {
(api.notify)(
RStr::from_str(summary),
RStr::from_str(body),
RStr::from_str(""),
NotifyUrgency::Normal,
);
}
}
/// Send a notification with an icon (convenience wrapper)
pub fn notify_with_icon(summary: &str, body: &str, icon: &str) {
if let Some(api) = host_api() {
(api.notify)(
RStr::from_str(summary),
RStr::from_str(body),
RStr::from_str(icon),
NotifyUrgency::Normal,
);
}
}
/// Send a notification with full options (convenience wrapper)
pub fn notify_with_urgency(summary: &str, body: &str, icon: &str, urgency: NotifyUrgency) {
if let Some(api) = host_api() {
(api.notify)(
RStr::from_str(summary),
RStr::from_str(body),
RStr::from_str(icon),
urgency,
);
}
}
/// Log an info message (convenience wrapper)
pub fn log_info(message: &str) {
if let Some(api) = host_api() {
(api.log_info)(RStr::from_str(message));
}
}
/// Log a warning message (convenience wrapper)
pub fn log_warn(message: &str) {
if let Some(api) = host_api() {
(api.log_warn)(RStr::from_str(message));
}
}
/// Log an error message (convenience wrapper)
pub fn log_error(message: &str) {
if let Some(api) = host_api() {
(api.log_error)(RStr::from_str(message));
}
}
/// Helper macro for defining plugin vtables
///
/// Usage:
/// ```ignore
/// owlry_plugin! {
/// info: my_plugin_info,
/// providers: my_providers,
/// init: my_init,
/// refresh: my_refresh,
/// query: my_query,
/// drop: my_drop,
/// }
/// ```
#[macro_export]
macro_rules! owlry_plugin {
(
info: $info:expr,
providers: $providers:expr,
init: $init:expr,
refresh: $refresh:expr,
query: $query:expr,
drop: $drop:expr $(,)?
) => {
static OWLRY_PLUGIN_VTABLE: $crate::PluginVTable = $crate::PluginVTable {
info: $info,
providers: $providers,
provider_init: $init,
provider_refresh: $refresh,
provider_query: $query,
provider_drop: $drop,
};
#[unsafe(no_mangle)]
pub extern "C" fn owlry_plugin_vtable() -> &'static $crate::PluginVTable {
&OWLRY_PLUGIN_VTABLE
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_item_builder() {
let item = PluginItem::new("test-id", "Test Item", "echo hello")
.with_description("A test item")
.with_icon("test-icon")
.with_terminal(true)
.with_keywords(vec!["test".to_string(), "example".to_string()])
.with_score_boost(100);
assert_eq!(item.id.as_str(), "test-id");
assert_eq!(item.name.as_str(), "Test Item");
assert_eq!(item.command.as_str(), "echo hello");
assert!(item.terminal);
assert_eq!(item.score_boost, 100);
}
#[test]
fn test_provider_handle() {
let value = Box::new(42i32);
let handle = ProviderHandle::from_box(value);
unsafe {
assert_eq!(*handle.as_ref::<i32>().unwrap(), 42);
handle.drop_as::<i32>();
}
}
}

View File

@@ -0,0 +1,44 @@
[package]
name = "owlry-rune"
version = "1.1.0"
edition = "2024"
rust-version = "1.90"
description = "Rune scripting runtime for owlry plugins"
license = "GPL-3.0-or-later"
[lib]
crate-type = ["cdylib"]
[dependencies]
# Shared plugin API
owlry-plugin-api = { path = "../owlry-plugin-api" }
# Rune scripting language
rune = "0.14"
rune-modules = { version = "0.14", features = ["full"] }
# Logging
log = "0.4"
env_logger = "0.11"
# HTTP client for network API
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Configuration parsing
toml = "0.8"
# Semantic versioning
semver = "1"
# Date/time
chrono = "0.4"
# Directory paths
dirs = "5"
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,171 @@
//! Owlry API bindings for Rune plugins
//!
//! This module provides the `owlry` module that Rune plugins can use.
use rune::{Any, ContextError, Module};
use std::sync::Mutex;
use owlry_plugin_api::{PluginItem, RString};
/// Provider registration info
#[derive(Debug, Clone)]
pub struct ProviderRegistration {
pub name: String,
pub display_name: String,
pub type_id: String,
pub default_icon: String,
pub is_static: bool,
pub prefix: Option<String>,
}
/// An item returned by a provider
///
/// Exposed to Rune scripts as `owlry::Item`.
#[derive(Debug, Clone, Any)]
#[rune(item = ::owlry)]
pub struct Item {
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub command: String,
pub terminal: bool,
pub keywords: Vec<String>,
}
impl Item {
/// Constructor exposed to Rune via #[rune::function]
#[rune::function(path = Self::new)]
pub fn rune_new(id: String, name: String, command: String) -> Self {
Self {
id,
name,
command,
description: None,
icon: None,
terminal: false,
keywords: Vec::new(),
}
}
/// Set description (builder pattern for Rune)
#[rune::function]
fn description(mut self, desc: String) -> Self {
self.description = Some(desc);
self
}
/// Set icon (builder pattern for Rune)
#[rune::function]
fn icon(mut self, icon: String) -> Self {
self.icon = Some(icon);
self
}
/// Set keywords (builder pattern for Rune)
#[rune::function]
fn keywords(mut self, keywords: Vec<String>) -> Self {
self.keywords = keywords;
self
}
/// Convert to PluginItem for FFI
pub fn to_plugin_item(&self) -> PluginItem {
let mut item = PluginItem::new(
RString::from(self.id.as_str()),
RString::from(self.name.as_str()),
RString::from(self.command.as_str()),
);
if let Some(ref desc) = self.description {
item = item.with_description(desc.clone());
}
if let Some(ref icon) = self.icon {
item = item.with_icon(icon.clone());
}
item.with_terminal(self.terminal)
.with_keywords(self.keywords.clone())
}
}
/// Global state for provider registrations (thread-safe)
pub static REGISTRATIONS: Mutex<Vec<ProviderRegistration>> = Mutex::new(Vec::new());
/// Create the owlry module for Rune
pub fn module() -> Result<Module, ContextError> {
let mut module = Module::with_crate("owlry")?;
// Register Item type with constructor and builder methods
module.ty::<Item>()?;
module.function_meta(Item::rune_new)?;
module.function_meta(Item::description)?;
module.function_meta(Item::icon)?;
module.function_meta(Item::keywords)?;
// Register logging functions
module.function("log_info", log_info).build()?;
module.function("log_debug", log_debug).build()?;
module.function("log_warn", log_warn).build()?;
module.function("log_error", log_error).build()?;
Ok(module)
}
// ============================================================================
// Logging Functions
// ============================================================================
fn log_info(message: &str) {
log::info!("[Rune] {}", message);
}
fn log_debug(message: &str) {
log::debug!("[Rune] {}", message);
}
fn log_warn(message: &str) {
log::warn!("[Rune] {}", message);
}
fn log_error(message: &str) {
log::error!("[Rune] {}", message);
}
/// Get all provider registrations
pub fn get_registrations() -> Vec<ProviderRegistration> {
REGISTRATIONS.lock().unwrap().clone()
}
/// Clear all registrations (for testing or reloading)
pub fn clear_registrations() {
REGISTRATIONS.lock().unwrap().clear();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_item_creation() {
let item = Item {
id: "test-1".to_string(),
name: "Test Item".to_string(),
description: Some("A test".to_string()),
icon: Some("test-icon".to_string()),
command: "echo test".to_string(),
terminal: false,
keywords: vec!["test".to_string()],
};
let plugin_item = item.to_plugin_item();
assert_eq!(plugin_item.id.as_str(), "test-1");
assert_eq!(plugin_item.name.as_str(), "Test Item");
}
#[test]
fn test_module_creation() {
let module = module();
assert!(module.is_ok());
}
}

View File

@@ -0,0 +1,264 @@
//! Owlry Rune Runtime
//!
//! This crate provides a Rune scripting runtime for owlry user plugins.
//! It is loaded dynamically by the core when installed.
//!
//! # Architecture
//!
//! The runtime exports a C-compatible vtable that the core uses to:
//! 1. Initialize the runtime with a plugins directory
//! 2. Get a list of providers from loaded plugins
//! 3. Refresh/query providers
//! 4. Clean up resources
//!
//! # Plugin Structure
//!
//! Rune plugins live in `~/.config/owlry/plugins/<plugin-name>/`:
//! ```text
//! my-plugin/
//! plugin.toml # Manifest
//! init.rn # Entry point (Rune script)
//! ```
mod api;
mod loader;
mod manifest;
mod runtime;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use owlry_plugin_api::{PluginItem, ROption, RStr, RString, RVec};
pub use loader::LoadedPlugin;
pub use manifest::PluginManifest;
// ============================================================================
// Runtime VTable (C-compatible interface)
// ============================================================================
/// Information about this runtime
#[repr(C)]
pub struct RuntimeInfo {
pub name: RString,
pub version: RString,
}
/// Information about a provider from a plugin
#[repr(C)]
#[derive(Clone)]
pub struct RuneProviderInfo {
pub name: RString,
pub display_name: RString,
pub type_id: RString,
pub default_icon: RString,
pub is_static: bool,
pub prefix: ROption<RString>,
}
/// Opaque handle to runtime state
#[repr(transparent)]
#[derive(Clone, Copy)]
pub struct RuntimeHandle(pub *mut ());
/// Runtime state managed by the handle
struct RuntimeState {
plugins: HashMap<String, LoadedPlugin>,
providers: Vec<RuneProviderInfo>,
}
/// VTable for the Rune runtime
#[repr(C)]
pub struct RuneRuntimeVTable {
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<RuneProviderInfo>,
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),
}
// ============================================================================
// VTable Implementation
// ============================================================================
extern "C" fn runtime_info() -> RuntimeInfo {
RuntimeInfo {
name: RString::from("rune"),
version: RString::from(env!("CARGO_PKG_VERSION")),
}
}
extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
let _ = env_logger::try_init();
let _version = owlry_version.as_str();
let plugins_dir = PathBuf::from(plugins_dir.as_str());
log::info!(
"Initializing Rune runtime with plugins from: {}",
plugins_dir.display()
);
let mut state = RuntimeState {
plugins: HashMap::new(),
providers: Vec::new(),
};
// Discover and load Rune plugins
match loader::discover_rune_plugins(&plugins_dir) {
Ok(plugins) => {
for (id, plugin) in plugins {
// Collect provider info before storing plugin
for reg in plugin.provider_registrations() {
state.providers.push(RuneProviderInfo {
name: RString::from(reg.name.as_str()),
display_name: RString::from(reg.display_name.as_str()),
type_id: RString::from(reg.type_id.as_str()),
default_icon: RString::from(reg.default_icon.as_str()),
is_static: reg.is_static,
prefix: reg
.prefix
.as_ref()
.map(|p| RString::from(p.as_str()))
.into(),
});
}
state.plugins.insert(id, plugin);
}
log::info!(
"Loaded {} Rune plugin(s) with {} provider(s)",
state.plugins.len(),
state.providers.len()
);
}
Err(e) => {
log::error!("Failed to discover Rune plugins: {}", e);
}
}
// Box and leak the state, returning an opaque handle
let boxed = Box::new(Mutex::new(state));
RuntimeHandle(Box::into_raw(boxed) as *mut ())
}
extern "C" fn runtime_providers(handle: RuntimeHandle) -> RVec<RuneProviderInfo> {
let state = unsafe { &*(handle.0 as *const Mutex<RuntimeState>) };
let guard = state.lock().unwrap();
guard.providers.clone().into_iter().collect()
}
extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem> {
let state = unsafe { &*(handle.0 as *const Mutex<RuntimeState>) };
let mut guard = state.lock().unwrap();
let provider_name = provider_id.as_str();
// Find the plugin that provides this provider
for plugin in guard.plugins.values_mut() {
if plugin.provides_provider(provider_name) {
match plugin.refresh_provider(provider_name) {
Ok(items) => return items.into_iter().collect(),
Err(e) => {
log::error!("Failed to refresh provider '{}': {}", provider_name, e);
return RVec::new();
}
}
}
}
log::warn!("Provider '{}' not found", provider_name);
RVec::new()
}
extern "C" fn runtime_query(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem> {
let state = unsafe { &*(handle.0 as *const Mutex<RuntimeState>) };
let mut guard = state.lock().unwrap();
let provider_name = provider_id.as_str();
let query_str = query.as_str();
// Find the plugin that provides this provider
for plugin in guard.plugins.values_mut() {
if plugin.provides_provider(provider_name) {
match plugin.query_provider(provider_name, query_str) {
Ok(items) => return items.into_iter().collect(),
Err(e) => {
log::error!("Failed to query provider '{}': {}", provider_name, e);
return RVec::new();
}
}
}
}
log::warn!("Provider '{}' not found", provider_name);
RVec::new()
}
extern "C" fn runtime_drop(handle: RuntimeHandle) {
if !handle.0.is_null() {
// SAFETY: We created this box in runtime_init
unsafe {
let _ = Box::from_raw(handle.0 as *mut Mutex<RuntimeState>);
}
log::info!("Rune runtime cleaned up");
}
}
/// Static vtable instance
static RUNE_RUNTIME_VTABLE: RuneRuntimeVTable = RuneRuntimeVTable {
info: runtime_info,
init: runtime_init,
providers: runtime_providers,
refresh: runtime_refresh,
query: runtime_query,
drop: runtime_drop,
};
/// Entry point - returns the runtime vtable
#[unsafe(no_mangle)]
pub extern "C" fn owlry_rune_runtime_vtable() -> &'static RuneRuntimeVTable {
&RUNE_RUNTIME_VTABLE
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runtime_info() {
let info = runtime_info();
assert_eq!(info.name.as_str(), "rune");
assert!(!info.version.as_str().is_empty());
}
#[test]
fn test_runtime_lifecycle() {
// Create a temp directory for plugins
let temp = tempfile::TempDir::new().unwrap();
let plugins_dir = temp.path().to_string_lossy();
// Initialize runtime
let handle = runtime_init(RStr::from_str(&plugins_dir), RStr::from_str("1.0.0"));
assert!(!handle.0.is_null());
// Get providers (should be empty with no plugins)
let providers = runtime_providers(handle);
assert!(providers.is_empty());
// Clean up
runtime_drop(handle);
}
}

View File

@@ -0,0 +1,214 @@
//! Rune plugin discovery and loading
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rune::{Context, Unit};
use crate::api::{self, ProviderRegistration};
use crate::manifest::PluginManifest;
use crate::runtime::{SandboxConfig, compile_source, create_context, create_vm};
use owlry_plugin_api::PluginItem;
/// A loaded Rune plugin
pub struct LoadedPlugin {
pub manifest: PluginManifest,
pub path: PathBuf,
/// Context for creating new VMs (reserved for refresh/query implementation)
#[allow(dead_code)]
context: Context,
/// Compiled unit (reserved for refresh/query implementation)
#[allow(dead_code)]
unit: Arc<Unit>,
registrations: Vec<ProviderRegistration>,
}
impl LoadedPlugin {
/// Create and initialize a new plugin
pub fn new(manifest: PluginManifest, path: PathBuf) -> Result<Self, String> {
let sandbox = SandboxConfig::from_permissions(&manifest.permissions);
let context =
create_context(&sandbox).map_err(|e| format!("Failed to create context: {}", e))?;
let entry_path = path.join(&manifest.plugin.entry);
if !entry_path.exists() {
return Err(format!("Entry point not found: {}", entry_path.display()));
}
// Clear previous registrations before loading
api::clear_registrations();
// Compile the source
let unit = compile_source(&context, &entry_path)
.map_err(|e| format!("Failed to compile: {}", e))?;
// Run the entry point to register providers
let mut vm =
create_vm(&context, unit.clone()).map_err(|e| format!("Failed to create VM: {}", e))?;
// Execute the main function if it exists
match vm.call(rune::Hash::type_hash(["main"]), ()) {
Ok(result) => {
// Try to complete the execution
let _: () = rune::from_value(result).unwrap_or(());
}
Err(_) => {
// No main function is okay
}
}
// Collect registrations — from runtime API or from manifest [[providers]]
let mut registrations = api::get_registrations();
if registrations.is_empty() && !manifest.providers.is_empty() {
for decl in &manifest.providers {
registrations.push(ProviderRegistration {
name: decl.id.clone(),
display_name: decl.name.clone(),
type_id: decl.type_id.clone().unwrap_or_else(|| decl.id.clone()),
default_icon: decl.icon.clone().unwrap_or_else(|| "application-x-addon".to_string()),
is_static: decl.provider_type != "dynamic",
prefix: decl.prefix.clone(),
});
}
}
log::info!(
"Loaded Rune plugin '{}' with {} provider(s)",
manifest.plugin.id,
registrations.len()
);
Ok(Self {
manifest,
path,
context,
unit,
registrations,
})
}
/// Get plugin ID
pub fn id(&self) -> &str {
&self.manifest.plugin.id
}
/// Get provider registrations
pub fn provider_registrations(&self) -> &[ProviderRegistration] {
&self.registrations
}
/// Check if this plugin provides a specific provider
pub fn provides_provider(&self, name: &str) -> bool {
self.registrations.iter().any(|r| r.name == name)
}
/// Refresh a static provider by calling the Rune `refresh()` function
pub fn refresh_provider(&mut self, _name: &str) -> Result<Vec<PluginItem>, String> {
let mut vm = create_vm(&self.context, self.unit.clone())
.map_err(|e| format!("Failed to create VM: {}", e))?;
let output = vm
.call(rune::Hash::type_hash(["refresh"]), ())
.map_err(|e| format!("refresh() call failed: {}", e))?;
let items: Vec<crate::api::Item> = rune::from_value(output)
.map_err(|e| format!("Failed to parse refresh() result: {}", e))?;
Ok(items.iter().map(|i| i.to_plugin_item()).collect())
}
/// Query a dynamic provider by calling the Rune `query(q)` function
pub fn query_provider(&mut self, _name: &str, query: &str) -> Result<Vec<PluginItem>, String> {
let mut vm = create_vm(&self.context, self.unit.clone())
.map_err(|e| format!("Failed to create VM: {}", e))?;
let output = vm
.call(
rune::Hash::type_hash(["query"]),
(query.to_string(),),
)
.map_err(|e| format!("query() call failed: {}", e))?;
let items: Vec<crate::api::Item> = rune::from_value(output)
.map_err(|e| format!("Failed to parse query() result: {}", e))?;
Ok(items.iter().map(|i| i.to_plugin_item()).collect())
}
}
/// Discover Rune plugins in a directory
pub fn discover_rune_plugins(plugins_dir: &Path) -> Result<HashMap<String, LoadedPlugin>, String> {
let mut plugins = HashMap::new();
if !plugins_dir.exists() {
log::debug!(
"Plugins directory does not exist: {}",
plugins_dir.display()
);
return Ok(plugins);
}
let entries = std::fs::read_dir(plugins_dir)
.map_err(|e| format!("Failed to read plugins directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("plugin.toml");
if !manifest_path.exists() {
continue;
}
// Load manifest
let manifest = match PluginManifest::load(&manifest_path) {
Ok(m) => m,
Err(e) => {
log::warn!(
"Failed to load manifest at {}: {}",
manifest_path.display(),
e
);
continue;
}
};
// Check if this is a Rune plugin (entry ends with .rn)
if !manifest.plugin.entry.ends_with(".rn") {
log::debug!("Skipping non-Rune plugin: {}", manifest.plugin.id);
continue;
}
// Load the plugin
match LoadedPlugin::new(manifest.clone(), path.clone()) {
Ok(plugin) => {
let id = manifest.plugin.id.clone();
plugins.insert(id, plugin);
}
Err(e) => {
log::warn!("Failed to load plugin '{}': {}", manifest.plugin.id, e);
}
}
}
Ok(plugins)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_discover_empty_dir() {
let temp = TempDir::new().unwrap();
let plugins = discover_rune_plugins(temp.path()).unwrap();
assert!(plugins.is_empty());
}
}

View File

@@ -0,0 +1,182 @@
//! Plugin manifest parsing for Rune plugins
use serde::Deserialize;
use std::path::Path;
/// Plugin manifest from plugin.toml
#[derive(Debug, Clone, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
#[serde(default)]
pub provides: PluginProvides,
#[serde(default)]
pub permissions: PluginPermissions,
/// Provider declarations from [[providers]] sections
#[serde(default)]
pub providers: Vec<ProviderDecl>,
}
/// A provider declared in [[providers]] section of plugin.toml
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderDecl {
pub id: String,
pub name: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default = "default_provider_type", rename = "type")]
pub provider_type: String,
#[serde(default)]
pub type_id: Option<String>,
}
fn default_provider_type() -> String {
"static".to_string()
}
/// Core plugin information
#[derive(Debug, Clone, Deserialize)]
pub struct PluginInfo {
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub author: String,
#[serde(default = "default_owlry_version")]
pub owlry_version: String,
#[serde(default = "default_entry", alias = "entry_point")]
pub entry: String,
}
fn default_owlry_version() -> String {
">=0.1.0".to_string()
}
fn default_entry() -> String {
"main.rn".to_string()
}
/// What the plugin provides
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PluginProvides {
#[serde(default)]
pub providers: Vec<String>,
#[serde(default)]
pub actions: bool,
#[serde(default)]
pub themes: Vec<String>,
#[serde(default)]
pub hooks: bool,
}
/// Plugin permissions
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PluginPermissions {
#[serde(default)]
pub network: bool,
#[serde(default)]
pub filesystem: Vec<String>,
#[serde(default)]
pub run_commands: Vec<String>,
}
impl PluginManifest {
/// Load manifest from a plugin.toml file
pub fn load(path: &Path) -> Result<Self, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read manifest: {}", e))?;
let manifest: PluginManifest =
toml::from_str(&content).map_err(|e| format!("Failed to parse manifest: {}", e))?;
manifest.validate()?;
Ok(manifest)
}
/// Validate the manifest
fn validate(&self) -> Result<(), String> {
if self.plugin.id.is_empty() {
return Err("Plugin ID cannot be empty".to_string());
}
if !self
.plugin
.id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err("Plugin ID must be lowercase alphanumeric with hyphens".to_string());
}
// Validate version format
if semver::Version::parse(&self.plugin.version).is_err() {
return Err(format!("Invalid version format: {}", self.plugin.version));
}
// Rune plugins must have .rn entry point
if !self.plugin.entry.ends_with(".rn") {
return Err("Entry point must be a .rn file for Rune plugins".to_string());
}
Ok(())
}
/// Check compatibility with owlry version
pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
Ok(r) => r,
Err(_) => return false,
};
let version = match semver::Version::parse(owlry_version) {
Ok(v) => v,
Err(_) => return false,
};
req.matches(&version)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_manifest() {
let toml_str = r#"
[plugin]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.entry, "main.rn");
}
#[test]
fn test_validate_entry_point() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
entry = "main.lua"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.validate().is_err()); // .lua not allowed for Rune
}
#[test]
fn test_version_compatibility() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
owlry_version = ">=0.3.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.is_compatible_with("0.3.5"));
assert!(!manifest.is_compatible_with("0.2.0"));
}
}

View File

@@ -0,0 +1,157 @@
//! Rune VM runtime creation and sandboxing
use rune::{Context, Diagnostics, Source, Sources, Unit, Vm};
use std::path::Path;
use std::sync::Arc;
use crate::manifest::PluginPermissions;
/// Configuration for the Rune sandbox
///
/// Some fields are reserved for future sandbox enforcement.
#[derive(Debug, Clone)]
#[allow(dead_code)]
#[derive(Default)]
pub struct SandboxConfig {
/// Allow network/HTTP operations
pub network: bool,
/// Allow filesystem operations
pub filesystem: bool,
/// Allowed filesystem paths (reserved for future sandbox enforcement)
pub allowed_paths: Vec<String>,
/// Allow running external commands (reserved for future sandbox enforcement)
pub run_commands: bool,
/// Allowed commands (reserved for future sandbox enforcement)
pub allowed_commands: Vec<String>,
}
impl SandboxConfig {
/// Create sandbox config from plugin permissions
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
Self {
network: permissions.network,
filesystem: !permissions.filesystem.is_empty(),
allowed_paths: permissions.filesystem.clone(),
run_commands: !permissions.run_commands.is_empty(),
allowed_commands: permissions.run_commands.clone(),
}
}
}
/// Create a Rune context with owlry API modules
pub fn create_context(sandbox: &SandboxConfig) -> Result<Context, rune::ContextError> {
let mut context = Context::with_default_modules()?;
// Add standard modules based on permissions
if sandbox.network {
log::debug!("Network access enabled for Rune plugin");
}
if sandbox.filesystem {
log::debug!("Filesystem access enabled for Rune plugin");
}
// Add owlry API module
context.install(crate::api::module()?)?;
Ok(context)
}
/// Compile Rune source code into a Unit
pub fn compile_source(context: &Context, source_path: &Path) -> Result<Arc<Unit>, CompileError> {
let source_content =
std::fs::read_to_string(source_path).map_err(|e| CompileError::Io(e.to_string()))?;
let source_name = source_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("init.rn");
let mut sources = Sources::new();
sources
.insert(
Source::new(source_name, &source_content)
.map_err(|e| CompileError::Compile(e.to_string()))?,
)
.map_err(|e| CompileError::Compile(format!("Failed to insert source: {}", e)))?;
let mut diagnostics = Diagnostics::new();
let result = rune::prepare(&mut sources)
.with_context(context)
.with_diagnostics(&mut diagnostics)
.build();
match result {
Ok(unit) => Ok(Arc::new(unit)),
Err(e) => {
// Collect error messages
let mut error_msg = format!("Compilation failed: {}", e);
for diagnostic in diagnostics.diagnostics() {
error_msg.push_str(&format!("\n {:?}", diagnostic));
}
Err(CompileError::Compile(error_msg))
}
}
}
/// Create a new Rune VM from compiled unit
pub fn create_vm(context: &Context, unit: Arc<Unit>) -> Result<Vm, CompileError> {
let runtime = Arc::new(
context
.runtime()
.map_err(|e| CompileError::Compile(format!("Failed to get runtime: {}", e)))?,
);
Ok(Vm::new(runtime, unit))
}
/// Error type for compilation
#[derive(Debug)]
pub enum CompileError {
Io(String),
Compile(String),
}
impl std::fmt::Display for CompileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CompileError::Io(e) => write!(f, "IO error: {}", e),
CompileError::Compile(e) => write!(f, "Compile error: {}", e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sandbox_config_default() {
let config = SandboxConfig::default();
assert!(!config.network);
assert!(!config.filesystem);
assert!(!config.run_commands);
}
#[test]
fn test_sandbox_from_permissions() {
let permissions = PluginPermissions {
network: true,
filesystem: vec!["~/.config".to_string()],
run_commands: vec!["notify-send".to_string()],
};
let config = SandboxConfig::from_permissions(&permissions);
assert!(config.network);
assert!(config.filesystem);
assert!(config.run_commands);
assert_eq!(config.allowed_paths, vec!["~/.config"]);
assert_eq!(config.allowed_commands, vec!["notify-send"]);
}
#[test]
fn test_create_context() {
let config = SandboxConfig::default();
let context = create_context(&config);
assert!(context.is_ok());
}
}

58
crates/owlry/Cargo.toml Normal file
View File

@@ -0,0 +1,58 @@
[package]
name = "owlry"
version = "1.0.1"
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"
authors = ["Your Name <you@example.com>"]
license = "GPL-3.0-or-later"
repository = "https://somegit.dev/Owlibou/owlry"
keywords = ["launcher", "wayland", "gtk4", "linux"]
categories = ["gui"]
[dependencies]
# Core backend library
owlry-core = { path = "../owlry-core" }
# GTK4 for the UI
gtk4 = { version = "0.10", features = ["v4_12"] }
# Layer shell support for Wayland overlay behavior
gtk4-layer-shell = "0.7"
# Low-level syscalls for stdin detection (dmenu mode)
libc = "0.2"
# Logging
log = "0.4"
env_logger = "0.11"
# Configuration (needed for config types used in app.rs/theme.rs)
serde = { version = "1", features = ["derive"] }
toml = "0.8"
# CLI argument parsing
clap = { version = "4", features = ["derive"] }
# JSON serialization (needed by plugin commands in CLI)
serde_json = "1"
# Date/time (needed by plugin commands in CLI)
chrono = { version = "0.4", features = ["serde"] }
# Directory utilities (needed by plugin commands)
dirs = "5"
# Semantic versioning (needed by plugin commands)
semver = "1"
[build-dependencies]
# GResource compilation for bundled icons
glib-build-tools = "0.20"
[features]
default = []
# Enable verbose debug logging (for development/testing builds)
dev-logging = ["owlry-core/dev-logging"]
# Enable built-in Lua runtime (disable to use external owlry-lua package)
lua = ["owlry-core/lua"]

12
crates/owlry/build.rs Normal file
View File

@@ -0,0 +1,12 @@
fn main() {
// Compile GResource bundle for icons
glib_build_tools::compile_resources(
&["src/resources/icons"],
"src/resources/icons.gresource.xml",
"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/");
}

281
crates/owlry/src/app.rs Normal file
View File

@@ -0,0 +1,281 @@
use crate::backend::SearchBackend;
use crate::cli::CliArgs;
use crate::client::CoreClient;
use crate::providers::DmenuProvider;
use crate::theme;
use crate::ui::MainWindow;
use gtk4::prelude::*;
use gtk4::{Application, CssProvider, gio};
use gtk4_layer_shell::{Edge, Layer, LayerShell};
use log::{debug, info, warn};
use owlry_core::config::Config;
use owlry_core::data::FrecencyStore;
use owlry_core::filter::ProviderFilter;
use owlry_core::paths;
use owlry_core::providers::{Provider, ProviderManager, ProviderType};
use std::cell::RefCell;
use std::rc::Rc;
const APP_ID: &str = "org.owlry.launcher";
pub struct OwlryApp {
app: Application,
}
impl OwlryApp {
pub fn new(args: CliArgs) -> Self {
let app = Application::builder()
.application_id(APP_ID)
.flags(gio::ApplicationFlags::FLAGS_NONE)
.build();
app.connect_activate(move |app| Self::on_activate(app, &args));
Self { app }
}
pub fn run(&self) -> i32 {
// Use empty args since clap already parsed our CLI arguments.
// This prevents GTK from trying to parse --mode, --profile, etc.
self.app.run_with_args(&[] as &[&str]).into()
}
fn on_activate(app: &Application, args: &CliArgs) {
debug!("Activating Owlry");
// Register bundled icon resources
gio::resources_register_include!("icons.gresource")
.expect("Failed to register icon resources");
let config = Rc::new(RefCell::new(Config::load_or_default()));
// Build backend based on mode
let dmenu_mode = DmenuProvider::has_stdin_data();
let backend = if dmenu_mode {
// dmenu mode: local ProviderManager, no daemon
let mut dmenu = DmenuProvider::new();
dmenu.enable();
let core_providers: Vec<Box<dyn Provider>> = vec![Box::new(dmenu)];
let provider_manager = ProviderManager::new(core_providers, Vec::new());
let frecency = FrecencyStore::load_or_default();
SearchBackend::Local {
providers: Box::new(provider_manager),
frecency,
}
} else {
// Normal mode: connect to daemon via IPC
match CoreClient::connect_or_start() {
Ok(client) => {
info!("Connected to owlry-core daemon");
SearchBackend::Daemon(client)
}
Err(e) => {
warn!(
"Failed to connect to daemon ({}), falling back to local providers",
e
);
Self::create_local_backend(&config.borrow())
}
}
};
let backend = Rc::new(RefCell::new(backend));
// Create filter from CLI args, profile, and config
let resolved_modes = resolve_modes(args, &config.borrow());
let filter = if let Some(modes) = resolved_modes {
// CLI --mode or --profile specified explicit modes
let provider_types: Vec<ProviderType> = modes
.iter()
.map(|s| ProviderFilter::mode_string_to_provider_type(s))
.collect();
if provider_types.len() == 1 {
ProviderFilter::new(
Some(provider_types[0].clone()),
None,
&config.borrow().providers,
)
} else {
ProviderFilter::new(None, Some(provider_types), &config.borrow().providers)
}
} else {
ProviderFilter::new(None, None, &config.borrow().providers)
};
let filter = Rc::new(RefCell::new(filter));
let window = MainWindow::new(
app,
config.clone(),
backend.clone(),
filter.clone(),
args.prompt.clone(),
);
// Set up layer shell for Wayland overlay behavior
window.init_layer_shell();
window.set_layer(Layer::Overlay);
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
// Anchor to all edges for centered overlay effect
// We'll use margins to control the actual size
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Bottom, false);
window.set_anchor(Edge::Left, false);
window.set_anchor(Edge::Right, false);
// Position from top
window.set_margin(Edge::Top, 200);
// Set up icon theme fallbacks
Self::setup_icon_theme();
// Load CSS styling with config for theming
Self::load_css(&config.borrow());
window.present();
}
/// Create a local backend as fallback when daemon is unavailable.
/// Loads native plugins and creates providers locally.
fn create_local_backend(config: &Config) -> SearchBackend {
use owlry_core::plugins::native_loader::NativePluginLoader;
use owlry_core::providers::native_provider::NativeProvider;
use owlry_core::providers::{ApplicationProvider, CommandProvider};
use std::sync::Arc;
// Load native plugins
let mut loader = NativePluginLoader::new();
loader.set_disabled(config.plugins.disabled_plugins.clone());
let native_providers: Vec<NativeProvider> = match loader.discover() {
Ok(count) if count > 0 => {
info!("Discovered {} native plugin(s) for local fallback", count);
let plugins: Vec<Arc<owlry_core::plugins::native_loader::NativePlugin>> =
loader.into_plugins();
let mut providers = Vec::new();
for plugin in plugins {
for provider_info in &plugin.providers {
let provider =
NativeProvider::new(Arc::clone(&plugin), provider_info.clone());
providers.push(provider);
}
}
providers
}
_ => Vec::new(),
};
let core_providers: Vec<Box<dyn Provider>> = vec![
Box::new(ApplicationProvider::new()),
Box::new(CommandProvider::new()),
];
let provider_manager = ProviderManager::new(core_providers, native_providers);
let frecency = FrecencyStore::load_or_default();
SearchBackend::Local {
providers: Box::new(provider_manager),
frecency,
}
}
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() {
let icon_theme = gtk4::IconTheme::for_display(&display);
// Add Adwaita as fallback search path (has weather and media icons)
icon_theme.add_search_path("/usr/share/icons/Adwaita");
icon_theme.add_search_path("/usr/share/icons/breeze");
debug!("Icon theme search paths configured with Adwaita/breeze fallbacks");
}
}
fn load_css(config: &Config) {
let display = gtk4::gdk::Display::default().expect("Could not get default display");
// 1. Load base structural CSS (always applied)
let base_provider = CssProvider::new();
base_provider.load_from_string(include_str!("resources/base.css"));
gtk4::style_context_add_provider_for_display(
&display,
&base_provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
debug!("Loaded base structural CSS");
// 2. Load theme if specified
if let Some(ref theme_name) = config.appearance.theme {
let theme_provider = CssProvider::new();
match theme_name.as_str() {
"owl" => {
theme_provider.load_from_string(include_str!("resources/owl-theme.css"));
debug!("Loaded built-in owl theme");
}
_ => {
// Check for custom theme in $XDG_CONFIG_HOME/owlry/themes/{name}.css
if let Some(theme_path) = paths::theme_file(theme_name) {
if theme_path.exists() {
theme_provider.load_from_path(&theme_path);
debug!("Loaded custom theme from {:?}", theme_path);
} else {
debug!("Theme '{}' not found at {:?}", theme_name, theme_path);
}
}
}
}
gtk4::style_context_add_provider_for_display(
&display,
&theme_provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
);
}
// 3. Load user's custom stylesheet if exists
if let Some(custom_path) = paths::custom_style_file()
&& custom_path.exists()
{
let custom_provider = CssProvider::new();
custom_provider.load_from_path(&custom_path);
gtk4::style_context_add_provider_for_display(
&display,
&custom_provider,
gtk4::STYLE_PROVIDER_PRIORITY_USER,
);
debug!("Loaded custom CSS from {:?}", custom_path);
}
// 4. Inject config variables (highest priority for overrides)
let vars_css = theme::generate_variables_css(&config.appearance);
let vars_provider = CssProvider::new();
vars_provider.load_from_string(&vars_css);
gtk4::style_context_add_provider_for_display(
&display,
&vars_provider,
gtk4::STYLE_PROVIDER_PRIORITY_USER + 1,
);
debug!("Injected config CSS variables");
}
}
/// Resolve which modes to enable based on CLI args and config profiles.
///
/// Priority: `--mode` > `--profile` > default (all providers from config).
/// Returns `None` when no explicit mode selection was made.
fn resolve_modes(args: &CliArgs, config: &Config) -> Option<Vec<String>> {
if let Some(ref mode) = args.mode {
return Some(vec![mode.to_string()]);
}
if let Some(ref profile_name) = args.profile {
if let Some(profile) = config.profiles.get(profile_name) {
return Some(profile.modes.clone());
}
eprintln!("Unknown profile: {}", profile_name);
std::process::exit(1);
}
None
}

276
crates/owlry/src/backend.rs Normal file
View File

@@ -0,0 +1,276 @@
//! Abstraction over search backends for the UI.
//!
//! In normal mode, the UI talks to the owlry-core daemon via IPC.
//! In dmenu mode, the UI uses a local ProviderManager directly (no daemon).
use crate::client::CoreClient;
use log::warn;
use owlry_core::config::Config;
use owlry_core::data::FrecencyStore;
use owlry_core::filter::ProviderFilter;
use owlry_core::ipc::ResultItem;
use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType};
/// Backend for search operations. Wraps either an IPC client (daemon mode)
/// or a local ProviderManager (dmenu mode).
pub enum SearchBackend {
/// IPC client connected to owlry-core daemon
Daemon(CoreClient),
/// Direct local provider manager (dmenu mode only)
Local {
providers: Box<ProviderManager>,
frecency: FrecencyStore,
},
}
impl SearchBackend {
/// Search for items matching the query.
///
/// In daemon mode, sends query over IPC. The modes list is derived from
/// the ProviderFilter's enabled set.
///
/// In local mode, delegates to ProviderManager directly.
pub fn search(
&mut self,
query: &str,
max_results: usize,
filter: &ProviderFilter,
config: &Config,
) -> Vec<LaunchItem> {
match self {
SearchBackend::Daemon(client) => {
// When accept_all, send None so daemon doesn't restrict to a specific set
// (otherwise dynamically loaded plugin types would be filtered out)
let modes_param = if filter.is_accept_all() {
None
} else {
let modes: Vec<String> = filter
.enabled_providers()
.iter()
.map(|p| p.to_string())
.collect();
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) => {
warn!("IPC query failed: {}", e);
Vec::new()
}
}
}
SearchBackend::Local {
providers,
frecency,
} => {
let frecency_weight = config.providers.frecency_weight;
let use_frecency = config.providers.frecency;
if use_frecency {
providers
.search_with_frecency(
query,
max_results,
filter,
frecency,
frecency_weight,
None,
)
.into_iter()
.map(|(item, _)| item)
.collect()
} else {
providers
.search_filtered(query, max_results, filter)
.into_iter()
.map(|(item, _)| item)
.collect()
}
}
}
}
/// Search with tag filter support.
pub fn search_with_tag(
&mut self,
query: &str,
max_results: usize,
filter: &ProviderFilter,
config: &Config,
tag_filter: Option<&str>,
) -> Vec<LaunchItem> {
match self {
SearchBackend::Daemon(client) => {
// 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 {
format!(":tag:{} {}", tag, query)
} else {
query.to_string()
};
// When accept_all, send None so daemon doesn't restrict to a specific set
// (otherwise dynamically loaded plugin types would be filtered out)
let modes_param = if filter.is_accept_all() {
None
} else {
let modes: Vec<String> = filter
.enabled_providers()
.iter()
.map(|p| p.to_string())
.collect();
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) => {
warn!("IPC query failed: {}", e);
Vec::new()
}
}
}
SearchBackend::Local {
providers,
frecency,
} => {
let frecency_weight = config.providers.frecency_weight;
let use_frecency = config.providers.frecency;
if use_frecency {
providers
.search_with_frecency(
query,
max_results,
filter,
frecency,
frecency_weight,
tag_filter,
)
.into_iter()
.map(|(item, _)| item)
.collect()
} else {
providers
.search_filtered(query, max_results, filter)
.into_iter()
.map(|(item, _)| item)
.collect()
}
}
}
}
/// Execute a plugin action command. Returns true if handled.
pub fn execute_plugin_action(&mut self, command: &str) -> bool {
match self {
SearchBackend::Daemon(client) => match client.plugin_action(command) {
Ok(handled) => handled,
Err(e) => {
warn!("IPC plugin_action failed: {}", e);
false
}
},
SearchBackend::Local { providers, .. } => providers.execute_plugin_action(command),
}
}
/// Query submenu actions for a plugin item.
/// Returns (display_name, actions) if available.
pub fn query_submenu_actions(
&mut self,
plugin_id: &str,
data: &str,
display_name: &str,
) -> Option<(String, Vec<LaunchItem>)> {
match self {
SearchBackend::Daemon(client) => match client.submenu(plugin_id, data) {
Ok(items) if !items.is_empty() => {
let actions: Vec<LaunchItem> =
items.into_iter().map(result_to_launch_item).collect();
Some((display_name.to_string(), actions))
}
Ok(_) => None,
Err(e) => {
warn!("IPC submenu query failed: {}", e);
None
}
},
SearchBackend::Local { providers, .. } => {
providers.query_submenu_actions(plugin_id, data, display_name)
}
}
}
/// Record a launch event for frecency tracking.
pub fn record_launch(&mut self, item_id: &str, provider: &str) {
match self {
SearchBackend::Daemon(client) => {
if let Err(e) = client.launch(item_id, provider) {
warn!("IPC launch notification failed: {}", e);
}
}
SearchBackend::Local { frecency, .. } => {
frecency.record_launch(item_id);
}
}
}
/// Whether this backend is in dmenu mode.
pub fn is_dmenu_mode(&self) -> bool {
match self {
SearchBackend::Daemon(_) => false,
SearchBackend::Local { providers, .. } => providers.is_dmenu_mode(),
}
}
/// Refresh widget providers. No-op for daemon mode (daemon handles refresh).
pub fn refresh_widgets(&mut self) {
if let SearchBackend::Local { providers, .. } = self {
providers.refresh_widgets();
}
}
/// Get available provider type IDs from the daemon, or from local manager.
#[allow(dead_code)]
pub fn available_provider_ids(&mut self) -> Vec<String> {
match self {
SearchBackend::Daemon(client) => match client.providers() {
Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
Err(e) => {
warn!("IPC providers query failed: {}", e);
Vec::new()
}
},
SearchBackend::Local { providers, .. } => providers
.available_providers()
.into_iter()
.map(|d| d.id)
.collect(),
}
}
}
/// Convert an IPC ResultItem to the internal LaunchItem type.
fn result_to_launch_item(item: ResultItem) -> LaunchItem {
let provider: ProviderType = item.provider.parse().unwrap_or(ProviderType::Application);
LaunchItem {
id: item.id,
name: item.title,
description: if item.description.is_empty() {
None
} else {
Some(item.description)
},
icon: if item.icon.is_empty() {
None
} else {
Some(item.icon)
},
provider,
command: item.command.unwrap_or_default(),
terminal: item.terminal,
tags: item.tags,
}
}

263
crates/owlry/src/cli.rs Normal file
View File

@@ -0,0 +1,263 @@
//! Command-line interface for owlry launcher
//!
//! Provides both the launcher interface and plugin management commands.
use clap::{Parser, Subcommand};
use owlry_core::providers::ProviderType;
#[derive(Parser, Debug, Clone)]
#[command(
name = "owlry",
about = "An owl-themed application launcher for Wayland",
long_about = "An owl-themed application launcher for Wayland, built with GTK4 and Layer Shell.\n\n\
Owlry provides fuzzy search across applications, commands, and plugins.\n\
Native plugins add features like calculator, clipboard, emoji, weather, and more.",
version,
after_help = "\
EXAMPLES:
owlry Launch with all providers
owlry -m app Applications only
owlry -m cmd PATH commands only
owlry -m dmenu dmenu-compatible mode (reads from stdin)
owlry --profile dev Use a named profile from config
owlry -m calc Calculator plugin only (if installed)
DMENU MODE:
Pipe input to owlry for interactive selection:
echo -e \"Option A\\nOption B\" | owlry -m dmenu
ls | owlry -m dmenu -p \"checkout:\"
git branch | owlry -m dmenu --prompt \"checkout:\"
PROFILES:
Define profiles in ~/.config/owlry/config.toml:
[profiles.dev]
modes = [\"app\", \"cmd\", \"ssh\"]
Then launch with: owlry --profile dev
SEARCH PREFIXES:
:app firefox Search applications
:cmd git Search PATH commands
= 5+3 Calculator (requires plugin)
? rust docs Web search (requires plugin)
/ .bashrc File search (requires plugin)
For configuration, see ~/.config/owlry/config.toml
For plugin management, see: owlry plugin --help"
)]
pub struct CliArgs {
/// Start in single-provider mode
///
/// Core modes: app, cmd, dmenu
/// Plugin modes: calc, clip, emoji, ssh, sys, bm, file, web, uuctl, weather, media, pomodoro
#[arg(long, short = 'm', value_parser = parse_provider, value_name = "MODE")]
pub mode: Option<ProviderType>,
/// Use a named profile from config (defines which modes to enable)
///
/// Profiles are defined in config.toml under [profiles.<name>].
/// Example: --profile dev (loads modes from [profiles.dev])
#[arg(long, value_name = "NAME")]
pub profile: Option<String>,
/// Custom prompt text for the search input
///
/// Useful in dmenu mode to indicate what the user is selecting.
/// Example: -p "Select file:" or --prompt "Select file:"
#[arg(long, short = 'p', value_name = "TEXT")]
pub prompt: Option<String>,
/// Subcommand to run (if any)
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand, Debug, Clone)]
pub enum Command {
/// Manage plugins
#[command(subcommand)]
Plugin(PluginCommand),
}
/// Plugin runtime type
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum PluginRuntime {
/// Lua runtime (requires owlry-lua package)
Lua,
/// Rune runtime (requires owlry-rune package)
Rune,
}
impl std::fmt::Display for PluginRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PluginRuntime::Lua => write!(f, "lua"),
PluginRuntime::Rune => write!(f, "rune"),
}
}
}
#[derive(Subcommand, Debug, Clone)]
pub enum PluginCommand {
/// List installed plugins
List {
/// Show only enabled plugins
#[arg(long)]
enabled: bool,
/// Show only disabled plugins
#[arg(long)]
disabled: bool,
/// Filter by runtime type (lua or rune)
#[arg(long, short = 'r', value_enum)]
runtime: Option<PluginRuntime>,
/// Show available plugins from registry instead of installed
#[arg(long)]
available: bool,
/// Force refresh of registry cache
#[arg(long)]
refresh: bool,
/// Output in JSON format
#[arg(long)]
json: bool,
},
/// Search for plugins in the registry
Search {
/// Search query (matches name, description, tags)
query: String,
/// Force refresh of registry cache
#[arg(long)]
refresh: bool,
/// Output in JSON format
#[arg(long)]
json: bool,
},
/// Show detailed information about a plugin
Info {
/// Plugin ID
name: String,
/// Show info from registry instead of installed plugin
#[arg(long)]
registry: bool,
/// Output in JSON format
#[arg(long)]
json: bool,
},
/// Install a plugin from registry, path, or URL
Install {
/// Plugin source (registry name, local path, or git URL)
source: String,
/// Force reinstall even if already installed
#[arg(long, short = 'f')]
force: bool,
},
/// Remove an installed plugin
Remove {
/// Plugin ID to remove
name: String,
/// Don't ask for confirmation
#[arg(long, short = 'y')]
yes: bool,
},
/// Update installed plugins
Update {
/// Specific plugin to update (all if not specified)
name: Option<String>,
},
/// Enable a disabled plugin
Enable {
/// Plugin ID to enable
name: String,
},
/// Disable an installed plugin
Disable {
/// Plugin ID to disable
name: String,
},
/// Create a new plugin from template
Create {
/// Plugin ID (directory name)
name: String,
/// Runtime type to use (default: lua)
#[arg(long, short = 'r', value_enum, default_value = "lua")]
runtime: PluginRuntime,
/// Target directory (default: current directory)
#[arg(long, short = 'd')]
dir: Option<String>,
/// Plugin display name
#[arg(long)]
display_name: Option<String>,
/// Plugin description
#[arg(long)]
description: Option<String>,
},
/// Validate a plugin's structure and manifest
Validate {
/// Path to plugin directory (default: current directory)
path: Option<String>,
},
/// Show available script runtimes
Runtimes,
/// Run a plugin command
///
/// Plugins can provide CLI commands that are invoked via:
/// owlry plugin run <plugin-id> <command> [args...]
///
/// Example:
/// owlry plugin run bookmark add https://example.com "My Bookmark"
Run {
/// Plugin ID
plugin_id: String,
/// Command to run
command: String,
/// Arguments to pass to the command
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
/// List commands provided by a plugin
Commands {
/// Plugin ID (optional - lists all if not specified)
plugin_id: Option<String>,
},
}
fn parse_provider(s: &str) -> Result<ProviderType, String> {
s.parse()
}
impl CliArgs {
pub fn parse_args() -> Self {
Self::parse()
}
}

367
crates/owlry/src/client.rs Normal file
View File

@@ -0,0 +1,367 @@
use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
/// IPC client that connects to the owlry-core daemon Unix socket
/// and provides typed methods for all IPC operations.
pub struct CoreClient {
stream: UnixStream,
reader: BufReader<UnixStream>,
}
impl CoreClient {
/// Connect to a running daemon at the given socket path.
///
/// Sets a 5-second read timeout so the client doesn't hang indefinitely
/// if the daemon stops responding.
pub fn connect(socket_path: &Path) -> io::Result<Self> {
let stream = UnixStream::connect(socket_path)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
let reader = BufReader::new(stream.try_clone()?);
Ok(Self { stream, reader })
}
/// Try connecting to the daemon. If the socket isn't available, attempt
/// to start the daemon via systemd and retry with exponential backoff.
///
/// Backoff schedule: 100ms, 200ms, 400ms.
pub fn connect_or_start() -> io::Result<Self> {
let path = Self::socket_path();
// First attempt: just try connecting.
if let Ok(client) = Self::connect(&path) {
return Ok(client);
}
// Socket not available — try to start the daemon.
let status = std::process::Command::new("systemctl")
.args(["--user", "start", "owlry-core"])
.status()
.map_err(|e| {
io::Error::other(format!("failed to start owlry-core via systemd: {e}"))
})?;
if !status.success() {
return Err(io::Error::other(format!(
"systemctl --user start owlry-core exited with status {}",
status
)));
}
// Retry with exponential backoff.
let delays = [100, 200, 400];
for (i, ms) in delays.iter().enumerate() {
std::thread::sleep(Duration::from_millis(*ms));
match Self::connect(&path) {
Ok(client) => return Ok(client),
Err(e) if i == delays.len() - 1 => {
return Err(io::Error::new(
io::ErrorKind::ConnectionRefused,
format!("daemon started but socket not available after retries: {e}"),
));
}
Err(_) => continue,
}
}
unreachable!()
}
/// Default socket path: `$XDG_RUNTIME_DIR/owlry/owlry.sock`.
///
/// Delegates to `owlry_core::paths::socket_path()` to keep a single
/// source of truth.
pub fn socket_path() -> PathBuf {
owlry_core::paths::socket_path()
}
/// Send a search query and return matching results.
pub fn query(&mut self, text: &str, modes: Option<Vec<String>>) -> io::Result<Vec<ResultItem>> {
self.send(&Request::Query {
text: text.to_string(),
modes,
})?;
match self.receive()? {
Response::Results { items } => Ok(items),
Response::Error { message } => Err(io::Error::other(message)),
other => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected response to Query: {other:?}"),
)),
}
}
/// Record a launch event for frecency tracking.
pub fn launch(&mut self, item_id: &str, provider: &str) -> io::Result<()> {
self.send(&Request::Launch {
item_id: item_id.to_string(),
provider: provider.to_string(),
})?;
match self.receive()? {
Response::Ack => Ok(()),
Response::Error { message } => Err(io::Error::other(message)),
other => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected response to Launch: {other:?}"),
)),
}
}
/// List all available providers from the daemon.
pub fn providers(&mut self) -> io::Result<Vec<ProviderDesc>> {
self.send(&Request::Providers)?;
match self.receive()? {
Response::Providers { list } => Ok(list),
Response::Error { message } => Err(io::Error::other(message)),
other => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected response to Providers: {other:?}"),
)),
}
}
/// Toggle the launcher window visibility.
pub fn toggle(&mut self) -> io::Result<()> {
self.send(&Request::Toggle)?;
match self.receive()? {
Response::Ack => Ok(()),
Response::Error { message } => Err(io::Error::other(message)),
other => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected response to Toggle: {other:?}"),
)),
}
}
/// Execute a plugin action command (e.g., "POMODORO:start").
/// Returns Ok(true) if the plugin handled the action, Ok(false) if not.
pub fn plugin_action(&mut self, command: &str) -> io::Result<bool> {
self.send(&Request::PluginAction {
command: command.to_string(),
})?;
match self.receive()? {
Response::Ack => Ok(true),
Response::Error { .. } => Ok(false),
other => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected response to PluginAction: {other:?}"),
)),
}
}
/// Query a plugin's submenu actions.
pub fn submenu(&mut self, plugin_id: &str, data: &str) -> io::Result<Vec<ResultItem>> {
self.send(&Request::Submenu {
plugin_id: plugin_id.to_string(),
data: data.to_string(),
})?;
match self.receive()? {
Response::SubmenuItems { items } => Ok(items),
Response::Error { message } => Err(io::Error::other(message)),
other => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected response to Submenu: {other:?}"),
)),
}
}
// =========================================================================
// Internal helpers
// =========================================================================
fn send(&mut self, request: &Request) -> io::Result<()> {
let json = serde_json::to_string(request)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
writeln!(self.stream, "{json}")?;
self.stream.flush()
}
fn receive(&mut self) -> io::Result<Response> {
let mut line = String::new();
self.reader.read_line(&mut line)?;
if line.is_empty() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"daemon closed the connection",
));
}
serde_json::from_str(line.trim()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::net::UnixListener;
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;
static COUNTER: AtomicU32 = AtomicU32::new(0);
/// Spawn a mock server that accepts one connection, reads one request,
/// and replies with the given canned response. Each call gets a unique
/// socket path to avoid collisions when tests run in parallel.
fn mock_server(response: Response) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("owlry-test-{}-{}", std::process::id(), n));
let _ = std::fs::create_dir_all(&dir);
let sock = dir.join("test.sock");
let _ = std::fs::remove_file(&sock);
let listener = UnixListener::bind(&sock).expect("bind mock socket");
let sock_clone = sock.clone();
thread::spawn(move || {
let (stream, _) = listener.accept().expect("accept");
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut writer = stream;
// Read one request line (we don't care about contents).
let mut line = String::new();
reader.read_line(&mut line).expect("read request");
// Send canned response.
let mut json = serde_json::to_string(&response).unwrap();
json.push('\n');
writer.write_all(json.as_bytes()).unwrap();
writer.flush().unwrap();
// Clean up socket after test.
let _ = std::fs::remove_file(&sock_clone);
let _ = std::fs::remove_dir(dir);
});
sock
}
#[test]
fn connect_and_query_returns_results() {
let canned = Response::Results {
items: vec![ResultItem {
id: "firefox".into(),
title: "Firefox".into(),
description: "Web Browser".into(),
icon: "firefox".into(),
provider: "app".into(),
score: 100,
command: Some("firefox".into()),
terminal: false,
tags: vec![],
}],
};
let sock = mock_server(canned);
// Give the listener thread a moment to start.
thread::sleep(Duration::from_millis(50));
let mut client = CoreClient::connect(&sock).expect("connect");
let results = client.query("fire", None).expect("query");
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "firefox");
assert_eq!(results[0].title, "Firefox");
assert_eq!(results[0].score, 100);
}
#[test]
fn toggle_returns_ack() {
let sock = mock_server(Response::Ack);
thread::sleep(Duration::from_millis(50));
let mut client = CoreClient::connect(&sock).expect("connect");
client.toggle().expect("toggle should succeed");
}
#[test]
fn launch_returns_ack() {
let sock = mock_server(Response::Ack);
thread::sleep(Duration::from_millis(50));
let mut client = CoreClient::connect(&sock).expect("connect");
client
.launch("firefox", "app")
.expect("launch should succeed");
}
#[test]
fn providers_returns_list() {
let canned = Response::Providers {
list: vec![ProviderDesc {
id: "app".into(),
name: "Applications".into(),
prefix: Some(":app".into()),
icon: "application-x-executable".into(),
position: "normal".into(),
}],
};
let sock = mock_server(canned);
thread::sleep(Duration::from_millis(50));
let mut client = CoreClient::connect(&sock).expect("connect");
let providers = client.providers().expect("providers");
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].id, "app");
}
#[test]
fn submenu_returns_items() {
let canned = Response::SubmenuItems {
items: vec![ResultItem {
id: "start".into(),
title: "Start Service".into(),
description: String::new(),
icon: "media-playback-start".into(),
provider: "systemd".into(),
score: 0,
command: Some("systemctl --user start foo".into()),
terminal: false,
tags: vec![],
}],
};
let sock = mock_server(canned);
thread::sleep(Duration::from_millis(50));
let mut client = CoreClient::connect(&sock).expect("connect");
let items = client.submenu("systemd", "foo.service").expect("submenu");
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "start");
}
#[test]
fn error_response_is_propagated() {
let canned = Response::Error {
message: "something went wrong".into(),
};
let sock = mock_server(canned);
thread::sleep(Duration::from_millis(50));
let mut client = CoreClient::connect(&sock).expect("connect");
let err = client.query("test", None).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("something went wrong"),
"error message should contain the server error, got: {msg}"
);
}
#[test]
fn socket_path_delegates_to_core() {
let path = CoreClient::socket_path();
assert!(path.ends_with("owlry/owlry.sock"));
}
}

123
crates/owlry/src/main.rs Normal file
View File

@@ -0,0 +1,123 @@
mod app;
mod backend;
mod cli;
pub mod client;
mod plugin_commands;
mod providers;
mod theme;
mod ui;
use app::OwlryApp;
use cli::{CliArgs, Command};
use log::{info, warn};
use std::os::unix::io::AsRawFd;
#[cfg(feature = "dev-logging")]
use log::debug;
/// Try to acquire an exclusive lock on the UI lock file.
///
/// Returns `Some(File)` if the lock was acquired (no other instance running),
/// or `None` if another instance already holds the lock.
/// The returned `File` must be kept alive for the duration of the process.
fn try_acquire_lock() -> Option<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
let lock_path = owlry_core::paths::socket_path()
.parent()
.unwrap()
.join("owlry-ui.lock");
// Ensure the parent directory exists
if let Some(parent) = lock_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&lock_path)
.ok()
.and_then(|f| {
let fd = f.as_raw_fd();
let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if ret == 0 { Some(f) } else { None }
})
}
fn main() {
let args = CliArgs::parse_args();
// Handle subcommands before initializing the full app
if let Some(command) = &args.command {
// CLI commands don't need full logging
match command {
Command::Plugin(plugin_cmd) => {
if let Err(e) = plugin_commands::execute(plugin_cmd.clone()) {
eprintln!("Error: {}", e);
std::process::exit(1);
}
std::process::exit(0);
}
}
}
// No subcommand - launch the app
let default_level = if cfg!(feature = "dev-logging") {
"debug"
} else {
"info"
};
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level))
.format_timestamp_millis()
.init();
#[cfg(feature = "dev-logging")]
{
debug!("┌─────────────────────────────────────────┐");
debug!("│ DEV-LOGGING: Verbose output enabled │");
debug!("└─────────────────────────────────────────┘");
debug!("CLI args: {:?}", args);
}
// Toggle behavior: if another instance is already running, tell the daemon
// to toggle visibility and exit immediately.
let _lock_guard = match try_acquire_lock() {
Some(file) => file,
None => {
// Another instance holds the lock — send toggle to daemon and exit
info!("Another owlry instance detected, sending toggle");
let socket_path = client::CoreClient::socket_path();
if let Ok(mut client) = client::CoreClient::connect(&socket_path) {
if let Err(e) = client.toggle() {
eprintln!("Failed to toggle existing instance: {}", e);
std::process::exit(1);
}
} else {
eprintln!("Another instance is running but daemon is unreachable");
std::process::exit(1);
}
std::process::exit(0);
}
};
info!("Starting Owlry launcher");
// Diagnostic: log critical environment variables
let home = std::env::var("HOME").unwrap_or_else(|_| "<not set>".to_string());
let path = std::env::var("PATH").unwrap_or_else(|_| "<not set>".to_string());
let xdg_data = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| "<not set>".to_string());
info!("HOME={}", home);
info!("PATH={}", path);
info!("XDG_DATA_HOME={}", xdg_data);
if home == "<not set>" || path == "<not set>" {
warn!("Critical environment variables missing! Items may not load correctly.");
}
let app = OwlryApp::new(args);
std::process::exit(app.run());
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,2 @@
pub mod dmenu;
pub use dmenu::DmenuProvider;

View File

@@ -14,7 +14,7 @@
background-color: var(--owlry-bg, @theme_bg_color);
border-radius: var(--owlry-border-radius, 12px);
border: 1px solid var(--owlry-border, @borders);
padding: 16px;
padding: 12px;
}
/* Search entry */
@@ -43,8 +43,8 @@
.owlry-result-row {
background-color: transparent;
border-radius: calc(var(--owlry-border-radius, 12px) - 4px);
margin: 2px 0;
padding: 8px 12px;
margin: 1px 0;
padding: 6px 12px;
}
.owlry-result-row:hover {
@@ -67,6 +67,18 @@
opacity: 1;
}
/* Symbolic icons - inherit text color */
.owlry-symbolic-icon {
-gtk-icon-style: symbolic;
}
/* Emoji icon - displayed as large text */
.owlry-emoji-icon {
font-size: 24px;
min-width: 32px;
min-height: 32px;
}
/* Result name */
.owlry-result-name {
font-size: var(--owlry-font-size, 14px);
@@ -81,7 +93,7 @@
/* Result description */
.owlry-result-description {
font-size: calc(var(--owlry-font-size, 14px) - 2px);
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.7));
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.85));
margin-top: 2px;
}
@@ -166,6 +178,22 @@
color: var(--owlry-badge-web, @teal_3);
}
/* Widget provider badges */
.owlry-badge-media {
background-color: alpha(var(--owlry-badge-media, #ec4899), 0.2);
color: var(--owlry-badge-media, #ec4899);
}
.owlry-badge-weather {
background-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.2);
color: var(--owlry-badge-weather, #06b6d4);
}
.owlry-badge-pomo {
background-color: alpha(var(--owlry-badge-pomo, #f97316), 0.2);
color: var(--owlry-badge-pomo, #f97316);
}
/* Header bar */
.owlry-header {
margin-bottom: 4px;
@@ -283,6 +311,25 @@
border-color: alpha(var(--owlry-badge-web, @teal_3), 0.4);
}
/* Widget filter buttons */
.owlry-filter-media:checked {
background-color: alpha(var(--owlry-badge-media, #ec4899), 0.2);
color: var(--owlry-badge-media, #ec4899);
border-color: alpha(var(--owlry-badge-media, #ec4899), 0.4);
}
.owlry-filter-weather:checked {
background-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.2);
color: var(--owlry-badge-weather, #06b6d4);
border-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.4);
}
.owlry-filter-pomodoro:checked {
background-color: alpha(var(--owlry-badge-pomo, #f97316), 0.2);
color: var(--owlry-badge-pomo, #f97316);
border-color: alpha(var(--owlry-badge-pomo, #f97316), 0.4);
}
/* Hints bar at bottom */
.owlry-hints {
padding-top: 8px;
@@ -291,7 +338,7 @@
.owlry-hints-label {
font-size: calc(var(--owlry-font-size, 14px) - 4px);
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.7));
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.75));
letter-spacing: 0.5px;
}
@@ -315,6 +362,22 @@ scrollbar slider:active {
background-color: var(--owlry-accent, @theme_selected_bg_color);
}
/* Tag badges */
.owlry-tag-badge {
font-size: calc(var(--owlry-font-size, 14px) - 4px);
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
background-color: alpha(var(--owlry-border, @borders), 0.5);
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.9));
margin-top: 2px;
}
.owlry-result-row:selected .owlry-tag-badge {
background-color: alpha(var(--owlry-accent-bright, @theme_selected_fg_color), 0.25);
color: var(--owlry-accent-bright, @theme_selected_fg_color);
}
/* Text selection */
selection {
background-color: alpha(var(--owlry-accent, @theme_selected_bg_color), 0.3);

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/owlry/launcher/icons">
<!-- Weather icons (Erik Flowers Weather Icons - OFL license) -->
<file>weather/wi-day-sunny.svg</file>
<file>weather/wi-day-cloudy.svg</file>
<file>weather/wi-cloudy.svg</file>
<file>weather/wi-fog.svg</file>
<file>weather/wi-rain.svg</file>
<file>weather/wi-snow.svg</file>
<file>weather/wi-thunderstorm.svg</file>
<file>weather/wi-thermometer.svg</file>
<file>weather/wi-night-clear.svg</file>
<!-- Media player icons -->
<file>media/music-note.svg</file>
<!-- Pomodoro icons -->
<file>pomodoro/tomato.svg</file>
</gresource>
</gresources>

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#e0e0e0">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
</svg>

After

Width:  |  Height:  |  Size: 183 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<ellipse cx="50" cy="58" rx="38" ry="35" fill="#e53935"/>
<ellipse cx="50" cy="58" rx="38" ry="35" fill="url(#tomato-gradient)"/>
<path d="M50 25 C45 15, 55 15, 50 25" fill="#4caf50"/>
<path d="M42 28 Q50 20 58 28" stroke="#2e7d32" stroke-width="3" fill="none"/>
<defs>
<radialGradient id="tomato-gradient" cx="30%" cy="30%">
<stop offset="0%" stop-color="#ff5722" stop-opacity="0.3"/>
<stop offset="100%" stop-color="#c62828" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 574 B

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M3.89,17.6c0-0.99,0.31-1.88,0.93-2.65s1.41-1.27,2.38-1.49c0.26-1.17,0.85-2.14,1.78-2.88c0.93-0.75,2-1.12,3.22-1.12
c1.18,0,2.24,0.36,3.16,1.09c0.93,0.73,1.53,1.66,1.8,2.8h0.27c1.18,0,2.18,0.41,3.01,1.24s1.25,1.83,1.25,3
c0,1.18-0.42,2.18-1.25,3.01s-1.83,1.25-3.01,1.25H8.16c-0.58,0-1.13-0.11-1.65-0.34S5.52,21,5.14,20.62
c-0.38-0.38-0.68-0.84-0.91-1.36S3.89,18.17,3.89,17.6z M5.34,17.6c0,0.76,0.28,1.42,0.82,1.96s1.21,0.82,1.99,0.82h9.28
c0.77,0,1.44-0.27,1.99-0.82c0.55-0.55,0.83-1.2,0.83-1.96c0-0.76-0.27-1.42-0.83-1.96c-0.55-0.54-1.21-0.82-1.99-0.82h-1.39
c-0.1,0-0.15-0.05-0.15-0.15l-0.07-0.49c-0.1-0.94-0.5-1.73-1.19-2.35s-1.51-0.93-2.45-0.93c-0.94,0-1.76,0.31-2.46,0.94
c-0.7,0.62-1.09,1.41-1.18,2.34l-0.07,0.42c0,0.1-0.05,0.15-0.16,0.15l-0.45,0.07c-0.72,0.06-1.32,0.36-1.81,0.89
C5.59,16.24,5.34,16.87,5.34,17.6z M14.19,8.88c-0.1,0.09-0.08,0.16,0.07,0.21c0.43,0.19,0.79,0.37,1.08,0.55
c0.11,0.03,0.19,0.02,0.22-0.03c0.61-0.57,1.31-0.86,2.12-0.86c0.81,0,1.5,0.27,2.1,0.81c0.59,0.54,0.92,1.21,0.99,2l0.09,0.64h1.42
c0.65,0,1.21,0.23,1.68,0.7c0.47,0.47,0.7,1.02,0.7,1.66c0,0.6-0.21,1.12-0.62,1.57s-0.92,0.7-1.53,0.77c-0.1,0-0.15,0.05-0.15,0.16
v1.13c0,0.11,0.05,0.16,0.15,0.16c1.01-0.06,1.86-0.46,2.55-1.19s1.04-1.6,1.04-2.6c0-1.06-0.37-1.96-1.12-2.7
c-0.75-0.75-1.65-1.12-2.7-1.12h-0.15c-0.26-1-0.81-1.82-1.65-2.47c-0.83-0.65-1.77-0.97-2.8-0.97C16.28,7.29,15.11,7.82,14.19,8.88
z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M1.56,16.9c0,0.9,0.22,1.73,0.66,2.49s1.04,1.36,1.8,1.8c0.76,0.44,1.58,0.66,2.47,0.66h10.83c0.89,0,1.72-0.22,2.48-0.66
c0.76-0.44,1.37-1.04,1.81-1.8c0.44-0.76,0.67-1.59,0.67-2.49c0-0.66-0.14-1.33-0.42-2C22.62,13.98,23,12.87,23,11.6
c0-0.71-0.14-1.39-0.41-2.04c-0.27-0.65-0.65-1.2-1.12-1.67C21,7.42,20.45,7.04,19.8,6.77c-0.65-0.28-1.33-0.41-2.04-0.41
c-1.48,0-2.77,0.58-3.88,1.74c-0.77-0.44-1.67-0.66-2.7-0.66c-1.41,0-2.65,0.44-3.73,1.31c-1.08,0.87-1.78,1.99-2.08,3.35
c-1.12,0.26-2.03,0.83-2.74,1.73S1.56,15.75,1.56,16.9z M3.27,16.9c0-0.84,0.28-1.56,0.84-2.17c0.56-0.61,1.26-0.96,2.1-1.06
l0.5-0.03c0.12,0,0.19-0.06,0.19-0.18l0.07-0.54c0.14-1.08,0.61-1.99,1.41-2.71c0.8-0.73,1.74-1.09,2.81-1.09
c1.1,0,2.06,0.37,2.87,1.1c0.82,0.73,1.27,1.63,1.37,2.71l0.07,0.58c0.02,0.11,0.09,0.17,0.21,0.17h1.61c0.88,0,1.64,0.32,2.28,0.96
c0.64,0.64,0.96,1.39,0.96,2.27c0,0.91-0.32,1.68-0.95,2.32c-0.63,0.64-1.4,0.96-2.28,0.96H6.49c-0.88,0-1.63-0.32-2.27-0.97
C3.59,18.57,3.27,17.8,3.27,16.9z M9.97,4.63c0,0.24,0.08,0.45,0.24,0.63l0.66,0.64c0.25,0.19,0.46,0.27,0.64,0.25
c0.21,0,0.39-0.09,0.55-0.26s0.24-0.38,0.24-0.62c0-0.24-0.09-0.44-0.26-0.59l-0.59-0.66c-0.18-0.16-0.38-0.24-0.61-0.24
c-0.24,0-0.45,0.08-0.62,0.25C10.05,4.19,9.97,4.39,9.97,4.63z M15.31,9.06c0.69-0.67,1.51-1,2.45-1c0.99,0,1.83,0.34,2.52,1.03
c0.69,0.69,1.04,1.52,1.04,2.51c0,0.62-0.17,1.24-0.51,1.84C19.84,12.48,18.68,12,17.32,12H17C16.75,10.91,16.19,9.93,15.31,9.06z
M16.94,3.78c0,0.26,0.08,0.46,0.23,0.62s0.35,0.23,0.59,0.23c0.26,0,0.46-0.08,0.62-0.23c0.16-0.16,0.23-0.36,0.23-0.62V1.73
c0-0.24-0.08-0.43-0.24-0.59s-0.36-0.23-0.61-0.23c-0.24,0-0.43,0.08-0.59,0.23s-0.23,0.35-0.23,0.59V3.78z M22.46,6.07
c0,0.26,0.07,0.46,0.22,0.62c0.21,0.16,0.42,0.24,0.62,0.24c0.18,0,0.38-0.08,0.59-0.24l1.43-1.43c0.16-0.18,0.24-0.39,0.24-0.64
c0-0.24-0.08-0.44-0.24-0.6c-0.16-0.16-0.36-0.24-0.59-0.24c-0.24,0-0.43,0.08-0.58,0.24l-1.47,1.43
C22.53,5.64,22.46,5.84,22.46,6.07z M23.25,17.91c0,0.24,0.08,0.45,0.25,0.63l0.65,0.63c0.15,0.16,0.34,0.24,0.58,0.24
s0.44-0.08,0.6-0.25c0.16-0.17,0.24-0.37,0.24-0.62c0-0.22-0.08-0.42-0.24-0.58l-0.65-0.65c-0.16-0.16-0.35-0.24-0.57-0.24
c-0.24,0-0.44,0.08-0.6,0.24C23.34,17.47,23.25,17.67,23.25,17.91z M24.72,11.6c0,0.23,0.09,0.42,0.26,0.58
c0.16,0.16,0.37,0.24,0.61,0.24h2.04c0.23,0,0.42-0.08,0.58-0.23s0.23-0.35,0.23-0.59c0-0.24-0.08-0.44-0.23-0.6
s-0.35-0.25-0.58-0.25h-2.04c-0.24,0-0.44,0.08-0.61,0.25C24.8,11.17,24.72,11.37,24.72,11.6z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.37,14.62c0-0.24,0.08-0.45,0.25-0.62c0.17-0.16,0.38-0.24,0.6-0.24h2.04c0.23,0,0.42,0.08,0.58,0.25
c0.15,0.17,0.23,0.37,0.23,0.61S8,15.06,7.85,15.23c-0.15,0.17-0.35,0.25-0.58,0.25H5.23c-0.23,0-0.43-0.08-0.6-0.25
C4.46,15.06,4.37,14.86,4.37,14.62z M7.23,21.55c0-0.23,0.08-0.43,0.23-0.61l1.47-1.43c0.15-0.16,0.35-0.23,0.59-0.23
c0.24,0,0.44,0.08,0.6,0.23s0.24,0.34,0.24,0.57c0,0.24-0.08,0.46-0.24,0.64L8.7,22.14c-0.41,0.32-0.82,0.32-1.23,0
C7.31,21.98,7.23,21.78,7.23,21.55z M7.23,7.71c0-0.23,0.08-0.43,0.23-0.61C7.66,6.93,7.87,6.85,8.1,6.85
c0.22,0,0.42,0.08,0.59,0.24l1.43,1.47c0.16,0.15,0.24,0.35,0.24,0.59c0,0.24-0.08,0.44-0.24,0.6s-0.36,0.24-0.6,0.24
c-0.24,0-0.44-0.08-0.59-0.24L7.47,8.32C7.31,8.16,7.23,7.95,7.23,7.71z M9.78,14.62c0-0.93,0.23-1.8,0.7-2.6s1.1-1.44,1.91-1.91
s1.67-0.7,2.6-0.7c0.7,0,1.37,0.14,2.02,0.42c0.64,0.28,1.2,0.65,1.66,1.12c0.47,0.47,0.84,1.02,1.11,1.66
c0.27,0.64,0.41,1.32,0.41,2.02c0,0.94-0.23,1.81-0.7,2.61c-0.47,0.8-1.1,1.43-1.9,1.9c-0.8,0.47-1.67,0.7-2.61,0.7
s-1.81-0.23-2.61-0.7c-0.8-0.47-1.43-1.1-1.9-1.9C10.02,16.43,9.78,15.56,9.78,14.62z M11.48,14.62c0,0.98,0.34,1.81,1.03,2.5
c0.68,0.69,1.51,1.04,2.49,1.04s1.81-0.35,2.5-1.04s1.04-1.52,1.04-2.5c0-0.96-0.35-1.78-1.04-2.47c-0.69-0.68-1.52-1.02-2.5-1.02
c-0.97,0-1.8,0.34-2.48,1.02C11.82,12.84,11.48,13.66,11.48,14.62z M14.14,22.4c0-0.24,0.08-0.44,0.25-0.6s0.37-0.24,0.6-0.24
c0.24,0,0.45,0.08,0.61,0.24s0.24,0.36,0.24,0.6v1.99c0,0.24-0.08,0.45-0.25,0.62c-0.17,0.17-0.37,0.25-0.6,0.25
s-0.44-0.08-0.6-0.25c-0.17-0.17-0.25-0.38-0.25-0.62V22.4z M14.14,6.9V4.86c0-0.23,0.08-0.43,0.25-0.6C14.56,4.09,14.76,4,15,4
s0.43,0.08,0.6,0.25c0.17,0.17,0.25,0.37,0.25,0.6V6.9c0,0.23-0.08,0.42-0.25,0.58S15.23,7.71,15,7.71s-0.44-0.08-0.6-0.23
S14.14,7.13,14.14,6.9z M19.66,20.08c0-0.23,0.08-0.42,0.23-0.56c0.15-0.16,0.34-0.23,0.56-0.23c0.24,0,0.44,0.08,0.6,0.23
l1.46,1.43c0.16,0.17,0.24,0.38,0.24,0.61c0,0.23-0.08,0.43-0.24,0.59c-0.4,0.31-0.8,0.31-1.2,0l-1.42-1.42
C19.74,20.55,19.66,20.34,19.66,20.08z M19.66,9.16c0-0.25,0.08-0.45,0.23-0.59l1.42-1.47c0.17-0.16,0.37-0.24,0.59-0.24
c0.24,0,0.44,0.08,0.6,0.25c0.17,0.17,0.25,0.37,0.25,0.6c0,0.25-0.08,0.46-0.24,0.62l-1.46,1.43c-0.18,0.16-0.38,0.24-0.6,0.24
c-0.23,0-0.41-0.08-0.56-0.24S19.66,9.4,19.66,9.16z M21.92,14.62c0-0.24,0.08-0.44,0.24-0.62c0.16-0.16,0.35-0.24,0.57-0.24h2.02
c0.23,0,0.43,0.09,0.6,0.26c0.17,0.17,0.26,0.37,0.26,0.6s-0.09,0.43-0.26,0.6c-0.17,0.17-0.37,0.25-0.6,0.25h-2.02
c-0.23,0-0.43-0.08-0.58-0.25S21.92,14.86,21.92,14.62z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M2.62,21.05c0-0.24,0.08-0.45,0.25-0.61c0.17-0.16,0.38-0.24,0.63-0.24h18.67c0.25,0,0.45,0.08,0.61,0.24
c0.16,0.16,0.24,0.36,0.24,0.61c0,0.23-0.08,0.43-0.25,0.58c-0.17,0.16-0.37,0.23-0.6,0.23H3.5c-0.25,0-0.46-0.08-0.63-0.23
C2.7,21.47,2.62,21.28,2.62,21.05z M5.24,17.91c0-0.24,0.09-0.44,0.26-0.6c0.15-0.15,0.35-0.23,0.59-0.23h18.67
c0.23,0,0.42,0.08,0.58,0.24c0.16,0.16,0.23,0.35,0.23,0.59c0,0.24-0.08,0.44-0.23,0.6c-0.16,0.17-0.35,0.25-0.58,0.25H6.09
c-0.24,0-0.44-0.08-0.6-0.25C5.32,18.34,5.24,18.14,5.24,17.91z M5.37,15.52c0,0.09,0.05,0.13,0.15,0.13h1.43
c0.06,0,0.13-0.05,0.2-0.16c0.24-0.52,0.59-0.94,1.06-1.27c0.47-0.33,0.99-0.52,1.55-0.56l0.55-0.07c0.11,0,0.17-0.06,0.17-0.18
l0.07-0.5c0.11-1.08,0.56-1.98,1.37-2.7c0.81-0.72,1.76-1.08,2.85-1.08c1.08,0,2.02,0.36,2.83,1.07c0.8,0.71,1.26,1.61,1.37,2.68
l0.08,0.57c0,0.11,0.07,0.17,0.2,0.17h1.59c0.64,0,1.23,0.17,1.76,0.52s0.92,0.8,1.18,1.37c0.07,0.11,0.14,0.16,0.21,0.16h1.43
c0.12,0,0.17-0.07,0.14-0.23c-0.29-1.02-0.88-1.86-1.74-2.51c-0.87-0.65-1.86-0.97-2.97-0.97h-0.32c-0.33-1.33-1.03-2.42-2.1-3.27
s-2.28-1.27-3.65-1.27c-1.4,0-2.64,0.44-3.73,1.32s-1.78,2-2.09,3.36c-0.85,0.2-1.6,0.6-2.24,1.21c-0.64,0.61-1.09,1.33-1.34,2.18
v-0.04C5.37,15.45,5.37,15.48,5.37,15.52z M6.98,24.11c0-0.24,0.09-0.43,0.26-0.59c0.15-0.15,0.35-0.23,0.6-0.23h18.68
c0.24,0,0.44,0.08,0.6,0.23c0.17,0.16,0.25,0.35,0.25,0.58c0,0.24-0.08,0.44-0.25,0.61c-0.17,0.17-0.37,0.25-0.6,0.25H7.84
c-0.23,0-0.43-0.09-0.6-0.26C7.07,24.55,6.98,24.34,6.98,24.11z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M7.91,14.48c0-0.96,0.19-1.87,0.56-2.75s0.88-1.63,1.51-2.26c0.63-0.63,1.39-1.14,2.27-1.52c0.88-0.38,1.8-0.57,2.75-0.57
h1.14c0.16,0.04,0.23,0.14,0.23,0.28l0.05,0.88c0.04,1.27,0.49,2.35,1.37,3.24c0.88,0.89,1.94,1.37,3.19,1.42l0.82,0.07
c0.16,0,0.24,0.08,0.24,0.23v0.98c0.01,1.28-0.3,2.47-0.93,3.56c-0.63,1.09-1.48,1.95-2.57,2.59c-1.08,0.63-2.27,0.95-3.55,0.95
c-0.97,0-1.9-0.19-2.78-0.56s-1.63-0.88-2.26-1.51c-0.63-0.63-1.13-1.39-1.5-2.26C8.1,16.37,7.91,15.45,7.91,14.48z M9.74,14.48
c0,0.76,0.15,1.48,0.45,2.16c0.3,0.67,0.7,1.24,1.19,1.7c0.49,0.46,1.05,0.82,1.69,1.08c0.63,0.27,1.28,0.4,1.94,0.4
c0.58,0,1.17-0.11,1.76-0.34c0.59-0.23,1.14-0.55,1.65-0.96c0.51-0.41,0.94-0.93,1.31-1.57c0.37-0.64,0.6-1.33,0.71-2.09
c-1.63-0.34-2.94-1.04-3.92-2.1s-1.55-2.3-1.7-3.74C13.86,9.08,13,9.37,12.21,9.9c-0.78,0.53-1.39,1.2-1.82,2.02
C9.96,12.74,9.74,13.59,9.74,14.48z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.64,16.91c0-1.15,0.36-2.17,1.08-3.07c0.72-0.9,1.63-1.47,2.73-1.73c0.31-1.36,1.02-2.48,2.11-3.36s2.34-1.31,3.75-1.31
c1.38,0,2.6,0.43,3.68,1.28c1.08,0.85,1.78,1.95,2.1,3.29h0.32c0.89,0,1.72,0.22,2.48,0.65s1.37,1.03,1.81,1.78
c0.44,0.75,0.67,1.58,0.67,2.47c0,0.88-0.21,1.69-0.63,2.44c-0.42,0.75-1,1.35-1.73,1.8c-0.73,0.45-1.53,0.69-2.4,0.71
c-0.13,0-0.2-0.06-0.2-0.17v-1.33c0-0.12,0.07-0.18,0.2-0.18c0.85-0.04,1.58-0.38,2.18-1.02s0.9-1.39,0.9-2.26s-0.33-1.62-0.98-2.26
s-1.42-0.96-2.31-0.96h-1.61c-0.12,0-0.18-0.06-0.18-0.17l-0.08-0.58c-0.11-1.08-0.58-1.99-1.39-2.71
c-0.82-0.73-1.76-1.09-2.85-1.09c-1.09,0-2.05,0.36-2.85,1.09c-0.81,0.73-1.26,1.63-1.36,2.71l-0.07,0.53c0,0.12-0.07,0.19-0.2,0.19
l-0.53,0.03c-0.83,0.1-1.53,0.46-2.1,1.07s-0.85,1.33-0.85,2.16c0,0.87,0.3,1.62,0.9,2.26s1.33,0.98,2.18,1.02
c0.11,0,0.17,0.06,0.17,0.18v1.33c0,0.11-0.06,0.17-0.17,0.17c-1.34-0.06-2.47-0.57-3.4-1.53S4.64,18.24,4.64,16.91z M9.99,23.6
c0-0.04,0.01-0.11,0.04-0.2l1.63-5.77c0.06-0.19,0.17-0.34,0.32-0.44c0.15-0.1,0.31-0.15,0.46-0.15c0.07,0,0.15,0.01,0.24,0.03
c0.24,0.04,0.42,0.17,0.54,0.37c0.12,0.2,0.15,0.42,0.08,0.67l-1.63,5.73c-0.12,0.43-0.4,0.64-0.82,0.64
c-0.04,0-0.07-0.01-0.11-0.02c-0.06-0.02-0.09-0.03-0.1-0.03c-0.22-0.06-0.38-0.17-0.49-0.33C10.04,23.93,9.99,23.77,9.99,23.6z
M12.61,26.41l2.44-8.77c0.04-0.19,0.14-0.34,0.3-0.44c0.16-0.1,0.32-0.15,0.49-0.15c0.09,0,0.18,0.01,0.27,0.03
c0.22,0.06,0.38,0.19,0.49,0.39c0.11,0.2,0.13,0.41,0.07,0.64l-2.43,8.78c-0.04,0.17-0.13,0.31-0.29,0.43
c-0.16,0.12-0.32,0.18-0.51,0.18c-0.09,0-0.18-0.02-0.25-0.05c-0.2-0.05-0.37-0.18-0.52-0.39C12.56,26.88,12.54,26.67,12.61,26.41z
M16.74,23.62c0-0.04,0.01-0.11,0.04-0.23l1.63-5.77c0.06-0.19,0.16-0.34,0.3-0.44c0.15-0.1,0.3-0.15,0.46-0.15
c0.08,0,0.17,0.01,0.26,0.03c0.21,0.06,0.36,0.16,0.46,0.31c0.1,0.15,0.15,0.31,0.15,0.47c0,0.03-0.01,0.08-0.02,0.14
s-0.02,0.1-0.02,0.12l-1.63,5.73c-0.04,0.19-0.13,0.35-0.28,0.46s-0.32,0.17-0.51,0.17l-0.24-0.05c-0.2-0.06-0.35-0.16-0.46-0.32
C16.79,23.94,16.74,23.78,16.74,23.62z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.64,16.95c0-1.16,0.35-2.18,1.06-3.08s1.62-1.48,2.74-1.76c0.31-1.36,1.01-2.48,2.1-3.36s2.34-1.31,3.75-1.31
c1.38,0,2.6,0.43,3.68,1.28c1.08,0.85,1.78,1.95,2.1,3.29h0.32c0.89,0,1.72,0.22,2.48,0.66c0.76,0.44,1.37,1.04,1.81,1.8
c0.44,0.76,0.67,1.59,0.67,2.48c0,1.32-0.46,2.47-1.39,3.42c-0.92,0.96-2.05,1.46-3.38,1.5c-0.13,0-0.2-0.06-0.2-0.17v-1.33
c0-0.12,0.07-0.18,0.2-0.18c0.85-0.04,1.58-0.38,2.18-1.02s0.9-1.38,0.9-2.23c0-0.89-0.32-1.65-0.97-2.3s-1.42-0.97-2.32-0.97h-1.61
c-0.12,0-0.18-0.06-0.18-0.17l-0.08-0.58c-0.11-1.08-0.58-1.99-1.39-2.72c-0.82-0.73-1.76-1.1-2.85-1.1c-1.1,0-2.05,0.37-2.86,1.11
c-0.81,0.74-1.27,1.65-1.37,2.75l-0.06,0.5c0,0.12-0.07,0.19-0.2,0.19l-0.53,0.07c-0.83,0.07-1.53,0.41-2.1,1.04
s-0.85,1.35-0.85,2.19c0,0.85,0.3,1.59,0.9,2.23s1.33,0.97,2.18,1.02c0.11,0,0.17,0.06,0.17,0.18v1.33c0,0.11-0.06,0.17-0.17,0.17
c-1.34-0.04-2.47-0.54-3.4-1.5C5.1,19.42,4.64,18.27,4.64,16.95z M11,21.02c0-0.22,0.08-0.42,0.24-0.58
c0.16-0.16,0.35-0.24,0.59-0.24c0.23,0,0.43,0.08,0.59,0.24c0.16,0.16,0.24,0.36,0.24,0.58c0,0.24-0.08,0.44-0.24,0.6
c-0.16,0.17-0.35,0.25-0.59,0.25c-0.23,0-0.43-0.08-0.59-0.25C11.08,21.46,11,21.26,11,21.02z M11,24.65c0-0.24,0.08-0.44,0.24-0.6
c0.16-0.15,0.35-0.23,0.58-0.23c0.23,0,0.43,0.08,0.59,0.23c0.16,0.16,0.24,0.35,0.24,0.59c0,0.24-0.08,0.43-0.24,0.59
c-0.16,0.16-0.35,0.23-0.59,0.23c-0.23,0-0.43-0.08-0.59-0.23C11.08,25.08,11,24.88,11,24.65z M14.19,22.95
c0-0.23,0.08-0.44,0.25-0.62c0.16-0.16,0.35-0.24,0.57-0.24c0.23,0,0.43,0.09,0.6,0.26c0.17,0.17,0.26,0.37,0.26,0.6
c0,0.23-0.08,0.43-0.25,0.6c-0.17,0.17-0.37,0.25-0.61,0.25c-0.23,0-0.42-0.08-0.58-0.25S14.19,23.18,14.19,22.95z M14.19,19.33
c0-0.23,0.08-0.43,0.25-0.6c0.18-0.16,0.37-0.24,0.57-0.24c0.24,0,0.44,0.08,0.61,0.25c0.17,0.17,0.25,0.36,0.25,0.6
c0,0.23-0.08,0.43-0.25,0.59c-0.17,0.16-0.37,0.24-0.61,0.24c-0.23,0-0.42-0.08-0.58-0.24C14.27,19.76,14.19,19.56,14.19,19.33z
M14.19,26.61c0-0.23,0.08-0.43,0.25-0.61c0.16-0.16,0.35-0.24,0.57-0.24c0.24,0,0.44,0.08,0.61,0.25c0.17,0.17,0.25,0.37,0.25,0.6
s-0.08,0.43-0.25,0.59c-0.17,0.16-0.37,0.24-0.61,0.24c-0.23,0-0.42-0.08-0.58-0.24C14.27,27.03,14.19,26.84,14.19,26.61z
M17.41,21.02c0-0.22,0.08-0.41,0.25-0.58c0.17-0.17,0.37-0.25,0.6-0.25c0.23,0,0.43,0.08,0.59,0.24c0.16,0.16,0.24,0.36,0.24,0.58
c0,0.24-0.08,0.44-0.24,0.6c-0.16,0.17-0.35,0.25-0.59,0.25c-0.24,0-0.44-0.08-0.6-0.25C17.5,21.45,17.41,21.25,17.41,21.02z
M17.41,24.65c0-0.22,0.08-0.42,0.25-0.6c0.16-0.15,0.36-0.23,0.6-0.23c0.24,0,0.43,0.08,0.59,0.23s0.23,0.35,0.23,0.59
c0,0.24-0.08,0.43-0.23,0.59c-0.16,0.16-0.35,0.23-0.59,0.23c-0.24,0-0.44-0.08-0.6-0.24C17.5,25.07,17.41,24.88,17.41,24.65z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M9.91,19.56c0-0.85,0.2-1.64,0.59-2.38s0.94-1.35,1.65-1.84V5.42c0-0.8,0.27-1.48,0.82-2.03S14.2,2.55,15,2.55
c0.81,0,1.49,0.28,2.04,0.83c0.55,0.56,0.83,1.23,0.83,2.03v9.92c0.71,0.49,1.25,1.11,1.64,1.84s0.58,1.53,0.58,2.38
c0,0.92-0.23,1.78-0.68,2.56s-1.07,1.4-1.85,1.85s-1.63,0.68-2.56,0.68c-0.92,0-1.77-0.23-2.55-0.68s-1.4-1.07-1.86-1.85
S9.91,20.48,9.91,19.56z M11.67,19.56c0,0.93,0.33,1.73,0.98,2.39c0.65,0.66,1.44,0.99,2.36,0.99c0.93,0,1.73-0.33,2.4-1
s1.01-1.46,1.01-2.37c0-0.62-0.16-1.2-0.48-1.73c-0.32-0.53-0.76-0.94-1.32-1.23l-0.28-0.14c-0.1-0.04-0.15-0.14-0.15-0.29V5.42
c0-0.32-0.11-0.59-0.34-0.81C15.62,4.4,15.34,4.29,15,4.29c-0.32,0-0.6,0.11-0.83,0.32c-0.23,0.21-0.34,0.48-0.34,0.81v10.74
c0,0.15-0.05,0.25-0.14,0.29l-0.27,0.14c-0.55,0.29-0.98,0.7-1.29,1.23C11.82,18.35,11.67,18.92,11.67,19.56z M12.45,19.56
c0,0.71,0.24,1.32,0.73,1.82s1.07,0.75,1.76,0.75s1.28-0.25,1.79-0.75c0.51-0.5,0.76-1.11,0.76-1.81c0-0.63-0.22-1.19-0.65-1.67
c-0.43-0.48-0.96-0.77-1.58-0.85V9.69c0-0.06-0.03-0.13-0.1-0.19c-0.07-0.07-0.14-0.1-0.22-0.1c-0.09,0-0.16,0.03-0.21,0.08
c-0.05,0.06-0.08,0.12-0.08,0.21v7.34c-0.61,0.09-1.13,0.37-1.56,0.85C12.66,18.37,12.45,18.92,12.45,19.56z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.63,16.91c0,1.11,0.33,2.1,0.99,2.97s1.52,1.47,2.58,1.79l-0.66,1.68c-0.03,0.14,0.02,0.22,0.14,0.22h2.13l-0.98,4.3h0.28
l3.92-5.75c0.04-0.04,0.04-0.09,0.01-0.14c-0.03-0.05-0.08-0.07-0.15-0.07h-2.18l2.48-4.64c0.07-0.14,0.02-0.22-0.14-0.22h-2.94
c-0.09,0-0.17,0.05-0.23,0.15l-1.07,2.87c-0.71-0.18-1.3-0.57-1.77-1.16c-0.47-0.59-0.7-1.26-0.7-2.01c0-0.83,0.28-1.55,0.85-2.17
c0.57-0.61,1.27-0.97,2.1-1.07l0.53-0.07c0.13,0,0.2-0.06,0.2-0.18l0.07-0.51c0.11-1.08,0.56-1.99,1.37-2.72
c0.81-0.73,1.76-1.1,2.85-1.1c1.09,0,2.04,0.37,2.85,1.1c0.82,0.73,1.28,1.64,1.4,2.72l0.07,0.58c0,0.11,0.06,0.17,0.18,0.17h1.6
c0.91,0,1.68,0.32,2.32,0.95c0.64,0.63,0.97,1.4,0.97,2.28c0,0.85-0.3,1.59-0.89,2.21c-0.59,0.62-1.33,0.97-2.2,1.04
c-0.13,0-0.2,0.06-0.2,0.18v1.37c0,0.11,0.07,0.17,0.2,0.17c1.33-0.04,2.46-0.55,3.39-1.51s1.39-2.11,1.39-3.45
c0-0.9-0.22-1.73-0.67-2.49c-0.44-0.76-1.05-1.36-1.81-1.8c-0.77-0.44-1.6-0.66-2.5-0.66H20.1c-0.33-1.33-1.04-2.42-2.11-3.26
s-2.3-1.27-3.68-1.27c-1.41,0-2.67,0.44-3.76,1.31s-1.79,1.99-2.1,3.36c-1.11,0.26-2.02,0.83-2.74,1.73S4.63,15.76,4.63,16.91z
M12.77,26.62c0,0.39,0.19,0.65,0.58,0.77c0.01,0,0.05,0,0.11,0.01c0.06,0.01,0.11,0.01,0.14,0.01c0.17,0,0.33-0.05,0.49-0.15
c0.16-0.1,0.27-0.26,0.32-0.48l2.25-8.69c0.06-0.24,0.04-0.45-0.07-0.65c-0.11-0.19-0.27-0.32-0.5-0.39
c-0.17-0.02-0.26-0.03-0.26-0.03c-0.16,0-0.32,0.05-0.47,0.15c-0.15,0.1-0.26,0.25-0.31,0.45l-2.26,8.72
C12.78,26.44,12.77,26.53,12.77,26.62z M16.93,23.56c0,0.13,0.03,0.26,0.1,0.38c0.14,0.22,0.31,0.37,0.51,0.44
c0.11,0.03,0.21,0.05,0.3,0.05s0.2-0.02,0.32-0.08c0.21-0.09,0.35-0.28,0.42-0.57l1.44-5.67c0.03-0.14,0.05-0.23,0.05-0.27
c0-0.15-0.05-0.3-0.16-0.45s-0.26-0.26-0.46-0.32c-0.17-0.02-0.26-0.03-0.26-0.03c-0.17,0-0.33,0.05-0.47,0.15
c-0.14,0.1-0.24,0.25-0.3,0.45l-1.46,5.7c0,0.02,0,0.05-0.01,0.11C16.93,23.5,16.93,23.53,16.93,23.56z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,4 +1,4 @@
use crate::config::AppearanceConfig;
use owlry_core::config::AppearanceConfig;
/// Generate CSS with :root variables from config settings
pub fn generate_variables_css(config: &AppearanceConfig) -> String {
@@ -6,7 +6,10 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
// Always inject layout config values
css.push_str(&format!(" --owlry-font-size: {}px;\n", config.font_size));
css.push_str(&format!(" --owlry-border-radius: {}px;\n", config.border_radius));
css.push_str(&format!(
" --owlry-border-radius: {}px;\n",
config.border_radius
));
// Only inject colors if user specified them
if let Some(ref bg) = config.colors.background {
@@ -22,7 +25,10 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
css.push_str(&format!(" --owlry-text: {};\n", text));
}
if let Some(ref text_secondary) = config.colors.text_secondary {
css.push_str(&format!(" --owlry-text-secondary: {};\n", text_secondary));
css.push_str(&format!(
" --owlry-text-secondary: {};\n",
text_secondary
));
}
if let Some(ref accent) = config.colors.accent {
css.push_str(&format!(" --owlry-accent: {};\n", accent));
@@ -36,7 +42,10 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
css.push_str(&format!(" --owlry-badge-app: {};\n", badge_app));
}
if let Some(ref badge_bookmark) = config.colors.badge_bookmark {
css.push_str(&format!(" --owlry-badge-bookmark: {};\n", badge_bookmark));
css.push_str(&format!(
" --owlry-badge-bookmark: {};\n",
badge_bookmark
));
}
if let Some(ref badge_calc) = config.colors.badge_calc {
css.push_str(&format!(" --owlry-badge-calc: {};\n", badge_calc));
@@ -72,6 +81,17 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
css.push_str(&format!(" --owlry-badge-web: {};\n", badge_web));
}
// Widget badge colors
if let Some(ref badge_media) = config.colors.badge_media {
css.push_str(&format!(" --owlry-badge-media: {};\n", badge_media));
}
if let Some(ref badge_weather) = config.colors.badge_weather {
css.push_str(&format!(" --owlry-badge-weather: {};\n", badge_weather));
}
if let Some(ref badge_pomo) = config.colors.badge_pomo {
css.push_str(&format!(" --owlry-badge-pomo: {};\n", badge_pomo));
}
css.push_str("}\n");
css
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
mod main_window;
mod result_row;
pub mod submenu;
pub use main_window::MainWindow;
pub use result_row::ResultRow;

View File

@@ -0,0 +1,165 @@
use gtk4::prelude::*;
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget};
use owlry_core::providers::LaunchItem;
#[allow(dead_code)]
pub struct ResultRow {
row: ListBoxRow,
}
/// Check if a string looks like an emoji (starts with a non-ASCII character
/// and is very short - typically 1-4 chars for complex emojis with ZWJ)
fn is_emoji_icon(s: &str) -> bool {
if s.is_empty() {
return false;
}
// Emojis are non-ASCII and typically very short (1-8 chars for complex ZWJ sequences)
let first_char = s.chars().next().unwrap();
!first_char.is_ascii() && s.chars().count() <= 8
}
impl ResultRow {
#[allow(clippy::new_ret_no_self)]
pub fn new(item: &LaunchItem) -> ListBoxRow {
let row = ListBoxRow::builder()
.selectable(true)
.activatable(true)
.build();
row.add_css_class("owlry-result-row");
let hbox = GtkBox::builder()
.orientation(Orientation::Horizontal)
.spacing(12)
.margin_top(8)
.margin_bottom(8)
.margin_start(12)
.margin_end(12)
.build();
// Icon - handle GResource paths, file paths, icon names, emojis, and fallbacks
let icon_widget: Widget = if let Some(icon_path) = &item.icon {
if is_emoji_icon(icon_path) {
// Emoji character - display as text label
let emoji_label = Label::builder()
.label(icon_path)
.width_request(32)
.height_request(32)
.valign(gtk4::Align::Center)
.halign(gtk4::Align::Center)
.build();
emoji_label.add_css_class("owlry-result-icon");
emoji_label.add_css_class("owlry-emoji-icon");
emoji_label.upcast()
} else if icon_path.starts_with("/org/owlry/launcher/icons/") {
// GResource path - load from bundled resources
let img = Image::from_resource(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
// SVG icons from resources should be treated as symbolic for color inheritance
if icon_path.ends_with(".svg") {
img.add_css_class("owlry-symbolic-icon");
}
img.upcast()
} else if icon_path.starts_with('/') {
// Absolute file path
let img = Image::from_file(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.upcast()
} else {
// Icon theme name
let img = Image::from_icon_name(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
// Add symbolic class for icons ending with "-symbolic"
if icon_path.ends_with("-symbolic") {
img.add_css_class("owlry-symbolic-icon");
}
img.upcast()
}
} else {
// Default icon based on provider type (only core types, plugins should provide icons)
let default_icon = match &item.provider {
owlry_core::providers::ProviderType::Application => {
"application-x-executable-symbolic"
}
owlry_core::providers::ProviderType::Command => "utilities-terminal-symbolic",
owlry_core::providers::ProviderType::Dmenu => "view-list-symbolic",
// Plugins should provide their own icon; fallback to generic addon icon
owlry_core::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic",
};
let img = Image::from_icon_name(default_icon);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.add_css_class("owlry-symbolic-icon");
img.upcast()
};
// Text container
let text_box = GtkBox::builder()
.orientation(Orientation::Vertical)
.hexpand(true)
.valign(gtk4::Align::Center)
.build();
// Name label
let name_label = Label::builder()
.label(&item.name)
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
name_label.add_css_class("owlry-result-name");
// Description label
if let Some(desc) = &item.description {
let desc_label = Label::builder()
.label(desc)
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
desc_label.add_css_class("owlry-result-description");
text_box.append(&name_label);
text_box.append(&desc_label);
} else {
text_box.append(&name_label);
}
// Tag badges (show first 3 tags)
if !item.tags.is_empty() {
let tags_box = GtkBox::builder()
.orientation(Orientation::Horizontal)
.spacing(4)
.halign(gtk4::Align::Start)
.build();
for tag in item.tags.iter().take(3) {
let tag_label = Label::builder().label(tag).build();
tag_label.add_css_class("owlry-tag-badge");
tags_box.append(&tag_label);
}
text_box.append(&tags_box);
}
// Provider badge
let badge = Label::builder()
.label(item.provider.to_string())
.halign(gtk4::Align::End)
.valign(gtk4::Align::Center)
.build();
badge.add_css_class("owlry-result-badge");
badge.add_css_class(&format!("owlry-badge-{}", item.provider));
hbox.append(&icon_widget);
hbox.append(&text_box);
hbox.append(&badge);
row.set_child(Some(&hbox));
row
}
}

View File

@@ -0,0 +1,112 @@
//! Universal Submenu Support for Plugins
//!
//! Provides parsing utilities for submenu commands. Plugins handle their own
//! submenu action generation through the query interface.
//!
//! ## Command Format
//!
//! Plugins should use this command format for submenu items:
//! ```text
//! SUBMENU:<plugin_id>:<data>
//! ```
//!
//! For example:
//! - `SUBMENU:systemd:nginx.service:true` (systemd service with active state)
//! - `SUBMENU:docker:container_id:running` (docker container with state)
//!
//! ## How It Works
//!
//! 1. Plugin returns items with `SUBMENU:...` commands
//! 2. When user selects item, main_window detects it's a submenu item
//! 3. main_window queries the plugin via `?SUBMENU:<data>` format
//! 4. Plugin returns submenu action items
//! 5. main_window displays the submenu
//!
//! ## Plugin Implementation
//!
//! Plugins should handle submenu queries in their `provider_query` function:
//!
//! ```rust,ignore
//! extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
//! let query_str = query.as_str();
//!
//! // Handle submenu action requests
//! if let Some(data) = query_str.strip_prefix("?SUBMENU:") {
//! return generate_submenu_actions(data);
//! }
//!
//! // Handle action execution
//! if let Some(action) = query_str.strip_prefix("!") {
//! execute_action(action);
//! return RVec::new();
//! }
//!
//! // Normal search query
//! search(query_str)
//! }
//! ```
use owlry_core::providers::LaunchItem;
/// Parse a submenu command and extract plugin_id and data
/// Returns (plugin_id, data) if command matches SUBMENU: format
pub fn parse_submenu_command(command: &str) -> Option<(&str, &str)> {
let rest = command.strip_prefix("SUBMENU:")?;
let colon_pos = rest.find(':')?;
let plugin_id = &rest[..colon_pos];
let data = &rest[colon_pos + 1..];
Some((plugin_id, data))
}
/// Check if an item should open a submenu
pub fn is_submenu_item(item: &LaunchItem) -> bool {
item.command.starts_with("SUBMENU:")
}
#[cfg(test)]
mod tests {
use super::*;
use owlry_core::providers::ProviderType;
#[test]
fn test_parse_submenu_command() {
assert_eq!(
parse_submenu_command("SUBMENU:systemd:nginx.service:true"),
Some(("systemd", "nginx.service:true"))
);
assert_eq!(
parse_submenu_command("SUBMENU:docker:abc123:running"),
Some(("docker", "abc123:running"))
);
assert_eq!(parse_submenu_command("not-a-submenu"), None);
assert_eq!(parse_submenu_command("SUBMENU:"), None);
assert_eq!(parse_submenu_command("SUBMENU:nocolon"), None);
}
#[test]
fn test_is_submenu_item() {
let submenu_item = LaunchItem {
id: "test".to_string(),
name: "Test".to_string(),
description: None,
icon: None,
provider: ProviderType::Plugin("test".to_string()),
command: "SUBMENU:plugin:data".to_string(),
terminal: false,
tags: vec![],
};
assert!(is_submenu_item(&submenu_item));
let normal_item = LaunchItem {
id: "test".to_string(),
name: "Test".to_string(),
description: None,
icon: None,
provider: ProviderType::Plugin("test".to_string()),
command: "some-command".to_string(),
terminal: false,
tags: vec![],
};
assert!(!is_submenu_item(&normal_item));
}
}

194
data/config.example.toml Normal file
View File

@@ -0,0 +1,194 @@
# Owlry Configuration
# Copy to: ~/.config/owlry/config.toml
#
# File Locations (XDG Base Directory compliant):
# ┌─────────────────────────────────────────────────────────────────────┐
# │ Config: ~/.config/owlry/config.toml Main configuration │
# │ Themes: ~/.config/owlry/themes/*.css Custom theme files │
# │ Style: ~/.config/owlry/style.css CSS overrides │
# │ Plugins: ~/.config/owlry/plugins/ User Lua/Rune plugins │
# │ Scripts: ~/.local/share/owlry/scripts/ Executable scripts │
# │ Data: ~/.local/share/owlry/frecency.json Usage history │
# └─────────────────────────────────────────────────────────────────────┘
#
# System Plugin Locations:
# ┌─────────────────────────────────────────────────────────────────────┐
# │ Native: /usr/lib/owlry/plugins/*.so Installed plugins │
# │ Runtimes: /usr/lib/owlry/runtimes/*.so Lua/Rune runtimes │
# └─────────────────────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════════════════════════
# DMENU MODE
# ═══════════════════════════════════════════════════════════════════════
#
# Dmenu mode provides interactive selection from piped input.
# The selected item is printed to stdout (not executed), so pipe
# the output to execute it:
#
# ┌─────────────────────────────────────────────────────────────────────┐
# │ # Screenshot menu │
# │ printf '%s\n' \ │
# │ "grimblast --notify copy screen" \ │
# │ "grimblast --notify copy area" \ │
# │ | owlry -m dmenu -p "Screenshot" \ │
# │ | sh │
# │ │
# │ # Git branch checkout │
# │ git branch | owlry -m dmenu -p "checkout" | xargs git checkout │
# │ │
# │ # Package search │
# │ pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S │
# └─────────────────────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════════════════════════
# GENERAL
# ═══════════════════════════════════════════════════════════════════════
[general]
show_icons = true
max_results = 100
# Terminal emulator for SSH, scripts, etc.
# Auto-detection order: $TERMINAL → xdg-terminal-exec → DE-native → Wayland → X11 → xterm
# Uncomment to override:
# terminal_command = "kitty"
# Enable uwsm (Universal Wayland Session Manager) for launching apps.
# When enabled, apps are launched via "uwsm app --" which starts them
# in a proper systemd user session for better process management.
# Requires: uwsm to be installed
# use_uwsm = true
# Header tabs - providers shown as toggle buttons (Ctrl+1, Ctrl+2, etc.)
# Values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
tabs = ["app", "cmd", "uuctl"]
# ═══════════════════════════════════════════════════════════════════════
# APPEARANCE
# ═══════════════════════════════════════════════════════════════════════
[appearance]
width = 850
height = 650
font_size = 14
border_radius = 12
# Theme name - loads ~/.config/owlry/themes/{name}.css
# Built-in: owl
# Or leave unset/empty for GTK default
# theme = "owl"
# Color overrides (applied on top of theme)
# [appearance.colors]
# background = "#1a1b26"
# background_secondary = "#24283b"
# border = "#414868"
# text = "#c0caf5"
# text_secondary = "#565f89"
# accent = "#7aa2f7"
# accent_bright = "#89b4fa"
#
# Provider badge colors (optional)
# badge_app = "#7aa2f7"
# badge_cmd = "#9ece6a"
# badge_bookmark = "#e0af68"
# badge_calc = "#bb9af7"
# badge_clip = "#7dcfff"
# badge_dmenu = "#c0caf5"
# badge_emoji = "#f7768e"
# badge_file = "#73daca"
# badge_script = "#ff9e64"
# badge_ssh = "#2ac3de"
# badge_sys = "#f7768e"
# badge_uuctl = "#9ece6a"
# badge_web = "#7aa2f7"
# badge_media = "#bb9af7"
# badge_weather = "#7dcfff"
# badge_pomo = "#f7768e"
# ═══════════════════════════════════════════════════════════════════════
# PLUGINS
# ═══════════════════════════════════════════════════════════════════════
#
# All installed plugins are loaded by default. Use 'disabled_plugins' to blacklist.
# Plugin IDs: calculator, system, ssh, clipboard, emoji, scripts, bookmarks,
# websearch, filesearch, systemd, weather, media, pomodoro
[plugins]
enabled = true # Master switch for all plugins
# Plugins to disable (by ID)
disabled_plugins = []
# Examples:
# disabled_plugins = ["emoji", "pomodoro"] # Disable specific plugins
# disabled_plugins = ["weather", "media"] # Disable widget plugins
# Custom plugin registry URL (defaults to official registry)
# registry_url = "https://my-registry.example.com/plugins.json"
# ─────────────────────────────────────────────────────────────────────────
# Sandbox settings (for Lua/Rune script plugins)
# ─────────────────────────────────────────────────────────────────────────
# [plugins.sandbox]
# allow_filesystem = false # Allow file system access beyond plugin dir
# allow_network = false # Allow network requests
# allow_commands = false # Allow shell command execution
# memory_limit = 67108864 # Memory limit in bytes (64 MB default)
# ═══════════════════════════════════════════════════════════════════════
# PROVIDERS
# ═══════════════════════════════════════════════════════════════════════
#
# Enable/disable providers and configure their settings.
# Core providers (applications, commands) are built into the binary.
# Plugin providers require their .so to be installed.
[providers]
# Core providers (always available)
applications = true # .desktop applications from XDG dirs
commands = true # Executables from $PATH
# Frecency - boost frequently/recently used items
# Data stored in: ~/.local/share/owlry/frecency.json
frecency = true
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
# ─────────────────────────────────────────────────────────────────────────
# Plugin provider toggles (require corresponding plugin installed)
# ─────────────────────────────────────────────────────────────────────────
uuctl = true # systemd user units
system = true # System commands (shutdown, reboot, etc.)
ssh = true # SSH hosts from ~/.ssh/config
clipboard = true # Clipboard history (requires cliphist)
bookmarks = true # Browser bookmarks
emoji = true # Emoji picker
scripts = true # Custom scripts from ~/.local/share/owlry/scripts/
files = true # File search (requires fd or mlocate)
calculator = true # Calculator (= expression)
websearch = true # Web search (? query)
# ─────────────────────────────────────────────────────────────────────────
# Widget providers (displayed at top of results)
# ─────────────────────────────────────────────────────────────────────────
media = true # MPRIS media player controls
weather = false # Weather widget (disabled by default)
pomodoro = false # Pomodoro timer (disabled by default)
# ─────────────────────────────────────────────────────────────────────────
# Provider settings
# ─────────────────────────────────────────────────────────────────────────
# Web search engine
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
# Or custom URL: "https://search.example.com/?q={query}"
search_engine = "duckduckgo"
# Weather settings (when weather = true)
# weather_provider = "wttr.in" # Options: wttr.in, openweathermap, open-meteo
# weather_location = "Berlin" # City name or coordinates
# weather_api_key = "" # Required for openweathermap
# Pomodoro settings (when pomodoro = true)
# pomodoro_work_mins = 25 # Work session duration
# pomodoro_break_mins = 5 # Break duration

24
data/scripts/example.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Example Owlry Script
# Copy to: ~/.local/share/owlry/scripts/
#
# Scripts in the scripts directory appear in Owlry search results.
# They are executed when selected.
#
# Naming convention:
# The filename (without extension) becomes the display name.
# Example: "system-update.sh" shows as "Script: system-update"
#
# Tips:
# - Make scripts executable: chmod +x script.sh
# - Use descriptive names for easy searching
# - Scripts can launch GUI apps, run terminal commands, etc.
# Example: Show a notification
notify-send "Owlry" "Hello from example script!"
# Example: Open a URL
# xdg-open "https://example.com"
# Example: Run a terminal command (set terminal: true in owlry if needed)
# echo "Script executed at $(date)"

73
data/style.example.css Normal file
View File

@@ -0,0 +1,73 @@
/*
* Owlry Custom Style Overrides
* Copy to: ~/.config/owlry/style.css
*
* This file is loaded AFTER themes, allowing you to override
* specific styles without creating a full theme.
*
* Available CSS classes:
* .owlry-window - Main window container
* .owlry-main - Main content area
* .owlry-header - Header with mode and tabs
* .owlry-search - Search input field
* .owlry-results - Results list container
* .owlry-result-row - Individual result row
* .owlry-result-name - Result item name
* .owlry-result-description - Result description text
* .owlry-result-icon - Result icon
* .owlry-tag-badge - Tag badges on results
* .owlry-badge-* - Provider badges (app, cmd, uuctl, etc.)
* .owlry-filter-button - Tab filter buttons
* .owlry-filter-* - Provider-specific filter buttons
* .owlry-mode-indicator - Current mode label
* .owlry-hints - Bottom hints bar
*
* CSS Variables (set in themes or override here):
* --owlry-bg - Main background color
* --owlry-bg-secondary - Secondary background
* --owlry-border - Border color
* --owlry-text - Primary text color
* --owlry-text-secondary - Secondary text color
* --owlry-accent - Accent/highlight color
* --owlry-accent-bright - Bright accent color
* --owlry-font-size - Base font size (default: 14px)
* --owlry-border-radius - Border radius (default: 12px)
*/
/* Example: Make the window slightly larger */
/*
.owlry-main {
padding: 20px;
}
*/
/* Example: Custom search field styling */
/*
.owlry-search {
font-size: 18px;
padding: 12px 16px;
}
*/
/* Example: Highlight selected row differently */
/*
.owlry-result-row:selected {
background-color: rgba(255, 255, 255, 0.1);
border-left: 4px solid var(--owlry-accent);
}
*/
/* Example: Hide tag badges */
/*
.owlry-tag-badge {
display: none;
}
*/
/* Example: Custom scrollbar */
/*
scrollbar slider {
background-color: rgba(128, 128, 128, 0.3);
border-radius: 4px;
}
*/

344
data/themes/apex-neon.css Normal file
View File

@@ -0,0 +1,344 @@
/*
* Owlry - Apex Neon Theme
* "State over Decoration."
*
* A high-contrast dark theme built for focus and clinical clarity.
* Color exists to signal STATE, not to decorate space.
*
* Author: S0wlz (Owlibou)
*
* ─────────────────────────────────────────────────────────────────
* APEX DNA - Semantic Color Roles:
*
* RED is the Predator: Active intent, cursor, current location, critical errors
* CYAN is Informational: Technical data, links, neutral highlights
* PURPLE is Sacred: Root access, special modes, exceptional states
* GREEN is Success: Completion, OK states, positive feedback
* YELLOW is Warning: Caution, load states, attention needed
*
* Rule: If a UI element is not important, it does not glow.
* ─────────────────────────────────────────────────────────────────
*
* Core Palette:
* - Void Black: #050505 (absolute background)
* - Dark Surface: #141414 (inputs, inactive elements)
* - Light Surface: #262626 (separators, borders)
* - Stark White: #ededed (primary text)
* - Muted: #737373 (secondary text)
* - Razor Red: #ff0044 (THE accent - focus, cursor, selection)
* - Electric Cyan: #00eaff (info, links, technical)
* - Sacred Purple: #9d00ff (special, root, elevated)
* - Neon Green: #00ff99 (success, OK)
* - Warning Yellow: #ffb700 (warning, caution)
*
* Bright Escalations:
* - Alert Red: #ff8899 (distinguishable from cursor)
* - Active Cyan: #5af3ff (active info)
* - Active Green: #2bffb2 (active success)
* - Urgent Yellow: #ffd24d (urgent warning)
* - Elevated Purple:#c84dff (elevated special)
*
* Usage: Set theme = "apex-neon" in config.toml
*/
:root {
/* Core surfaces */
--owlry-bg: #050505;
--owlry-bg-secondary: #141414;
--owlry-border: #262626;
--owlry-text: #ededed;
--owlry-text-secondary: #737373;
/* The Predator - primary accent */
--owlry-accent: #ff0044;
--owlry-accent-bright: #ff8899;
/* Provider badges - mapped to Apex semantics */
--owlry-badge-app: #00eaff; /* Cyan: apps are informational */
--owlry-badge-bookmark: #ffb700; /* Yellow: bookmarks need attention */
--owlry-badge-calc: #ffd24d; /* Bright Yellow: calculator results */
--owlry-badge-clip: #9d00ff; /* Purple: clipboard is special */
--owlry-badge-cmd: #9d00ff; /* Purple: commands are elevated */
--owlry-badge-dmenu: #00ff99; /* Green: dmenu is success/pipe */
--owlry-badge-emoji: #c84dff; /* Bright Purple: emoji is special */
--owlry-badge-file: #5af3ff; /* Bright Cyan: file search is active info */
--owlry-badge-script: #2bffb2; /* Bright Green: scripts execute successfully */
--owlry-badge-ssh: #00eaff; /* Cyan: SSH is technical/info */
--owlry-badge-sys: #ff0044; /* Red: system actions are critical */
--owlry-badge-uuctl: #ffb700; /* Yellow: uuctl requires attention */
--owlry-badge-web: #00eaff; /* Cyan: web is informational */
/* Widget badges */
--owlry-badge-media: #c84dff; /* Bright Purple: media is special */
--owlry-badge-weather: #5af3ff; /* Bright Cyan: weather is active info */
--owlry-badge-pomo: #ff8899; /* Alert Red: pomodoro demands attention */
}
.owlry-main {
background-color: rgba(5, 5, 5, 0.98);
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 {
background-color: rgba(20, 20, 20, 0.9);
border: 2px solid rgba(38, 38, 38, 0.8);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(255, 0, 68, 0.3);
}
.owlry-result-row:hover {
background-color: rgba(20, 20, 20, 0.8);
}
.owlry-result-row:selected {
background-color: rgba(255, 0, 68, 0.15);
border-left: 3px solid var(--owlry-accent);
}
.owlry-result-row:selected .owlry-result-name {
color: var(--owlry-accent-bright);
}
.owlry-result-row:selected .owlry-result-icon {
color: var(--owlry-accent);
}
/* Provider badges - styled per Apex semantics */
.owlry-badge-app {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-app);
}
.owlry-badge-bookmark {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-bookmark);
}
.owlry-badge-calc {
background-color: rgba(255, 210, 77, 0.15);
color: var(--owlry-badge-calc);
}
.owlry-badge-clip {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-clip);
}
.owlry-badge-cmd {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(0, 255, 153, 0.15);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-emoji {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-emoji);
}
.owlry-badge-file {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-file);
}
.owlry-badge-script {
background-color: rgba(43, 255, 178, 0.15);
color: var(--owlry-badge-script);
}
.owlry-badge-ssh {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-ssh);
}
.owlry-badge-sys {
background-color: rgba(255, 0, 68, 0.15);
color: var(--owlry-badge-sys);
}
.owlry-badge-uuctl {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-uuctl);
}
.owlry-badge-web {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-web);
}
/* Widget badges */
.owlry-badge-media {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-media);
}
.owlry-badge-weather {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-weather);
}
.owlry-badge-pomo {
background-color: rgba(255, 136, 153, 0.15);
color: var(--owlry-badge-pomo);
}
/* Filter button - default uses The Predator */
.owlry-filter-button:checked {
background-color: rgba(255, 0, 68, 0.2);
color: var(--owlry-accent);
border-color: rgba(255, 0, 68, 0.5);
}
/* Provider-specific filter buttons - follow Apex semantics */
.owlry-filter-app:checked {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-app);
border-color: rgba(0, 234, 255, 0.5);
}
.owlry-filter-bookmark:checked {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-bookmark);
border-color: rgba(255, 183, 0, 0.5);
}
.owlry-filter-calc:checked {
background-color: rgba(255, 210, 77, 0.15);
color: var(--owlry-badge-calc);
border-color: rgba(255, 210, 77, 0.5);
}
.owlry-filter-clip:checked {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-clip);
border-color: rgba(157, 0, 255, 0.5);
}
.owlry-filter-cmd:checked {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-cmd);
border-color: rgba(157, 0, 255, 0.5);
}
.owlry-filter-dmenu:checked {
background-color: rgba(0, 255, 153, 0.15);
color: var(--owlry-badge-dmenu);
border-color: rgba(0, 255, 153, 0.5);
}
.owlry-filter-emoji:checked {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-emoji);
border-color: rgba(200, 77, 255, 0.5);
}
.owlry-filter-file:checked {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-file);
border-color: rgba(90, 243, 255, 0.5);
}
.owlry-filter-script:checked {
background-color: rgba(43, 255, 178, 0.15);
color: var(--owlry-badge-script);
border-color: rgba(43, 255, 178, 0.5);
}
.owlry-filter-ssh:checked {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-ssh);
border-color: rgba(0, 234, 255, 0.5);
}
.owlry-filter-sys:checked {
background-color: rgba(255, 0, 68, 0.15);
color: var(--owlry-badge-sys);
border-color: rgba(255, 0, 68, 0.5);
}
.owlry-filter-uuctl:checked {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-uuctl);
border-color: rgba(255, 183, 0, 0.5);
}
.owlry-filter-web:checked {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-web);
border-color: rgba(0, 234, 255, 0.5);
}
/* Widget filter buttons */
.owlry-filter-media:checked {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-media);
border-color: rgba(200, 77, 255, 0.5);
}
.owlry-filter-weather:checked {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-weather);
border-color: rgba(90, 243, 255, 0.5);
}
.owlry-filter-pomodoro:checked {
background-color: rgba(255, 136, 153, 0.15);
color: var(--owlry-badge-pomo);
border-color: rgba(255, 136, 153, 0.5);
}
/* Scrollbar - subtle in Void, The Predator on active */
scrollbar slider {
background-color: rgba(38, 38, 38, 0.8);
}
scrollbar slider:hover {
background-color: rgba(64, 64, 64, 0.9);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}
/* Text selection - Apex Hard Rule: black text on red (target locked) */
selection {
background-color: var(--owlry-accent);
color: #050505;
}
/* Mode indicator - The Predator marks current mode */
.owlry-mode-indicator {
background-color: rgba(255, 0, 68, 0.2);
color: var(--owlry-accent);
border: 1px solid rgba(255, 0, 68, 0.3);
}
/* Hints bar */
.owlry-hints {
border-top: 1px solid rgba(38, 38, 38, 0.8);
}
.owlry-hints-label {
color: var(--owlry-text-secondary);
}
/* Tag badges in results */
.owlry-tag-badge {
background-color: rgba(38, 38, 38, 0.6);
color: var(--owlry-text-secondary);
}
.owlry-result-row:selected .owlry-tag-badge {
background-color: rgba(255, 136, 153, 0.25);
color: var(--owlry-accent-bright);
}

Some files were not shown because too many files have changed in this diff Show More