Compare commits

..

74 Commits

Author SHA1 Message Date
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
116 changed files with 10190 additions and 9002 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ aur/*/*.tar.gz
aur/*/*.tar.xz
aur/*/*.pkg.tar.*
# Keep PKGBUILD and .SRCINFO tracked
.SRCINFO
aur/

411
CLAUDE.md Normal file
View File

@@ -0,0 +1,411 @@
# 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 - do NOT manually edit Cargo.toml for version bumps:
```bash
# Bump a single crate
just bump-crate owlry-core 0.5.1
# 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
# 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
```
## AUR Packaging
The `aur/` directory contains PKGBUILDs for core packages:
| 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`).
`ProviderManager` (in `owlry-core`) orchestrates providers and handles:
- Fuzzy matching via `SkimMatcherV2`
- Frecency score boosting
- Native plugin loading from `/usr/lib/owlry/plugins/`
**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.)
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
- **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)

1458
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,20 +2,8 @@
resolver = "2"
members = [
"crates/owlry",
"crates/owlry-core",
"crates/owlry-plugin-api",
"crates/owlry-plugin-calculator",
"crates/owlry-plugin-system",
"crates/owlry-plugin-ssh",
"crates/owlry-plugin-clipboard",
"crates/owlry-plugin-emoji",
"crates/owlry-plugin-scripts",
"crates/owlry-plugin-bookmarks",
"crates/owlry-plugin-websearch",
"crates/owlry-plugin-filesearch",
"crates/owlry-plugin-weather",
"crates/owlry-plugin-media",
"crates/owlry-plugin-pomodoro",
"crates/owlry-plugin-systemd",
"crates/owlry-lua",
"crates/owlry-rune",
]

254
README.md
View File

@@ -10,12 +10,15 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
## Features
- **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
@@ -32,10 +35,10 @@ yay -S owlry
yay -S owlry-plugin-calculator owlry-plugin-weather
# Or install bundles:
yay -S owlry-essentials # calculator, system, ssh, scripts, bookmarks
yay -S owlry-widgets # weather, media, pomodoro
yay -S owlry-tools # clipboard, emoji, websearch, filesearch, systemd
yay -S owlry-full # everything
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
@@ -46,14 +49,14 @@ yay -S owlry-rune # Rune runtime
| Package | Description |
|---------|-------------|
| `owlry` | Core binary with applications and commands |
| `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` | Chrome, Brave, Edge bookmarks |
| `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 |
@@ -80,8 +83,8 @@ sudo dnf install gtk4-devel gtk4-layer-shell-devel
git clone https://somegit.dev/Owlibou/owlry.git
cd owlry
# Build core only
cargo build --release -p owlry
# Build core only (daemon + UI)
cargo build --release -p owlry -p owlry-core
# Build specific plugin
cargo build --release -p owlry-plugin-calculator
@@ -90,21 +93,137 @@ cargo build --release -p owlry-plugin-calculator
cargo build --release --workspace
```
**Install plugins manually:**
**Install locally:**
```bash
sudo mkdir -p /usr/lib/owlry/plugins
sudo cp target/release/libowlry_plugin_*.so /usr/lib/owlry/plugins/
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
owlry # Launch with defaults
owlry --mode app # Applications only
owlry --providers app,cmd # Specific providers
owlry --help # Show all options
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 |
@@ -158,6 +277,21 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
| `~/.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
@@ -165,12 +299,12 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
show_icons = true
max_results = 10
tabs = ["app", "cmd", "uuctl"]
# terminal_command = "kitty" # Auto-detected
# launch_wrapper = "uwsm app --" # Auto-detected
# terminal_command = "kitty" # Auto-detected
# use_uwsm = false # Enable for systemd session integration
[appearance]
width = 700
height = 500
width = 850
height = 650
font_size = 14
border_radius = 12
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc.
@@ -178,20 +312,28 @@ border_radius = 12
[plugins]
disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
# Per-plugin configuration (new in 0.4.0)
[plugins.weather]
provider = "wttr.in" # or: openweathermap, open-meteo
location = "Berlin" # city name or "lat,lon"
# api_key = "..." # Required for OpenWeatherMap
[providers]
applications = true # .desktop files
commands = true # PATH executables
frecency = true # Boost frequently used items
frecency_weight = 0.3 # 0.0-1.0
[plugins.pomodoro]
work_mins = 25 # Work session duration
break_mins = 5 # Break duration
# 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"]
```
See `/usr/share/doc/owlry/config.example.toml` for all options with documentation.
## Plugin System
Owlry uses a modular plugin architecture. Plugins are loaded from:
Owlry uses a modular plugin architecture. Plugins are loaded by the daemon (`owlry-core`) from:
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
@@ -205,6 +347,38 @@ Add plugin IDs to the disabled list in your config:
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:
@@ -264,17 +438,25 @@ Create `~/.config/owlry/themes/mytheme.css`:
## Architecture
Owlry uses a client/daemon split:
```
owlry (core)
├── Applications provider (XDG .desktop files)
├── Commands provider (PATH executables)
├── Dmenu provider (pipe compatibility)
── Plugin loader
├── /usr/lib/owlry/plugins/*.so (native plugins)
├── /usr/lib/owlry/runtimes/ (Lua/Rune runtimes)
└── ~/.config/owlry/plugins/ (user plugins)
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

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

@@ -0,0 +1,58 @@
[package]
name = "owlry-core"
version = "1.0.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"
# 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

@@ -6,43 +6,81 @@ use std::process::Command;
use crate::paths;
#[derive(Debug, Clone, Serialize, Deserialize)]
/// 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,
pub terminal_command: String,
/// Launch wrapper command for app execution.
/// Examples: "uwsm app --", "hyprctl dispatch exec --", "systemd-run --user --"
/// If None or empty, launches directly via sh -c
/// Terminal command (auto-detected if not specified)
#[serde(default)]
pub launch_wrapper: Option<String>,
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(),
]
vec!["app".to_string(), "cmd".to_string(), "uuctl".to_string()]
}
/// User-customizable theme colors
/// All fields are optional - unset values inherit from GTK theme
/// 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>,
@@ -64,13 +102,21 @@ pub struct ThemeColors {
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)]
@@ -80,10 +126,39 @@ pub struct AppearanceConfig {
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")]
@@ -125,7 +200,6 @@ pub struct ProvidersConfig {
pub files: bool,
// ─── Widget Providers ───────────────────────────────────────────────
/// Enable MPRIS media player widget
#[serde(default = "default_true")]
pub media: bool,
@@ -159,6 +233,36 @@ pub struct ProvidersConfig {
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:
@@ -249,28 +353,19 @@ impl PluginsConfig {
/// 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()
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()
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()
self.plugin_configs.get(plugin_name)?.get(key)?.as_bool()
}
}
@@ -313,29 +408,6 @@ fn default_pomodoro_break() -> u32 {
5
}
/// Detect the best launch wrapper for the current session
/// Checks for uwsm (Universal Wayland Session Manager) and hyprland
fn detect_launch_wrapper() -> Option<String> {
// Check if running under uwsm (has UWSM_FINALIZE_VARNAMES or similar uwsm env vars)
if (std::env::var("UWSM_FINALIZE_VARNAMES").is_ok()
|| std::env::var("__UWSM_SELECT_TAG").is_ok())
&& command_exists("uwsm") {
debug!("Detected uwsm session, using 'uwsm app --' wrapper");
return Some("uwsm app --".to_string());
}
// Check if running under Hyprland
if std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok()
&& command_exists("hyprctl") {
debug!("Detected Hyprland session, using 'hyprctl dispatch exec --' wrapper");
return Some("hyprctl dispatch exec --".to_string());
}
// No wrapper needed for other environments
debug!("No launch wrapper detected, using direct execution");
None
}
/// Detect the best available terminal emulator
/// Fallback chain:
/// 1. $TERMINAL env var (user's explicit preference)
@@ -348,10 +420,12 @@ fn detect_launch_wrapper() -> Option<String> {
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;
}
&& !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") {
@@ -375,7 +449,14 @@ fn detect_terminal() -> String {
}
// 5. Common X11/legacy terminals
let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "mate-terminal", "tilix", "terminator"];
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);
@@ -450,57 +531,7 @@ fn command_exists(cmd: &str) -> bool {
.unwrap_or(false)
}
impl Default for Config {
fn default() -> Self {
let terminal = detect_terminal();
info!("Detected terminal: {}", terminal);
Self {
general: GeneralConfig {
show_icons: true,
max_results: 10,
terminal_command: terminal,
launch_wrapper: detect_launch_wrapper(),
tabs: default_tabs(),
},
appearance: AppearanceConfig {
width: 850,
height: 650,
font_size: 14,
border_radius: 12,
theme: None,
colors: ThemeColors::default(),
},
providers: ProvidersConfig {
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,
// Widget providers
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,
},
plugins: PluginsConfig::default(),
}
}
}
// Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default
impl Config {
pub fn config_path() -> Option<PathBuf> {
@@ -517,23 +548,32 @@ impl Config {
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?;
if !path.exists() {
let mut config = if !path.exists() {
info!("Config file not found, using defaults");
return Ok(Self::default());
}
Self::default()
} else {
let content = std::fs::read_to_string(&path)?;
let config: Config = toml::from_str(&content)?;
info!("Loaded config from {:?}", path);
config
};
let content = std::fs::read_to_string(&path)?;
let mut config: Config = toml::from_str(&content)?;
info!("Loaded config from {:?}", path);
// Validate terminal - if configured terminal doesn't exist, auto-detect
if !command_exists(&config.general.terminal_command) {
warn!(
"Configured terminal '{}' not found, auto-detecting",
config.general.terminal_command
);
config.general.terminal_command = detect_terminal();
info!("Using detected terminal: {}", config.general.terminal_command);
// 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)

View File

@@ -0,0 +1,632 @@
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 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: false,
};
#[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)
}
/// 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(),
};
}
}
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,38 @@
use log::info;
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();
ctrlc::set_handler(move || {
let _ = std::fs::remove_file(&sock_cleanup);
std::process::exit(0);
})
.ok();
if let Err(e) = server.run() {
eprintln!("Server error: {e}");
std::process::exit(1);
}
}

View File

@@ -32,7 +32,6 @@ pub fn cache_home() -> Option<PathBuf> {
dirs::cache_dir()
}
// =============================================================================
// Owlry-specific directories
// =============================================================================
@@ -99,27 +98,75 @@ pub fn frecency_file() -> Option<PathBuf> {
// =============================================================================
/// 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();
// User data directory first
// 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() {
dirs.push(data.join("applications"));
add_dir(data.join("applications"));
}
// System directories
dirs.push(PathBuf::from("/usr/share/applications"));
dirs.push(PathBuf::from("/usr/local/share/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());
// Flatpak directories
if let Some(data) = data_home() {
dirs.push(data.join("flatpak/exports/share/applications"));
for dir in xdg_data_dirs.split(':') {
if !dir.is_empty() {
add_dir(PathBuf::from(dir).join("applications"));
}
}
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/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
// =============================================================================
@@ -127,9 +174,10 @@ pub fn system_data_dirs() -> Vec<PathBuf> {
/// 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)?;
}
&& !parent.exists()
{
std::fs::create_dir_all(parent)?;
}
Ok(())
}

View File

@@ -54,9 +54,9 @@ pub fn register_action_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResu
.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"))?;
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();
@@ -166,7 +166,7 @@ pub fn get_actions_for_item(lua: &Lua, item: &Table) -> LuaResult<Vec<ActionRegi
// Check filter if present
if let Ok(filter) = entry.get::<Function>("filter") {
match filter.call::<bool>(item.clone()) {
Ok(true) => {} // Include this action
Ok(true) => {} // Include this action
Ok(false) => continue, // Skip this action
Err(e) => {
log::warn!("Action filter failed: {}", e);
@@ -220,7 +220,8 @@ mod tests {
fn test_action_registration() {
let lua = setup_lua("test-plugin");
let chunk = lua.load(r#"
let chunk = lua.load(
r#"
return owlry.action.register({
id = "copy-name",
name = "Copy Name",
@@ -229,7 +230,8 @@ mod tests {
-- copy logic here
end
})
"#);
"#,
);
let action_id: String = chunk.call(()).unwrap();
assert_eq!(action_id, "test-plugin:copy-name");
@@ -243,7 +245,8 @@ mod tests {
fn test_action_with_filter() {
let lua = setup_lua("test-plugin");
let chunk = lua.load(r#"
let chunk = lua.load(
r#"
owlry.action.register({
id = "bookmark-action",
name = "Open in Browser",
@@ -252,7 +255,8 @@ mod tests {
end,
handler = function(item) end
})
"#);
"#,
);
chunk.call::<()>(()).unwrap();
// Create bookmark item
@@ -276,14 +280,16 @@ mod tests {
fn test_action_unregister() {
let lua = setup_lua("test-plugin");
let chunk = lua.load(r#"
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);
@@ -296,7 +302,8 @@ mod tests {
let lua = setup_lua("test-plugin");
// Register action that sets a global
let chunk = lua.load(r#"
let chunk = lua.load(
r#"
result = nil
owlry.action.register({
id = "test-exec",
@@ -305,7 +312,8 @@ mod tests {
result = item.name
end
})
"#);
"#,
);
chunk.call::<()>(()).unwrap();
// Create test item

View File

@@ -35,9 +35,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
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))
})?;
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() {
@@ -50,8 +50,10 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
}
// 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)))?;
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 {
@@ -75,9 +77,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
expires_at,
};
let mut cache = CACHE.lock().map_err(|e| {
mlua::Error::external(format!("Failed to lock cache: {}", e))
})?;
let mut cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
cache.insert(key, entry);
Ok(true)
@@ -88,9 +90,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
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))
})?;
let mut cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
Ok(cache.remove(&key).is_some())
})?,
@@ -100,9 +102,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
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 mut cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
let count = cache.len();
cache.clear();
@@ -114,9 +116,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
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))
})?;
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())
@@ -249,10 +251,12 @@ mod tests {
let _: bool = chunk.call(()).unwrap();
// Get and verify
let chunk = lua.load(r#"
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);
@@ -262,12 +266,14 @@ mod tests {
fn test_cache_delete() {
let lua = setup_lua();
let chunk = lua.load(r#"
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());
@@ -277,12 +283,14 @@ mod tests {
fn test_cache_has() {
let lua = setup_lua();
let chunk = lua.load(r#"
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);

View File

@@ -329,13 +329,15 @@ mod tests {
clear_all_hooks();
let lua = setup_lua("test-plugin");
let chunk = lua.load(r#"
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);
@@ -349,11 +351,13 @@ mod tests {
clear_all_hooks();
let lua = setup_lua("test-plugin");
let chunk = lua.load(r#"
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
@@ -367,11 +371,13 @@ mod tests {
clear_all_hooks();
let lua = setup_lua("test-plugin");
let chunk = lua.load(r#"
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);
@@ -383,14 +389,16 @@ mod tests {
clear_all_hooks();
let lua = setup_lua("test-plugin");
let chunk = lua.load(r#"
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

View File

@@ -26,18 +26,21 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
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)))?;
.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 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()
@@ -45,9 +48,9 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
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 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)?;
@@ -78,18 +81,21 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
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)))?;
.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);
}
&& 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 {
@@ -102,11 +108,7 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
.body(json_str)
}
Value::Nil => request,
_ => {
return Err(mlua::Error::external(
"POST body must be a string or table",
))
}
_ => return Err(mlua::Error::external("POST body must be a string or table")),
};
let response = request
@@ -115,9 +117,9 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
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 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)?;
@@ -149,19 +151,22 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
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)))?;
.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 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()
@@ -174,9 +179,9 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
)));
}
let body = response
.text()
.map_err(|e| mlua::Error::external(format!("Failed to read response body: {}", e)))?;
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)

View File

@@ -14,20 +14,20 @@ pub fn register_math_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
// 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())))
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()))),
}
Err(e) => {
Ok((None, Some(e.to_string())))
}
}
})?,
},
)?,
)?;
// owlry.math.calc(expression) -> number (throws on error)
@@ -106,11 +106,13 @@ mod tests {
fn test_calculate_basic() {
let lua = setup_lua();
let chunk = lua.load(r#"
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);
}
@@ -119,11 +121,13 @@ mod tests {
fn test_calculate_complex() {
let lua = setup_lua();
let chunk = lua.load(r#"
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
}
@@ -132,14 +136,16 @@ mod tests {
fn test_calculate_error() {
let lua = setup_lua();
let chunk = lua.load(r#"
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);
}

View File

@@ -27,8 +27,14 @@ pub fn register_process_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
.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(
"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())?;
@@ -95,9 +101,7 @@ pub fn register_env_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
// owlry.env.get(name) -> string or nil
env_table.set(
"get",
lua.create_function(|_lua, name: String| {
Ok(std::env::var(&name).ok())
})?,
lua.create_function(|_lua, name: String| Ok(std::env::var(&name).ok()))?,
)?;
// owlry.env.get_or(name, default) -> string
@@ -166,7 +170,8 @@ mod tests {
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 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);
}
@@ -190,7 +195,8 @@ mod tests {
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 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");
}

View File

@@ -21,7 +21,12 @@ pub struct ThemeRegistration {
}
/// Register theme APIs
pub fn register_theme_api(lua: &Lua, owlry: &Table, plugin_id: &str, plugin_dir: &Path) -> LuaResult<()> {
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();
@@ -50,9 +55,7 @@ pub fn register_theme_api(lua: &Lua, owlry: &Table, plugin_id: &str, plugin_dir:
.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());
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") {
@@ -197,13 +200,15 @@ mod tests {
let temp = TempDir::new().unwrap();
let lua = setup_lua("test-plugin", temp.path());
let chunk = lua.load(r#"
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");
@@ -221,12 +226,14 @@ mod tests {
let lua = setup_lua("test-plugin", temp.path());
let chunk = lua.load(r#"
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");
@@ -240,11 +247,13 @@ mod tests {
let temp = TempDir::new().unwrap();
let lua = setup_lua("test-plugin", temp.path());
let chunk = lua.load(r#"
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();
@@ -262,10 +271,12 @@ mod tests {
let temp = TempDir::new().unwrap();
let lua = setup_lua("test-plugin", temp.path());
let chunk = lua.load(r#"
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);

View File

@@ -189,9 +189,10 @@ pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult
// 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())?)));
}
&& 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)),
@@ -295,7 +296,8 @@ pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult
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()
let is_exec = full_path
.metadata()
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false);
Ok(is_exec)
@@ -335,28 +337,24 @@ pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
// 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)?))),
}
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)?))),
}
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)?))),
})?,
)?;
@@ -388,13 +386,16 @@ fn lua_to_json(value: &Value) -> Result<serde_json::Value, String> {
.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()
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)));
&& (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)
@@ -475,9 +476,13 @@ mod tests {
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.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.warn('warning')")
.call::<()>(())
.unwrap();
lua.load("owlry.log.error('error')").call::<()>(()).unwrap();
}
@@ -485,10 +490,7 @@ mod tests {
fn test_path_api() {
let (lua, _temp) = create_test_lua();
let home: String = lua
.load("return owlry.path.home()")
.call(())
.unwrap();
let home: String = lua.load("return owlry.path.home()").call(()).unwrap();
assert!(!home.is_empty());
let joined: String = lua

View File

@@ -7,7 +7,7 @@ use mlua::Lua;
use super::api;
use super::error::{PluginError, PluginResult};
use super::manifest::PluginManifest;
use super::runtime::{create_lua_runtime, load_file, SandboxConfig};
use super::runtime::{SandboxConfig, create_lua_runtime, load_file};
/// A loaded plugin instance
#[derive(Debug)]
@@ -94,7 +94,10 @@ impl LoadedPlugin {
}
/// Call a provider's refresh function
pub fn call_provider_refresh(&self, provider_name: &str) -> PluginResult<Vec<super::PluginItem>> {
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(),
@@ -108,7 +111,11 @@ impl LoadedPlugin {
/// 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>> {
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(),
@@ -138,8 +145,8 @@ impl LoadedPlugin {
#[cfg(test)]
mod tests {
use super::*;
use super::super::manifest::{check_compatibility, discover_plugins};
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;

View File

@@ -112,11 +112,16 @@ pub struct PluginPermissions {
/// 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)>> {
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());
log::debug!(
"Plugins directory does not exist: {}",
plugins_dir.display()
);
return Ok(plugins);
}
@@ -143,7 +148,11 @@ pub fn discover_plugins(plugins_dir: &Path) -> PluginResult<HashMap<String, (Plu
log::warn!("Duplicate plugin ID '{}', skipping {}", id, path.display());
continue;
}
log::info!("Discovered plugin: {} v{}", manifest.plugin.name, manifest.plugin.version);
log::info!(
"Discovered plugin: {} v{}",
manifest.plugin.name,
manifest.plugin.version
);
plugins.insert(id, (manifest, path));
}
Err(e) => {
@@ -156,6 +165,7 @@ pub fn discover_plugins(plugins_dir: &Path) -> PluginResult<HashMap<String, (Plu
}
/// 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 {
@@ -203,7 +213,12 @@ impl PluginManifest {
});
}
if !self.plugin.id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
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(),
@@ -222,7 +237,10 @@ impl PluginManifest {
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),
message: format!(
"Invalid owlry_version constraint: {}",
self.plugin.owlry_version
),
});
}
@@ -230,6 +248,7 @@ impl PluginManifest {
}
/// 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,

View File

@@ -21,7 +21,6 @@
//! ```
// Always available
pub mod commands;
pub mod error;
pub mod manifest;
pub mod native_loader;
@@ -43,6 +42,7 @@ pub use api::provider::{PluginItem, ProviderRegistration};
#[allow(unused_imports)]
pub use api::{ActionRegistration, HookEvent, ThemeRegistration};
#[allow(unused_imports)]
pub use error::{PluginError, PluginResult};
#[cfg(feature = "lua")]
@@ -50,7 +50,7 @@ pub use loader::LoadedPlugin;
// Used by plugins/commands.rs for plugin CLI commands
#[allow(unused_imports)]
pub use manifest::{check_compatibility, discover_plugins, PluginManifest};
pub use manifest::{PluginManifest, check_compatibility, discover_plugins};
// ============================================================================
// Lua Plugin Manager (only available with lua feature)
@@ -64,7 +64,7 @@ mod lua_manager {
use std::path::PathBuf;
use std::rc::Rc;
use manifest::{discover_plugins, check_compatibility};
use manifest::{check_compatibility, discover_plugins};
/// Plugin manager coordinates loading, initialization, and lifecycle of Lua plugins
pub struct PluginManager {
@@ -158,7 +158,10 @@ mod lua_manager {
/// Get all enabled plugins
pub fn enabled_plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
self.plugins.values().filter(|p| p.borrow().enabled).cloned()
self.plugins
.values()
.filter(|p| p.borrow().enabled)
.cloned()
}
/// Get the number of loaded plugins
@@ -176,7 +179,10 @@ mod lua_manager {
/// 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 plugin_rc = self
.plugins
.get(id)
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
let mut plugin = plugin_rc.borrow_mut();
if !plugin.enabled {
@@ -191,7 +197,10 @@ mod lua_manager {
/// 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()))?;
let plugin_rc = self
.plugins
.get(id)
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
plugin_rc.borrow_mut().enabled = false;
Ok(())
}
@@ -200,7 +209,13 @@ mod lua_manager {
#[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()))
.filter(|p| {
p.borrow()
.manifest
.provides
.providers
.contains(&provider_name.to_string())
})
.map(|p| p.borrow().id().to_string())
.collect()
}
@@ -208,13 +223,15 @@ mod lua_manager {
/// 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)
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)
self.enabled_plugins()
.any(|p| p.borrow().manifest.provides.hooks)
}
/// Get all theme names provided by plugins

View File

@@ -17,8 +17,8 @@ use std::sync::{Arc, Once};
use libloading::Library;
use log::{debug, error, info, warn};
use owlry_plugin_api::{
HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo, ProviderKind,
RStr, API_VERSION,
API_VERSION, HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo,
ProviderKind, RStr,
};
use crate::notify;
@@ -28,9 +28,18 @@ use crate::notify;
// ============================================================================
/// Host notification handler
extern "C" fn host_notify(summary: RStr<'_>, body: RStr<'_>, icon: RStr<'_>, urgency: NotifyUrgency) {
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 icon_opt = if icon_str.is_empty() {
None
} else {
Some(icon_str)
};
let notify_urgency = match urgency {
NotifyUrgency::Low => notify::NotifyUrgency::Low,
@@ -121,7 +130,9 @@ impl NativePlugin {
handle: ProviderHandle,
query: &str,
) -> Vec<owlry_plugin_api::PluginItem> {
(self.vtable.provider_query)(handle, query.into()).into_iter().collect()
(self.vtable.provider_query)(handle, query.into())
.into_iter()
.collect()
}
/// Drop a provider handle

View File

@@ -110,9 +110,10 @@ impl RegistryClient {
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;
}
&& let Ok(elapsed) = SystemTime::now().duration_since(modified)
{
return elapsed < CACHE_DURATION;
}
false
}
@@ -120,11 +121,13 @@ impl RegistryClient {
/// 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()
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);
}
&& let Ok(index) = toml::from_str(&content)
{
return Ok(index);
}
// Fetch from network
self.fetch_from_network()
@@ -134,12 +137,7 @@ impl RegistryClient {
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,
])
.args(["-fsSL", "--max-time", "30", &self.registry_url])
.output()
.map_err(|e| format!("Failed to run curl: {}", e))?;
@@ -185,7 +183,9 @@ impl RegistryClient {
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))
|| p.tags
.iter()
.any(|t| t.to_lowercase().contains(&query_lower))
})
.collect();
@@ -210,8 +210,7 @@ impl RegistryClient {
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))?;
fs::remove_file(&cache_path).map_err(|e| format!("Failed to remove cache: {}", e))?;
}
Ok(())
}

View File

@@ -26,7 +26,7 @@ impl Default for SandboxConfig {
allow_commands: false,
allow_network: false,
allow_external_fs: false,
max_run_time_ms: 5000, // 5 seconds
max_run_time_ms: 5000, // 5 seconds
max_memory: 64 * 1024 * 1024, // 64 MB
}
}
@@ -49,11 +49,7 @@ 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 libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH;
let lua = Lua::new_with(libs, mlua::LuaOptions::default())?;
@@ -75,9 +71,15 @@ fn setup_safe_globals(lua: &Lua) -> LuaResult<()> {
// 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(
"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(
"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)?;
@@ -107,8 +109,7 @@ fn os_time(_lua: &Lua, _args: ()) -> LuaResult<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)?;
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()?

View File

@@ -59,7 +59,11 @@ pub struct ScriptRuntimeVTable {
pub init: extern "C" fn(plugins_dir: 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 query: extern "C" fn(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem>,
pub drop: extern "C" fn(handle: RuntimeHandle),
}
@@ -100,9 +104,8 @@ impl LoadedRuntime {
}
// 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 = unsafe { Library::new(library_path) }
.map_err(|e| PluginError::LoadError(format!("{}: {}", library_path.display(), e)))?;
let library = Arc::new(library);
@@ -152,12 +155,8 @@ impl LoadedRuntime {
self.providers
.iter()
.map(|info| {
let provider = RuntimeProvider::new(
self.name,
self.vtable,
self.handle,
info.clone(),
);
let provider =
RuntimeProvider::new(self.name, self.vtable, self.handle, info.clone());
Box::new(provider) as Box<dyn Provider>
})
.collect()
@@ -227,7 +226,10 @@ impl Provider for RuntimeProvider {
let name_rstr = RStr::from_str(self.info.name.as_str());
let items_rvec = (self.vtable.refresh)(self.handle, name_rstr);
self.items = items_rvec.into_iter().map(|i| self.convert_item(i)).collect();
self.items = items_rvec
.into_iter()
.map(|i| self.convert_item(i))
.collect();
log::debug!(
"[RuntimeProvider] '{}' refreshed with {} items",
@@ -246,12 +248,16 @@ unsafe impl Send for RuntimeProvider {}
/// Check if the Lua runtime is available
pub fn lua_runtime_available() -> bool {
PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so").exists()
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()
PathBuf::from(SYSTEM_RUNTIMES_DIR)
.join("librune.so")
.exists()
}
impl LoadedRuntime {
@@ -271,14 +277,16 @@ mod tests {
use super::*;
#[test]
fn test_lua_runtime_not_installed() {
// In test environment, runtime shouldn't be installed
assert!(!lua_runtime_available());
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_not_installed() {
// In test environment, runtime shouldn't be installed
assert!(!rune_runtime_available());
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

@@ -66,13 +66,14 @@ 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<std::path::PathBuf> {
@@ -98,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,
@@ -125,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,
@@ -135,12 +166,17 @@ impl Provider for ApplicationProvider {
None => continue,
};
// Extract categories as tags (lowercase for consistency)
let tags: Vec<String> = desktop_entry
// 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,
@@ -157,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] {
@@ -180,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]
@@ -210,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> {
@@ -97,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

@@ -95,9 +95,7 @@ impl Provider for LuaProvider {
unsafe impl Send 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>> {
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() {

View File

@@ -0,0 +1,957 @@
// Core providers (no plugin equivalents)
mod application;
mod command;
// Native plugin bridge
pub mod native_provider;
// Lua plugin bridge (optional)
#[cfg(feature = "lua")]
pub mod lua_provider;
// Re-exports for core providers
pub use application::ApplicationProvider;
pub use command::CommandProvider;
// Re-export native provider for plugin loading
pub use native_provider::NativeProvider;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use log::info;
#[cfg(feature = "dev-logging")]
use log::debug;
use crate::config::Config;
use crate::data::FrecencyStore;
/// Metadata descriptor for an available provider (used by IPC/daemon API)
#[derive(Debug, Clone)]
pub struct ProviderDescriptor {
pub id: String,
pub name: String,
pub prefix: Option<String>,
pub icon: String,
pub position: String,
}
/// Represents a single searchable/launchable item
#[derive(Debug, Clone)]
pub struct LaunchItem {
#[allow(dead_code)]
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub provider: ProviderType,
pub command: String,
pub terminal: bool,
/// Tags/categories for filtering (e.g., from .desktop Categories)
pub tags: Vec<String>,
}
/// Provider type identifier for filtering and badge display
///
/// Core types are built-in providers. All native plugins use Plugin(type_id).
/// This keeps the core app free of plugin-specific knowledge.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ProviderType {
/// Built-in: Desktop applications from XDG directories
Application,
/// Built-in: Shell commands from PATH
Command,
/// Built-in: Pipe-based input (dmenu compatibility)
Dmenu,
/// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji")
Plugin(String),
}
impl std::str::FromStr for ProviderType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
// Core built-in providers
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
"dmenu" => Ok(ProviderType::Dmenu),
// Everything else is a plugin
other => Ok(ProviderType::Plugin(other.to_string())),
}
}
}
impl std::fmt::Display for ProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProviderType::Application => write!(f, "app"),
ProviderType::Command => write!(f, "cmd"),
ProviderType::Dmenu => write!(f, "dmenu"),
ProviderType::Plugin(type_id) => write!(f, "{}", type_id),
}
}
}
/// Trait for all search providers
pub trait Provider: Send {
#[allow(dead_code)]
fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType;
fn refresh(&mut self);
fn items(&self) -> &[LaunchItem];
}
/// Manages all providers and handles searching
pub struct ProviderManager {
/// Core static providers (apps, commands, dmenu)
providers: Vec<Box<dyn Provider>>,
/// Static native plugin providers (need query() for submenu support)
static_native_providers: Vec<NativeProvider>,
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
/// These are queried per-keystroke, not cached
dynamic_providers: Vec<NativeProvider>,
/// Widget providers from native plugins (weather, media, pomodoro)
/// These appear at the top of results
widget_providers: Vec<NativeProvider>,
/// Fuzzy matcher for search
matcher: SkimMatcherV2,
}
impl ProviderManager {
/// Create a new ProviderManager with core providers and native plugins.
///
/// Core providers (e.g., ApplicationProvider, CommandProvider, DmenuProvider) are
/// passed in by the caller. Native plugins are categorized based on their declared
/// ProviderKind and ProviderPosition.
pub fn new(
core_providers: Vec<Box<dyn Provider>>,
native_providers: Vec<NativeProvider>,
) -> Self {
let mut manager = Self {
providers: core_providers,
static_native_providers: Vec::new(),
dynamic_providers: Vec::new(),
widget_providers: Vec::new(),
matcher: SkimMatcherV2::default(),
};
// Categorize native plugins based on their declared ProviderKind and ProviderPosition
for provider in native_providers {
let type_id = provider.type_id();
if provider.is_dynamic() {
info!(
"Registered dynamic provider: {} ({})",
provider.name(),
type_id
);
manager.dynamic_providers.push(provider);
} else if provider.is_widget() {
info!(
"Registered widget provider: {} ({})",
provider.name(),
type_id
);
manager.widget_providers.push(provider);
} else {
info!(
"Registered static provider: {} ({})",
provider.name(),
type_id
);
manager.static_native_providers.push(provider);
}
}
// Initial refresh
manager.refresh_all();
manager
}
/// Create a self-contained ProviderManager from config.
///
/// Loads native plugins, creates core providers (Application + Command),
/// categorizes everything, and performs initial refresh. Used by the daemon
/// which doesn't have the UI-driven setup path from `app.rs`.
pub fn new_with_config(config: &Config) -> Self {
use crate::plugins::native_loader::NativePluginLoader;
use std::sync::Arc;
// Create core providers
let core_providers: Vec<Box<dyn Provider>> = vec![
Box::new(ApplicationProvider::new()),
Box::new(CommandProvider::new()),
];
// Load native plugins
let mut loader = NativePluginLoader::new();
loader.set_disabled(config.plugins.disabled_plugins.clone());
let native_providers = match loader.discover() {
Ok(count) => {
if count == 0 {
info!("No native plugins found");
Vec::new()
} else {
info!("Discovered {} native plugin(s)", count);
let plugins: Vec<Arc<crate::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());
info!(
"Created native provider: {} ({})",
provider.name(),
provider.type_id()
);
providers.push(provider);
}
}
providers
}
}
Err(e) => {
log::warn!("Failed to discover native plugins: {}", e);
Vec::new()
}
};
Self::new(core_providers, native_providers)
}
#[allow(dead_code)]
pub fn is_dmenu_mode(&self) -> bool {
self.providers
.iter()
.any(|p| p.provider_type() == ProviderType::Dmenu)
}
pub fn refresh_all(&mut self) {
// Refresh core providers (apps, commands)
for provider in &mut self.providers {
provider.refresh();
info!(
"Provider '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
// Refresh static native providers (clipboard, emoji, ssh, etc.)
for provider in &mut self.static_native_providers {
provider.refresh();
info!(
"Static provider '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
// Widget providers are refreshed separately to avoid blocking startup
// Call refresh_widgets() after window is shown
// Dynamic providers don't need refresh (they query on demand)
}
/// Refresh widget providers (weather, media, pomodoro)
/// Call this separately from refresh_all() to avoid blocking startup
/// since widgets may make network requests or spawn processes
pub fn refresh_widgets(&mut self) {
for provider in &mut self.widget_providers {
provider.refresh();
info!(
"Widget '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
}
/// Find a native provider by type ID
/// Searches in all native provider lists (static, dynamic, widget)
pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> {
// Check static native providers first (clipboard, emoji, ssh, systemd, etc.)
if let Some(p) = self
.static_native_providers
.iter()
.find(|p| p.type_id() == type_id)
{
return Some(p);
}
// Check widget providers (pomodoro, weather, media)
if let Some(p) = self
.widget_providers
.iter()
.find(|p| p.type_id() == type_id)
{
return Some(p);
}
// Then dynamic providers (calc, websearch, filesearch)
self.dynamic_providers
.iter()
.find(|p| p.type_id() == type_id)
}
/// Execute a plugin action command
/// Command format: PLUGIN_ID:action_data (e.g., "POMODORO:start", "SYSTEMD:unit:restart")
/// Returns true if the command was handled by a plugin
pub fn execute_plugin_action(&self, command: &str) -> bool {
// Parse command format: PLUGIN_ID:action_data
if let Some(colon_pos) = command.find(':') {
let plugin_id = &command[..colon_pos];
let action = command; // Pass full command to plugin
// Find provider by type ID (case-insensitive for convenience)
let type_id = plugin_id.to_lowercase();
if let Some(provider) = self.find_native_provider(&type_id) {
provider.execute_action(action);
return true;
}
}
false
}
/// Add a dynamic provider (e.g., from a Lua plugin)
#[allow(dead_code)]
pub fn add_provider(&mut self, provider: Box<dyn Provider>) {
info!("Added plugin provider: {}", provider.name());
self.providers.push(provider);
}
/// Add multiple providers at once (for batch plugin loading)
#[allow(dead_code)]
pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) {
for provider in providers {
self.add_provider(provider);
}
}
/// Iterate over all static provider items (core + native static plugins)
fn all_static_items(&self) -> impl Iterator<Item = &LaunchItem> {
self.providers.iter().flat_map(|p| p.items().iter()).chain(
self.static_native_providers
.iter()
.flat_map(|p| p.items().iter()),
)
}
#[allow(dead_code)]
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
if query.is_empty() {
// Return recent/popular items when query is empty
return self
.all_static_items()
.take(max_results)
.map(|item| (item.clone(), 0))
.collect();
}
let mut results: Vec<(LaunchItem, i64)> = self
.all_static_items()
.filter_map(|item| {
// Match against name and description
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item
.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
let score = match (name_score, desc_score) {
(Some(n), Some(d)) => Some(n.max(d)),
(Some(n), None) => Some(n),
(None, Some(d)) => Some(d / 2), // Lower weight for description matches
(None, None) => None,
};
score.map(|s| (item.clone(), s))
})
.collect();
// Sort by score (descending)
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
results
}
/// Search with provider filtering
pub fn search_filtered(
&self,
query: &str,
max_results: usize,
filter: &crate::filter::ProviderFilter,
) -> Vec<(LaunchItem, i64)> {
// Collect items from core providers
let core_items = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
// Collect items from static native providers
let native_items = self
.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
if query.is_empty() {
return core_items
.chain(native_items)
.take(max_results)
.map(|item| (item, 0))
.collect();
}
let mut results: Vec<(LaunchItem, i64)> = core_items
.chain(native_items)
.filter_map(|item| {
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item
.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
let score = match (name_score, desc_score) {
(Some(n), Some(d)) => Some(n.max(d)),
(Some(n), None) => Some(n),
(None, Some(d)) => Some(d / 2),
(None, None) => None,
};
score.map(|s| (item, s))
})
.collect();
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
results
}
/// Search with frecency boosting, dynamic providers, and tag filtering
pub fn search_with_frecency(
&self,
query: &str,
max_results: usize,
filter: &crate::filter::ProviderFilter,
frecency: &FrecencyStore,
frecency_weight: f64,
tag_filter: Option<&str>,
) -> Vec<(LaunchItem, i64)> {
#[cfg(feature = "dev-logging")]
debug!(
"[Search] query={:?}, max={}, frecency_weight={}",
query, max_results, frecency_weight
);
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
// Add widget items first (highest priority) - only when:
// 1. No specific filter prefix is active
// 2. Query is empty (user hasn't started searching)
// This keeps widgets visible on launch but hides them during active search
// Widgets are always visible regardless of filter settings (they declare position via API)
if filter.active_prefix().is_none() && query.is_empty() {
// Widget priority comes from plugin-declared priority field
for provider in &self.widget_providers {
let base_score = provider.priority() as i64;
for (idx, item) in provider.items().iter().enumerate() {
results.push((item.clone(), base_score - idx as i64));
}
}
}
// Query dynamic providers (calculator, websearch, filesearch)
// Only query if:
// 1. Their specific filter is active (e.g., :file prefix or Files tab selected), OR
// 2. No specific single-mode filter is active (showing all providers)
if !query.is_empty() {
for provider in &self.dynamic_providers {
// Skip if this provider type is explicitly filtered out
if !filter.is_active(provider.provider_type()) {
continue;
}
let dynamic_results = provider.query(query);
// Priority comes from plugin-declared priority field
let base_score = provider.priority() as i64;
for (idx, item) in dynamic_results.into_iter().enumerate() {
results.push((item, base_score - idx as i64));
}
}
}
// Empty query (after checking special providers) - return frecency-sorted items
if query.is_empty() {
// Collect items from core providers
let core_items = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
// Collect items from static native providers
let native_items = self
.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
let items: Vec<(LaunchItem, i64)> = core_items
.chain(native_items)
.filter(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter {
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
} else {
true
}
})
.map(|item| {
let frecency_score = frecency.get_score(&item.id);
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
(item, boosted)
})
.collect();
// Combine widgets (already in results) with frecency items
results.extend(items);
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
return results;
}
// Regular search with frecency boost and tag matching
// Helper closure for scoring items
let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> {
// Apply tag filter if present
if let Some(tag) = tag_filter
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag))
{
return None;
}
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item
.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
// Also match against tags (lower weight)
let tag_score = item
.tags
.iter()
.filter_map(|t| self.matcher.fuzzy_match(t, query))
.max()
.map(|s| s / 3); // Lower weight for tag matches
let base_score = match (name_score, desc_score, tag_score) {
(Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)),
(Some(n), Some(d), None) => Some(n.max(d)),
(Some(n), None, Some(t)) => Some(n.max(t)),
(Some(n), None, None) => Some(n),
(None, Some(d), Some(t)) => Some((d / 2).max(t)),
(None, Some(d), None) => Some(d / 2),
(None, None, Some(t)) => Some(t),
(None, None, None) => None,
};
base_score.map(|s| {
let frecency_score = frecency.get_score(&item.id);
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
(item.clone(), s + frecency_boost)
})
};
// Search core providers
for provider in &self.providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(scored) = score_item(item) {
results.push(scored);
}
}
}
// Search static native providers
for provider in &self.static_native_providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(scored) = score_item(item) {
results.push(scored);
}
}
}
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
#[cfg(feature = "dev-logging")]
{
debug!("[Search] Returning {} results", results.len());
for (i, (item, score)) in results.iter().take(5).enumerate() {
debug!(
"[Search] #{}: {} (score={}, provider={:?})",
i + 1,
item.name,
score,
item.provider
);
}
if results.len() > 5 {
debug!("[Search] ... and {} more", results.len() - 5);
}
}
results
}
/// Get all available provider types (for UI tabs)
#[allow(dead_code)]
pub fn available_provider_types(&self) -> Vec<ProviderType> {
self.providers
.iter()
.map(|p| p.provider_type())
.chain(
self.static_native_providers
.iter()
.map(|p| p.provider_type()),
)
.collect()
}
/// Get descriptors for all registered providers (core + native plugins).
///
/// Used by the IPC server to report what providers are available to clients.
pub fn available_providers(&self) -> Vec<ProviderDescriptor> {
let mut descs = Vec::new();
// Core providers
for provider in &self.providers {
let (id, prefix, icon) = match provider.provider_type() {
ProviderType::Application => (
"app".to_string(),
Some(":app".to_string()),
"application-x-executable".to_string(),
),
ProviderType::Command => (
"cmd".to_string(),
Some(":cmd".to_string()),
"utilities-terminal".to_string(),
),
ProviderType::Dmenu => {
("dmenu".to_string(), None, "view-list-symbolic".to_string())
}
ProviderType::Plugin(type_id) => (type_id, None, "application-x-addon".to_string()),
};
descs.push(ProviderDescriptor {
id,
name: provider.name().to_string(),
prefix,
icon,
position: "normal".to_string(),
});
}
// Static native plugin providers
for provider in &self.static_native_providers {
descs.push(ProviderDescriptor {
id: provider.type_id().to_string(),
name: provider.name().to_string(),
prefix: provider.prefix().map(String::from),
icon: provider.icon().to_string(),
position: provider.position_str().to_string(),
});
}
// Dynamic native plugin providers
for provider in &self.dynamic_providers {
descs.push(ProviderDescriptor {
id: provider.type_id().to_string(),
name: provider.name().to_string(),
prefix: provider.prefix().map(String::from),
icon: provider.icon().to_string(),
position: provider.position_str().to_string(),
});
}
// Widget native plugin providers
for provider in &self.widget_providers {
descs.push(ProviderDescriptor {
id: provider.type_id().to_string(),
name: provider.name().to_string(),
prefix: provider.prefix().map(String::from),
icon: provider.icon().to_string(),
position: provider.position_str().to_string(),
});
}
descs
}
/// Refresh a specific provider by its type_id.
///
/// Searches core providers (by ProviderType string), static native providers,
/// and widget providers. Dynamic providers are skipped (they query on demand).
pub fn refresh_provider(&mut self, provider_id: &str) {
// Check core providers
for provider in &mut self.providers {
let matches = match provider.provider_type() {
ProviderType::Application => provider_id == "app",
ProviderType::Command => provider_id == "cmd",
ProviderType::Dmenu => provider_id == "dmenu",
ProviderType::Plugin(ref id) => provider_id == id,
};
if matches {
provider.refresh();
info!("Refreshed core provider '{}'", provider.name());
return;
}
}
// Check static native providers
for provider in &mut self.static_native_providers {
if provider.type_id() == provider_id {
provider.refresh();
info!("Refreshed static provider '{}'", provider.name());
return;
}
}
// Check widget providers
for provider in &mut self.widget_providers {
if provider.type_id() == provider_id {
provider.refresh();
info!("Refreshed widget provider '{}'", provider.name());
return;
}
}
info!("Provider '{}' not found for refresh", provider_id);
}
/// Get a widget item by type_id (e.g., "pomodoro", "weather", "media")
/// Returns the first item from the widget provider, if any
pub fn get_widget_item(&self, type_id: &str) -> Option<LaunchItem> {
self.widget_providers
.iter()
.find(|p| p.type_id() == type_id)
.and_then(|p| p.items().first().cloned())
}
/// Get all loaded widget provider type_ids
/// Returns an iterator over the type_ids of currently loaded widget providers
pub fn widget_type_ids(&self) -> impl Iterator<Item = &str> {
self.widget_providers.iter().map(|p| p.type_id())
}
/// Query a plugin for submenu actions
///
/// This is used when a user selects a SUBMENU:plugin_id:data item.
/// The plugin is queried with "?SUBMENU:data" and returns action items.
///
/// Returns (display_name, actions) where display_name is the item name
/// and actions are the submenu items returned by the plugin.
pub fn query_submenu_actions(
&self,
plugin_id: &str,
data: &str,
display_name: &str,
) -> Option<(String, Vec<LaunchItem>)> {
// Build the submenu query
let submenu_query = format!("?SUBMENU:{}", data);
#[cfg(feature = "dev-logging")]
debug!(
"[Submenu] Querying plugin '{}' with: {}",
plugin_id, submenu_query
);
// Search in static native providers (clipboard, emoji, ssh, systemd, etc.)
for provider in &self.static_native_providers {
if provider.type_id() == plugin_id {
let actions = provider.query(&submenu_query);
if !actions.is_empty() {
return Some((display_name.to_string(), actions));
}
}
}
// Search in dynamic providers
for provider in &self.dynamic_providers {
if provider.type_id() == plugin_id {
let actions = provider.query(&submenu_query);
if !actions.is_empty() {
return Some((display_name.to_string(), actions));
}
}
}
// Search in widget providers
for provider in &self.widget_providers {
if provider.type_id() == plugin_id {
let actions = provider.query(&submenu_query);
if !actions.is_empty() {
return Some((display_name.to_string(), actions));
}
}
}
#[cfg(feature = "dev-logging")]
debug!(
"[Submenu] No submenu actions found for plugin '{}'",
plugin_id
);
None
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal mock provider for testing ProviderManager
struct MockProvider {
name: String,
provider_type: ProviderType,
items: Vec<LaunchItem>,
refresh_count: usize,
}
impl MockProvider {
fn new(name: &str, provider_type: ProviderType) -> Self {
Self {
name: name.to_string(),
provider_type,
items: Vec::new(),
refresh_count: 0,
}
}
fn with_items(mut self, items: Vec<LaunchItem>) -> Self {
self.items = items;
self
}
}
impl Provider for MockProvider {
fn name(&self) -> &str {
&self.name
}
fn provider_type(&self) -> ProviderType {
self.provider_type.clone()
}
fn refresh(&mut self) {
self.refresh_count += 1;
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
fn make_item(id: &str, name: &str, provider: ProviderType) -> LaunchItem {
LaunchItem {
id: id.to_string(),
name: name.to_string(),
description: None,
icon: None,
provider,
command: format!("run-{}", id),
terminal: false,
tags: Vec::new(),
}
}
#[test]
fn test_available_providers_core_only() {
let providers: Vec<Box<dyn Provider>> = vec![
Box::new(MockProvider::new("Applications", ProviderType::Application)),
Box::new(MockProvider::new("Commands", ProviderType::Command)),
];
let pm = ProviderManager::new(providers, Vec::new());
let descs = pm.available_providers();
assert_eq!(descs.len(), 2);
assert_eq!(descs[0].id, "app");
assert_eq!(descs[0].name, "Applications");
assert_eq!(descs[0].prefix, Some(":app".to_string()));
assert_eq!(descs[0].icon, "application-x-executable");
assert_eq!(descs[0].position, "normal");
assert_eq!(descs[1].id, "cmd");
assert_eq!(descs[1].name, "Commands");
}
#[test]
fn test_available_providers_dmenu() {
let providers: Vec<Box<dyn Provider>> =
vec![Box::new(MockProvider::new("dmenu", ProviderType::Dmenu))];
let pm = ProviderManager::new(providers, Vec::new());
let descs = pm.available_providers();
assert_eq!(descs.len(), 1);
assert_eq!(descs[0].id, "dmenu");
assert!(descs[0].prefix.is_none());
}
#[test]
fn test_available_provider_types() {
let providers: Vec<Box<dyn Provider>> = vec![
Box::new(MockProvider::new("Applications", ProviderType::Application)),
Box::new(MockProvider::new("Commands", ProviderType::Command)),
];
let pm = ProviderManager::new(providers, Vec::new());
let types = pm.available_provider_types();
assert_eq!(types.len(), 2);
assert!(types.contains(&ProviderType::Application));
assert!(types.contains(&ProviderType::Command));
}
#[test]
fn test_refresh_provider_core() {
let app = MockProvider::new("Applications", ProviderType::Application);
let cmd = MockProvider::new("Commands", ProviderType::Command);
let providers: Vec<Box<dyn Provider>> = vec![Box::new(app), Box::new(cmd)];
let mut pm = ProviderManager::new(providers, Vec::new());
// refresh_all was called during construction, now refresh individual
pm.refresh_provider("app");
pm.refresh_provider("cmd");
// Just verifying it doesn't panic; can't easily inspect refresh_count
// through Box<dyn Provider>
}
#[test]
fn test_refresh_provider_unknown_does_not_panic() {
let providers: Vec<Box<dyn Provider>> = vec![Box::new(MockProvider::new(
"Applications",
ProviderType::Application,
))];
let mut pm = ProviderManager::new(providers, Vec::new());
pm.refresh_provider("nonexistent");
// Should complete without panicking
}
#[test]
fn test_search_with_core_providers() {
let items = vec![
make_item("firefox", "Firefox", ProviderType::Application),
make_item("vim", "Vim", ProviderType::Application),
];
let provider =
MockProvider::new("Applications", ProviderType::Application).with_items(items);
let providers: Vec<Box<dyn Provider>> = vec![Box::new(provider)];
let pm = ProviderManager::new(providers, Vec::new());
let results = pm.search("fire", 10);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0.name, "Firefox");
}
}

View File

@@ -9,7 +9,9 @@
use std::sync::{Arc, RwLock};
use log::debug;
use owlry_plugin_api::{PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind};
use owlry_plugin_api::{
PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition,
};
use super::{LaunchItem, Provider, ProviderType};
use crate::plugins::native_loader::NativePlugin;
@@ -42,6 +44,12 @@ impl NativeProvider {
}
}
/// 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 {
@@ -49,7 +57,7 @@ impl NativeProvider {
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: ProviderType::Plugin(self.info.type_id.to_string()),
provider: self.get_provider_type(),
command: item.command.to_string(),
terminal: item.terminal,
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
@@ -70,7 +78,10 @@ impl NativeProvider {
}
let api_items = self.plugin.query_provider(self.handle, query);
api_items.into_iter().map(|item| self.convert_item(item)).collect()
api_items
.into_iter()
.map(|item| self.convert_item(item))
.collect()
}
/// Check if this provider has a prefix that matches the query
@@ -99,6 +110,30 @@ impl NativeProvider {
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) {
@@ -113,7 +148,7 @@ impl Provider for NativeProvider {
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(self.info.type_id.to_string())
self.get_provider_type()
}
fn refresh(&mut self) {

View File

@@ -0,0 +1,258 @@
use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
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<Mutex<ProviderManager>>,
frecency: Arc<Mutex<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)?;
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(Mutex::new(provider_manager)),
frecency: Arc::new(Mutex::new(frecency)),
config: Arc::new(config),
})
}
/// Accept connections in a loop, spawning a thread per client.
pub fn run(&self) -> io::Result<()> {
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<Mutex<ProviderManager>>,
frecency: Arc<Mutex<FrecencyStore>>,
config: Arc<Config>,
) -> io::Result<()> {
let reader = BufReader::new(stream.try_clone()?);
let mut writer = stream;
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let request: Request = match serde_json::from_str(trimmed) {
Ok(req) => req,
Err(e) => {
let resp = Response::Error {
message: format!("invalid request JSON: {}", e),
};
write_response(&mut writer, &resp)?;
continue;
}
};
let response = Self::handle_request(&request, &pm, &frecency, &config);
write_response(&mut writer, &response)?;
}
Ok(())
}
/// Dispatch a single request to the appropriate subsystem and return
/// the response.
fn handle_request(
request: &Request,
pm: &Arc<Mutex<ProviderManager>>,
frecency: &Arc<Mutex<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.lock().unwrap();
let frecency_guard = frecency.lock().unwrap();
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.lock().unwrap();
frecency_guard.record_launch(item_id);
Response::Ack
}
Request::Providers => {
let pm_guard = pm.lock().unwrap();
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.lock().unwrap();
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.lock().unwrap();
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.lock().unwrap();
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

@@ -1,6 +1,6 @@
[package]
name = "owlry-lua"
version = "0.2.0"
version = "1.0.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -20,7 +20,7 @@ owlry-plugin-api = { path = "../owlry-plugin-api" }
abi_stable = "0.11"
# Lua runtime
mlua = { version = "0.10", features = ["lua54", "vendored", "send", "serialize"] }
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"] }
# Plugin manifest parsing
toml = "0.8"
@@ -31,7 +31,7 @@ serde_json = "1.0"
semver = "1"
# HTTP client for plugins
reqwest = { version = "0.12", features = ["blocking", "json"] }
reqwest = { version = "0.13", features = ["blocking", "json"] }
# Math expression evaluation
meval = "0.2"

View File

@@ -24,11 +24,14 @@ pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
/// 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")?
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")?
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")?
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")?;
@@ -116,13 +119,14 @@ fn call_provider_function(
// 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);
}
&& 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
@@ -153,7 +157,9 @@ fn parse_item(table: &Table) -> LuaResult<PluginItem> {
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 tags: Vec<String> = table
.get::<Option<Vec<String>>>("tags")?
.unwrap_or_default();
let mut item = PluginItem::new(id, name, command);
@@ -176,7 +182,7 @@ fn parse_item(table: &Table) -> LuaResult<PluginItem> {
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{create_lua_runtime, SandboxConfig};
use crate::runtime::{SandboxConfig, create_lua_runtime};
#[test]
fn test_register_static_provider() {

View File

@@ -11,25 +11,37 @@ use std::path::{Path, PathBuf};
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(
"debug",
lua.create_function(|_, msg: String| {
eprintln!("[DEBUG] {}", msg);
Ok(())
})?,
)?;
log.set("info", lua.create_function(|_, msg: String| {
eprintln!("[INFO] {}", 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(
"warn",
lua.create_function(|_, msg: String| {
eprintln!("[WARN] {}", msg);
Ok(())
})?,
)?;
log.set("error", lua.create_function(|_, msg: String| {
eprintln!("[ERROR] {}", msg);
Ok(())
})?)?;
log.set(
"error",
lua.create_function(|_, msg: String| {
eprintln!("[ERROR] {}", msg);
Ok(())
})?,
)?;
owlry.set("log", log)?;
Ok(())
@@ -44,59 +56,79 @@ pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResu
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())
})?)?;
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())
})?)?;
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())
})?)?;
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())
})?)?;
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())
})?)?;
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())
})?)?;
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() {
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)
})?)?;
Ok(path)
})?,
)?;
owlry.set("path", path)?;
Ok(())
@@ -111,76 +143,95 @@ pub fn register_fs_api(lua: &Lua, owlry: &Table, _plugin_dir: &Path) -> LuaResul
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())
})?)?;
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())
})?)?;
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),
}
})?)?;
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)?))
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),
}
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)?))
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),
}
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) {
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),
}
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())
})?)?;
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(())
@@ -195,18 +246,24 @@ 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()))
})?)?;
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),
}
})?)?;
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(())
@@ -219,9 +276,10 @@ pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
/// 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();
}
&& let Some(home) = dirs::home_dir()
{
return home.join(&path[2..]).to_string_lossy().to_string();
}
path.to_string()
}
@@ -305,7 +363,7 @@ fn lua_to_json(_lua: &Lua, value: &Value) -> LuaResult<serde_json::Value> {
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{create_lua_runtime, SandboxConfig};
use crate::runtime::{SandboxConfig, create_lua_runtime};
#[test]
fn test_log_api() {
@@ -316,7 +374,10 @@ mod tests {
lua.globals().set("owlry", owlry).unwrap();
// Just verify it doesn't panic
lua.load("owlry.log.info('test message')").set_name("test").call::<()>(()).unwrap();
lua.load("owlry.log.info('test message')")
.set_name("test")
.call::<()>(())
.unwrap();
}
#[test]
@@ -327,10 +388,18 @@ mod tests {
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();
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();
let plugin_dir: String = lua
.load("return owlry.path.plugin_dir()")
.set_name("test")
.call(())
.unwrap();
assert_eq!(plugin_dir, "/tmp/test-plugin");
}
@@ -342,10 +411,18 @@ mod tests {
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();
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();
let is_dir: bool = lua
.load("return owlry.fs.is_dir('/tmp')")
.set_name("test")
.call(())
.unwrap();
assert!(is_dir);
}

View File

@@ -54,7 +54,11 @@ pub struct LuaRuntimeVTable {
/// 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>,
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),
}
@@ -83,11 +87,15 @@ impl RuntimeHandle {
/// Create a null handle (reserved for error cases)
#[allow(dead_code)]
fn null() -> Self {
Self { ptr: std::ptr::null_mut() }
Self {
ptr: std::ptr::null_mut(),
}
}
fn from_box<T>(state: Box<T>) -> Self {
Self { ptr: Box::into_raw(state) as *mut () }
Self {
ptr: Box::into_raw(state) as *mut (),
}
}
unsafe fn drop_as<T>(&self) {
@@ -147,7 +155,10 @@ impl LuaRuntimeState {
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);
eprintln!(
"owlry-lua: Plugin '{}' not compatible with owlry {}",
id, owlry_version
);
continue;
}
@@ -285,13 +296,19 @@ extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> R
state.refresh_provider(provider_id.as_str()).into()
}
extern "C" fn runtime_query(handle: RuntimeHandle, provider_id: RStr<'_>, query: RStr<'_>) -> RVec<PluginItem> {
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()
state
.query_provider(provider_id.as_str(), query.as_str())
.into()
}
extern "C" fn runtime_drop(handle: RuntimeHandle) {

View File

@@ -8,7 +8,7 @@ use owlry_plugin_api::PluginItem;
use crate::api;
use crate::manifest::PluginManifest;
use crate::runtime::{create_lua_runtime, load_file, SandboxConfig};
use crate::runtime::{SandboxConfig, create_lua_runtime, load_file};
/// Provider registration info from Lua
#[derive(Debug, Clone)]
@@ -77,11 +77,13 @@ impl LoadedPlugin {
// 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));
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))?;
load_file(&lua, &entry_path).map_err(|e| format!("Failed to load entry point: {}", e))?;
self.lua = Some(lua);
Ok(())
@@ -89,7 +91,9 @@ impl LoadedPlugin {
/// Get provider registrations from this plugin
pub fn get_provider_registrations(&self) -> Result<Vec<ProviderRegistration>, String> {
let lua = self.lua.as_ref()
let lua = self
.lua
.as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?;
api::get_provider_registrations(lua)
@@ -98,25 +102,33 @@ impl LoadedPlugin {
/// 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()
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))
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()
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))
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> {
pub fn discover_plugins(
plugins_dir: &Path,
) -> Result<HashMap<String, (PluginManifest, PathBuf)>, String> {
let mut plugins = HashMap::new();
if !plugins_dir.exists() {
@@ -146,13 +158,21 @@ pub fn discover_plugins(plugins_dir: &Path) -> Result<HashMap<String, (PluginMan
Ok(manifest) => {
let id = manifest.plugin.id.clone();
if plugins.contains_key(&id) {
eprintln!("owlry-lua: Duplicate plugin ID '{}', skipping {}", id, path.display());
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);
eprintln!(
"owlry-lua: Failed to load plugin at {}: {}",
path.display(),
e
);
}
}
}

View File

@@ -90,10 +90,10 @@ pub struct PluginPermissions {
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))?;
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)
}
@@ -105,7 +105,12 @@ impl PluginManifest {
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 == '-') {
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());
}
@@ -116,7 +121,10 @@ impl PluginManifest {
// 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));
return Err(format!(
"Invalid owlry_version constraint: {}",
self.plugin.owlry_version
));
}
Ok(())

View File

@@ -28,7 +28,7 @@ impl Default for SandboxConfig {
allow_commands: false,
allow_network: false,
allow_external_fs: false,
max_run_time_ms: 5000, // 5 seconds
max_run_time_ms: 5000, // 5 seconds
max_memory: 64 * 1024 * 1024, // 64 MB
}
}
@@ -50,11 +50,7 @@ impl SandboxConfig {
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 libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH;
let lua = Lua::new_with(libs, mlua::LuaOptions::default())?;
@@ -74,11 +70,15 @@ fn setup_safe_globals(lua: &Lua) -> LuaResult<()> {
// 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(
"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(
"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)?;
@@ -107,8 +107,7 @@ fn os_time(_lua: &Lua, _args: ()) -> LuaResult<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)?;
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()?

View File

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

View File

@@ -31,7 +31,9 @@ use abi_stable::StableAbi;
pub use abi_stable::std_types::{ROption, RStr, RString, RVec};
/// Current plugin API version - plugins must match this
pub const API_VERSION: u32 = 1;
/// 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)]
@@ -65,6 +67,14 @@ pub struct ProviderInfo {
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
@@ -77,6 +87,20 @@ pub enum ProviderKind {
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)]
@@ -260,12 +284,8 @@ pub enum NotifyUrgency {
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,
),
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<'_>),

View File

@@ -1,27 +0,0 @@
[package]
name = "owlry-plugin-bookmarks"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Bookmarks plugin for owlry - browser bookmark search"
keywords = ["owlry", "plugin", "bookmarks", "browser"]
categories = ["web-programming"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"
# For finding browser config directories
dirs = "5.0"
# For parsing Chrome bookmarks JSON
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@@ -1,301 +0,0 @@
//! Bookmarks Plugin for Owlry
//!
//! A static provider that reads browser bookmarks from Chrome/Chromium.
//! Firefox support would require the rusqlite crate for reading places.sqlite.
//!
//! Supported browsers:
//! - Chrome
//! - Chromium
//! - Brave
//! - Edge
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
// Plugin metadata
const PLUGIN_ID: &str = "bookmarks";
const PLUGIN_NAME: &str = "Bookmarks";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Browser bookmark search";
// Provider metadata
const PROVIDER_ID: &str = "bookmarks";
const PROVIDER_NAME: &str = "Bookmarks";
const PROVIDER_PREFIX: &str = ":bm";
const PROVIDER_ICON: &str = "web-browser";
const PROVIDER_TYPE_ID: &str = "bookmarks";
/// Bookmarks provider state - holds cached items
struct BookmarksState {
items: Vec<PluginItem>,
}
impl BookmarksState {
fn new() -> Self {
Self { items: Vec::new() }
}
fn chromium_bookmark_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(config_dir) = dirs::config_dir() {
// Chrome
paths.push(config_dir.join("google-chrome/Default/Bookmarks"));
paths.push(config_dir.join("google-chrome-stable/Default/Bookmarks"));
// Chromium
paths.push(config_dir.join("chromium/Default/Bookmarks"));
// Brave
paths.push(config_dir.join("BraveSoftware/Brave-Browser/Default/Bookmarks"));
// Edge
paths.push(config_dir.join("microsoft-edge/Default/Bookmarks"));
}
paths
}
fn load_bookmarks(&mut self) {
self.items.clear();
// Load Chrome/Chromium bookmarks
for path in Self::chromium_bookmark_paths() {
if path.exists() {
self.read_chrome_bookmarks(&path);
}
}
}
fn read_chrome_bookmarks(&mut self, path: &PathBuf) {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return,
};
let bookmarks: ChromeBookmarks = match serde_json::from_str(&content) {
Ok(b) => b,
Err(_) => return,
};
// Process bookmark bar and other folders
if let Some(roots) = bookmarks.roots {
if let Some(bar) = roots.bookmark_bar {
self.process_chrome_folder(&bar);
}
if let Some(other) = roots.other {
self.process_chrome_folder(&other);
}
if let Some(synced) = roots.synced {
self.process_chrome_folder(&synced);
}
}
}
fn process_chrome_folder(&mut self, folder: &ChromeBookmarkNode) {
if let Some(ref children) = folder.children {
for child in children {
match child.node_type.as_deref() {
Some("url") => {
if let Some(ref url) = child.url {
let name = child.name.clone().unwrap_or_else(|| url.clone());
self.items.push(
PluginItem::new(
format!("bookmark:{}", url),
name,
format!("xdg-open '{}'", url.replace('\'', "'\\''")),
)
.with_description(url.clone())
.with_icon(PROVIDER_ICON)
.with_keywords(vec!["bookmark".to_string(), "web".to_string()]),
);
}
}
Some("folder") => {
// Recursively process subfolders
self.process_chrome_folder(child);
}
_ => {}
}
}
}
}
}
// Chrome bookmark JSON structures
#[derive(Debug, Deserialize)]
struct ChromeBookmarks {
roots: Option<ChromeBookmarkRoots>,
}
#[derive(Debug, Deserialize)]
struct ChromeBookmarkRoots {
bookmark_bar: Option<ChromeBookmarkNode>,
other: Option<ChromeBookmarkNode>,
synced: Option<ChromeBookmarkNode>,
}
#[derive(Debug, Deserialize)]
struct ChromeBookmarkNode {
name: Option<String>,
url: Option<String>,
#[serde(rename = "type")]
node_type: Option<String>,
children: Option<Vec<ChromeBookmarkNode>>,
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(BookmarksState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<BookmarksState>
let state = unsafe { &mut *(handle.ptr as *mut BookmarksState) };
// Load bookmarks
state.load_bookmarks();
// Return items
state.items.to_vec().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query is handled by the core using cached items
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<BookmarksState>
unsafe {
handle.drop_as::<BookmarksState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bookmarks_state_new() {
let state = BookmarksState::new();
assert!(state.items.is_empty());
}
#[test]
fn test_chromium_paths() {
let paths = BookmarksState::chromium_bookmark_paths();
// Should have at least some paths configured
assert!(!paths.is_empty());
}
#[test]
fn test_parse_chrome_bookmarks() {
let json = r#"{
"roots": {
"bookmark_bar": {
"type": "folder",
"children": [
{
"type": "url",
"name": "Example",
"url": "https://example.com"
}
]
}
}
}"#;
let bookmarks: ChromeBookmarks = serde_json::from_str(json).unwrap();
assert!(bookmarks.roots.is_some());
let roots = bookmarks.roots.unwrap();
assert!(roots.bookmark_bar.is_some());
let bar = roots.bookmark_bar.unwrap();
assert!(bar.children.is_some());
assert_eq!(bar.children.unwrap().len(), 1);
}
#[test]
fn test_process_folder() {
let mut state = BookmarksState::new();
let folder = ChromeBookmarkNode {
name: Some("Test Folder".to_string()),
url: None,
node_type: Some("folder".to_string()),
children: Some(vec![
ChromeBookmarkNode {
name: Some("Test Bookmark".to_string()),
url: Some("https://test.com".to_string()),
node_type: Some("url".to_string()),
children: None,
},
]),
};
state.process_chrome_folder(&folder);
assert_eq!(state.items.len(), 1);
assert_eq!(state.items[0].name.as_str(), "Test Bookmark");
}
#[test]
fn test_url_escaping() {
let url = "https://example.com/path?query='test'";
let command = format!("xdg-open '{}'", url.replace('\'', "'\\''"));
assert!(command.contains("'\\''"));
}
}

View File

@@ -1,23 +0,0 @@
[package]
name = "owlry-plugin-calculator"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Calculator plugin for owlry - evaluates mathematical expressions"
keywords = ["owlry", "plugin", "calculator"]
categories = ["mathematics"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# Math expression evaluation
meval = "0.2"
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -1,228 +0,0 @@
//! Calculator Plugin for Owlry
//!
//! A dynamic provider that evaluates mathematical expressions.
//! Supports queries prefixed with `=` or `calc `.
//!
//! Examples:
//! - `= 5 + 3` → 8
//! - `calc sqrt(16)` → 4
//! - `= pi * 2` → 6.283185...
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
// Plugin metadata
const PLUGIN_ID: &str = "calculator";
const PLUGIN_NAME: &str = "Calculator";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Evaluate mathematical expressions";
// Provider metadata
const PROVIDER_ID: &str = "calculator";
const PROVIDER_NAME: &str = "Calculator";
const PROVIDER_PREFIX: &str = "=";
const PROVIDER_ICON: &str = "accessories-calculator";
const PROVIDER_TYPE_ID: &str = "calc";
/// Calculator provider state (empty for now, but could cache results)
struct CalculatorState;
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Dynamic,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
// Create state and return handle
let state = Box::new(CalculatorState);
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
// Dynamic provider - refresh does nothing
RVec::new()
}
extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
let query_str = query.as_str();
// Extract expression from query
let expr = match extract_expression(query_str) {
Some(e) if !e.is_empty() => e,
_ => return RVec::new(),
};
// Evaluate the expression
match evaluate_expression(expr) {
Some(item) => vec![item].into(),
None => RVec::new(),
}
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<CalculatorState>
unsafe {
handle.drop_as::<CalculatorState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Calculator Logic
// ============================================================================
/// Extract expression from query (handles `= expr` and `calc expr` formats)
fn extract_expression(query: &str) -> Option<&str> {
let trimmed = query.trim();
// Support both "= expr" and "=expr" (with or without space)
if let Some(expr) = trimmed.strip_prefix("= ") {
Some(expr.trim())
} else if let Some(expr) = trimmed.strip_prefix('=') {
Some(expr.trim())
} else if let Some(expr) = trimmed.strip_prefix("calc ") {
Some(expr.trim())
} else {
// For filter mode - accept raw expressions
Some(trimmed)
}
}
/// Evaluate a mathematical expression and return a PluginItem
fn evaluate_expression(expr: &str) -> Option<PluginItem> {
match meval::eval_str(expr) {
Ok(result) => {
// Format result nicely
let result_str = format_result(result);
Some(
PluginItem::new(
format!("calc:{}", expr),
result_str.clone(),
format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str),
)
.with_description(format!("= {}", expr))
.with_icon(PROVIDER_ICON)
.with_keywords(vec!["math".to_string(), "calculator".to_string()]),
)
}
Err(_) => None,
}
}
/// Format a numeric result nicely
fn format_result(result: f64) -> String {
if result.fract() == 0.0 && result.abs() < 1e15 {
// Integer result
format!("{}", result as i64)
} else {
// Float result with reasonable precision, trimming trailing zeros
let formatted = format!("{:.10}", result);
formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_expression() {
assert_eq!(extract_expression("= 5+3"), Some("5+3"));
assert_eq!(extract_expression("=5+3"), Some("5+3"));
assert_eq!(extract_expression("calc 5+3"), Some("5+3"));
assert_eq!(extract_expression(" = 5 + 3 "), Some("5 + 3"));
assert_eq!(extract_expression("5+3"), Some("5+3")); // Raw expression
}
#[test]
fn test_format_result() {
assert_eq!(format_result(8.0), "8");
assert_eq!(format_result(2.5), "2.5");
assert_eq!(format_result(3.14159265358979), "3.1415926536");
}
#[test]
fn test_evaluate_basic() {
let item = evaluate_expression("5+3").unwrap();
assert_eq!(item.name.as_str(), "8");
let item = evaluate_expression("10 * 2").unwrap();
assert_eq!(item.name.as_str(), "20");
let item = evaluate_expression("15 / 3").unwrap();
assert_eq!(item.name.as_str(), "5");
}
#[test]
fn test_evaluate_float() {
let item = evaluate_expression("5/2").unwrap();
assert_eq!(item.name.as_str(), "2.5");
}
#[test]
fn test_evaluate_functions() {
let item = evaluate_expression("sqrt(16)").unwrap();
assert_eq!(item.name.as_str(), "4");
let item = evaluate_expression("abs(-5)").unwrap();
assert_eq!(item.name.as_str(), "5");
}
#[test]
fn test_evaluate_constants() {
let item = evaluate_expression("pi").unwrap();
assert!(item.name.as_str().starts_with("3.14159"));
let item = evaluate_expression("e").unwrap();
assert!(item.name.as_str().starts_with("2.718"));
}
#[test]
fn test_evaluate_invalid() {
assert!(evaluate_expression("").is_none());
assert!(evaluate_expression("invalid").is_none());
assert!(evaluate_expression("5 +").is_none());
}
}

View File

@@ -1,20 +0,0 @@
[package]
name = "owlry-plugin-clipboard"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Clipboard plugin for owlry - clipboard history via cliphist"
keywords = ["owlry", "plugin", "clipboard"]
categories = ["os"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -1,256 +0,0 @@
//! Clipboard Plugin for Owlry
//!
//! A static provider that integrates with cliphist to show clipboard history.
//! Requires cliphist and wl-clipboard to be installed.
//!
//! Dependencies:
//! - cliphist: clipboard history manager
//! - wl-clipboard: Wayland clipboard utilities (wl-copy)
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
use std::process::Command;
// Plugin metadata
const PLUGIN_ID: &str = "clipboard";
const PLUGIN_NAME: &str = "Clipboard";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Clipboard history via cliphist";
// Provider metadata
const PROVIDER_ID: &str = "clipboard";
const PROVIDER_NAME: &str = "Clipboard";
const PROVIDER_PREFIX: &str = ":clip";
const PROVIDER_ICON: &str = "edit-paste";
const PROVIDER_TYPE_ID: &str = "clipboard";
// Default max entries to show
const DEFAULT_MAX_ENTRIES: usize = 50;
/// Clipboard provider state - holds cached items
struct ClipboardState {
items: Vec<PluginItem>,
max_entries: usize,
}
impl ClipboardState {
fn new() -> Self {
Self {
items: Vec::new(),
max_entries: DEFAULT_MAX_ENTRIES,
}
}
/// Check if cliphist is available
fn has_cliphist() -> bool {
Command::new("which")
.arg("cliphist")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn load_clipboard_history(&mut self) {
self.items.clear();
if !Self::has_cliphist() {
return;
}
// Get clipboard history from cliphist
let output = match Command::new("cliphist").arg("list").output() {
Ok(o) => o,
Err(_) => return,
};
if !output.status.success() {
return;
}
let content = String::from_utf8_lossy(&output.stdout);
for (idx, line) in content.lines().take(self.max_entries).enumerate() {
// cliphist format: "id\tpreview"
let parts: Vec<&str> = line.splitn(2, '\t').collect();
if parts.is_empty() {
continue;
}
let clip_id = parts[0];
let preview = if parts.len() > 1 {
// Truncate long previews (char-safe for UTF-8)
let p = parts[1];
if p.chars().count() > 80 {
let truncated: String = p.chars().take(77).collect();
format!("{}...", truncated)
} else {
p.to_string()
}
} else {
"[binary data]".to_string()
};
// Clean up preview - replace newlines with spaces
let preview_clean = preview
.replace('\n', " ")
.replace('\r', "")
.replace('\t', " ");
// Command to paste this entry
// echo "id" | cliphist decode | wl-copy
let command = format!(
"echo '{}' | cliphist decode | wl-copy",
clip_id.replace('\'', "'\\''")
);
self.items.push(
PluginItem::new(format!("clipboard:{}", idx), preview_clean, command)
.with_description("Copy to clipboard")
.with_icon(PROVIDER_ICON),
);
}
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(ClipboardState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<ClipboardState>
let state = unsafe { &mut *(handle.ptr as *mut ClipboardState) };
// Load clipboard history
state.load_clipboard_history();
// Return items
state.items.to_vec().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query is handled by the core using cached items
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<ClipboardState>
unsafe {
handle.drop_as::<ClipboardState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clipboard_state_new() {
let state = ClipboardState::new();
assert!(state.items.is_empty());
assert_eq!(state.max_entries, DEFAULT_MAX_ENTRIES);
}
#[test]
fn test_preview_truncation() {
// Test that long strings would be truncated (char-safe)
let long_text = "a".repeat(100);
let truncated = if long_text.chars().count() > 80 {
let t: String = long_text.chars().take(77).collect();
format!("{}...", t)
} else {
long_text.clone()
};
assert_eq!(truncated.chars().count(), 80);
assert!(truncated.ends_with("..."));
}
#[test]
fn test_preview_truncation_utf8() {
// Test with multi-byte UTF-8 characters (box-drawing chars are 3 bytes each)
let utf8_text = "├── ".repeat(30); // Each "├── " is 7 bytes but 4 chars
let truncated = if utf8_text.chars().count() > 80 {
let t: String = utf8_text.chars().take(77).collect();
format!("{}...", t)
} else {
utf8_text.clone()
};
assert_eq!(truncated.chars().count(), 80);
assert!(truncated.ends_with("..."));
}
#[test]
fn test_preview_cleaning() {
let dirty = "line1\nline2\tcolumn\rend";
let clean = dirty
.replace('\n', " ")
.replace('\r', "")
.replace('\t', " ");
assert_eq!(clean, "line1 line2 columnend");
}
#[test]
fn test_command_escaping() {
let clip_id = "test'id";
let command = format!(
"echo '{}' | cliphist decode | wl-copy",
clip_id.replace('\'', "'\\''")
);
assert!(command.contains("test'\\''id"));
}
#[test]
fn test_has_cliphist_runs() {
// Just ensure it doesn't panic - cliphist may or may not be installed
let _ = ClipboardState::has_cliphist();
}
}

View File

@@ -1,20 +0,0 @@
[package]
name = "owlry-plugin-emoji"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Emoji plugin for owlry - search and copy emojis"
keywords = ["owlry", "plugin", "emoji"]
categories = ["text-processing"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -1,561 +0,0 @@
//! Emoji Plugin for Owlry
//!
//! A static provider that provides emoji search and copy functionality.
//! Requires wl-clipboard (wl-copy) for copying to clipboard.
//!
//! Examples:
//! - Search "smile" → 😀 😃 😄 etc.
//! - Search "heart" → ❤️ 💙 💚 etc.
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
// Plugin metadata
const PLUGIN_ID: &str = "emoji";
const PLUGIN_NAME: &str = "Emoji";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Search and copy emojis";
// Provider metadata
const PROVIDER_ID: &str = "emoji";
const PROVIDER_NAME: &str = "Emoji";
const PROVIDER_PREFIX: &str = ":emoji";
const PROVIDER_ICON: &str = "face-smile";
const PROVIDER_TYPE_ID: &str = "emoji";
/// Emoji provider state - holds cached items
struct EmojiState {
items: Vec<PluginItem>,
}
impl EmojiState {
fn new() -> Self {
Self { items: Vec::new() }
}
fn load_emojis(&mut self) {
self.items.clear();
// Common emojis with searchable names
// Format: (emoji, name, keywords)
let emojis: &[(&str, &str, &str)] = &[
// Smileys & Emotion
("😀", "grinning face", "smile happy"),
("😃", "grinning face with big eyes", "smile happy"),
("😄", "grinning face with smiling eyes", "smile happy laugh"),
("😁", "beaming face with smiling eyes", "smile happy grin"),
("😅", "grinning face with sweat", "smile nervous"),
("🤣", "rolling on the floor laughing", "lol rofl funny"),
("😂", "face with tears of joy", "laugh cry funny lol"),
("🙂", "slightly smiling face", "smile"),
("😊", "smiling face with smiling eyes", "blush happy"),
("😇", "smiling face with halo", "angel innocent"),
("🥰", "smiling face with hearts", "love adore"),
("😍", "smiling face with heart-eyes", "love crush"),
("🤩", "star-struck", "excited wow amazing"),
("😘", "face blowing a kiss", "kiss love"),
("😜", "winking face with tongue", "playful silly"),
("🤪", "zany face", "crazy silly wild"),
("😎", "smiling face with sunglasses", "cool"),
("🤓", "nerd face", "geek glasses"),
("🧐", "face with monocle", "thinking inspect"),
("😏", "smirking face", "smug"),
("😒", "unamused face", "meh annoyed"),
("🙄", "face with rolling eyes", "whatever annoyed"),
("😬", "grimacing face", "awkward nervous"),
("😮‍💨", "face exhaling", "sigh relief"),
("🤥", "lying face", "pinocchio lie"),
("😌", "relieved face", "relaxed peaceful"),
("😔", "pensive face", "sad thoughtful"),
("😪", "sleepy face", "tired"),
("🤤", "drooling face", "hungry yummy"),
("😴", "sleeping face", "zzz tired"),
("😷", "face with medical mask", "sick covid"),
("🤒", "face with thermometer", "sick fever"),
("🤕", "face with head-bandage", "hurt injured"),
("🤢", "nauseated face", "sick gross"),
("🤮", "face vomiting", "sick puke"),
("🤧", "sneezing face", "achoo sick"),
("🥵", "hot face", "sweating heat"),
("🥶", "cold face", "freezing"),
("😵", "face with crossed-out eyes", "dizzy dead"),
("🤯", "exploding head", "mind blown wow"),
("🤠", "cowboy hat face", "yeehaw western"),
("🥳", "partying face", "celebration party"),
("🥸", "disguised face", "incognito"),
("🤡", "clown face", "circus"),
("👻", "ghost", "halloween spooky"),
("💀", "skull", "dead death"),
("☠️", "skull and crossbones", "danger death"),
("👽", "alien", "ufo extraterrestrial"),
("🤖", "robot", "bot android"),
("💩", "pile of poo", "poop"),
("😈", "smiling face with horns", "devil evil"),
("👿", "angry face with horns", "devil evil"),
// Gestures & People
("👋", "waving hand", "hello hi bye wave"),
("🤚", "raised back of hand", "stop"),
("🖐️", "hand with fingers splayed", "five high"),
("", "raised hand", "stop high five"),
("🖖", "vulcan salute", "spock trek"),
("👌", "ok hand", "okay perfect"),
("🤌", "pinched fingers", "italian"),
("🤏", "pinching hand", "small tiny"),
("✌️", "victory hand", "peace two"),
("🤞", "crossed fingers", "luck hope"),
("🤟", "love-you gesture", "ily rock"),
("🤘", "sign of the horns", "rock metal"),
("🤙", "call me hand", "shaka hang loose"),
("👈", "backhand index pointing left", "left point"),
("👉", "backhand index pointing right", "right point"),
("👆", "backhand index pointing up", "up point"),
("👇", "backhand index pointing down", "down point"),
("☝️", "index pointing up", "one point"),
("👍", "thumbs up", "like yes good approve"),
("👎", "thumbs down", "dislike no bad"),
("", "raised fist", "power solidarity"),
("👊", "oncoming fist", "punch bump"),
("🤛", "left-facing fist", "fist bump"),
("🤜", "right-facing fist", "fist bump"),
("👏", "clapping hands", "applause bravo"),
("🙌", "raising hands", "hooray celebrate"),
("👐", "open hands", "hug"),
("🤲", "palms up together", "prayer"),
("🤝", "handshake", "agreement deal"),
("🙏", "folded hands", "prayer please thanks"),
("✍️", "writing hand", "write"),
("💪", "flexed biceps", "strong muscle"),
("🦾", "mechanical arm", "robot prosthetic"),
("🦵", "leg", "kick"),
("🦶", "foot", "kick"),
("👂", "ear", "listen hear"),
("👃", "nose", "smell"),
("🧠", "brain", "smart think"),
("👀", "eyes", "look see watch"),
("👁️", "eye", "see look"),
("👅", "tongue", "taste lick"),
("👄", "mouth", "lips kiss"),
// Hearts & Love
("❤️", "red heart", "love"),
("🧡", "orange heart", "love"),
("💛", "yellow heart", "love friendship"),
("💚", "green heart", "love"),
("💙", "blue heart", "love"),
("💜", "purple heart", "love"),
("🖤", "black heart", "love dark"),
("🤍", "white heart", "love pure"),
("🤎", "brown heart", "love"),
("💔", "broken heart", "heartbreak sad"),
("❤️‍🔥", "heart on fire", "passion love"),
("❤️‍🩹", "mending heart", "healing recovery"),
("💕", "two hearts", "love"),
("💞", "revolving hearts", "love"),
("💓", "beating heart", "love"),
("💗", "growing heart", "love"),
("💖", "sparkling heart", "love"),
("💘", "heart with arrow", "love cupid"),
("💝", "heart with ribbon", "love gift"),
("💟", "heart decoration", "love"),
// Animals
("🐶", "dog face", "puppy"),
("🐱", "cat face", "kitty"),
("🐭", "mouse face", ""),
("🐹", "hamster", ""),
("🐰", "rabbit face", "bunny"),
("🦊", "fox", ""),
("🐻", "bear", ""),
("🐼", "panda", ""),
("🐨", "koala", ""),
("🐯", "tiger face", ""),
("🦁", "lion", ""),
("🐮", "cow face", ""),
("🐷", "pig face", ""),
("🐸", "frog", ""),
("🐵", "monkey face", ""),
("🦄", "unicorn", "magic"),
("🐝", "bee", "honeybee"),
("🦋", "butterfly", ""),
("🐌", "snail", "slow"),
("🐛", "bug", "caterpillar"),
("🦀", "crab", ""),
("🐙", "octopus", ""),
("🐠", "tropical fish", ""),
("🐟", "fish", ""),
("🐬", "dolphin", ""),
("🐳", "whale", ""),
("🦈", "shark", ""),
("🐊", "crocodile", "alligator"),
("🐢", "turtle", ""),
("🦎", "lizard", ""),
("🐍", "snake", ""),
("🦖", "t-rex", "dinosaur"),
("🦕", "sauropod", "dinosaur"),
("🐔", "chicken", ""),
("🐧", "penguin", ""),
("🦅", "eagle", "bird"),
("🦆", "duck", ""),
("🦉", "owl", ""),
// Food & Drink
("🍎", "red apple", "fruit"),
("🍐", "pear", "fruit"),
("🍊", "orange", "tangerine fruit"),
("🍋", "lemon", "fruit"),
("🍌", "banana", "fruit"),
("🍉", "watermelon", "fruit"),
("🍇", "grapes", "fruit"),
("🍓", "strawberry", "fruit"),
("🍒", "cherries", "fruit"),
("🍑", "peach", "fruit"),
("🥭", "mango", "fruit"),
("🍍", "pineapple", "fruit"),
("🥥", "coconut", "fruit"),
("🥝", "kiwi", "fruit"),
("🍅", "tomato", "vegetable"),
("🥑", "avocado", ""),
("🥦", "broccoli", "vegetable"),
("🥬", "leafy green", "vegetable salad"),
("🥒", "cucumber", "vegetable"),
("🌶️", "hot pepper", "spicy chili"),
("🌽", "corn", ""),
("🥕", "carrot", "vegetable"),
("🧄", "garlic", ""),
("🧅", "onion", ""),
("🥔", "potato", ""),
("🍞", "bread", ""),
("🥐", "croissant", ""),
("🥖", "baguette", "bread french"),
("🥨", "pretzel", ""),
("🧀", "cheese", ""),
("🥚", "egg", ""),
("🍳", "cooking", "frying pan egg"),
("🥞", "pancakes", "breakfast"),
("🧇", "waffle", "breakfast"),
("🥓", "bacon", "breakfast"),
("🍔", "hamburger", "burger"),
("🍟", "french fries", ""),
("🍕", "pizza", ""),
("🌭", "hot dog", ""),
("🥪", "sandwich", ""),
("🌮", "taco", "mexican"),
("🌯", "burrito", "mexican"),
("🍜", "steaming bowl", "ramen noodles"),
("🍝", "spaghetti", "pasta"),
("🍣", "sushi", "japanese"),
("🍱", "bento box", "japanese"),
("🍩", "doughnut", "donut dessert"),
("🍪", "cookie", "dessert"),
("🎂", "birthday cake", "dessert"),
("🍰", "shortcake", "dessert"),
("🧁", "cupcake", "dessert"),
("🍫", "chocolate bar", "dessert"),
("🍬", "candy", "sweet"),
("🍭", "lollipop", "candy sweet"),
("🍦", "soft ice cream", "dessert"),
("🍨", "ice cream", "dessert"),
("", "hot beverage", "coffee tea"),
("🍵", "teacup", "tea"),
("🧃", "juice box", ""),
("🥤", "cup with straw", "soda drink"),
("🍺", "beer mug", "drink alcohol"),
("🍻", "clinking beer mugs", "cheers drink"),
("🥂", "clinking glasses", "champagne cheers"),
("🍷", "wine glass", "drink alcohol"),
("🥃", "tumbler glass", "whiskey drink"),
("🍸", "cocktail glass", "martini drink"),
// Objects & Symbols
("💻", "laptop", "computer"),
("🖥️", "desktop computer", "pc"),
("⌨️", "keyboard", ""),
("🖱️", "computer mouse", ""),
("💾", "floppy disk", "save"),
("💿", "optical disk", "cd"),
("📱", "mobile phone", "smartphone"),
("☎️", "telephone", "phone"),
("📧", "email", "mail"),
("📨", "incoming envelope", "email"),
("📩", "envelope with arrow", "email send"),
("📝", "memo", "note write"),
("📄", "page facing up", "document"),
("📃", "page with curl", "document"),
("📑", "bookmark tabs", ""),
("📚", "books", "library read"),
("📖", "open book", "read"),
("🔗", "link", "chain url"),
("📎", "paperclip", "attachment"),
("🔒", "locked", "security"),
("🔓", "unlocked", "security open"),
("🔑", "key", "password"),
("🔧", "wrench", "tool fix"),
("🔨", "hammer", "tool"),
("⚙️", "gear", "settings"),
("🧲", "magnet", ""),
("💡", "light bulb", "idea"),
("🔦", "flashlight", ""),
("🔋", "battery", "power"),
("🔌", "electric plug", "power"),
("💰", "money bag", ""),
("💵", "dollar", "money cash"),
("💳", "credit card", "payment"),
("", "alarm clock", "time"),
("⏱️", "stopwatch", "timer"),
("📅", "calendar", "date"),
("📆", "tear-off calendar", "date"),
("", "check mark", "done yes"),
("", "cross mark", "no wrong delete"),
("", "question mark", "help"),
("", "exclamation mark", "important warning"),
("⚠️", "warning", "caution alert"),
("🚫", "prohibited", "no ban forbidden"),
("", "hollow circle", ""),
("🔴", "red circle", ""),
("🟠", "orange circle", ""),
("🟡", "yellow circle", ""),
("🟢", "green circle", ""),
("🔵", "blue circle", ""),
("🟣", "purple circle", ""),
("", "black circle", ""),
("", "white circle", ""),
("🟤", "brown circle", ""),
("", "black square", ""),
("", "white square", ""),
("🔶", "large orange diamond", ""),
("🔷", "large blue diamond", ""),
("", "star", "favorite"),
("🌟", "glowing star", "sparkle"),
("", "sparkles", "magic shine"),
("💫", "dizzy", "star"),
("🔥", "fire", "hot lit"),
("💧", "droplet", "water"),
("🌊", "wave", "water ocean"),
("🎵", "musical note", "music"),
("🎶", "musical notes", "music"),
("🎤", "microphone", "sing karaoke"),
("🎧", "headphones", "music"),
("🎮", "video game", "gaming controller"),
("🕹️", "joystick", "gaming"),
("🎯", "direct hit", "target bullseye"),
("🏆", "trophy", "winner award"),
("🥇", "1st place medal", "gold winner"),
("🥈", "2nd place medal", "silver"),
("🥉", "3rd place medal", "bronze"),
("🎁", "wrapped gift", "present"),
("🎈", "balloon", "party"),
("🎉", "party popper", "celebration tada"),
("🎊", "confetti ball", "celebration"),
// Arrows & Misc
("➡️", "right arrow", ""),
("⬅️", "left arrow", ""),
("⬆️", "up arrow", ""),
("⬇️", "down arrow", ""),
("↗️", "up-right arrow", ""),
("↘️", "down-right arrow", ""),
("↙️", "down-left arrow", ""),
("↖️", "up-left arrow", ""),
("↕️", "up-down arrow", ""),
("↔️", "left-right arrow", ""),
("🔄", "counterclockwise arrows", "refresh reload"),
("🔃", "clockwise arrows", "refresh reload"),
("", "plus", "add"),
("", "minus", "subtract"),
("", "division", "divide"),
("✖️", "multiply", "times"),
("♾️", "infinity", "forever"),
("💯", "hundred points", "100 perfect"),
("🆗", "ok button", "okay"),
("🆕", "new button", ""),
("🆓", "free button", ""),
("", "information", "info"),
("🅿️", "parking", ""),
("🚀", "rocket", "launch startup"),
("✈️", "airplane", "travel flight"),
("🚗", "car", "automobile"),
("🚕", "taxi", "cab"),
("🚌", "bus", ""),
("🚂", "locomotive", "train"),
("🏠", "house", "home"),
("🏢", "office building", "work"),
("🏥", "hospital", ""),
("🏫", "school", ""),
("🏛️", "classical building", ""),
("", "church", ""),
("🕌", "mosque", ""),
("🕍", "synagogue", ""),
("🗽", "statue of liberty", "usa america"),
("🗼", "tokyo tower", "japan"),
("🗾", "map of japan", ""),
("🌍", "globe europe-africa", "earth world"),
("🌎", "globe americas", "earth world"),
("🌏", "globe asia-australia", "earth world"),
("🌑", "new moon", ""),
("🌕", "full moon", ""),
("☀️", "sun", "sunny"),
("🌙", "crescent moon", "night"),
("☁️", "cloud", ""),
("🌧️", "cloud with rain", "rainy"),
("⛈️", "cloud with lightning", "storm thunder"),
("🌈", "rainbow", ""),
("❄️", "snowflake", "cold winter"),
("☃️", "snowman", "winter"),
("🎄", "christmas tree", "xmas holiday"),
("🎃", "jack-o-lantern", "halloween pumpkin"),
("🐚", "shell", "beach"),
("🌸", "cherry blossom", "flower spring"),
("🌺", "hibiscus", "flower"),
("🌻", "sunflower", "flower"),
("🌹", "rose", "flower love"),
("🌷", "tulip", "flower"),
("🌱", "seedling", "plant grow"),
("🌲", "evergreen tree", ""),
("🌳", "deciduous tree", ""),
("🌴", "palm tree", "tropical"),
("🌵", "cactus", "desert"),
("🍀", "four leaf clover", "luck irish"),
("🍁", "maple leaf", "fall autumn canada"),
("🍂", "fallen leaf", "fall autumn"),
];
for (emoji, name, keywords) in emojis {
self.items.push(
PluginItem::new(
format!("emoji:{}", emoji),
name.to_string(),
format!("printf '%s' '{}' | wl-copy", emoji),
)
.with_description(format!("{} {}", emoji, keywords))
.with_keywords(vec![name.to_string(), keywords.to_string()]),
);
}
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(EmojiState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<EmojiState>
let state = unsafe { &mut *(handle.ptr as *mut EmojiState) };
// Load emojis
state.load_emojis();
// Return items
state.items.to_vec().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query is handled by the core using cached items
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<EmojiState>
unsafe {
handle.drop_as::<EmojiState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emoji_state_new() {
let state = EmojiState::new();
assert!(state.items.is_empty());
}
#[test]
fn test_emoji_count() {
let mut state = EmojiState::new();
state.load_emojis();
assert!(state.items.len() > 100, "Should have more than 100 emojis");
}
#[test]
fn test_emoji_has_grinning_face() {
let mut state = EmojiState::new();
state.load_emojis();
let grinning = state
.items
.iter()
.find(|i| i.name.as_str() == "grinning face");
assert!(grinning.is_some());
let item = grinning.unwrap();
assert!(item.description.as_ref().unwrap().as_str().contains("😀"));
}
#[test]
fn test_emoji_command_format() {
let mut state = EmojiState::new();
state.load_emojis();
let item = &state.items[0];
assert!(item.command.as_str().contains("wl-copy"));
assert!(item.command.as_str().contains("printf"));
}
#[test]
fn test_emojis_have_keywords() {
let mut state = EmojiState::new();
state.load_emojis();
// Check that items have keywords for searching
let heart = state
.items
.iter()
.find(|i| i.name.as_str() == "red heart");
assert!(heart.is_some());
}
}

View File

@@ -1,23 +0,0 @@
[package]
name = "owlry-plugin-filesearch"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "File search plugin for owlry - find files with fd or locate"
keywords = ["owlry", "plugin", "files", "search"]
categories = ["filesystem"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"
# For finding home directory
dirs = "5.0"

View File

@@ -1,319 +0,0 @@
//! File Search Plugin for Owlry
//!
//! A dynamic provider that searches for files using `fd` or `locate`.
//!
//! Examples:
//! - `/ config.toml` → Search for files matching "config.toml"
//! - `file bashrc` → Search for files matching "bashrc"
//! - `find readme` → Search for files matching "readme"
//!
//! Dependencies:
//! - fd (preferred) or locate
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
use std::path::Path;
use std::process::Command;
// Plugin metadata
const PLUGIN_ID: &str = "filesearch";
const PLUGIN_NAME: &str = "File Search";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Find files with fd or locate";
// Provider metadata
const PROVIDER_ID: &str = "filesearch";
const PROVIDER_NAME: &str = "Files";
const PROVIDER_PREFIX: &str = "/";
const PROVIDER_ICON: &str = "folder";
const PROVIDER_TYPE_ID: &str = "filesearch";
// Maximum results to return
const MAX_RESULTS: usize = 20;
#[derive(Debug, Clone, Copy)]
enum SearchTool {
Fd,
Locate,
None,
}
/// File search provider state
struct FileSearchState {
search_tool: SearchTool,
home: String,
}
impl FileSearchState {
fn new() -> Self {
let search_tool = Self::detect_search_tool();
let home = dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "/".to_string());
Self { search_tool, home }
}
fn detect_search_tool() -> SearchTool {
// Prefer fd (faster, respects .gitignore)
if Self::command_exists("fd") {
return SearchTool::Fd;
}
// Fall back to locate (requires updatedb)
if Self::command_exists("locate") {
return SearchTool::Locate;
}
SearchTool::None
}
fn command_exists(cmd: &str) -> bool {
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Extract the search term from the query
fn extract_search_term(query: &str) -> Option<&str> {
let trimmed = query.trim();
if let Some(rest) = trimmed.strip_prefix("/ ") {
Some(rest.trim())
} else if let Some(rest) = trimmed.strip_prefix("/") {
Some(rest.trim())
} else {
// Handle "file " and "find " prefixes (case-insensitive), or raw query in filter mode
let lower = trimmed.to_lowercase();
if lower.starts_with("file ") || lower.starts_with("find ") {
Some(trimmed[5..].trim())
} else {
Some(trimmed)
}
}
}
/// Evaluate a query and return file results
fn evaluate(&self, query: &str) -> Vec<PluginItem> {
let search_term = match Self::extract_search_term(query) {
Some(t) if !t.is_empty() => t,
_ => return Vec::new(),
};
self.search_files(search_term)
}
fn search_files(&self, pattern: &str) -> Vec<PluginItem> {
match self.search_tool {
SearchTool::Fd => self.search_with_fd(pattern),
SearchTool::Locate => self.search_with_locate(pattern),
SearchTool::None => Vec::new(),
}
}
fn search_with_fd(&self, pattern: &str) -> Vec<PluginItem> {
let output = match Command::new("fd")
.args([
"--max-results",
&MAX_RESULTS.to_string(),
"--type",
"f", // Files only
"--type",
"d", // And directories
pattern,
])
.current_dir(&self.home)
.output()
{
Ok(o) => o,
Err(_) => return Vec::new(),
};
self.parse_file_results(&String::from_utf8_lossy(&output.stdout))
}
fn search_with_locate(&self, pattern: &str) -> Vec<PluginItem> {
let output = match Command::new("locate")
.args([
"--limit",
&MAX_RESULTS.to_string(),
"--ignore-case",
pattern,
])
.output()
{
Ok(o) => o,
Err(_) => return Vec::new(),
};
self.parse_file_results(&String::from_utf8_lossy(&output.stdout))
}
fn parse_file_results(&self, output: &str) -> Vec<PluginItem> {
output
.lines()
.filter(|line| !line.is_empty())
.map(|path| {
let path = path.trim();
let full_path = if path.starts_with('/') {
path.to_string()
} else {
format!("{}/{}", self.home, path)
};
// Get filename for display
let filename = Path::new(&full_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| full_path.clone());
// Determine icon based on whether it's a directory
let is_dir = Path::new(&full_path).is_dir();
let icon = if is_dir { "folder" } else { "text-x-generic" };
// Command to open with xdg-open
let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''"));
PluginItem::new(format!("file:{}", full_path), filename, command)
.with_description(full_path.clone())
.with_icon(icon)
.with_keywords(vec!["file".to_string()])
})
.collect()
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Dynamic,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(FileSearchState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
// Dynamic provider - refresh does nothing
RVec::new()
}
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<FileSearchState>
let state = unsafe { &*(handle.ptr as *const FileSearchState) };
let query_str = query.as_str();
state.evaluate(query_str).into()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<FileSearchState>
unsafe {
handle.drop_as::<FileSearchState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_search_term() {
assert_eq!(
FileSearchState::extract_search_term("/ config.toml"),
Some("config.toml")
);
assert_eq!(
FileSearchState::extract_search_term("/config"),
Some("config")
);
assert_eq!(
FileSearchState::extract_search_term("file bashrc"),
Some("bashrc")
);
assert_eq!(
FileSearchState::extract_search_term("find readme"),
Some("readme")
);
}
#[test]
fn test_extract_search_term_empty() {
assert_eq!(FileSearchState::extract_search_term("/"), Some(""));
assert_eq!(FileSearchState::extract_search_term("/ "), Some(""));
}
#[test]
fn test_command_exists() {
// 'which' should exist on any Unix system
assert!(FileSearchState::command_exists("which"));
// This should not exist
assert!(!FileSearchState::command_exists("nonexistent-command-12345"));
}
#[test]
fn test_detect_search_tool() {
// Just ensure it doesn't panic
let _ = FileSearchState::detect_search_tool();
}
#[test]
fn test_state_new() {
let state = FileSearchState::new();
assert!(!state.home.is_empty());
}
#[test]
fn test_evaluate_empty() {
let state = FileSearchState::new();
let results = state.evaluate("/");
assert!(results.is_empty());
let results = state.evaluate("/ ");
assert!(results.is_empty());
}
}

View File

@@ -1,23 +0,0 @@
[package]
name = "owlry-plugin-media"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "MPRIS media player widget plugin for owlry - shows and controls currently playing media. Requires playerctl."
keywords = ["owlry", "plugin", "media", "mpris", "widget", "playerctl"]
categories = ["gui"]
# System dependencies (for packagers):
# - playerctl: for media control commands
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -1,465 +0,0 @@
//! MPRIS Media Player Widget Plugin for Owlry
//!
//! Shows currently playing track as a single row with play/pause action.
//! Uses D-Bus via dbus-send to communicate with MPRIS-compatible players.
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
use std::process::Command;
// Plugin metadata
const PLUGIN_ID: &str = "media";
const PLUGIN_NAME: &str = "Media Player";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "MPRIS media player widget - shows and controls currently playing media";
// Provider metadata
const PROVIDER_ID: &str = "media";
const PROVIDER_NAME: &str = "Media";
const PROVIDER_ICON: &str = "applications-multimedia";
const PROVIDER_TYPE_ID: &str = "media";
#[derive(Debug, Default, Clone)]
struct MediaState {
player_name: String,
title: String,
artist: String,
is_playing: bool,
}
/// Media provider state
struct MediaProviderState {
items: Vec<PluginItem>,
/// Current player name for submenu actions
current_player: Option<String>,
/// Current playback state
is_playing: bool,
}
impl MediaProviderState {
fn new() -> Self {
// Don't query D-Bus during init - defer to first refresh() call
// This prevents blocking the main thread during startup
Self {
items: Vec::new(),
current_player: None,
is_playing: false,
}
}
fn refresh(&mut self) {
self.items.clear();
let players = Self::find_players();
if players.is_empty() {
return;
}
// Find first active player
for player in &players {
if let Some(state) = Self::get_player_state(player) {
self.generate_items(&state);
return;
}
}
}
/// Find active MPRIS players via dbus-send
fn find_players() -> Vec<String> {
let output = Command::new("dbus-send")
.args([
"--session",
"--dest=org.freedesktop.DBus",
"--type=method_call",
"--print-reply",
"/org/freedesktop/DBus",
"org.freedesktop.DBus.ListNames",
])
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("string \"org.mpris.MediaPlayer2.") {
let start = "string \"org.mpris.MediaPlayer2.".len();
let end = trimmed.len() - 1;
Some(trimmed[start..end].to_string())
} else {
None
}
})
.collect()
}
Err(_) => Vec::new(),
}
}
/// Get metadata from an MPRIS player
fn get_player_state(player: &str) -> Option<MediaState> {
let dest = format!("org.mpris.MediaPlayer2.{}", player);
// Get playback status
let status_output = Command::new("dbus-send")
.args([
"--session",
&format!("--dest={}", dest),
"--type=method_call",
"--print-reply",
"/org/mpris/MediaPlayer2",
"org.freedesktop.DBus.Properties.Get",
"string:org.mpris.MediaPlayer2.Player",
"string:PlaybackStatus",
])
.output()
.ok()?;
let status_str = String::from_utf8_lossy(&status_output.stdout);
let is_playing = status_str.contains("\"Playing\"");
let is_paused = status_str.contains("\"Paused\"");
// Only show if playing or paused (not stopped)
if !is_playing && !is_paused {
return None;
}
// Get metadata
let metadata_output = Command::new("dbus-send")
.args([
"--session",
&format!("--dest={}", dest),
"--type=method_call",
"--print-reply",
"/org/mpris/MediaPlayer2",
"org.freedesktop.DBus.Properties.Get",
"string:org.mpris.MediaPlayer2.Player",
"string:Metadata",
])
.output()
.ok()?;
let metadata_str = String::from_utf8_lossy(&metadata_output.stdout);
let title = Self::extract_string(&metadata_str, "xesam:title")
.unwrap_or_else(|| "Unknown".to_string());
let artist = Self::extract_array(&metadata_str, "xesam:artist")
.unwrap_or_else(|| "Unknown".to_string());
Some(MediaState {
player_name: player.to_string(),
title,
artist,
is_playing,
})
}
/// Extract string value from D-Bus output
fn extract_string(output: &str, key: &str) -> Option<String> {
let key_pattern = format!("\"{}\"", key);
let mut found = false;
for line in output.lines() {
let trimmed = line.trim();
if trimmed.contains(&key_pattern) {
found = true;
continue;
}
if found {
if let Some(pos) = trimmed.find("string \"") {
let start = pos + "string \"".len();
if let Some(end) = trimmed[start..].find('"') {
let value = &trimmed[start..start + end];
if !value.is_empty() {
return Some(value.to_string());
}
}
}
if !trimmed.starts_with("variant") {
found = false;
}
}
}
None
}
/// Extract array value from D-Bus output
fn extract_array(output: &str, key: &str) -> Option<String> {
let key_pattern = format!("\"{}\"", key);
let mut found = false;
let mut in_array = false;
let mut values = Vec::new();
for line in output.lines() {
let trimmed = line.trim();
if trimmed.contains(&key_pattern) {
found = true;
continue;
}
if found && trimmed.contains("array [") {
in_array = true;
continue;
}
if in_array {
if let Some(pos) = trimmed.find("string \"") {
let start = pos + "string \"".len();
if let Some(end) = trimmed[start..].find('"') {
values.push(trimmed[start..start + end].to_string());
}
}
if trimmed.contains(']') {
break;
}
}
}
if values.is_empty() {
None
} else {
Some(values.join(", "))
}
}
/// Generate single LaunchItem for media state (opens submenu)
fn generate_items(&mut self, state: &MediaState) {
self.items.clear();
// Store state for submenu
self.current_player = Some(state.player_name.clone());
self.is_playing = state.is_playing;
// Single row: "Title — Artist"
let name = format!("{}{}", state.title, state.artist);
// Extract player display name (e.g., "firefox.instance_1_94" -> "Firefox")
let player_display = Self::format_player_name(&state.player_name);
// Opens submenu with media controls
self.items.push(
PluginItem::new("media-now-playing", name, "SUBMENU:media:controls")
.with_description(format!("{} · Select for controls", player_display))
.with_icon("/org/owlry/launcher/icons/media/music-note.svg")
.with_keywords(vec!["media".to_string(), "widget".to_string()]),
);
}
/// Format player name for display
fn format_player_name(player_name: &str) -> String {
let player_display = player_name.split('.').next().unwrap_or(player_name);
if player_display.is_empty() {
"Player".to_string()
} else {
let mut chars = player_display.chars();
match chars.next() {
None => "Player".to_string(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
}
}
/// Generate submenu items for media controls
fn generate_submenu_items(&self) -> Vec<PluginItem> {
let player = match &self.current_player {
Some(p) => p,
None => return Vec::new(),
};
let mut items = Vec::new();
// Use playerctl for simpler, more reliable media control
// playerctl -p <player> <command>
// Play/Pause
if self.is_playing {
items.push(
PluginItem::new(
"media-pause",
"Pause",
format!("playerctl -p {} pause", player),
)
.with_description("Pause playback")
.with_icon("media-playback-pause"),
);
} else {
items.push(
PluginItem::new(
"media-play",
"Play",
format!("playerctl -p {} play", player),
)
.with_description("Resume playback")
.with_icon("media-playback-start"),
);
}
// Next track
items.push(
PluginItem::new(
"media-next",
"Next",
format!("playerctl -p {} next", player),
)
.with_description("Skip to next track")
.with_icon("media-skip-forward"),
);
// Previous track
items.push(
PluginItem::new(
"media-previous",
"Previous",
format!("playerctl -p {} previous", player),
)
.with_description("Go to previous track")
.with_icon("media-skip-backward"),
);
// Stop
items.push(
PluginItem::new(
"media-stop",
"Stop",
format!("playerctl -p {} stop", player),
)
.with_description("Stop playback")
.with_icon("media-playback-stop"),
);
items
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RNone,
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(MediaProviderState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<MediaProviderState>
let state = unsafe { &mut *(handle.ptr as *mut MediaProviderState) };
state.refresh();
state.items.clone().into()
}
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
let query_str = query.as_str();
let state = unsafe { &*(handle.ptr as *const MediaProviderState) };
// Handle submenu request
if query_str == "?SUBMENU:controls" {
return state.generate_submenu_items().into();
}
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<MediaProviderState>
unsafe {
handle.drop_as::<MediaProviderState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_string() {
let output = r#"
string "xesam:title"
variant string "My Song Title"
"#;
assert_eq!(
MediaProviderState::extract_string(output, "xesam:title"),
Some("My Song Title".to_string())
);
}
#[test]
fn test_extract_array() {
let output = r#"
string "xesam:artist"
variant array [
string "Artist One"
string "Artist Two"
]
"#;
assert_eq!(
MediaProviderState::extract_array(output, "xesam:artist"),
Some("Artist One, Artist Two".to_string())
);
}
#[test]
fn test_extract_string_not_found() {
let output = "some other output";
assert_eq!(
MediaProviderState::extract_string(output, "xesam:title"),
None
);
}
#[test]
fn test_find_players_empty() {
// This will return empty on systems without D-Bus
let players = MediaProviderState::find_players();
// Just verify it doesn't panic
let _ = players;
}
}

View File

@@ -1,30 +0,0 @@
[package]
name = "owlry-plugin-pomodoro"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Pomodoro timer widget plugin for owlry - work/break cycles with persistent state"
keywords = ["owlry", "plugin", "pomodoro", "timer", "widget"]
categories = ["gui"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"
# JSON serialization for persistent state
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# TOML config parsing
toml = "0.8"
# For finding data directory
dirs = "5.0"

View File

@@ -1,476 +0,0 @@
//! Pomodoro Timer Widget Plugin for Owlry
//!
//! Shows timer with work/break cycles. Select to open controls submenu.
//! State persists across sessions via JSON file.
//!
//! ## Configuration
//!
//! Configure via `~/.config/owlry/config.toml`:
//!
//! ```toml
//! [plugins.pomodoro]
//! work_mins = 25 # Work session duration (default: 25)
//! break_mins = 5 # Break duration (default: 5)
//! ```
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
notify_with_urgency, owlry_plugin, NotifyUrgency, PluginInfo, PluginItem, ProviderHandle,
ProviderInfo, ProviderKind, API_VERSION,
};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
// Plugin metadata
const PLUGIN_ID: &str = "pomodoro";
const PLUGIN_NAME: &str = "Pomodoro Timer";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Pomodoro timer widget with work/break cycles";
// Provider metadata
const PROVIDER_ID: &str = "pomodoro";
const PROVIDER_NAME: &str = "Pomodoro";
const PROVIDER_ICON: &str = "alarm";
const PROVIDER_TYPE_ID: &str = "pomodoro";
// Default timing (in minutes)
const DEFAULT_WORK_MINS: u32 = 25;
const DEFAULT_BREAK_MINS: u32 = 5;
/// Pomodoro configuration
#[derive(Debug, Clone)]
struct PomodoroConfig {
work_mins: u32,
break_mins: u32,
}
impl PomodoroConfig {
/// Load config from ~/.config/owlry/config.toml
///
/// Reads from [plugins.pomodoro] section, with fallback to [providers] for compatibility.
fn load() -> Self {
let config_path = dirs::config_dir()
.map(|d| d.join("owlry").join("config.toml"));
let config_content = config_path
.and_then(|p| fs::read_to_string(p).ok());
if let Some(content) = config_content {
if let Ok(toml) = content.parse::<toml::Table>() {
// Try [plugins.pomodoro] first (new format)
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) {
if let Some(pomodoro) = plugins.get("pomodoro").and_then(|v| v.as_table()) {
return Self::from_toml_table(pomodoro);
}
}
// Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let work_mins = providers
.get("pomodoro_work_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_WORK_MINS);
let break_mins = providers
.get("pomodoro_break_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_BREAK_MINS);
return Self { work_mins, break_mins };
}
}
}
// Default config
Self {
work_mins: DEFAULT_WORK_MINS,
break_mins: DEFAULT_BREAK_MINS,
}
}
/// Parse config from a TOML table
fn from_toml_table(table: &toml::Table) -> Self {
let work_mins = table
.get("work_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_WORK_MINS);
let break_mins = table
.get("break_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_BREAK_MINS);
Self { work_mins, break_mins }
}
}
/// Timer phase
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
enum PomodoroPhase {
#[default]
Idle,
Working,
WorkPaused,
Break,
BreakPaused,
}
/// Persistent state (saved to disk)
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct PomodoroState {
phase: PomodoroPhase,
remaining_secs: u32,
sessions: u32,
last_update: u64,
}
/// Pomodoro provider state
struct PomodoroProviderState {
items: Vec<PluginItem>,
state: PomodoroState,
work_mins: u32,
break_mins: u32,
}
impl PomodoroProviderState {
fn new() -> Self {
let config = PomodoroConfig::load();
let state = Self::load_state().unwrap_or_else(|| PomodoroState {
phase: PomodoroPhase::Idle,
remaining_secs: config.work_mins * 60,
sessions: 0,
last_update: Self::now_secs(),
});
let mut provider = Self {
items: Vec::new(),
state,
work_mins: config.work_mins,
break_mins: config.break_mins,
};
provider.update_elapsed_time();
provider.generate_items();
provider
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn data_dir() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("owlry"))
}
fn load_state() -> Option<PomodoroState> {
let path = Self::data_dir()?.join("pomodoro.json");
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
fn save_state(&self) {
if let Some(data_dir) = Self::data_dir() {
let path = data_dir.join("pomodoro.json");
if fs::create_dir_all(&data_dir).is_err() {
return;
}
let mut state = self.state.clone();
state.last_update = Self::now_secs();
if let Ok(json) = serde_json::to_string_pretty(&state) {
let _ = fs::write(&path, json);
}
}
}
fn update_elapsed_time(&mut self) {
let now = Self::now_secs();
let elapsed = now.saturating_sub(self.state.last_update);
match self.state.phase {
PomodoroPhase::Working | PomodoroPhase::Break => {
if elapsed >= self.state.remaining_secs as u64 {
self.complete_phase();
} else {
self.state.remaining_secs -= elapsed as u32;
}
}
_ => {}
}
self.state.last_update = now;
}
fn complete_phase(&mut self) {
match self.state.phase {
PomodoroPhase::Working => {
self.state.sessions += 1;
self.state.phase = PomodoroPhase::Break;
self.state.remaining_secs = self.break_mins * 60;
notify_with_urgency(
"Pomodoro Complete!",
&format!(
"Great work! Session {} complete. Time for a {}-minute break.",
self.state.sessions, self.break_mins
),
"alarm",
NotifyUrgency::Normal,
);
}
PomodoroPhase::Break => {
self.state.phase = PomodoroPhase::Idle;
self.state.remaining_secs = self.work_mins * 60;
notify_with_urgency(
"Break Complete",
"Break time's over! Ready for another work session?",
"alarm",
NotifyUrgency::Normal,
);
}
_ => {}
}
self.save_state();
}
fn refresh(&mut self) {
self.update_elapsed_time();
self.generate_items();
}
fn handle_action(&mut self, action: &str) {
match action {
"start" => {
self.state.phase = PomodoroPhase::Working;
self.state.remaining_secs = self.work_mins * 60;
self.state.last_update = Self::now_secs();
}
"pause" => match self.state.phase {
PomodoroPhase::Working => self.state.phase = PomodoroPhase::WorkPaused,
PomodoroPhase::Break => self.state.phase = PomodoroPhase::BreakPaused,
_ => {}
},
"resume" => {
self.state.last_update = Self::now_secs();
match self.state.phase {
PomodoroPhase::WorkPaused => self.state.phase = PomodoroPhase::Working,
PomodoroPhase::BreakPaused => self.state.phase = PomodoroPhase::Break,
_ => {}
}
}
"skip" => self.complete_phase(),
"reset" => {
self.state.phase = PomodoroPhase::Idle;
self.state.remaining_secs = self.work_mins * 60;
self.state.sessions = 0;
}
_ => {}
}
self.save_state();
self.generate_items();
}
fn format_time(secs: u32) -> String {
let mins = secs / 60;
let secs = secs % 60;
format!("{:02}:{:02}", mins, secs)
}
/// Generate single main item with submenu for controls
fn generate_items(&mut self) {
self.items.clear();
let (phase_name, _is_running) = match self.state.phase {
PomodoroPhase::Idle => ("Ready", false),
PomodoroPhase::Working => ("Work", true),
PomodoroPhase::WorkPaused => ("Paused", false),
PomodoroPhase::Break => ("Break", true),
PomodoroPhase::BreakPaused => ("Paused", false),
};
let time_str = Self::format_time(self.state.remaining_secs);
let name = format!("{}: {}", phase_name, time_str);
let description = if self.state.sessions > 0 {
format!(
"Sessions: {} | {}min work / {}min break",
self.state.sessions, self.work_mins, self.break_mins
)
} else {
format!("{}min work / {}min break", self.work_mins, self.break_mins)
};
// Single item that opens submenu with controls
self.items.push(
PluginItem::new("pomo-timer", name, "SUBMENU:pomodoro:controls")
.with_description(description)
.with_icon("/org/owlry/launcher/icons/pomodoro/tomato.svg")
.with_keywords(vec![
"pomodoro".to_string(),
"widget".to_string(),
"timer".to_string(),
]),
);
}
/// Generate submenu items for controls
fn generate_submenu_items(&self) -> Vec<PluginItem> {
let mut items = Vec::new();
let is_running = matches!(
self.state.phase,
PomodoroPhase::Working | PomodoroPhase::Break
);
// Primary control: Start/Pause/Resume
if is_running {
items.push(
PluginItem::new("pomo-pause", "Pause", "POMODORO:pause")
.with_description("Pause the timer")
.with_icon("media-playback-pause"),
);
} else {
match self.state.phase {
PomodoroPhase::Idle => {
items.push(
PluginItem::new("pomo-start", "Start Work", "POMODORO:start")
.with_description("Start a new work session")
.with_icon("media-playback-start"),
);
}
_ => {
items.push(
PluginItem::new("pomo-resume", "Resume", "POMODORO:resume")
.with_description("Resume the timer")
.with_icon("media-playback-start"),
);
}
}
}
// Skip (only when not idle)
if self.state.phase != PomodoroPhase::Idle {
items.push(
PluginItem::new("pomo-skip", "Skip", "POMODORO:skip")
.with_description("Skip to next phase")
.with_icon("media-skip-forward"),
);
}
// Reset
items.push(
PluginItem::new("pomo-reset", "Reset", "POMODORO:reset")
.with_description("Reset timer and sessions")
.with_icon("view-refresh"),
);
items
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RNone,
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(PomodoroProviderState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
let state = unsafe { &mut *(handle.ptr as *mut PomodoroProviderState) };
state.refresh();
state.items.clone().into()
}
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
let query_str = query.as_str();
let state = unsafe { &mut *(handle.ptr as *mut PomodoroProviderState) };
// Handle submenu request
if query_str == "?SUBMENU:controls" {
return state.generate_submenu_items().into();
}
// Handle action commands
if let Some(action) = query_str.strip_prefix("!POMODORO:") {
state.handle_action(action);
}
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
let state = unsafe { &*(handle.ptr as *const PomodoroProviderState) };
state.save_state();
unsafe {
handle.drop_as::<PomodoroProviderState>();
}
}
}
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_time() {
assert_eq!(PomodoroProviderState::format_time(0), "00:00");
assert_eq!(PomodoroProviderState::format_time(60), "01:00");
assert_eq!(PomodoroProviderState::format_time(90), "01:30");
assert_eq!(PomodoroProviderState::format_time(1500), "25:00");
assert_eq!(PomodoroProviderState::format_time(3599), "59:59");
}
#[test]
fn test_default_phase() {
let phase: PomodoroPhase = Default::default();
assert_eq!(phase, PomodoroPhase::Idle);
}
}

View File

@@ -1,23 +0,0 @@
[package]
name = "owlry-plugin-scripts"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Scripts plugin for owlry - run user scripts from ~/.local/share/owlry/scripts/"
keywords = ["owlry", "plugin", "scripts"]
categories = ["os"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"
# For finding ~/.local/share/owlry/scripts
dirs = "5.0"

View File

@@ -1,287 +0,0 @@
//! Scripts Plugin for Owlry
//!
//! A static provider that scans `~/.local/share/owlry/scripts/` for executable
//! scripts and provides them as launch items.
//!
//! Scripts can include a description by adding a comment after the shebang:
//! ```bash
//! #!/bin/bash
//! # This is my script description
//! echo "Hello"
//! ```
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
// Plugin metadata
const PLUGIN_ID: &str = "scripts";
const PLUGIN_NAME: &str = "Scripts";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Run user scripts from ~/.local/share/owlry/scripts/";
// Provider metadata
const PROVIDER_ID: &str = "scripts";
const PROVIDER_NAME: &str = "Scripts";
const PROVIDER_PREFIX: &str = ":script";
const PROVIDER_ICON: &str = "utilities-terminal";
const PROVIDER_TYPE_ID: &str = "scripts";
/// Scripts provider state - holds cached items
struct ScriptsState {
items: Vec<PluginItem>,
}
impl ScriptsState {
fn new() -> Self {
Self { items: Vec::new() }
}
fn scripts_dir() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("owlry").join("scripts"))
}
fn load_scripts(&mut self) {
self.items.clear();
let scripts_dir = match Self::scripts_dir() {
Some(p) => p,
None => return,
};
if !scripts_dir.exists() {
// Create the directory for the user
let _ = fs::create_dir_all(&scripts_dir);
return;
}
let entries = match fs::read_dir(&scripts_dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
// Skip directories
if path.is_dir() {
continue;
}
// Check if executable
let metadata = match path.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let is_executable = metadata.permissions().mode() & 0o111 != 0;
if !is_executable {
continue;
}
// Get script name without extension
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let name = path
.file_stem()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(filename.clone());
// Try to read description from first line comment
let description = Self::read_script_description(&path);
// Determine icon based on extension or shebang
let icon = Self::determine_icon(&path);
let mut item = PluginItem::new(
format!("script:{}", filename),
format!("Script: {}", name),
path.to_string_lossy().to_string(),
)
.with_icon(icon)
.with_keywords(vec!["script".to_string()]);
if let Some(desc) = description {
item = item.with_description(desc);
}
self.items.push(item);
}
}
fn read_script_description(path: &PathBuf) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let mut lines = content.lines();
// Skip shebang if present
let first_line = lines.next()?;
let check_line = if first_line.starts_with("#!") {
lines.next()?
} else {
first_line
};
// Look for a comment description
if let Some(desc) = check_line.strip_prefix("# ") {
Some(desc.trim().to_string())
} else { check_line.strip_prefix("// ").map(|desc| desc.trim().to_string()) }
}
fn determine_icon(path: &PathBuf) -> String {
// Check extension first
if let Some(ext) = path.extension() {
match ext.to_string_lossy().as_ref() {
"sh" | "bash" | "zsh" => return "utilities-terminal".to_string(),
"py" | "python" => return "text-x-python".to_string(),
"js" | "ts" => return "text-x-javascript".to_string(),
"rb" => return "text-x-ruby".to_string(),
"pl" => return "text-x-perl".to_string(),
_ => {}
}
}
// Check shebang
if let Ok(content) = fs::read_to_string(path)
&& let Some(first_line) = content.lines().next() {
if first_line.contains("bash") || first_line.contains("sh") {
return "utilities-terminal".to_string();
} else if first_line.contains("python") {
return "text-x-python".to_string();
} else if first_line.contains("node") {
return "text-x-javascript".to_string();
} else if first_line.contains("ruby") {
return "text-x-ruby".to_string();
}
}
"application-x-executable".to_string()
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(ScriptsState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<ScriptsState>
let state = unsafe { &mut *(handle.ptr as *mut ScriptsState) };
// Load scripts
state.load_scripts();
// Return items
state.items.to_vec().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query is handled by the core using cached items
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<ScriptsState>
unsafe {
handle.drop_as::<ScriptsState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scripts_state_new() {
let state = ScriptsState::new();
assert!(state.items.is_empty());
}
#[test]
fn test_determine_icon_sh() {
let path = PathBuf::from("/test/script.sh");
let icon = ScriptsState::determine_icon(&path);
assert_eq!(icon, "utilities-terminal");
}
#[test]
fn test_determine_icon_python() {
let path = PathBuf::from("/test/script.py");
let icon = ScriptsState::determine_icon(&path);
assert_eq!(icon, "text-x-python");
}
#[test]
fn test_determine_icon_js() {
let path = PathBuf::from("/test/script.js");
let icon = ScriptsState::determine_icon(&path);
assert_eq!(icon, "text-x-javascript");
}
#[test]
fn test_determine_icon_unknown() {
let path = PathBuf::from("/test/script.xyz");
let icon = ScriptsState::determine_icon(&path);
assert_eq!(icon, "application-x-executable");
}
#[test]
fn test_scripts_dir() {
// Should return Some path
let dir = ScriptsState::scripts_dir();
assert!(dir.is_some());
assert!(dir.unwrap().ends_with("owlry/scripts"));
}
}

View File

@@ -1,23 +0,0 @@
[package]
name = "owlry-plugin-ssh"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "SSH plugin for owlry - quick connect to SSH hosts from ~/.ssh/config"
keywords = ["owlry", "plugin", "ssh"]
categories = ["network-programming"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"
# For finding ~/.ssh/config
dirs = "5.0"

View File

@@ -1,325 +0,0 @@
//! SSH Plugin for Owlry
//!
//! A static provider that parses ~/.ssh/config and provides quick-connect
//! entries for SSH hosts.
//!
//! Examples:
//! - `SSH: myserver` → Connect to myserver
//! - `SSH: work-box` → Connect to work-box with configured user/port
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
use std::fs;
use std::path::PathBuf;
// Plugin metadata
const PLUGIN_ID: &str = "ssh";
const PLUGIN_NAME: &str = "SSH";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Quick connect to SSH hosts from ~/.ssh/config";
// Provider metadata
const PROVIDER_ID: &str = "ssh";
const PROVIDER_NAME: &str = "SSH";
const PROVIDER_PREFIX: &str = ":ssh";
const PROVIDER_ICON: &str = "utilities-terminal";
const PROVIDER_TYPE_ID: &str = "ssh";
// Default terminal command (TODO: make configurable via plugin config)
const DEFAULT_TERMINAL: &str = "kitty";
/// SSH provider state - holds cached items
struct SshState {
items: Vec<PluginItem>,
terminal_command: String,
}
impl SshState {
fn new() -> Self {
// Try to detect terminal from environment, fall back to default
let terminal = std::env::var("TERMINAL")
.unwrap_or_else(|_| DEFAULT_TERMINAL.to_string());
Self {
items: Vec::new(),
terminal_command: terminal,
}
}
fn ssh_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".ssh").join("config"))
}
fn parse_ssh_config(&mut self) {
self.items.clear();
let config_path = match Self::ssh_config_path() {
Some(p) => p,
None => return,
};
if !config_path.exists() {
return;
}
let content = match fs::read_to_string(&config_path) {
Ok(c) => c,
Err(_) => return,
};
let mut current_host: Option<String> = None;
let mut current_hostname: Option<String> = None;
let mut current_user: Option<String> = None;
let mut current_port: Option<String> = None;
for line in content.lines() {
let line = line.trim();
// Skip comments and empty lines
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split on whitespace or '='
let parts: Vec<&str> = line
.splitn(2, |c: char| c.is_whitespace() || c == '=')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if parts.len() < 2 {
continue;
}
let key = parts[0].to_lowercase();
let value = parts[1];
match key.as_str() {
"host" => {
// Save previous host if exists
if let Some(host) = current_host.take() {
self.add_host_item(
&host,
current_hostname.take(),
current_user.take(),
current_port.take(),
);
}
// Skip wildcards and patterns
if !value.contains('*') && !value.contains('?') && value != "*" {
current_host = Some(value.to_string());
}
current_hostname = None;
current_user = None;
current_port = None;
}
"hostname" => {
current_hostname = Some(value.to_string());
}
"user" => {
current_user = Some(value.to_string());
}
"port" => {
current_port = Some(value.to_string());
}
_ => {}
}
}
// Don't forget the last host
if let Some(host) = current_host.take() {
self.add_host_item(&host, current_hostname, current_user, current_port);
}
}
fn add_host_item(
&mut self,
host: &str,
hostname: Option<String>,
user: Option<String>,
port: Option<String>,
) {
// Build description
let mut desc_parts = Vec::new();
if let Some(ref h) = hostname {
desc_parts.push(h.clone());
}
if let Some(ref u) = user {
desc_parts.push(format!("user: {}", u));
}
if let Some(ref p) = port {
desc_parts.push(format!("port: {}", p));
}
let description = if desc_parts.is_empty() {
None
} else {
Some(desc_parts.join(", "))
};
// Build SSH command - just use the host alias, SSH will resolve the rest
let ssh_command = format!("ssh {}", host);
// Wrap in terminal
let command = format!("{} -e {}", self.terminal_command, ssh_command);
let mut item = PluginItem::new(
format!("ssh:{}", host),
format!("SSH: {}", host),
command,
)
.with_icon(PROVIDER_ICON)
.with_keywords(vec!["ssh".to_string(), "remote".to_string()]);
if let Some(desc) = description {
item = item.with_description(desc);
}
self.items.push(item);
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(SshState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<SshState>
let state = unsafe { &mut *(handle.ptr as *mut SshState) };
// Parse SSH config
state.parse_ssh_config();
// Return items
state.items.to_vec().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query is handled by the core using cached items
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<SshState>
unsafe {
handle.drop_as::<SshState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssh_state_new() {
let state = SshState::new();
assert!(state.items.is_empty());
}
#[test]
fn test_parse_simple_config() {
let mut state = SshState::new();
// We can't easily test the full flow without mocking file paths,
// but we can test the add_host_item method
state.add_host_item(
"myserver",
Some("192.168.1.100".to_string()),
Some("admin".to_string()),
Some("2222".to_string()),
);
assert_eq!(state.items.len(), 1);
assert_eq!(state.items[0].name.as_str(), "SSH: myserver");
assert!(state.items[0].command.as_str().contains("ssh myserver"));
}
#[test]
fn test_add_host_without_details() {
let mut state = SshState::new();
state.add_host_item("simple-host", None, None, None);
assert_eq!(state.items.len(), 1);
assert_eq!(state.items[0].name.as_str(), "SSH: simple-host");
assert!(state.items[0].description.is_none());
}
#[test]
fn test_add_host_with_partial_details() {
let mut state = SshState::new();
state.add_host_item("partial", Some("example.com".to_string()), None, None);
assert_eq!(state.items.len(), 1);
let desc = state.items[0].description.as_ref().unwrap();
assert_eq!(desc.as_str(), "example.com");
}
#[test]
fn test_items_have_icons() {
let mut state = SshState::new();
state.add_host_item("test", None, None, None);
assert!(state.items[0].icon.is_some());
assert_eq!(state.items[0].icon.as_ref().unwrap().as_str(), PROVIDER_ICON);
}
#[test]
fn test_items_have_keywords() {
let mut state = SshState::new();
state.add_host_item("test", None, None, None);
assert!(!state.items[0].keywords.is_empty());
let keywords: Vec<&str> = state.items[0].keywords.iter().map(|s| s.as_str()).collect();
assert!(keywords.contains(&"ssh"));
}
}

View File

@@ -1,20 +0,0 @@
[package]
name = "owlry-plugin-system"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "System plugin for owlry - power and session management commands"
keywords = ["owlry", "plugin", "system", "power"]
categories = ["os"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -1,251 +0,0 @@
//! System Plugin for Owlry
//!
//! A static provider that provides system power and session management commands.
//!
//! Commands:
//! - Shutdown - Power off the system
//! - Reboot - Restart the system
//! - Reboot into BIOS - Restart into UEFI/BIOS setup
//! - Suspend - Suspend to RAM
//! - Hibernate - Suspend to disk
//! - Lock Screen - Lock the session
//! - Log Out - End the current session
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
// Plugin metadata
const PLUGIN_ID: &str = "system";
const PLUGIN_NAME: &str = "System";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Power and session management commands";
// Provider metadata
const PROVIDER_ID: &str = "system";
const PROVIDER_NAME: &str = "System";
const PROVIDER_PREFIX: &str = ":sys";
const PROVIDER_ICON: &str = "system-shutdown";
const PROVIDER_TYPE_ID: &str = "system";
/// System provider state - holds cached items
struct SystemState {
items: Vec<PluginItem>,
}
impl SystemState {
fn new() -> Self {
Self { items: Vec::new() }
}
fn load_commands(&mut self) {
self.items.clear();
// Define system commands
// Format: (id, name, description, icon, command)
let commands: &[(&str, &str, &str, &str, &str)] = &[
(
"system:shutdown",
"Shutdown",
"Power off the system",
"system-shutdown",
"systemctl poweroff",
),
(
"system:reboot",
"Reboot",
"Restart the system",
"system-reboot",
"systemctl reboot",
),
(
"system:reboot-bios",
"Reboot into BIOS",
"Restart into UEFI/BIOS setup",
"system-reboot",
"systemctl reboot --firmware-setup",
),
(
"system:suspend",
"Suspend",
"Suspend to RAM",
"system-suspend",
"systemctl suspend",
),
(
"system:hibernate",
"Hibernate",
"Suspend to disk",
"system-suspend-hibernate",
"systemctl hibernate",
),
(
"system:lock",
"Lock Screen",
"Lock the session",
"system-lock-screen",
"loginctl lock-session",
),
(
"system:logout",
"Log Out",
"End the current session",
"system-log-out",
"loginctl terminate-session self",
),
];
for (id, name, description, icon, command) in commands {
self.items.push(
PluginItem::new(*id, *name, *command)
.with_description(*description)
.with_icon(*icon)
.with_keywords(vec!["power".to_string(), "system".to_string()]),
);
}
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(SystemState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<SystemState>
let state = unsafe { &mut *(handle.ptr as *mut SystemState) };
// Load/reload commands
state.load_commands();
// Return items
state.items.to_vec().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query is handled by the core using cached items
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<SystemState>
unsafe {
handle.drop_as::<SystemState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_system_state_new() {
let state = SystemState::new();
assert!(state.items.is_empty());
}
#[test]
fn test_system_commands_loaded() {
let mut state = SystemState::new();
state.load_commands();
assert!(state.items.len() >= 6);
// Check for specific commands
let names: Vec<&str> = state.items.iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"Shutdown"));
assert!(names.contains(&"Reboot"));
assert!(names.contains(&"Suspend"));
assert!(names.contains(&"Lock Screen"));
assert!(names.contains(&"Log Out"));
}
#[test]
fn test_reboot_bios_command() {
let mut state = SystemState::new();
state.load_commands();
let bios_cmd = state
.items
.iter()
.find(|i| i.name.as_str() == "Reboot into BIOS")
.expect("Reboot into BIOS should exist");
assert_eq!(bios_cmd.command.as_str(), "systemctl reboot --firmware-setup");
}
#[test]
fn test_commands_have_icons() {
let mut state = SystemState::new();
state.load_commands();
for item in &state.items {
assert!(
item.icon.is_some(),
"Item '{}' should have an icon",
item.name.as_str()
);
}
}
#[test]
fn test_commands_have_descriptions() {
let mut state = SystemState::new();
state.load_commands();
for item in &state.items {
assert!(
item.description.is_some(),
"Item '{}' should have a description",
item.name.as_str()
);
}
}
}

View File

@@ -1,20 +0,0 @@
[package]
name = "owlry-plugin-systemd"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "systemd user services plugin for owlry - list and control user-level systemd services"
keywords = ["owlry", "plugin", "systemd", "services"]
categories = ["os"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -1,454 +0,0 @@
//! systemd User Services Plugin for Owlry
//!
//! Lists and controls systemd user-level services.
//! Uses `systemctl --user` commands to interact with services.
//!
//! Each service item opens a submenu with actions like:
//! - Start/Stop/Restart/Reload/Kill
//! - Enable/Disable on startup
//! - View status and journal logs
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
use std::process::Command;
// Plugin metadata
const PLUGIN_ID: &str = "systemd";
const PLUGIN_NAME: &str = "systemd Services";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "List and control systemd user services";
// Provider metadata
const PROVIDER_ID: &str = "systemd";
const PROVIDER_NAME: &str = "User Units";
const PROVIDER_PREFIX: &str = ":uuctl";
const PROVIDER_ICON: &str = "system-run";
const PROVIDER_TYPE_ID: &str = "uuctl";
/// systemd provider state
struct SystemdState {
items: Vec<PluginItem>,
}
impl SystemdState {
fn new() -> Self {
let mut state = Self { items: Vec::new() };
state.refresh();
state
}
fn refresh(&mut self) {
self.items.clear();
if !Self::systemctl_available() {
return;
}
// List all user services (both running and available)
let output = match Command::new("systemctl")
.args([
"--user",
"list-units",
"--type=service",
"--all",
"--no-legend",
"--no-pager",
])
.output()
{
Ok(o) if o.status.success() => o,
_ => return,
};
let stdout = String::from_utf8_lossy(&output.stdout);
self.items = Self::parse_systemctl_output(&stdout);
// Sort by name
self.items.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
}
fn systemctl_available() -> bool {
Command::new("systemctl")
.args(["--user", "--version"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn parse_systemctl_output(output: &str) -> Vec<PluginItem> {
let mut items = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
// Parse systemctl output - handle variable whitespace
// Format: UNIT LOAD ACTIVE SUB DESCRIPTION...
let mut parts = line.split_whitespace();
let unit_name = match parts.next() {
Some(u) => u,
None => continue,
};
// Skip if not a proper service name
if !unit_name.ends_with(".service") {
continue;
}
let _load_state = parts.next().unwrap_or("");
let active_state = parts.next().unwrap_or("");
let sub_state = parts.next().unwrap_or("");
let description: String = parts.collect::<Vec<_>>().join(" ");
// Create a clean display name
let display_name = unit_name
.trim_end_matches(".service")
.replace("app-", "")
.replace("@autostart", "")
.replace("\\x2d", "-");
let is_active = active_state == "active";
let status_icon = if is_active { "" } else { "" };
let status_desc = if description.is_empty() {
format!("{} {} ({})", status_icon, sub_state, active_state)
} else {
format!("{} {} ({})", status_icon, description, sub_state)
};
// Store service info in the command field as encoded data
// Format: SUBMENU:type_id:data where data is "unit_name:is_active"
let submenu_data = format!("SUBMENU:uuctl:{}:{}", unit_name, is_active);
let icon = if is_active {
"emblem-ok-symbolic"
} else {
"emblem-pause-symbolic"
};
items.push(
PluginItem::new(
format!("systemd:service:{}", unit_name),
display_name,
submenu_data,
)
.with_description(status_desc)
.with_icon(icon)
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
}
items
}
}
// ============================================================================
// Submenu Action Generation (exported for core to use)
// ============================================================================
/// Generate submenu actions for a given service
/// This function is called by the core when a service is selected
pub fn actions_for_service(unit_name: &str, display_name: &str, is_active: bool) -> Vec<PluginItem> {
let mut actions = Vec::new();
if is_active {
actions.push(
PluginItem::new(
format!("systemd:restart:{}", unit_name),
"↻ Restart",
format!("systemctl --user restart {}", unit_name),
)
.with_description(format!("Restart {}", display_name))
.with_icon("view-refresh")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
actions.push(
PluginItem::new(
format!("systemd:stop:{}", unit_name),
"■ Stop",
format!("systemctl --user stop {}", unit_name),
)
.with_description(format!("Stop {}", display_name))
.with_icon("process-stop")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
actions.push(
PluginItem::new(
format!("systemd:reload:{}", unit_name),
"⟳ Reload",
format!("systemctl --user reload {}", unit_name),
)
.with_description(format!("Reload {} configuration", display_name))
.with_icon("view-refresh")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
actions.push(
PluginItem::new(
format!("systemd:kill:{}", unit_name),
"✗ Kill",
format!("systemctl --user kill {}", unit_name),
)
.with_description(format!("Force kill {}", display_name))
.with_icon("edit-delete")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
} else {
actions.push(
PluginItem::new(
format!("systemd:start:{}", unit_name),
"▶ Start",
format!("systemctl --user start {}", unit_name),
)
.with_description(format!("Start {}", display_name))
.with_icon("media-playback-start")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
}
// Always available actions
actions.push(
PluginItem::new(
format!("systemd:status:{}", unit_name),
" Status",
format!("systemctl --user status {}", unit_name),
)
.with_description(format!("Show {} status", display_name))
.with_icon("dialog-information")
.with_keywords(vec!["systemd".to_string(), "service".to_string()])
.with_terminal(true),
);
actions.push(
PluginItem::new(
format!("systemd:journal:{}", unit_name),
"📋 Journal",
format!("journalctl --user -u {} -f", unit_name),
)
.with_description(format!("Show {} logs", display_name))
.with_icon("utilities-system-monitor")
.with_keywords(vec!["systemd".to_string(), "service".to_string()])
.with_terminal(true),
);
actions.push(
PluginItem::new(
format!("systemd:enable:{}", unit_name),
"⊕ Enable",
format!("systemctl --user enable {}", unit_name),
)
.with_description(format!("Enable {} on startup", display_name))
.with_icon("emblem-default")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
actions.push(
PluginItem::new(
format!("systemd:disable:{}", unit_name),
"⊖ Disable",
format!("systemctl --user disable {}", unit_name),
)
.with_description(format!("Disable {} on startup", display_name))
.with_icon("emblem-unreadable")
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
);
actions
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(SystemdState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<SystemdState>
let state = unsafe { &mut *(handle.ptr as *mut SystemdState) };
state.refresh();
state.items.clone().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
let query_str = query.as_str();
// Handle submenu action requests: ?SUBMENU:unit.service:is_active
if let Some(data) = query_str.strip_prefix("?SUBMENU:") {
// Parse data format: "unit_name:is_active"
let parts: Vec<&str> = data.splitn(2, ':').collect();
if parts.len() >= 2 {
let unit_name = parts[0];
let is_active = parts[1] == "true";
let display_name = unit_name
.trim_end_matches(".service")
.replace("app-", "")
.replace("@autostart", "")
.replace("\\x2d", "-");
return actions_for_service(unit_name, &display_name, is_active).into();
} else if !data.is_empty() {
// Fallback: just unit name, assume not active
let display_name = data
.trim_end_matches(".service")
.replace("app-", "")
.replace("@autostart", "")
.replace("\\x2d", "-");
return actions_for_service(data, &display_name, false).into();
}
}
// Static provider - normal queries not used
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<SystemdState>
unsafe {
handle.drop_as::<SystemdState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_systemctl_output() {
let output = r#"
foo.service loaded active running Foo Service
bar.service loaded inactive dead Bar Service
baz@autostart.service loaded active running Baz App
"#;
let items = SystemdState::parse_systemctl_output(output);
assert_eq!(items.len(), 3);
// Check first item
assert_eq!(items[0].name.as_str(), "foo");
assert!(items[0].command.as_str().contains("SUBMENU:uuctl:foo.service:true"));
// Check second item (inactive)
assert_eq!(items[1].name.as_str(), "bar");
assert!(items[1].command.as_str().contains("SUBMENU:uuctl:bar.service:false"));
// Check third item (cleaned name)
assert_eq!(items[2].name.as_str(), "baz");
}
#[test]
fn test_actions_for_active_service() {
let actions = actions_for_service("test.service", "Test", true);
// Active services should have restart, stop, reload, kill + common actions
let action_ids: Vec<_> = actions.iter().map(|a| a.id.as_str()).collect();
assert!(action_ids.contains(&"systemd:restart:test.service"));
assert!(action_ids.contains(&"systemd:stop:test.service"));
assert!(action_ids.contains(&"systemd:status:test.service"));
assert!(!action_ids.contains(&"systemd:start:test.service")); // Not for active
}
#[test]
fn test_actions_for_inactive_service() {
let actions = actions_for_service("test.service", "Test", false);
// Inactive services should have start + common actions
let action_ids: Vec<_> = actions.iter().map(|a| a.id.as_str()).collect();
assert!(action_ids.contains(&"systemd:start:test.service"));
assert!(action_ids.contains(&"systemd:status:test.service"));
assert!(!action_ids.contains(&"systemd:stop:test.service")); // Not for inactive
}
#[test]
fn test_terminal_actions() {
let actions = actions_for_service("test.service", "Test", true);
// Status and journal should have terminal=true
for action in &actions {
let id = action.id.as_str();
if id.contains(":status:") || id.contains(":journal:") {
assert!(action.terminal, "Action {} should have terminal=true", id);
}
}
}
#[test]
fn test_submenu_query() {
// Test that provider_query handles ?SUBMENU: queries correctly
let handle = ProviderHandle { ptr: std::ptr::null_mut() };
// Query for active service
let query = RStr::from_str("?SUBMENU:test.service:true");
let actions = provider_query(handle, query);
assert!(!actions.is_empty(), "Should return actions for submenu query");
// Should have restart action for active service
let has_restart = actions.iter().any(|a| a.id.as_str().contains(":restart:"));
assert!(has_restart, "Active service should have restart action");
// Query for inactive service
let query = RStr::from_str("?SUBMENU:test.service:false");
let actions = provider_query(handle, query);
assert!(!actions.is_empty(), "Should return actions for submenu query");
// Should have start action for inactive service
let has_start = actions.iter().any(|a| a.id.as_str().contains(":start:"));
assert!(has_start, "Inactive service should have start action");
// Normal query should return empty
let query = RStr::from_str("some search");
let actions = provider_query(handle, query);
assert!(actions.is_empty(), "Normal query should return empty");
}
}

View File

@@ -1,33 +0,0 @@
[package]
name = "owlry-plugin-weather"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Weather widget plugin for owlry - shows current weather with multiple API support"
keywords = ["owlry", "plugin", "weather", "widget"]
categories = ["gui"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"
# HTTP client for weather API requests
reqwest = { version = "0.12", features = ["blocking", "json"] }
# JSON parsing for API responses
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# TOML config parsing
toml = "0.8"
# XDG directories for cache persistence
dirs = "5.0"

View File

@@ -1,751 +0,0 @@
//! Weather Widget Plugin for Owlry
//!
//! Shows current weather with support for multiple APIs:
//! - wttr.in (default, no API key required)
//! - OpenWeatherMap (requires API key)
//! - Open-Meteo (no API key required)
//!
//! Weather data is cached for 15 minutes.
//!
//! ## Configuration
//!
//! Configure via `~/.config/owlry/config.toml`:
//!
//! ```toml
//! [plugins.weather]
//! provider = "wttr.in" # or: openweathermap, open-meteo
//! location = "Berlin" # city name or "lat,lon"
//! # api_key = "..." # Required for OpenWeatherMap
//! ```
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
// Plugin metadata
const PLUGIN_ID: &str = "weather";
const PLUGIN_NAME: &str = "Weather";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Weather widget with multiple API support";
// Provider metadata
const PROVIDER_ID: &str = "weather";
const PROVIDER_NAME: &str = "Weather";
const PROVIDER_ICON: &str = "weather-clear";
const PROVIDER_TYPE_ID: &str = "weather";
// Timing constants
const CACHE_DURATION_SECS: u64 = 900; // 15 minutes
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
const USER_AGENT: &str = "owlry-launcher/0.3";
#[derive(Debug, Clone, PartialEq)]
enum WeatherProviderType {
WttrIn,
OpenWeatherMap,
OpenMeteo,
}
impl std::str::FromStr for WeatherProviderType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"wttr.in" | "wttr" | "wttrin" => Ok(Self::WttrIn),
"openweathermap" | "owm" => Ok(Self::OpenWeatherMap),
"open-meteo" | "openmeteo" | "meteo" => Ok(Self::OpenMeteo),
_ => Err(format!("Unknown weather provider: {}", s)),
}
}
}
#[derive(Debug, Clone)]
struct WeatherConfig {
provider: WeatherProviderType,
api_key: Option<String>,
location: String,
}
impl WeatherConfig {
/// Load config from ~/.config/owlry/config.toml
///
/// Reads from [plugins.weather] section, with fallback to [providers] for compatibility.
fn load() -> Self {
let config_path = dirs::config_dir()
.map(|d| d.join("owlry").join("config.toml"));
let config_content = config_path
.and_then(|p| fs::read_to_string(p).ok());
if let Some(content) = config_content {
if let Ok(toml) = content.parse::<toml::Table>() {
// Try [plugins.weather] first (new format)
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) {
if let Some(weather) = plugins.get("weather").and_then(|v| v.as_table()) {
return Self::from_toml_table(weather);
}
}
// Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let provider_str = providers
.get("weather_provider")
.and_then(|v| v.as_str())
.unwrap_or("wttr.in");
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
let api_key = providers
.get("weather_api_key")
.and_then(|v| v.as_str())
.map(String::from);
let location = providers
.get("weather_location")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
return Self {
provider,
api_key,
location,
};
}
}
}
// Default config
Self {
provider: WeatherProviderType::WttrIn,
api_key: None,
location: String::new(),
}
}
/// Parse config from a TOML table
fn from_toml_table(table: &toml::Table) -> Self {
let provider_str = table
.get("provider")
.and_then(|v| v.as_str())
.unwrap_or("wttr.in");
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
let api_key = table
.get("api_key")
.and_then(|v| v.as_str())
.map(String::from);
let location = table
.get("location")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Self {
provider,
api_key,
location,
}
}
}
/// Cached weather data (persisted to disk)
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WeatherData {
temperature: f32,
feels_like: Option<f32>,
condition: String,
humidity: Option<u8>,
wind_speed: Option<f32>,
icon: String,
location: String,
}
/// Persistent cache structure (saved to ~/.local/share/owlry/weather_cache.json)
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WeatherCache {
last_fetch_epoch: u64,
data: WeatherData,
}
/// Weather provider state
struct WeatherState {
items: Vec<PluginItem>,
config: WeatherConfig,
last_fetch_epoch: u64,
cached_data: Option<WeatherData>,
}
impl WeatherState {
fn new() -> Self {
Self::with_config(WeatherConfig::load())
}
fn with_config(config: WeatherConfig) -> Self {
// Load cached weather from disk if available
// This prevents blocking HTTP requests on every app open
let (last_fetch_epoch, cached_data) = Self::load_cache()
.map(|c| (c.last_fetch_epoch, Some(c.data)))
.unwrap_or((0, None));
Self {
items: Vec::new(),
config,
last_fetch_epoch,
cached_data,
}
}
fn data_dir() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("owlry"))
}
fn cache_path() -> Option<PathBuf> {
Self::data_dir().map(|d| d.join("weather_cache.json"))
}
fn load_cache() -> Option<WeatherCache> {
let path = Self::cache_path()?;
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
fn save_cache(&self) {
if let (Some(data_dir), Some(cache_path), Some(data)) =
(Self::data_dir(), Self::cache_path(), &self.cached_data)
{
if fs::create_dir_all(&data_dir).is_err() {
return;
}
let cache = WeatherCache {
last_fetch_epoch: self.last_fetch_epoch,
data: data.clone(),
};
if let Ok(json) = serde_json::to_string_pretty(&cache) {
let _ = fs::write(&cache_path, json);
}
}
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn is_cache_valid(&self) -> bool {
if self.last_fetch_epoch == 0 {
return false;
}
let now = Self::now_epoch();
now.saturating_sub(self.last_fetch_epoch) < CACHE_DURATION_SECS
}
fn refresh(&mut self) {
// Use cache if still valid (works across app restarts)
if self.is_cache_valid()
&& let Some(data) = self.cached_data.clone() {
self.generate_items(&data);
return;
}
// Fetch new data from API
if let Some(data) = self.fetch_weather() {
self.cached_data = Some(data.clone());
self.last_fetch_epoch = Self::now_epoch();
self.save_cache(); // Persist to disk for next app open
self.generate_items(&data);
} else {
// On fetch failure, try to use stale cache if available
if let Some(data) = self.cached_data.clone() {
self.generate_items(&data);
} else {
self.items.clear();
}
}
}
fn fetch_weather(&self) -> Option<WeatherData> {
match self.config.provider {
WeatherProviderType::WttrIn => self.fetch_wttr_in(),
WeatherProviderType::OpenWeatherMap => self.fetch_openweathermap(),
WeatherProviderType::OpenMeteo => self.fetch_open_meteo(),
}
}
fn fetch_wttr_in(&self) -> Option<WeatherData> {
let location = if self.config.location.is_empty() {
String::new()
} else {
self.config.location.clone()
};
let url = format!("https://wttr.in/{}?format=j1", location);
let client = reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.user_agent(USER_AGENT)
.build()
.ok()?;
let response = client.get(&url).send().ok()?;
let json: WttrInResponse = response.json().ok()?;
let current = json.current_condition.first()?;
let nearest = json.nearest_area.first()?;
let location_name = nearest
.area_name
.first()
.map(|a| a.value.clone())
.unwrap_or_else(|| "Unknown".to_string());
Some(WeatherData {
temperature: current.temp_c.parse().unwrap_or(0.0),
feels_like: current.feels_like_c.parse().ok(),
condition: current
.weather_desc
.first()
.map(|d| d.value.clone())
.unwrap_or_else(|| "Unknown".to_string()),
humidity: current.humidity.parse().ok(),
wind_speed: current.windspeed_kmph.parse().ok(),
icon: Self::wttr_code_to_icon(&current.weather_code),
location: location_name,
})
}
fn fetch_openweathermap(&self) -> Option<WeatherData> {
let api_key = self.config.api_key.as_ref()?;
if self.config.location.is_empty() {
return None; // OWM requires a location
}
let url = format!(
"https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric",
self.config.location, api_key
);
let client = reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.ok()?;
let response = client.get(&url).send().ok()?;
let json: OpenWeatherMapResponse = response.json().ok()?;
let weather = json.weather.first()?;
Some(WeatherData {
temperature: json.main.temp,
feels_like: Some(json.main.feels_like),
condition: weather.description.clone(),
humidity: Some(json.main.humidity),
wind_speed: Some(json.wind.speed * 3.6), // m/s to km/h
icon: Self::owm_icon_to_freedesktop(&weather.icon),
location: json.name,
})
}
fn fetch_open_meteo(&self) -> Option<WeatherData> {
let (lat, lon, location_name) = self.get_coordinates()?;
let url = format!(
"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=auto",
lat, lon
);
let client = reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.ok()?;
let response = client.get(&url).send().ok()?;
let json: OpenMeteoResponse = response.json().ok()?;
let current = json.current;
Some(WeatherData {
temperature: current.temperature_2m,
feels_like: None,
condition: Self::wmo_code_to_description(current.weather_code),
humidity: Some(current.relative_humidity_2m as u8),
wind_speed: Some(current.wind_speed_10m),
icon: Self::wmo_code_to_icon(current.weather_code),
location: location_name,
})
}
fn get_coordinates(&self) -> Option<(f64, f64, String)> {
let location = &self.config.location;
// Check if location is already coordinates (lat,lon)
if location.contains(',') {
let parts: Vec<&str> = location.split(',').collect();
if parts.len() == 2
&& let (Ok(lat), Ok(lon)) = (
parts[0].trim().parse::<f64>(),
parts[1].trim().parse::<f64>(),
) {
return Some((lat, lon, location.clone()));
}
}
// Use Open-Meteo geocoding API
let url = format!(
"https://geocoding-api.open-meteo.com/v1/search?name={}&count=1",
location
);
let client = reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.ok()?;
let response = client.get(&url).send().ok()?;
let json: GeocodingResponse = response.json().ok()?;
let result = json.results?.into_iter().next()?;
Some((result.latitude, result.longitude, result.name))
}
fn wttr_code_to_icon(code: &str) -> String {
match code {
"113" => "weather-clear",
"116" => "weather-few-clouds",
"119" => "weather-overcast",
"122" => "weather-overcast",
"143" | "248" | "260" => "weather-fog",
"176" | "263" | "266" | "293" | "296" | "299" | "302" | "305" | "308" => {
"weather-showers"
}
"179" | "182" | "185" | "227" | "230" | "323" | "326" | "329" | "332" | "335"
| "338" | "350" | "368" | "371" | "374" | "377" => "weather-snow",
"200" | "386" | "389" | "392" | "395" => "weather-storm",
_ => "weather-clear",
}
.to_string()
}
fn owm_icon_to_freedesktop(icon: &str) -> String {
match icon {
"01d" | "01n" => "weather-clear",
"02d" | "02n" => "weather-few-clouds",
"03d" | "03n" | "04d" | "04n" => "weather-overcast",
"09d" | "09n" | "10d" | "10n" => "weather-showers",
"11d" | "11n" => "weather-storm",
"13d" | "13n" => "weather-snow",
"50d" | "50n" => "weather-fog",
_ => "weather-clear",
}
.to_string()
}
fn wmo_code_to_description(code: i32) -> String {
match code {
0 => "Clear sky",
1 => "Mainly clear",
2 => "Partly cloudy",
3 => "Overcast",
45 | 48 => "Foggy",
51 | 53 | 55 => "Drizzle",
61 | 63 | 65 => "Rain",
66 | 67 => "Freezing rain",
71 | 73 | 75 | 77 => "Snow",
80..=82 => "Rain showers",
85 | 86 => "Snow showers",
95 | 96 | 99 => "Thunderstorm",
_ => "Unknown",
}
.to_string()
}
fn wmo_code_to_icon(code: i32) -> String {
match code {
0 | 1 => "weather-clear",
2 => "weather-few-clouds",
3 => "weather-overcast",
45 | 48 => "weather-fog",
51 | 53 | 55 | 61 | 63 | 65 | 80 | 81 | 82 => "weather-showers",
66 | 67 | 71 | 73 | 75 | 77 | 85 | 86 => "weather-snow",
95 | 96 | 99 => "weather-storm",
_ => "weather-clear",
}
.to_string()
}
fn icon_to_resource_path(icon: &str) -> String {
let weather_icon = if icon.contains("clear") {
"wi-day-sunny"
} else if icon.contains("few-clouds") {
"wi-day-cloudy"
} else if icon.contains("overcast") || icon.contains("clouds") {
"wi-cloudy"
} else if icon.contains("fog") {
"wi-fog"
} else if icon.contains("showers") || icon.contains("rain") {
"wi-rain"
} else if icon.contains("snow") {
"wi-snow"
} else if icon.contains("storm") {
"wi-thunderstorm"
} else {
"wi-thermometer"
};
format!("/org/owlry/launcher/icons/weather/{}.svg", weather_icon)
}
fn generate_items(&mut self, data: &WeatherData) {
self.items.clear();
let temp_str = format!("{}°C", data.temperature.round() as i32);
let name = format!("{} {}", temp_str, data.condition);
let mut details = vec![data.location.clone()];
if let Some(humidity) = data.humidity {
details.push(format!("Humidity {}%", humidity));
}
if let Some(wind) = data.wind_speed {
details.push(format!("Wind {} km/h", wind.round() as i32));
}
if let Some(feels) = data.feels_like
&& (feels - data.temperature).abs() > 2.0 {
details.push(format!("Feels like {}°C", feels.round() as i32));
}
let encoded_location = data.location.replace(' ', "+");
let command = format!("xdg-open 'https://wttr.in/{}'", encoded_location);
self.items.push(
PluginItem::new("weather-current", name, command)
.with_description(details.join(" | "))
.with_icon(Self::icon_to_resource_path(&data.icon))
.with_keywords(vec!["weather".to_string(), "widget".to_string()]),
);
}
}
// ============================================================================
// API Response Types
// ============================================================================
#[derive(Debug, Deserialize)]
struct WttrInResponse {
current_condition: Vec<WttrInCurrent>,
nearest_area: Vec<WttrInArea>,
}
#[derive(Debug, Deserialize)]
struct WttrInCurrent {
#[serde(rename = "temp_C")]
temp_c: String,
#[serde(rename = "FeelsLikeC")]
feels_like_c: String,
humidity: String,
#[serde(rename = "weatherCode")]
weather_code: String,
#[serde(rename = "weatherDesc")]
weather_desc: Vec<WttrInValue>,
#[serde(rename = "windspeedKmph")]
windspeed_kmph: String,
}
#[derive(Debug, Deserialize)]
struct WttrInValue {
value: String,
}
#[derive(Debug, Deserialize)]
struct WttrInArea {
#[serde(rename = "areaName")]
area_name: Vec<WttrInValue>,
}
#[derive(Debug, Deserialize)]
struct OpenWeatherMapResponse {
main: OwmMain,
weather: Vec<OwmWeather>,
wind: OwmWind,
name: String,
}
#[derive(Debug, Deserialize)]
struct OwmMain {
temp: f32,
feels_like: f32,
humidity: u8,
}
#[derive(Debug, Deserialize)]
struct OwmWeather {
description: String,
icon: String,
}
#[derive(Debug, Deserialize)]
struct OwmWind {
speed: f32,
}
#[derive(Debug, Deserialize)]
struct OpenMeteoResponse {
current: OpenMeteoCurrent,
}
#[derive(Debug, Deserialize)]
struct OpenMeteoCurrent {
temperature_2m: f32,
relative_humidity_2m: f32,
weather_code: i32,
wind_speed_10m: f32,
}
#[derive(Debug, Deserialize)]
struct GeocodingResponse {
results: Option<Vec<GeocodingResult>>,
}
#[derive(Debug, Deserialize)]
struct GeocodingResult {
name: String,
latitude: f64,
longitude: f64,
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RNone,
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Static,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let state = Box::new(WeatherState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<WeatherState>
let state = unsafe { &mut *(handle.ptr as *mut WeatherState) };
state.refresh();
state.items.clone().into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
// Static provider - query not used, return empty
RVec::new()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<WeatherState>
unsafe {
handle.drop_as::<WeatherState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_weather_provider_type_from_str() {
assert_eq!(
"wttr.in".parse::<WeatherProviderType>().unwrap(),
WeatherProviderType::WttrIn
);
assert_eq!(
"owm".parse::<WeatherProviderType>().unwrap(),
WeatherProviderType::OpenWeatherMap
);
assert_eq!(
"open-meteo".parse::<WeatherProviderType>().unwrap(),
WeatherProviderType::OpenMeteo
);
}
#[test]
fn test_wttr_code_to_icon() {
assert_eq!(WeatherState::wttr_code_to_icon("113"), "weather-clear");
assert_eq!(WeatherState::wttr_code_to_icon("116"), "weather-few-clouds");
assert_eq!(WeatherState::wttr_code_to_icon("176"), "weather-showers");
assert_eq!(WeatherState::wttr_code_to_icon("200"), "weather-storm");
}
#[test]
fn test_wmo_code_to_description() {
assert_eq!(WeatherState::wmo_code_to_description(0), "Clear sky");
assert_eq!(WeatherState::wmo_code_to_description(3), "Overcast");
assert_eq!(WeatherState::wmo_code_to_description(95), "Thunderstorm");
}
#[test]
fn test_icon_to_resource_path() {
assert_eq!(
WeatherState::icon_to_resource_path("weather-clear"),
"/org/owlry/launcher/icons/weather/wi-day-sunny.svg"
);
}
#[test]
fn test_cache_validity() {
let state = WeatherState {
items: Vec::new(),
config: WeatherConfig {
provider: WeatherProviderType::WttrIn,
api_key: None,
location: String::new(),
},
last_fetch_epoch: 0,
cached_data: None,
};
assert!(!state.is_cache_valid());
}
}

View File

@@ -1,20 +0,0 @@
[package]
name = "owlry-plugin-websearch"
version = "0.2.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Web search plugin for owlry - search the web with configurable search engines"
keywords = ["owlry", "plugin", "websearch", "search"]
categories = ["web-programming"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[dependencies]
# Plugin API for owlry
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
abi_stable = "0.11"

View File

@@ -1,296 +0,0 @@
//! Web Search Plugin for Owlry
//!
//! A dynamic provider that opens web searches in the browser.
//! Supports multiple search engines.
//!
//! Examples:
//! - `? rust programming` → Search DuckDuckGo for "rust programming"
//! - `web rust docs` → Search for "rust docs"
//! - `search how to rust` → Search for "how to rust"
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
// Plugin metadata
const PLUGIN_ID: &str = "websearch";
const PLUGIN_NAME: &str = "Web Search";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Search the web with configurable search engines";
// Provider metadata
const PROVIDER_ID: &str = "websearch";
const PROVIDER_NAME: &str = "Web Search";
const PROVIDER_PREFIX: &str = "?";
const PROVIDER_ICON: &str = "web-browser";
const PROVIDER_TYPE_ID: &str = "websearch";
/// Common search engine URL templates
/// {query} is replaced with the URL-encoded search term
const SEARCH_ENGINES: &[(&str, &str)] = &[
("google", "https://www.google.com/search?q={query}"),
("duckduckgo", "https://duckduckgo.com/?q={query}"),
("bing", "https://www.bing.com/search?q={query}"),
("startpage", "https://www.startpage.com/search?q={query}"),
("searxng", "https://searx.be/search?q={query}"),
("brave", "https://search.brave.com/search?q={query}"),
("ecosia", "https://www.ecosia.org/search?q={query}"),
];
/// Default search engine if not configured
const DEFAULT_ENGINE: &str = "duckduckgo";
/// Web search provider state
struct WebSearchState {
/// URL template with {query} placeholder
url_template: String,
}
impl WebSearchState {
fn new() -> Self {
Self::with_engine(DEFAULT_ENGINE)
}
fn with_engine(engine_name: &str) -> Self {
let url_template = SEARCH_ENGINES
.iter()
.find(|(name, _)| *name == engine_name.to_lowercase())
.map(|(_, url)| url.to_string())
.unwrap_or_else(|| {
// If not a known engine, treat it as a custom URL template
if engine_name.contains("{query}") {
engine_name.to_string()
} else {
// Fall back to default
SEARCH_ENGINES
.iter()
.find(|(name, _)| *name == DEFAULT_ENGINE)
.map(|(_, url)| url.to_string())
.unwrap()
}
});
Self { url_template }
}
/// Extract the search term from the query
fn extract_search_term(query: &str) -> Option<&str> {
let trimmed = query.trim();
if let Some(rest) = trimmed.strip_prefix("? ") {
Some(rest.trim())
} else if let Some(rest) = trimmed.strip_prefix("?") {
Some(rest.trim())
} else if trimmed.to_lowercase().starts_with("web ") {
Some(trimmed[4..].trim())
} else if trimmed.to_lowercase().starts_with("search ") {
Some(trimmed[7..].trim())
} else {
// In filter mode, accept raw query
Some(trimmed)
}
}
/// URL-encode a search query
fn url_encode(query: &str) -> String {
query
.chars()
.map(|c| match c {
' ' => "+".to_string(),
'&' => "%26".to_string(),
'=' => "%3D".to_string(),
'?' => "%3F".to_string(),
'#' => "%23".to_string(),
'+' => "%2B".to_string(),
'%' => "%25".to_string(),
c if c.is_ascii_alphanumeric() || "-_.~".contains(c) => c.to_string(),
c => format!("%{:02X}", c as u32),
})
.collect()
}
/// Build the search URL from a query
fn build_search_url(&self, search_term: &str) -> String {
let encoded = Self::url_encode(search_term);
self.url_template.replace("{query}", &encoded)
}
/// Evaluate a query and return a PluginItem if valid
fn evaluate(&self, query: &str) -> Option<PluginItem> {
let search_term = Self::extract_search_term(query)?;
if search_term.is_empty() {
return None;
}
let url = self.build_search_url(search_term);
// Use xdg-open to open the browser
let command = format!("xdg-open '{}'", url);
Some(
PluginItem::new(
format!("websearch:{}", search_term),
format!("Search: {}", search_term),
command,
)
.with_description("Open in browser")
.with_icon(PROVIDER_ICON)
.with_keywords(vec!["web".to_string(), "search".to_string()]),
)
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Dynamic,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
// TODO: Read search engine from config when plugin config is available
let state = Box::new(WebSearchState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
// Dynamic provider - refresh does nothing
RVec::new()
}
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<WebSearchState>
let state = unsafe { &*(handle.ptr as *const WebSearchState) };
let query_str = query.as_str();
match state.evaluate(query_str) {
Some(item) => vec![item].into(),
None => RVec::new(),
}
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<WebSearchState>
unsafe {
handle.drop_as::<WebSearchState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_search_term() {
assert_eq!(
WebSearchState::extract_search_term("? rust programming"),
Some("rust programming")
);
assert_eq!(
WebSearchState::extract_search_term("?rust"),
Some("rust")
);
assert_eq!(
WebSearchState::extract_search_term("web rust docs"),
Some("rust docs")
);
assert_eq!(
WebSearchState::extract_search_term("search how to rust"),
Some("how to rust")
);
}
#[test]
fn test_url_encode() {
assert_eq!(WebSearchState::url_encode("hello world"), "hello+world");
assert_eq!(WebSearchState::url_encode("foo&bar"), "foo%26bar");
assert_eq!(WebSearchState::url_encode("a=b"), "a%3Db");
assert_eq!(WebSearchState::url_encode("test?query"), "test%3Fquery");
}
#[test]
fn test_build_search_url() {
let state = WebSearchState::with_engine("duckduckgo");
let url = state.build_search_url("rust programming");
assert_eq!(url, "https://duckduckgo.com/?q=rust+programming");
}
#[test]
fn test_build_search_url_google() {
let state = WebSearchState::with_engine("google");
let url = state.build_search_url("rust");
assert_eq!(url, "https://www.google.com/search?q=rust");
}
#[test]
fn test_evaluate() {
let state = WebSearchState::new();
let item = state.evaluate("? rust docs").unwrap();
assert_eq!(item.name.as_str(), "Search: rust docs");
assert!(item.command.as_str().contains("xdg-open"));
assert!(item.command.as_str().contains("duckduckgo"));
}
#[test]
fn test_evaluate_empty() {
let state = WebSearchState::new();
assert!(state.evaluate("?").is_none());
assert!(state.evaluate("? ").is_none());
}
#[test]
fn test_custom_url_template() {
let state = WebSearchState::with_engine("https://custom.search/q={query}");
let url = state.build_search_url("test");
assert_eq!(url, "https://custom.search/q=test");
}
#[test]
fn test_fallback_to_default() {
let state = WebSearchState::with_engine("nonexistent");
let url = state.build_search_url("test");
assert!(url.contains("duckduckgo")); // Falls back to default
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-rune"
version = "0.2.0"
version = "1.0.0"
edition = "2024"
rust-version = "1.90"
description = "Rune scripting runtime for owlry plugins"
@@ -22,7 +22,7 @@ log = "0.4"
env_logger = "0.11"
# HTTP client for network API
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] }
# Serialization
serde = { version = "1", features = ["derive"] }

View File

@@ -75,7 +75,11 @@ pub struct RuneRuntimeVTable {
pub init: extern "C" fn(plugins_dir: 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 query: extern "C" fn(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem>,
pub drop: extern "C" fn(handle: RuntimeHandle),
}
@@ -94,7 +98,10 @@ extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle {
let _ = env_logger::try_init();
let plugins_dir = PathBuf::from(plugins_dir.as_str());
log::info!("Initializing Rune runtime with plugins from: {}", plugins_dir.display());
log::info!(
"Initializing Rune runtime with plugins from: {}",
plugins_dir.display()
);
let mut state = RuntimeState {
plugins: HashMap::new(),
@@ -113,15 +120,20 @@ extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle {
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()
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());
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);

View File

@@ -8,7 +8,7 @@ use rune::{Context, Unit};
use crate::api::{self, ProviderRegistration};
use crate::manifest::PluginManifest;
use crate::runtime::{compile_source, create_context, create_vm, SandboxConfig};
use crate::runtime::{SandboxConfig, compile_source, create_context, create_vm};
use owlry_plugin_api::PluginItem;
@@ -29,8 +29,8 @@ 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 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() {
@@ -45,15 +45,14 @@ impl LoadedPlugin {
.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))?;
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(());
let _: () = rune::from_value(result).unwrap_or(());
}
Err(_) => {
// No main function is okay
@@ -111,7 +110,10 @@ pub fn discover_rune_plugins(plugins_dir: &Path) -> Result<HashMap<String, Loade
let mut plugins = HashMap::new();
if !plugins_dir.exists() {
log::debug!("Plugins directory does not exist: {}", plugins_dir.display());
log::debug!(
"Plugins directory does not exist: {}",
plugins_dir.display()
);
return Ok(plugins);
}
@@ -135,7 +137,11 @@ pub fn discover_rune_plugins(plugins_dir: &Path) -> Result<HashMap<String, Loade
let manifest = match PluginManifest::load(&manifest_path) {
Ok(m) => m,
Err(e) => {
log::warn!("Failed to load manifest at {}: {}", manifest_path.display(), e);
log::warn!(
"Failed to load manifest at {}: {}",
manifest_path.display(),
e
);
continue;
}
};

View File

@@ -64,10 +64,10 @@ pub struct PluginPermissions {
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))?;
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)
}
@@ -78,7 +78,12 @@ impl PluginManifest {
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 == '-') {
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());
}

View File

@@ -25,7 +25,6 @@ pub struct SandboxConfig {
pub allowed_commands: Vec<String>,
}
impl SandboxConfig {
/// Create sandbox config from plugin permissions
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
@@ -59,12 +58,9 @@ pub fn create_context(sandbox: &SandboxConfig) -> Result<Context, rune::ContextE
}
/// 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()))?;
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()
@@ -73,7 +69,10 @@ pub fn compile_source(
let mut sources = Sources::new();
sources
.insert(Source::new(source_name, &source_content).map_err(|e| CompileError::Compile(e.to_string()))?)
.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();
@@ -97,13 +96,11 @@ pub fn compile_source(
}
/// Create a new Rune VM from compiled unit
pub fn create_vm(
context: &Context,
unit: Arc<Unit>,
) -> Result<Vm, CompileError> {
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)))?
context
.runtime()
.map_err(|e| CompileError::Compile(format!("Failed to get runtime: {}", e)))?,
);
Ok(Vm::new(runtime, unit))
}

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry"
version = "0.4.0"
version = "1.0.0"
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"
@@ -11,8 +11,8 @@ keywords = ["launcher", "wayland", "gtk4", "linux"]
categories = ["gui"]
[dependencies]
# Shared plugin API
owlry-plugin-api = { path = "../owlry-plugin-api" }
# Core backend library
owlry-core = { path = "../owlry-core" }
# GTK4 for the UI
gtk4 = { version = "0.10", features = ["v4_12"] }
@@ -20,60 +20,32 @@ gtk4 = { version = "0.10", features = ["v4_12"] }
# Layer shell support for Wayland overlay behavior
gtk4-layer-shell = "0.7"
# 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
# Low-level syscalls for stdin detection (dmenu mode)
libc = "0.2"
# Logging
log = "0.4"
env_logger = "0.11"
# Error handling
thiserror = "2"
# Configuration
# 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"] }
# Math expression evaluation (for Lua plugins)
meval = { version = "0.2", optional = true }
# JSON serialization for data persistence
# JSON serialization (needed by plugin commands in CLI)
serde_json = "1"
# Date/time for frecency calculations
# Date/time (needed by plugin commands in CLI)
chrono = { version = "0.4", features = ["serde"] }
# HTTP client (for Lua plugins)
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"], optional = true }
# Directory utilities (needed by plugin commands)
dirs = "5"
# Lua runtime for plugin system (optional - can be loaded dynamically via owlry-lua)
mlua = { version = "0.10", features = ["lua54", "vendored", "send", "serialize"], optional = true }
# Semantic versioning for plugin compatibility
# Semantic versioning (needed by plugin commands)
semver = "1"
# Dynamic library loading for native plugins
libloading = "0.8"
# Desktop notifications (freedesktop notification spec)
notify-rust = "4"
[dev-dependencies]
# Temporary directories for tests
tempfile = "3"
[build-dependencies]
# GResource compilation for bundled icons
glib-build-tools = "0.20"
@@ -81,7 +53,6 @@ glib-build-tools = "0.20"
[features]
default = []
# Enable verbose debug logging (for development/testing builds)
dev-logging = []
dev-logging = ["owlry-core/dev-logging"]
# Enable built-in Lua runtime (disable to use external owlry-lua package)
# Includes: mlua, meval (math), reqwest (http)
lua = ["dep:mlua", "dep:meval", "dep:reqwest"]
lua = ["owlry-core/lua"]

View File

@@ -1,23 +1,20 @@
use crate::backend::SearchBackend;
use crate::cli::CliArgs;
use crate::config::Config;
use crate::data::FrecencyStore;
use crate::filter::ProviderFilter;
use crate::paths;
use crate::plugins::native_loader::NativePluginLoader;
#[cfg(feature = "lua")]
use crate::plugins::PluginManager;
use crate::providers::native_provider::NativeProvider;
use crate::providers::Provider; // For name() method
use crate::providers::ProviderManager;
use crate::client::CoreClient;
use crate::providers::DmenuProvider;
use crate::theme;
use crate::ui::MainWindow;
use gtk4::prelude::*;
use gtk4::{gio, Application, CssProvider};
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;
use std::sync::Arc;
const APP_ID: &str = "org.owlry.launcher";
@@ -39,7 +36,7 @@ impl OwlryApp {
pub fn run(&self) -> i32 {
// Use empty args since clap already parsed our CLI arguments.
// This prevents GTK from trying to parse --mode, --providers, etc.
// This prevents GTK from trying to parse --mode, --profile, etc.
self.app.run_with_args(&[] as &[&str]).into()
}
@@ -52,30 +49,69 @@ impl OwlryApp {
let config = Rc::new(RefCell::new(Config::load_or_default()));
// Load native plugins from /usr/lib/owlry/plugins/
let native_providers = Self::load_native_plugins(&config.borrow());
// Build backend based on mode
let dmenu_mode = DmenuProvider::has_stdin_data();
// Create provider manager with native plugins
let mut provider_manager = ProviderManager::with_native_plugins(native_providers);
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();
// Load Lua plugins if enabled (requires lua feature)
#[cfg(feature = "lua")]
if config.borrow().plugins.enabled {
Self::load_lua_plugins(&mut provider_manager, &config.borrow());
}
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 providers = Rc::new(RefCell::new(provider_manager));
let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default()));
let backend = Rc::new(RefCell::new(backend));
// Create filter from CLI args and config
let filter = ProviderFilter::new(
args.mode.clone(),
args.providers.clone(),
&config.borrow().providers,
);
// 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(), providers.clone(), frecency.clone(), filter.clone());
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();
@@ -101,97 +137,47 @@ impl OwlryApp {
window.present();
}
/// Load native (.so) plugins from the system plugins directory
/// Returns NativeProvider instances that can be passed to ProviderManager
fn load_native_plugins(config: &Config) -> Vec<NativeProvider> {
let mut loader = NativePluginLoader::new();
/// 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;
// Set disabled plugins from config
// Load native plugins
let mut loader = NativePluginLoader::new();
loader.set_disabled(config.plugins.disabled_plugins.clone());
// Discover and load plugins
match loader.discover() {
Ok(count) => {
if count == 0 {
debug!("No native plugins found in {}",
crate::plugins::native_loader::SYSTEM_PLUGINS_DIR);
return Vec::new();
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);
}
}
info!("Discovered {} native plugin(s)", count);
}
Err(e) => {
warn!("Failed to discover native plugins: {}", e);
return Vec::new();
}
}
// Get all plugins and create providers
let plugins: Vec<Arc<crate::plugins::native_loader::NativePlugin>> =
loader.into_plugins();
// Create NativeProvider instances from loaded 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());
info!("Created native provider: {} ({})", provider.name(), provider.type_id());
providers.push(provider);
}
}
info!("Loaded {} provider(s) from native plugins", providers.len());
providers
}
/// Load Lua plugins from the user plugins directory (requires lua feature)
#[cfg(feature = "lua")]
fn load_lua_plugins(provider_manager: &mut ProviderManager, config: &Config) {
let plugins_dir = match paths::plugins_dir() {
Some(dir) => dir,
None => {
warn!("Could not determine plugins directory");
return;
providers
}
_ => Vec::new(),
};
// Get owlry version from Cargo.toml at compile time
let owlry_version = env!("CARGO_PKG_VERSION");
let core_providers: Vec<Box<dyn Provider>> = vec![
Box::new(ApplicationProvider::new()),
Box::new(CommandProvider::new()),
];
let mut plugin_manager = PluginManager::new(plugins_dir, owlry_version);
let provider_manager = ProviderManager::new(core_providers, native_providers);
let frecency = FrecencyStore::load_or_default();
// Set disabled plugins from config
plugin_manager.set_disabled(config.plugins.disabled_plugins.clone());
// Discover plugins
match plugin_manager.discover() {
Ok(count) => {
if count == 0 {
debug!("No Lua plugins found");
return;
}
info!("Discovered {} Lua plugin(s)", count);
}
Err(e) => {
warn!("Failed to discover Lua plugins: {}", e);
return;
}
}
// Initialize all plugins (load Lua code)
let init_errors = plugin_manager.initialize_all();
for error in &init_errors {
warn!("Plugin initialization error: {}", error);
}
// Create providers from initialized plugins
let plugin_providers = plugin_manager.create_providers();
let provider_count = plugin_providers.len();
// Add plugin providers to the main provider manager
provider_manager.add_providers(plugin_providers);
if provider_count > 0 {
info!("Loaded {} provider(s) from Lua plugins", provider_count);
SearchBackend::Local {
providers: Box::new(provider_manager),
frecency,
}
}
@@ -251,16 +237,17 @@ impl OwlryApp {
// 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);
}
&& 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);
@@ -274,3 +261,21 @@ impl OwlryApp {
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
}

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

@@ -0,0 +1,266 @@
//! 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) => {
let modes: Vec<String> = filter
.enabled_providers()
.iter()
.map(|p| p.to_string())
.collect();
let modes_param = if modes.is_empty() { None } else { Some(modes) };
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()
};
let modes: Vec<String> = filter
.enabled_providers()
.iter()
.map(|p| p.to_string())
.collect();
let modes_param = if modes.is_empty() { None } else { Some(modes) };
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,
}
}

View File

@@ -4,22 +4,71 @@
use clap::{Parser, Subcommand};
use crate::providers::ProviderType;
use owlry_core::providers::ProviderType;
#[derive(Parser, Debug, Clone)]
#[command(
name = "owlry",
about = "An owl-themed application launcher for Wayland",
version
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 (app, cmd, uuctl)
#[arg(long, short = 'm', value_parser = parse_provider)]
/// 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>,
/// Comma-separated list of enabled providers (app,cmd,uuctl)
#[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider)]
pub providers: Option<Vec<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)]

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"));
}
}

View File

@@ -1,391 +0,0 @@
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>,
}
/// 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 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();
if config_providers.applications {
set.insert(ProviderType::Application);
}
if config_providers.commands {
set.insert(ProviderType::Command);
}
if config_providers.uuctl {
set.insert(ProviderType::Uuctl);
}
if config_providers.system {
set.insert(ProviderType::System);
}
if config_providers.ssh {
set.insert(ProviderType::Ssh);
}
if config_providers.clipboard {
set.insert(ProviderType::Clipboard);
}
if config_providers.bookmarks {
set.insert(ProviderType::Bookmarks);
}
if config_providers.emoji {
set.insert(ProviderType::Emoji);
}
if config_providers.scripts {
set.insert(ProviderType::Scripts);
}
// Note: Files, Calculator, WebSearch are dynamic providers
// that don't need to be in the filter set - they're triggered by prefix
// Default to apps if nothing enabled
if set.is_empty() {
set.insert(ProviderType::Application);
}
set
};
let filter = Self {
enabled,
active_prefix: None,
};
#[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,
}
}
/// 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 {
self.enabled.insert(provider);
#[cfg(feature = "dev-logging")]
debug!("[Filter] Toggled ON {:?}, enabled: {:?}", provider, 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 {
self.enabled.contains(&provider)
}
}
/// Check if provider is in enabled set (ignoring prefix)
pub fn is_enabled(&self, provider: ProviderType) -> bool {
self.enabled.contains(&provider)
}
/// 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
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(),
};
}
}
// Check for prefix patterns (with trailing space)
let prefixes = [
(":app ", ProviderType::Application),
(":apps ", ProviderType::Application),
(":bm ", ProviderType::Bookmarks),
(":bookmark ", ProviderType::Bookmarks),
(":bookmarks ", ProviderType::Bookmarks),
(":calc ", ProviderType::Calculator),
(":calculator ", ProviderType::Calculator),
(":clip ", ProviderType::Clipboard),
(":clipboard ", ProviderType::Clipboard),
(":cmd ", ProviderType::Command),
(":command ", ProviderType::Command),
(":emoji ", ProviderType::Emoji),
(":emojis ", ProviderType::Emoji),
(":file ", ProviderType::Files),
(":files ", ProviderType::Files),
(":find ", ProviderType::Files),
(":script ", ProviderType::Scripts),
(":scripts ", ProviderType::Scripts),
(":ssh ", ProviderType::Ssh),
(":sys ", ProviderType::System),
(":system ", ProviderType::System),
(":power ", ProviderType::System),
(":uuctl ", ProviderType::Uuctl),
(":web ", ProviderType::WebSearch),
(":search ", ProviderType::WebSearch),
];
for (prefix_str, provider) in 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),
tag_filter: None,
query: rest.to_string(),
};
}
}
// Handle prefix without trailing space (still typing)
let partial_prefixes = [
(":app", ProviderType::Application),
(":apps", ProviderType::Application),
(":bm", ProviderType::Bookmarks),
(":bookmark", ProviderType::Bookmarks),
(":bookmarks", ProviderType::Bookmarks),
(":calc", ProviderType::Calculator),
(":calculator", ProviderType::Calculator),
(":clip", ProviderType::Clipboard),
(":clipboard", ProviderType::Clipboard),
(":cmd", ProviderType::Command),
(":command", ProviderType::Command),
(":emoji", ProviderType::Emoji),
(":emojis", ProviderType::Emoji),
(":file", ProviderType::Files),
(":files", ProviderType::Files),
(":find", ProviderType::Files),
(":script", ProviderType::Scripts),
(":scripts", ProviderType::Scripts),
(":ssh", ProviderType::Ssh),
(":sys", ProviderType::System),
(":system", ProviderType::System),
(":power", ProviderType::System),
(":uuctl", ProviderType::Uuctl),
(":web", ProviderType::WebSearch),
(":search", ProviderType::WebSearch),
];
for (prefix_str, provider) in partial_prefixes {
if trimmed == prefix_str {
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider);
return ParsedQuery {
prefix: Some(provider),
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::Bookmarks => 1,
ProviderType::Calculator => 2,
ProviderType::Clipboard => 3,
ProviderType::Command => 4,
ProviderType::Dmenu => 5,
ProviderType::Emoji => 6,
ProviderType::Files => 7,
ProviderType::MediaPlayer => 8,
ProviderType::Pomodoro => 9,
ProviderType::Scripts => 10,
ProviderType::Ssh => 11,
ProviderType::System => 12,
ProviderType::Uuctl => 13,
ProviderType::Weather => 14,
ProviderType::WebSearch => 15,
ProviderType::Plugin(_) => 100, // Plugin providers sort last
});
providers
}
/// 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::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clipboard",
ProviderType::Command => "Commands",
ProviderType::Dmenu => "dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::MediaPlayer => "Media",
ProviderType::Pomodoro => "Pomodoro",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::Weather => "Weather",
ProviderType::WebSearch => "Web",
ProviderType::Plugin(_) => "Plugin",
};
}
let enabled: Vec<_> = self.enabled_providers();
if enabled.len() == 1 {
match &enabled[0] {
ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clipboard",
ProviderType::Command => "Commands",
ProviderType::Dmenu => "dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::MediaPlayer => "Media",
ProviderType::Pomodoro => "Pomodoro",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::Weather => "Weather",
ProviderType::WebSearch => "Web",
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_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));
}
}

View File

@@ -1,11 +1,8 @@
mod app;
mod backend;
mod cli;
mod config;
mod data;
mod filter;
mod notify;
mod paths;
mod plugins;
pub mod client;
mod plugin_commands;
mod providers;
mod theme;
mod ui;
@@ -13,10 +10,43 @@ 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();
@@ -25,7 +55,7 @@ fn main() {
// CLI commands don't need full logging
match command {
Command::Plugin(plugin_cmd) => {
if let Err(e) = plugins::commands::execute(plugin_cmd.clone()) {
if let Err(e) = plugin_commands::execute(plugin_cmd.clone()) {
eprintln!("Error: {}", e);
std::process::exit(1);
}
@@ -35,7 +65,11 @@ fn main() {
}
// No subcommand - launch the app
let default_level = if cfg!(feature = "dev-logging") { "debug" } else { "info" };
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()
@@ -49,6 +83,27 @@ fn main() {
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

View File

@@ -7,11 +7,11 @@ use std::io::{self, Write};
use std::path::{Path, PathBuf};
use crate::cli::{PluginCommand as CliPluginCommand, PluginRuntime};
use crate::config::Config;
use crate::paths;
use crate::plugins::manifest::{discover_plugins, PluginManifest};
use crate::plugins::registry::{self, RegistryClient};
use crate::plugins::runtime_loader::{lua_runtime_available, rune_runtime_available};
use owlry_core::config::Config;
use owlry_core::paths;
use owlry_core::plugins::manifest::{PluginManifest, discover_plugins};
use owlry_core::plugins::registry::{self, RegistryClient};
use owlry_core::plugins::runtime_loader::{lua_runtime_available, rune_runtime_available};
/// Result type for plugin commands
pub type CommandResult = Result<(), String>;
@@ -46,15 +46,30 @@ fn any_runtime_available() -> bool {
/// Execute a plugin command
pub fn execute(cmd: CliPluginCommand) -> CommandResult {
match cmd {
CliPluginCommand::List { enabled, disabled, runtime, available, refresh, json } => {
CliPluginCommand::List {
enabled,
disabled,
runtime,
available,
refresh,
json,
} => {
if available {
cmd_list_available(refresh, json)
} else {
cmd_list_installed(enabled, disabled, runtime, json)
}
}
CliPluginCommand::Search { query, refresh, json } => cmd_search(&query, refresh, json),
CliPluginCommand::Info { name, registry, json } => {
CliPluginCommand::Search {
query,
refresh,
json,
} => cmd_search(&query, refresh, json),
CliPluginCommand::Info {
name,
registry,
json,
} => {
if registry {
cmd_info_registry(&name, json)
} else {
@@ -74,15 +89,29 @@ pub fn execute(cmd: CliPluginCommand) -> CommandResult {
CliPluginCommand::Update { name } => cmd_update(name.as_deref()),
CliPluginCommand::Enable { name } => cmd_enable(&name),
CliPluginCommand::Disable { name } => cmd_disable(&name),
CliPluginCommand::Create { name, runtime, dir, display_name, description } => {
CliPluginCommand::Create {
name,
runtime,
dir,
display_name,
description,
} => {
check_runtime_available(runtime)?;
cmd_create(&name, runtime, dir.as_deref(), display_name.as_deref(), description.as_deref())
cmd_create(
&name,
runtime,
dir.as_deref(),
display_name.as_deref(),
description.as_deref(),
)
}
CliPluginCommand::Validate { path } => cmd_validate(path.as_deref()),
CliPluginCommand::Runtimes => cmd_runtimes(),
CliPluginCommand::Run { plugin_id, command, args } => {
cmd_run_plugin_command(&plugin_id, &command, &args)
}
CliPluginCommand::Run {
plugin_id,
command,
args,
} => cmd_run_plugin_command(&plugin_id, &command, &args),
CliPluginCommand::Commands { plugin_id } => cmd_list_commands(plugin_id.as_deref()),
}
}
@@ -351,7 +380,10 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
});
println!("{}", serde_json::to_string_pretty(&info).unwrap());
} else {
println!("Plugin: {} v{}", manifest.plugin.name, manifest.plugin.version);
println!(
"Plugin: {} v{}",
manifest.plugin.name, manifest.plugin.version
);
println!("ID: {}", manifest.plugin.id);
if !manifest.plugin.description.is_empty() {
println!("Description: {}", manifest.plugin.description);
@@ -359,11 +391,18 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
if !manifest.plugin.author.is_empty() {
println!("Author: {}", manifest.plugin.author);
}
println!("Status: {}", if is_enabled { "enabled" } else { "disabled" });
println!(
"Status: {}",
if is_enabled { "enabled" } else { "disabled" }
);
println!(
"Runtime: {}{}",
runtime,
if runtime_available { "" } else { " (NOT INSTALLED)" }
if runtime_available {
""
} else {
" (NOT INSTALLED)"
}
);
println!("Path: {}", plugin_path.display());
println!();
@@ -382,12 +421,25 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
}
println!();
println!("Permissions:");
println!(" Network: {}", if manifest.permissions.network { "yes" } else { "no" });
println!(
" Network: {}",
if manifest.permissions.network {
"yes"
} else {
"no"
}
);
if !manifest.permissions.filesystem.is_empty() {
println!(" Filesystem: {}", manifest.permissions.filesystem.join(", "));
println!(
" Filesystem: {}",
manifest.permissions.filesystem.join(", ")
);
}
if !manifest.permissions.run_commands.is_empty() {
println!(" Commands: {}", manifest.permissions.run_commands.join(", "));
println!(
" Commands: {}",
manifest.permissions.run_commands.join(", ")
);
}
}
@@ -398,7 +450,8 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
fn cmd_info_registry(name: &str, json_output: bool) -> CommandResult {
let client = get_registry_client();
let plugin = client.find(name, false)?
let plugin = client
.find(name, false)?
.ok_or_else(|| format!("Plugin '{}' not found in registry", name))?;
if json_output {
@@ -466,12 +519,10 @@ fn cmd_install(source: &str, force: bool) -> CommandResult {
println!("Found: {} v{}", plugin.name, plugin.version);
install_from_git(&plugin.repository, &plugins_dir, force)
}
None => {
Err(format!(
"Plugin '{}' not found in registry. Use a local path or git URL.",
source
))
}
None => Err(format!(
"Plugin '{}' not found in registry. Use a local path or git URL.",
source
)),
}
}
}
@@ -597,8 +648,7 @@ fn cmd_remove(name: &str, yes: bool) -> CommandResult {
}
}
fs::remove_dir_all(&plugin_path)
.map_err(|e| format!("Failed to remove plugin: {}", e))?;
fs::remove_dir_all(&plugin_path).map_err(|e| format!("Failed to remove plugin: {}", e))?;
// Also remove from disabled list if present
if let Ok(mut config) = Config::load() {
@@ -645,7 +695,9 @@ fn cmd_enable(name: &str) -> CommandResult {
}
config.plugins.disabled_plugins.retain(|id| id != name);
config.save().map_err(|e| format!("Failed to save config: {}", e))?;
config
.save()
.map_err(|e| format!("Failed to save config: {}", e))?;
println!("Enabled plugin '{}'", name);
Ok(())
@@ -668,7 +720,9 @@ fn cmd_disable(name: &str) -> CommandResult {
}
config.plugins.disabled_plugins.push(name.to_string());
config.save().map_err(|e| format!("Failed to save config: {}", e))?;
config
.save()
.map_err(|e| format!("Failed to save config: {}", e))?;
println!("Disabled plugin '{}'", name);
Ok(())
@@ -688,11 +742,13 @@ fn cmd_create(
let plugin_dir = base_dir.join(name);
if plugin_dir.exists() {
return Err(format!("Directory '{}' already exists", plugin_dir.display()));
return Err(format!(
"Directory '{}' already exists",
plugin_dir.display()
));
}
fs::create_dir_all(&plugin_dir)
.map_err(|e| format!("Failed to create directory: {}", e))?;
fs::create_dir_all(&plugin_dir).map_err(|e| format!("Failed to create directory: {}", e))?;
let display = display_name.unwrap_or(name);
let desc = description.unwrap_or("A custom owlry plugin");
@@ -825,14 +881,28 @@ pub fn register(owlry) {{{{
}
}
println!("Created {} plugin '{}' at {}", runtime, name, plugin_dir.display());
println!(
"Created {} plugin '{}' at {}",
runtime,
name,
plugin_dir.display()
);
println!();
println!("Next steps:");
println!(" 1. Edit {}/{} to implement your provider", name, entry_file);
println!(" 2. Install: owlry plugin install {}", plugin_dir.display());
println!(
" 1. Edit {}/{} to implement your provider",
name, entry_file
);
println!(
" 2. Install: owlry plugin install {}",
plugin_dir.display()
);
println!(" 3. Test: owlry (your plugin items should appear)");
println!();
println!("Runtime: {} (requires owlry-{} package)", runtime, entry_ext);
println!(
"Runtime: {} (requires owlry-{} package)",
runtime, entry_ext
);
Ok(())
}
@@ -932,7 +1002,7 @@ fn cmd_validate(path: Option<&str>) -> CommandResult {
/// Show available script runtimes
fn cmd_runtimes() -> CommandResult {
use crate::plugins::runtime_loader::SYSTEM_RUNTIMES_DIR;
use owlry_core::plugins::runtime_loader::SYSTEM_RUNTIMES_DIR;
println!("Script Runtimes:\n");
@@ -996,15 +1066,29 @@ fn cmd_run_plugin_command(plugin_id: &str, command: &str, args: &[String]) -> Co
.map_err(|e| format!("Failed to parse manifest: {}", e))?;
// Check if plugin provides this command
let cmd_info = manifest.provides.commands.iter().find(|c| c.name == command);
let cmd_info = manifest
.provides
.commands
.iter()
.find(|c| c.name == command);
if cmd_info.is_none() {
let available: Vec<_> = manifest.provides.commands.iter().map(|c| c.name.as_str()).collect();
let available: Vec<_> = manifest
.provides
.commands
.iter()
.map(|c| c.name.as_str())
.collect();
if available.is_empty() {
return Err(format!("Plugin '{}' does not provide any CLI commands", plugin_id));
return Err(format!(
"Plugin '{}' does not provide any CLI commands",
plugin_id
));
}
return Err(format!(
"Plugin '{}' does not have command '{}'. Available: {}",
plugin_id, command, available.join(", ")
plugin_id,
command,
available.join(", ")
));
}
@@ -1024,16 +1108,14 @@ fn execute_plugin_command(
command: &str,
args: &[String],
) -> CommandResult {
use crate::plugins::runtime_loader::{LoadedRuntime, SYSTEM_RUNTIMES_DIR};
use owlry_core::plugins::runtime_loader::{LoadedRuntime, SYSTEM_RUNTIMES_DIR};
let runtime = detect_runtime(manifest);
// Load the appropriate runtime
let loaded_runtime = match runtime {
PluginRuntime::Lua => {
LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path))
.map_err(|e| format!("Failed to load Lua runtime: {}", e))?
}
PluginRuntime::Lua => LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path))
.map_err(|e| format!("Failed to load Lua runtime: {}", e))?,
PluginRuntime::Rune => {
LoadedRuntime::load_rune(plugin_path.parent().unwrap_or(plugin_path))
.map_err(|e| format!("Failed to load Rune runtime: {}", e))?
@@ -1047,7 +1129,10 @@ fn execute_plugin_command(
let _query = query_parts.join(":");
// Find the provider from this plugin and send the command query
let _provider_name = manifest.provides.providers.first()
let _provider_name = manifest
.provides
.providers
.first()
.ok_or_else(|| format!("Plugin '{}' has no providers", manifest.plugin.id))?;
// Query the provider with the command
@@ -1056,14 +1141,31 @@ fn execute_plugin_command(
// For now, we use a simpler approach: invoke the entry point with command args
// This requires runtime support for command execution
println!("Executing: owlry plugin run {} {} {}", manifest.plugin.id, command, args.join(" "));
println!(
"Executing: owlry plugin run {} {} {}",
manifest.plugin.id,
command,
args.join(" ")
);
println!();
println!("Note: Plugin command execution requires runtime support.");
println!("The plugin entry point should handle CLI commands via owlry.command.register()");
println!();
println!("Runtime: {} ({})", runtime, if PathBuf::from(SYSTEM_RUNTIMES_DIR).join(
match runtime { PluginRuntime::Lua => "liblua.so", PluginRuntime::Rune => "librune.so" }
).exists() { "available" } else { "NOT INSTALLED" });
println!(
"Runtime: {} ({})",
runtime,
if PathBuf::from(SYSTEM_RUNTIMES_DIR)
.join(match runtime {
PluginRuntime::Lua => "liblua.so",
PluginRuntime::Rune => "librune.so",
})
.exists()
{
"available"
} else {
"NOT INSTALLED"
}
);
// TODO: Implement actual command execution through runtime
// This would involve:
@@ -1087,7 +1189,8 @@ fn cmd_list_commands(plugin_id: Option<&str>) -> CommandResult {
if let Some(id) = plugin_id {
// Show commands for a specific plugin
let (manifest, _path) = discovered.get(id)
let (manifest, _path) = discovered
.get(id)
.ok_or_else(|| format!("Plugin '{}' not found", id))?;
if manifest.provides.commands.is_empty() {

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

View File

@@ -1,605 +1,2 @@
// Core providers (no plugin equivalents)
mod application;
mod command;
mod dmenu;
// Native plugin bridge
pub mod native_provider;
// Lua plugin bridge (optional)
#[cfg(feature = "lua")]
pub mod lua_provider;
// Re-exports for core providers
pub use application::ApplicationProvider;
pub use command::CommandProvider;
pub mod dmenu;
pub use dmenu::DmenuProvider;
// Re-export native provider for plugin loading
pub use native_provider::NativeProvider;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use log::info;
#[cfg(feature = "dev-logging")]
use log::debug;
use crate::data::FrecencyStore;
/// Represents a single searchable/launchable item
#[derive(Debug, Clone)]
pub struct LaunchItem {
#[allow(dead_code)]
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub provider: ProviderType,
pub command: String,
pub terminal: bool,
/// Tags/categories for filtering (e.g., from .desktop Categories)
pub tags: Vec<String>,
}
/// Provider type identifier for filtering and badge display
///
/// Note: Plugin is a special case that stores a type_id string
/// for custom plugin-defined provider types.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ProviderType {
Application,
Bookmarks,
Calculator,
Clipboard,
Command,
Dmenu,
Emoji,
Files,
MediaPlayer,
Pomodoro,
Scripts,
Ssh,
System,
Uuctl,
Weather,
WebSearch,
/// Plugin-defined provider type with custom type_id
Plugin(String),
}
impl std::str::FromStr for ProviderType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
"bookmark" | "bookmarks" | "bm" => Ok(ProviderType::Bookmarks),
"calc" | "calculator" => Ok(ProviderType::Calculator),
"clip" | "clipboard" => Ok(ProviderType::Clipboard),
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
"dmenu" => Ok(ProviderType::Dmenu),
"emoji" | "emojis" => Ok(ProviderType::Emoji),
"file" | "files" | "find" => Ok(ProviderType::Files),
"media" | "mpris" | "player" => Ok(ProviderType::MediaPlayer),
"pomo" | "pomodoro" | "timer" => Ok(ProviderType::Pomodoro),
"script" | "scripts" => Ok(ProviderType::Scripts),
"ssh" => Ok(ProviderType::Ssh),
"sys" | "system" | "power" => Ok(ProviderType::System),
"uuctl" => Ok(ProviderType::Uuctl),
"weather" => Ok(ProviderType::Weather),
"web" | "websearch" | "search" => Ok(ProviderType::WebSearch),
// Plugin types are prefixed with "plugin:" (e.g., "plugin:github-repos")
other if other.starts_with("plugin:") => {
Ok(ProviderType::Plugin(other[7..].to_string()))
}
// Unknown types become plugin types
other => Ok(ProviderType::Plugin(other.to_string())),
}
}
}
impl std::fmt::Display for ProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProviderType::Application => write!(f, "app"),
ProviderType::Bookmarks => write!(f, "bookmark"),
ProviderType::Calculator => write!(f, "calc"),
ProviderType::Clipboard => write!(f, "clip"),
ProviderType::Command => write!(f, "cmd"),
ProviderType::Dmenu => write!(f, "dmenu"),
ProviderType::Emoji => write!(f, "emoji"),
ProviderType::Files => write!(f, "file"),
ProviderType::MediaPlayer => write!(f, "media"),
ProviderType::Pomodoro => write!(f, "pomo"),
ProviderType::Scripts => write!(f, "script"),
ProviderType::Ssh => write!(f, "ssh"),
ProviderType::System => write!(f, "sys"),
ProviderType::Uuctl => write!(f, "uuctl"),
ProviderType::Weather => write!(f, "weather"),
ProviderType::WebSearch => write!(f, "web"),
ProviderType::Plugin(type_id) => write!(f, "{}", type_id),
}
}
}
/// Trait for all search providers
pub trait Provider: Send {
#[allow(dead_code)]
fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType;
fn refresh(&mut self);
fn items(&self) -> &[LaunchItem];
}
/// Manages all providers and handles searching
pub struct ProviderManager {
/// Static providers (apps, commands, and native static plugins)
providers: Vec<Box<dyn Provider>>,
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
/// These are queried per-keystroke, not cached
dynamic_providers: Vec<NativeProvider>,
/// Widget providers from native plugins (weather, media, pomodoro)
/// These appear at the top of results
widget_providers: Vec<NativeProvider>,
/// Fuzzy matcher for search
matcher: SkimMatcherV2,
}
/// Known dynamic provider type IDs (need per-query evaluation)
const DYNAMIC_TYPE_IDS: &[&str] = &["calc", "websearch", "filesearch"];
/// Known widget provider type IDs (appear at top of results)
const WIDGET_TYPE_IDS: &[&str] = &["weather", "media", "pomodoro"];
impl ProviderManager {
/// Create a new ProviderManager with native plugins
///
/// Native plugins are loaded from /usr/lib/owlry/plugins/ and categorized into:
/// - Static providers (added to providers vec)
/// - Dynamic providers (queried per-keystroke: calculator, websearch, filesearch)
/// - Widget providers (shown at top: weather, media, pomodoro)
pub fn with_native_plugins(native_providers: Vec<NativeProvider>) -> Self {
let mut manager = Self {
providers: Vec::new(),
dynamic_providers: Vec::new(),
widget_providers: Vec::new(),
matcher: SkimMatcherV2::default(),
};
// Check if running in dmenu mode (stdin has data)
let dmenu_mode = DmenuProvider::has_stdin_data();
if dmenu_mode {
// In dmenu mode, only use dmenu provider
let mut dmenu = DmenuProvider::new();
dmenu.enable();
manager.providers.push(Box::new(dmenu));
} else {
// Core providers (no plugin equivalents)
manager.providers.push(Box::new(ApplicationProvider::new()));
manager.providers.push(Box::new(CommandProvider::new()));
// Categorize native plugins
for provider in native_providers {
let type_id = provider.type_id();
if DYNAMIC_TYPE_IDS.contains(&type_id) {
info!("Registered dynamic provider: {} ({})", provider.name(), type_id);
manager.dynamic_providers.push(provider);
} else if WIDGET_TYPE_IDS.contains(&type_id) {
info!("Registered widget provider: {} ({})", provider.name(), type_id);
manager.widget_providers.push(provider);
} else {
info!("Registered static provider: {} ({})", provider.name(), type_id);
manager.providers.push(Box::new(provider));
}
}
}
// Initial refresh
manager.refresh_all();
manager
}
#[allow(dead_code)]
pub fn is_dmenu_mode(&self) -> bool {
self.providers
.iter()
.any(|p| p.provider_type() == ProviderType::Dmenu)
}
pub fn refresh_all(&mut self) {
// Refresh static providers (fast, local operations)
for provider in &mut self.providers {
provider.refresh();
info!(
"Provider '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
// Widget providers are refreshed separately to avoid blocking startup
// Call refresh_widgets() after window is shown
// Dynamic providers don't need refresh (they query on demand)
}
/// Refresh widget providers (weather, media, pomodoro)
/// Call this separately from refresh_all() to avoid blocking startup
/// since widgets may make network requests or spawn processes
pub fn refresh_widgets(&mut self) {
for provider in &mut self.widget_providers {
provider.refresh();
info!(
"Widget '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
}
/// Find a native provider by type ID
/// Searches in widget providers and dynamic providers
pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> {
// Check widget providers first (pomodoro, weather, media)
if let Some(p) = self.widget_providers.iter().find(|p| p.type_id() == type_id) {
return Some(p);
}
// Then dynamic providers (calc, websearch, filesearch)
self.dynamic_providers.iter().find(|p| p.type_id() == type_id)
}
/// Execute a plugin action command
/// Command format: PLUGIN_ID:action_data (e.g., "POMODORO:start", "SYSTEMD:unit:restart")
/// Returns true if the command was handled by a plugin
pub fn execute_plugin_action(&self, command: &str) -> bool {
// Parse command format: PLUGIN_ID:action_data
if let Some(colon_pos) = command.find(':') {
let plugin_id = &command[..colon_pos];
let action = command; // Pass full command to plugin
// Find provider by type ID (case-insensitive for convenience)
let type_id = plugin_id.to_lowercase();
if let Some(provider) = self.find_native_provider(&type_id) {
provider.execute_action(action);
return true;
}
}
false
}
/// Add a dynamic provider (e.g., from a Lua plugin)
pub fn add_provider(&mut self, provider: Box<dyn Provider>) {
info!("Added plugin provider: {}", provider.name());
self.providers.push(provider);
}
/// Add multiple providers at once (for batch plugin loading)
pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) {
for provider in providers {
self.add_provider(provider);
}
}
#[allow(dead_code)]
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
if query.is_empty() {
// Return recent/popular items when query is empty
return self.providers
.iter()
.flat_map(|p| p.items().iter().cloned())
.take(max_results)
.map(|item| (item, 0))
.collect();
}
let mut results: Vec<(LaunchItem, i64)> = self.providers
.iter()
.flat_map(|provider| {
provider.items().iter().filter_map(|item| {
// Match against name and description
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
let score = match (name_score, desc_score) {
(Some(n), Some(d)) => Some(n.max(d)),
(Some(n), None) => Some(n),
(None, Some(d)) => Some(d / 2), // Lower weight for description matches
(None, None) => None,
};
score.map(|s| (item.clone(), s))
})
})
.collect();
// Sort by score (descending)
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
results
}
/// Search with provider filtering
pub fn search_filtered(
&self,
query: &str,
max_results: usize,
filter: &crate::filter::ProviderFilter,
) -> Vec<(LaunchItem, i64)> {
if query.is_empty() {
return self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned())
.take(max_results)
.map(|item| (item, 0))
.collect();
}
let mut results: Vec<(LaunchItem, i64)> = self
.providers
.iter()
.filter(|provider| filter.is_active(provider.provider_type()))
.flat_map(|provider| {
provider.items().iter().filter_map(|item| {
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item
.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
let score = match (name_score, desc_score) {
(Some(n), Some(d)) => Some(n.max(d)),
(Some(n), None) => Some(n),
(None, Some(d)) => Some(d / 2),
(None, None) => None,
};
score.map(|s| (item.clone(), s))
})
})
.collect();
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
results
}
/// Search with frecency boosting, dynamic providers, and tag filtering
pub fn search_with_frecency(
&self,
query: &str,
max_results: usize,
filter: &crate::filter::ProviderFilter,
frecency: &FrecencyStore,
frecency_weight: f64,
tag_filter: Option<&str>,
) -> Vec<(LaunchItem, i64)> {
#[cfg(feature = "dev-logging")]
debug!("[Search] query={:?}, max={}, frecency_weight={}", query, max_results, frecency_weight);
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
// Add widget items first (highest priority) - only when:
// 1. No specific filter prefix is active
// 2. Query is empty (user hasn't started searching)
// This keeps widgets visible on launch but hides them during active search
if filter.active_prefix().is_none() && query.is_empty() {
// Widget priority scores based on type
for provider in &self.widget_providers {
let base_score = match provider.type_id() {
"weather" => 12000,
"pomodoro" => 11500,
"media" => 11000,
_ => 10500,
};
for (idx, item) in provider.items().iter().enumerate() {
results.push((item.clone(), base_score - idx as i64));
}
}
}
// Query dynamic providers (calculator, websearch, filesearch)
// Each provider internally checks if the query matches its prefix
if !query.is_empty() {
for (provider_idx, provider) in self.dynamic_providers.iter().enumerate() {
let dynamic_results = provider.query(query);
let base_score = match provider.type_id() {
"calc" => 10000,
"websearch" => 9000,
"filesearch" => 8000,
_ => 7000 - (provider_idx as i64 * 1000),
};
for (idx, item) in dynamic_results.into_iter().enumerate() {
results.push((item, base_score - idx as i64));
}
}
}
// Empty query (after checking special providers) - return frecency-sorted items
if query.is_empty() {
let items: Vec<(LaunchItem, i64)> = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned())
.filter(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter {
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
} else {
true
}
})
.map(|item| {
let frecency_score = frecency.get_score(&item.id);
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
(item, boosted)
})
.collect();
// Combine widgets (already in results) with frecency items
results.extend(items);
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
return results;
}
// Regular search with frecency boost and tag matching
let search_results: Vec<(LaunchItem, i64)> = self
.providers
.iter()
.filter(|provider| filter.is_active(provider.provider_type()))
.flat_map(|provider| {
provider.items().iter().filter_map(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) {
return None;
}
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item
.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
// Also match against tags (lower weight)
let tag_score = item
.tags
.iter()
.filter_map(|t| self.matcher.fuzzy_match(t, query))
.max()
.map(|s| s / 3); // Lower weight for tag matches
let base_score = match (name_score, desc_score, tag_score) {
(Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)),
(Some(n), Some(d), None) => Some(n.max(d)),
(Some(n), None, Some(t)) => Some(n.max(t)),
(Some(n), None, None) => Some(n),
(None, Some(d), Some(t)) => Some((d / 2).max(t)),
(None, Some(d), None) => Some(d / 2),
(None, None, Some(t)) => Some(t),
(None, None, None) => None,
};
base_score.map(|s| {
let frecency_score = frecency.get_score(&item.id);
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
(item.clone(), s + frecency_boost)
})
})
})
.collect();
results.extend(search_results);
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
#[cfg(feature = "dev-logging")]
{
debug!("[Search] Returning {} results", results.len());
for (i, (item, score)) in results.iter().take(5).enumerate() {
debug!("[Search] #{}: {} (score={}, provider={:?})", i + 1, item.name, score, item.provider);
}
if results.len() > 5 {
debug!("[Search] ... and {} more", results.len() - 5);
}
}
results
}
/// Get all available provider types (for UI tabs)
#[allow(dead_code)]
pub fn available_providers(&self) -> Vec<ProviderType> {
self.providers.iter().map(|p| p.provider_type()).collect()
}
/// Get a widget item by type_id (e.g., "pomodoro", "weather", "media")
/// Returns the first item from the widget provider, if any
pub fn get_widget_item(&self, type_id: &str) -> Option<LaunchItem> {
self.widget_providers
.iter()
.find(|p| p.type_id() == type_id)
.and_then(|p| p.items().first().cloned())
}
/// Get all loaded widget provider type_ids
/// Returns an iterator over the type_ids of currently loaded widget providers
pub fn widget_type_ids(&self) -> impl Iterator<Item = &str> {
self.widget_providers.iter().map(|p| p.type_id())
}
/// Query a plugin for submenu actions
///
/// This is used when a user selects a SUBMENU:plugin_id:data item.
/// The plugin is queried with "?SUBMENU:data" and returns action items.
///
/// Returns (display_name, actions) where display_name is the item name
/// and actions are the submenu items returned by the plugin.
pub fn query_submenu_actions(
&self,
plugin_id: &str,
data: &str,
display_name: &str,
) -> Option<(String, Vec<LaunchItem>)> {
// Build the submenu query
let submenu_query = format!("?SUBMENU:{}", data);
#[cfg(feature = "dev-logging")]
debug!(
"[Submenu] Querying plugin '{}' with: {}",
plugin_id, submenu_query
);
// Search in dynamic providers
for provider in &self.dynamic_providers {
if provider.type_id() == plugin_id {
let actions = provider.query(&submenu_query);
if !actions.is_empty() {
return Some((display_name.to_string(), actions));
}
}
}
// Search in widget providers
for provider in &self.widget_providers {
if provider.type_id() == plugin_id {
let actions = provider.query(&submenu_query);
if !actions.is_empty() {
return Some((display_name.to_string(), actions));
}
}
}
// Search in static providers (boxed)
// Note: Static providers don't typically have submenu support,
// but we check for completeness
for provider in &self.providers {
if let ProviderType::Plugin(type_id) = provider.provider_type()
&& type_id == plugin_id
{
// Static providers use the items() method, not query
// Submenu support requires dynamic query capability
#[cfg(feature = "dev-logging")]
debug!(
"[Submenu] Plugin '{}' is static, cannot query for submenu",
plugin_id
);
}
}
#[cfg(feature = "dev-logging")]
debug!("[Submenu] No submenu actions found for plugin '{}'", plugin_id);
None
}
}

View File

@@ -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);

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<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>

Before

Width:  |  Height:  |  Size: 188 B

After

Width:  |  Height:  |  Size: 183 B

View File

@@ -1,7 +1,7 @@
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
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

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,7 +1,7 @@
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
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

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,7 +1,7 @@
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
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

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,7 +1,7 @@
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
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

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,7 +1,7 @@
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
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

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,7 +1,7 @@
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
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

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

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