Compare commits

...

41 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
102 changed files with 8738 additions and 8906 deletions

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)

1500
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",
]

208
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
@@ -46,7 +49,7 @@ 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` |
@@ -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 |
@@ -180,8 +299,8 @@ cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
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 = 850
@@ -201,13 +320,20 @@ frecency_weight = 0.3 # 0.0-1.0
# Web search engine: google, duckduckgo, bing, startpage, brave, ecosia
search_engine = "duckduckgo"
# Profiles: named sets of modes
[profiles.dev]
modes = ["app", "cmd", "ssh"]
[profiles.media]
modes = ["media", "emoji"]
```
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`)
@@ -221,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:
@@ -280,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

View File

@@ -75,6 +75,24 @@ 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+.

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,6 +6,21 @@ use std::process::Command;
use crate::paths;
/// A named profile that selects a set of provider modes.
///
/// Defined in config.toml as:
/// ```toml
/// [profiles.dev]
/// modes = ["app", "cmd", "ssh"]
///
/// [profiles.media]
/// modes = ["media", "emoji"]
/// ```
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ProfileConfig {
pub modes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
@@ -16,6 +31,8 @@ pub struct Config {
pub providers: ProvidersConfig,
#[serde(default)]
pub plugins: PluginsConfig,
#[serde(default)]
pub profiles: HashMap<String, ProfileConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -27,11 +44,12 @@ pub struct GeneralConfig {
/// Terminal command (auto-detected if not specified)
#[serde(default)]
pub terminal_command: Option<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
/// 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 launch_wrapper: Option<String>,
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")]
@@ -44,7 +62,7 @@ impl Default for GeneralConfig {
show_icons: true,
max_results: 100,
terminal_command: None,
launch_wrapper: None,
use_uwsm: false,
tabs: default_tabs(),
}
}
@@ -55,11 +73,7 @@ fn default_max_results() -> usize {
}
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
@@ -125,10 +139,18 @@ impl Default for AppearanceConfig {
}
}
fn default_width() -> i32 { 850 }
fn default_height() -> i32 { 650 }
fn default_font_size() -> u32 { 14 }
fn default_border_radius() -> u32 { 12 }
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 {
@@ -178,7 +200,6 @@ pub struct ProvidersConfig {
pub files: bool,
// ─── Widget Providers ───────────────────────────────────────────────
/// Enable MPRIS media player widget
#[serde(default = "default_true")]
pub media: bool,
@@ -332,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()
}
}
@@ -396,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)
@@ -431,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") {
@@ -458,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);
@@ -578,11 +576,6 @@ impl Config {
}
}
// Auto-detect launch wrapper if not configured
if config.general.launch_wrapper.is_none() {
config.general.launch_wrapper = detect_launch_wrapper();
}
Ok(config)
}

View File

@@ -11,6 +11,10 @@ use crate::providers::ProviderType;
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
@@ -86,10 +90,14 @@ impl ProviderFilter {
let filter = Self {
enabled,
active_prefix: None,
accept_all: false,
};
#[cfg(feature = "dev-logging")]
debug!("[Filter] Created with enabled providers: {:?}", filter.enabled);
debug!(
"[Filter] Created with enabled providers: {:?}",
filter.enabled
);
filter
}
@@ -100,6 +108,7 @@ impl ProviderFilter {
Self {
enabled: HashSet::from([ProviderType::Application]),
active_prefix: None,
accept_all: false,
}
}
@@ -112,13 +121,19 @@ impl ProviderFilter {
self.enabled.insert(ProviderType::Application);
}
#[cfg(feature = "dev-logging")]
debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled);
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);
debug!(
"[Filter] Toggled ON {}, enabled: {:?}",
provider_debug, self.enabled
);
}
}
@@ -145,7 +160,10 @@ impl ProviderFilter {
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);
debug!(
"[Filter] Prefix changed: {:?} -> {:?}",
self.active_prefix, prefix
);
}
self.active_prefix = prefix;
}
@@ -154,6 +172,8 @@ impl ProviderFilter {
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)
}
@@ -161,7 +181,7 @@ impl ProviderFilter {
/// Check if provider is in enabled set (ignoring prefix)
pub fn is_enabled(&self, provider: ProviderType) -> bool {
self.enabled.contains(&provider)
self.accept_all || self.enabled.contains(&provider)
}
/// Get current active prefix if any
@@ -182,7 +202,10 @@ impl ProviderFilter {
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);
debug!(
"[Filter] parse_query({:?}) -> tag={:?}, query={:?}",
query, tag, query_part
);
return ParsedQuery {
prefix: None,
tag_filter: Some(tag),
@@ -237,7 +260,10 @@ impl ProviderFilter {
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);
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
query, provider, rest
);
return ParsedQuery {
prefix: Some(provider.clone()),
tag_filter: None,
@@ -251,7 +277,10 @@ impl ProviderFilter {
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);
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
query, provider, rest
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
@@ -296,7 +325,10 @@ impl ProviderFilter {
for (prefix_str, provider) in partial_core {
if trimmed == *prefix_str {
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider);
debug!(
"[Filter] parse_query({:?}) -> partial prefix {:?}",
query, provider
);
return ParsedQuery {
prefix: Some(provider.clone()),
tag_filter: None,
@@ -309,7 +341,10 @@ impl ProviderFilter {
if trimmed == *prefix_str {
let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider);
debug!(
"[Filter] parse_query({:?}) -> partial prefix {:?}",
query, provider
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
@@ -325,7 +360,10 @@ impl ProviderFilter {
};
#[cfg(feature = "dev-logging")]
debug!("[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}", query, result.prefix, result.tag_filter, result.query);
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}",
query, result.prefix, result.tag_filter, result.query
);
result
}
@@ -342,6 +380,56 @@ impl ProviderFilter {
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 {
@@ -395,7 +483,10 @@ mod tests {
#[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.prefix,
Some(ProviderType::Plugin("calc".to_string()))
);
assert_eq!(result.query, "5+3");
}
@@ -406,4 +497,136 @@ mod tests {
// 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) => {
@@ -204,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(),
@@ -223,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
),
});
}

View File

@@ -21,7 +21,6 @@
//! ```
// Always available
pub mod commands;
pub mod error;
pub mod manifest;
pub mod native_loader;
@@ -51,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)
@@ -65,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 {
@@ -159,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
@@ -177,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 {
@@ -192,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(())
}
@@ -201,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()
}
@@ -209,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 {

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, ProviderPosition};
use owlry_plugin_api::{
PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition,
};
use super::{LaunchItem, Provider, ProviderType};
use crate::plugins::native_loader::NativePlugin;
@@ -76,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
@@ -116,6 +121,19 @@ impl NativeProvider {
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) {

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.4.6"
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.4.6"
version = "1.0.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -284,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,31 +0,0 @@
[package]
name = "owlry-plugin-bookmarks"
version = "0.4.6"
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"
# For reading Firefox bookmarks (places.sqlite)
# Use bundled SQLite to avoid system library version conflicts
rusqlite = { version = "0.32", features = ["bundled"] }

View File

@@ -1,662 +0,0 @@
//! Bookmarks Plugin for Owlry
//!
//! A static provider that reads browser bookmarks from various browsers.
//!
//! Supported browsers:
//! - Firefox (via places.sqlite using rusqlite with bundled SQLite)
//! - Chrome
//! - Chromium
//! - Brave
//! - Edge
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
ProviderPosition, API_VERSION,
};
use rusqlite::{Connection, OpenFlags};
use serde::Deserialize;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
// 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 = "user-bookmarks-symbolic";
const PROVIDER_TYPE_ID: &str = "bookmarks";
/// Bookmarks provider state - holds cached items
struct BookmarksState {
/// Cached bookmark items (returned immediately on refresh)
items: Vec<PluginItem>,
/// Flag to prevent concurrent background loads
loading: Arc<AtomicBool>,
}
impl BookmarksState {
fn new() -> Self {
Self {
items: Vec::new(),
loading: Arc::new(AtomicBool::new(false)),
}
}
/// Get or create the favicon cache directory
fn favicon_cache_dir() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("owlry/favicons"))
}
/// Ensure the favicon cache directory exists
fn ensure_favicon_cache_dir() -> Option<PathBuf> {
Self::favicon_cache_dir().and_then(|dir| {
fs::create_dir_all(&dir).ok()?;
Some(dir)
})
}
/// Hash a URL to create a cache filename
fn url_to_cache_filename(url: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
format!("{:016x}.png", hasher.finish())
}
/// Get the bookmark cache file path
fn bookmark_cache_file() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("owlry/bookmarks.json"))
}
/// Load cached bookmarks from disk (fast)
fn load_cached_bookmarks() -> Vec<PluginItem> {
let cache_file = match Self::bookmark_cache_file() {
Some(f) => f,
None => return Vec::new(),
};
if !cache_file.exists() {
return Vec::new();
}
let content = match fs::read_to_string(&cache_file) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
// Parse cached bookmarks (simple JSON format)
#[derive(serde::Deserialize)]
struct CachedBookmark {
id: String,
name: String,
command: String,
description: Option<String>,
icon: String,
}
let cached: Vec<CachedBookmark> = match serde_json::from_str(&content) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
cached
.into_iter()
.map(|b| {
let mut item = PluginItem::new(b.id, b.name, b.command)
.with_icon(&b.icon)
.with_keywords(vec!["bookmark".to_string()]);
if let Some(desc) = b.description {
item = item.with_description(desc);
}
item
})
.collect()
}
/// Save bookmarks to cache file
fn save_cached_bookmarks(items: &[PluginItem]) {
let cache_file = match Self::bookmark_cache_file() {
Some(f) => f,
None => return,
};
// Ensure cache directory exists
if let Some(parent) = cache_file.parent() {
let _ = fs::create_dir_all(parent);
}
#[derive(serde::Serialize)]
struct CachedBookmark {
id: String,
name: String,
command: String,
description: Option<String>,
icon: String,
}
let cached: Vec<CachedBookmark> = items
.iter()
.map(|item| {
let desc: Option<String> = match &item.description {
abi_stable::std_types::ROption::RSome(s) => Some(s.to_string()),
abi_stable::std_types::ROption::RNone => None,
};
let icon: String = match &item.icon {
abi_stable::std_types::ROption::RSome(s) => s.to_string(),
abi_stable::std_types::ROption::RNone => PROVIDER_ICON.to_string(),
};
CachedBookmark {
id: item.id.to_string(),
name: item.name.to_string(),
command: item.command.to_string(),
description: desc,
icon,
}
})
.collect();
if let Ok(json) = serde_json::to_string(&cached) {
let _ = fs::write(&cache_file, json);
}
}
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 firefox_places_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(home) = dirs::home_dir() {
let firefox_dir = home.join(".mozilla/firefox");
if firefox_dir.exists() {
// Find all profile directories
if let Ok(entries) = fs::read_dir(&firefox_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let places = path.join("places.sqlite");
if places.exists() {
paths.push(places);
}
}
}
}
}
}
paths
}
/// Find Firefox favicons.sqlite paths (paired with places.sqlite)
fn firefox_favicons_path(places_path: &Path) -> Option<PathBuf> {
let favicons = places_path.parent()?.join("favicons.sqlite");
if favicons.exists() {
Some(favicons)
} else {
None
}
}
fn load_bookmarks(&mut self) {
// Fast path: load from cache immediately
if self.items.is_empty() {
self.items = Self::load_cached_bookmarks();
}
// Don't start another background load if one is already running
if self.loading.swap(true, Ordering::SeqCst) {
return;
}
// Spawn background thread to refresh bookmarks
let loading = self.loading.clone();
thread::spawn(move || {
let mut items = Vec::new();
// Load Chrome/Chromium bookmarks (fast - just JSON parsing)
for path in Self::chromium_bookmark_paths() {
if path.exists() {
Self::read_chrome_bookmarks_static(&path, &mut items);
}
}
// Load Firefox bookmarks with favicons (synchronous with rusqlite)
for path in Self::firefox_places_paths() {
Self::read_firefox_bookmarks(&path, &mut items);
}
// Save to cache for next startup
Self::save_cached_bookmarks(&items);
loading.store(false, Ordering::SeqCst);
});
}
/// Read Chrome bookmarks (static helper for background thread)
fn read_chrome_bookmarks_static(path: &PathBuf, items: &mut Vec<PluginItem>) {
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,
};
if let Some(roots) = bookmarks.roots {
if let Some(bar) = roots.bookmark_bar {
Self::process_chrome_folder_static(&bar, items);
}
if let Some(other) = roots.other {
Self::process_chrome_folder_static(&other, items);
}
if let Some(synced) = roots.synced {
Self::process_chrome_folder_static(&synced, items);
}
}
}
fn process_chrome_folder_static(folder: &ChromeBookmarkNode, items: &mut Vec<PluginItem>) {
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());
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(), "chrome".to_string()]),
);
}
}
Some("folder") => {
Self::process_chrome_folder_static(child, items);
}
_ => {}
}
}
}
}
/// Read Firefox bookmarks using rusqlite (synchronous, bundled SQLite)
fn read_firefox_bookmarks(places_path: &PathBuf, items: &mut Vec<PluginItem>) {
let temp_dir = std::env::temp_dir();
let temp_db = temp_dir.join("owlry_places_temp.sqlite");
// Copy database to temp location to avoid locking issues
if fs::copy(places_path, &temp_db).is_err() {
return;
}
// Also copy WAL file if it exists
let wal_path = places_path.with_extension("sqlite-wal");
if wal_path.exists() {
let temp_wal = temp_db.with_extension("sqlite-wal");
let _ = fs::copy(&wal_path, &temp_wal);
}
// Copy favicons database if available
let favicons_path = Self::firefox_favicons_path(places_path);
let temp_favicons = temp_dir.join("owlry_favicons_temp.sqlite");
if let Some(ref fp) = favicons_path {
let _ = fs::copy(fp, &temp_favicons);
let fav_wal = fp.with_extension("sqlite-wal");
if fav_wal.exists() {
let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal"));
}
}
let cache_dir = Self::ensure_favicon_cache_dir();
// Read bookmarks from places.sqlite
let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref());
// Clean up temp files
let _ = fs::remove_file(&temp_db);
let _ = fs::remove_file(temp_db.with_extension("sqlite-wal"));
let _ = fs::remove_file(&temp_favicons);
let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal"));
for (title, url, favicon_path) in bookmarks {
let icon = favicon_path.unwrap_or_else(|| PROVIDER_ICON.to_string());
items.push(
PluginItem::new(
format!("bookmark:firefox:{}", url),
title,
format!("xdg-open '{}'", url.replace('\'', "'\\''")),
)
.with_description(url)
.with_icon(&icon)
.with_keywords(vec!["bookmark".to_string(), "firefox".to_string()]),
);
}
}
/// Fetch Firefox bookmarks with optional favicons
fn fetch_firefox_bookmarks(
places_path: &Path,
favicons_path: &Path,
cache_dir: Option<&PathBuf>,
) -> Vec<(String, String, Option<String>)> {
// Open places.sqlite in read-only mode
let conn = match Connection::open_with_flags(
places_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
// Query bookmarks joining moz_bookmarks with moz_places
// type=1 means URL bookmarks (not folders, separators, etc.)
let query = r#"
SELECT b.title, p.url
FROM moz_bookmarks b
JOIN moz_places p ON b.fk = p.id
WHERE b.type = 1
AND p.url NOT LIKE 'place:%'
AND p.url NOT LIKE 'about:%'
AND b.title IS NOT NULL
AND b.title != ''
ORDER BY b.dateAdded DESC
LIMIT 500
"#;
let mut stmt = match conn.prepare(query) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let bookmarks: Vec<(String, String)> = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.ok()
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default();
// If no favicons or cache dir, return without favicons
let cache_dir = match cache_dir {
Some(c) => c,
None => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
};
// Try to open favicons database
let fav_conn = match Connection::open_with_flags(
favicons_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(c) => c,
Err(_) => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
};
// Fetch favicons for each URL
let mut results = Vec::new();
for (title, url) in bookmarks {
let favicon_path = Self::get_favicon_for_url(&fav_conn, &url, cache_dir);
results.push((title, url, favicon_path));
}
results
}
/// Get favicon for a URL, caching to file if needed
fn get_favicon_for_url(
conn: &Connection,
page_url: &str,
cache_dir: &Path,
) -> Option<String> {
// Check if already cached
let cache_filename = Self::url_to_cache_filename(page_url);
let cache_path = cache_dir.join(&cache_filename);
if cache_path.exists() {
return Some(cache_path.to_string_lossy().to_string());
}
// Query favicon data from database
// Join moz_pages_w_icons -> moz_icons_to_pages -> moz_icons
// Prefer smaller icons (32px) for efficiency
let query = r#"
SELECT i.data
FROM moz_pages_w_icons p
JOIN moz_icons_to_pages ip ON p.id = ip.page_id
JOIN moz_icons i ON ip.icon_id = i.id
WHERE p.page_url = ?
AND i.data IS NOT NULL
ORDER BY ABS(i.width - 32) ASC
LIMIT 1
"#;
let data: Option<Vec<u8>> = conn
.query_row(query, [page_url], |row| row.get(0))
.ok();
let data = data?;
if data.is_empty() {
return None;
}
// Write favicon data to cache file
let mut file = fs::File::create(&cache_path).ok()?;
file.write_all(&data).ok()?;
Some(cache_path.to_string_lossy().to_string())
}
}
// 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),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.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_firefox_paths() {
// This will find paths if Firefox is installed
let paths = BookmarksState::firefox_places_paths();
// Path detection should work (may be empty if Firefox not installed)
let _ = paths.len(); // Just ensure it doesn't panic
}
#[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 items = Vec::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,
},
]),
};
BookmarksState::process_chrome_folder_static(&folder, &mut items);
assert_eq!(items.len(), 1);
assert_eq!(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.4.6"
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,231 +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,
ProviderPosition, 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),
position: ProviderPosition::Normal,
priority: 10000, // Dynamic: calculator results first
}]
.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.4.6"
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,259 +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,
ProviderPosition, 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),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.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.4.6"
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,565 +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,
ProviderPosition, 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_icon(*emoji) // Use emoji character as icon
.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),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.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.4.6"
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,322 +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,
ProviderPosition, 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),
position: ProviderPosition::Normal,
priority: 8000, // Dynamic: file search
}]
.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.4.6"
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,468 +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,
ProviderPosition, 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),
position: ProviderPosition::Widget,
priority: 11000, // Widget: media player
}]
.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.4.6"
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,478 +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, ProviderPosition, 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
&& 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())
&& 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),
position: ProviderPosition::Widget,
priority: 11500, // Widget: pomodoro timer
}]
.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.4.6"
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,290 +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,
ProviderPosition, 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),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.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.4.6"
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,328 +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,
ProviderPosition, 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),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.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.4.6"
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,254 +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,
ProviderPosition, 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),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.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.4.6"
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,457 +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,
ProviderPosition, 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),
position: ProviderPosition::Normal,
priority: 0, // Static: use frecency ordering
}]
.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.4.6"
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,754 +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,
ProviderPosition, 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
&& 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())
&& 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),
position: ProviderPosition::Widget,
priority: 12000, // Widget: highest priority
}]
.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.4.6"
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,299 +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,
ProviderPosition, 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),
position: ProviderPosition::Normal,
priority: 9000, // Dynamic: web search
}]
.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.4.6"
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.6"
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,33 +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
#[cfg(feature = "lua")]
let mut provider_manager = ProviderManager::with_native_plugins(native_providers);
#[cfg(not(feature = "lua"))]
let 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(), args.prompt.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();
@@ -104,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,
}
}
@@ -254,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);
@@ -277,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,25 +4,70 @@
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 for dmenu mode)
#[arg(long)]
/// 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)

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,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,564 +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
///
/// 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 {
/// 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,
}
impl ProviderManager {
/// Create a new ProviderManager with native plugins
///
/// Native plugins are loaded from /usr/lib/owlry/plugins/ and categorized based on
/// their declared ProviderKind and ProviderPosition:
/// - Static providers with Normal position (added to providers vec)
/// - Dynamic providers (queried per-keystroke, declared via ProviderKind::Dynamic)
/// - Widget providers (shown at top, declared via ProviderPosition::Widget)
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 based on their declared ProviderKind and ProviderPosition
for provider in native_providers {
let type_id = provider.type_id();
if provider.is_dynamic() {
// Dynamic providers declare ProviderKind::Dynamic
info!("Registered dynamic provider: {} ({})", provider.name(), type_id);
manager.dynamic_providers.push(provider);
} else if provider.is_widget() {
// Widgets declare ProviderPosition::Widget
info!("Registered widget provider: {} ({})", provider.name(), type_id);
manager.widget_providers.push(provider);
} else {
// Static providers with Normal position
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)
#[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);
}
}
#[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
// 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() {
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

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

View File

@@ -1,9 +1,6 @@
use crate::config::Config;
use crate::data::FrecencyStore;
use crate::filter::ProviderFilter;
use crate::providers::{LaunchItem, ProviderManager, ProviderType};
use crate::ui::submenu;
use crate::backend::SearchBackend;
use crate::ui::ResultRow;
use crate::ui::submenu;
use gtk4::gdk::Key;
use gtk4::prelude::*;
use gtk4::{
@@ -11,6 +8,9 @@ use gtk4::{
ListBoxRow, Orientation, ScrolledWindow, SelectionMode, ToggleButton,
};
use log::info;
use owlry_core::config::Config;
use owlry_core::filter::ProviderFilter;
use owlry_core::providers::{LaunchItem, ProviderType};
#[cfg(feature = "dev-logging")]
use log::debug;
@@ -47,6 +47,8 @@ struct LazyLoadState {
/// Number of items to display initially and per batch
const INITIAL_RESULTS: usize = 15;
const LOAD_MORE_BATCH: usize = 10;
/// Debounce delay for search input (milliseconds)
const SEARCH_DEBOUNCE_MS: u64 = 50;
pub struct MainWindow {
window: ApplicationWindow,
@@ -54,8 +56,7 @@ pub struct MainWindow {
results_list: ListBox,
scrolled: ScrolledWindow,
config: Rc<RefCell<Config>>,
providers: Rc<RefCell<ProviderManager>>,
frecency: Rc<RefCell<FrecencyStore>>,
backend: Rc<RefCell<SearchBackend>>,
current_results: Rc<RefCell<Vec<LaunchItem>>>,
filter: Rc<RefCell<ProviderFilter>>,
mode_label: Label,
@@ -69,14 +70,17 @@ pub struct MainWindow {
custom_prompt: Option<String>,
/// Lazy loading state
lazy_state: Rc<RefCell<LazyLoadState>>,
/// Debounce source ID for cancelling pending searches
debounce_source: Rc<RefCell<Option<gtk4::glib::SourceId>>>,
/// Whether we're in dmenu mode (stdin pipe input)
is_dmenu_mode: bool,
}
impl MainWindow {
pub fn new(
app: &Application,
config: Rc<RefCell<Config>>,
providers: Rc<RefCell<ProviderManager>>,
frecency: Rc<RefCell<FrecencyStore>>,
backend: Rc<RefCell<SearchBackend>>,
filter: Rc<RefCell<ProviderFilter>>,
custom_prompt: Option<String>,
) -> Self {
@@ -144,7 +148,9 @@ impl MainWindow {
header_box.append(&filter_tabs);
// Search entry with dynamic placeholder (or custom prompt if provided)
let placeholder = custom_prompt.clone().unwrap_or_else(|| Self::build_placeholder(&filter.borrow()));
let placeholder = custom_prompt
.clone()
.unwrap_or_else(|| Self::build_placeholder(&filter.borrow()));
let search_entry = Entry::builder()
.placeholder_text(&placeholder)
.hexpand(true)
@@ -193,14 +199,16 @@ impl MainWindow {
let lazy_state = Rc::new(RefCell::new(LazyLoadState::default()));
// Check if we're in dmenu mode
let is_dmenu_mode = backend.borrow().is_dmenu_mode();
let main_window = Self {
window,
search_entry,
results_list,
scrolled,
config,
providers,
frecency,
backend,
current_results: Rc::new(RefCell::new(Vec::new())),
filter,
mode_label,
@@ -210,6 +218,8 @@ impl MainWindow {
tab_order,
custom_prompt,
lazy_state,
debounce_source: Rc::new(RefCell::new(None)),
is_dmenu_mode,
};
main_window.setup_signals();
@@ -219,46 +229,43 @@ impl MainWindow {
// Ensure search entry has focus when window is shown
main_window.search_entry.grab_focus();
// Schedule widget refresh after window is shown
// Schedule widget refresh after window is shown (only for local backend)
// Widget providers (weather, media, pomodoro) may make network/dbus calls
// We defer this to avoid blocking startup, then re-render results
let providers_for_refresh = main_window.providers.clone();
let backend_for_refresh = main_window.backend.clone();
let search_entry_for_refresh = main_window.search_entry.clone();
gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(50), move || {
providers_for_refresh.borrow_mut().refresh_widgets();
backend_for_refresh.borrow_mut().refresh_widgets();
// Trigger UI update by emitting changed signal on search entry
search_entry_for_refresh.emit_by_name::<()>("changed", &[]);
});
// Set up periodic widget auto-refresh (every 5 seconds)
// Always refresh widgets (for pomodoro timer/notifications), but only update UI when visible
let providers_for_auto = main_window.providers.clone();
// Set up periodic widget auto-refresh (every 5 seconds) — local backend only
// In daemon mode, the daemon handles widget refresh and results come via IPC
if main_window.is_dmenu_mode {
// dmenu typically has no widgets, but this is harmless
}
let backend_for_auto = main_window.backend.clone();
let current_results_for_auto = main_window.current_results.clone();
let submenu_state_for_auto = main_window.submenu_state.clone();
let search_entry_for_auto = main_window.search_entry.clone();
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || {
// Skip UI updates if in submenu, but still refresh providers for notifications
let in_submenu = submenu_state_for_auto.borrow().active;
// Always refresh widget providers (pomodoro needs this for timer/notifications)
providers_for_auto.borrow_mut().refresh_widgets();
// For local backend: refresh widgets (daemon handles this itself)
backend_for_auto.borrow_mut().refresh_widgets();
// Only update UI if not in submenu and widgets are visible
// For daemon backend: re-query to get updated widget data
if !in_submenu {
// Collect widget type_ids first to avoid borrow conflicts
let widget_ids: Vec<String> = providers_for_auto
.borrow()
.widget_type_ids()
.map(|s| s.to_string())
.collect();
let mut results = current_results_for_auto.borrow_mut();
for type_id in &widget_ids {
if let Some(new_item) = providers_for_auto.borrow().get_widget_item(type_id)
&& let Some(existing) = results.iter_mut().find(|i| i.id == new_item.id)
{
existing.name = new_item.name;
existing.description = new_item.description;
}
if let SearchBackend::Daemon(_) = &*backend_for_auto.borrow() {
// Trigger a re-search to pick up updated widget items from daemon
search_entry_for_auto.emit_by_name::<()>("changed", &[]);
} else {
// Local backend: update widget items in-place (legacy behavior)
// This path is only hit in dmenu mode which doesn't have widgets,
// but keep it for completeness.
let _results = current_results_for_auto.borrow();
// No-op for local mode without widget access
}
}
gtk4::glib::ControlFlow::Continue
@@ -288,8 +295,16 @@ impl MainWindow {
// Show number hint in the label for first 9 tabs (using superscript)
let label = if idx < 9 {
let superscript = match idx + 1 {
1 => "¹", 2 => "²", 3 => "³", 4 => "", 5 => "",
6 => "", 7 => "", 8 => "", 9 => "", _ => "",
1 => "¹",
2 => "²",
3 => "³",
4 => "",
5 => "",
6 => "",
7 => "",
8 => "",
9 => "",
_ => "",
};
format!("{}{}", base_label, superscript)
} else {
@@ -397,7 +412,7 @@ impl MainWindow {
}
/// Build dynamic hints based on enabled providers
fn build_hints(config: &crate::config::ProvidersConfig) -> String {
fn build_hints(config: &owlry_core::config::ProvidersConfig) -> String {
let mut parts: Vec<String> = vec![
"Tab: cycle".to_string(),
"↑↓: nav".to_string(),
@@ -489,7 +504,11 @@ impl MainWindow {
actions: Vec<LaunchItem>,
) {
#[cfg(feature = "dev-logging")]
debug!("[UI] Entering submenu: {} ({} actions)", display_name, actions.len());
debug!(
"[UI] Entering submenu: {} ({} actions)",
display_name,
actions.len()
);
// Save current state
{
@@ -554,22 +573,22 @@ impl MainWindow {
}
fn setup_signals(&self) {
// Search input handling with prefix detection
let providers = self.providers.clone();
// Search input handling with prefix detection and debouncing
let backend = self.backend.clone();
let results_list = self.results_list.clone();
let config = self.config.clone();
let frecency = self.frecency.clone();
let current_results = self.current_results.clone();
let filter = self.filter.clone();
let mode_label = self.mode_label.clone();
let search_entry_for_change = self.search_entry.clone();
let submenu_state = self.submenu_state.clone();
let lazy_state = self.lazy_state.clone();
let debounce_source = self.debounce_source.clone();
self.search_entry.connect_changed(move |entry| {
let raw_query = entry.text();
// If in submenu, filter the submenu items
// If in submenu, filter immediately (no debounce needed for small local lists)
if submenu_state.borrow().active {
let state = submenu_state.borrow();
let query = raw_query.to_lowercase();
@@ -607,7 +626,7 @@ impl MainWindow {
return;
}
// Normal mode: parse prefix and search
// Normal mode: update prefix/UI immediately for responsiveness
let parsed = ProviderFilter::parse_query(&raw_query);
{
@@ -643,87 +662,108 @@ impl MainWindow {
.set_placeholder_text(Some(&format!("Search {}...", prefix_name)));
}
let cfg = config.borrow();
let max_results = cfg.general.max_results;
let frecency_weight = cfg.providers.frecency_weight;
let use_frecency = cfg.providers.frecency;
drop(cfg);
let results: Vec<LaunchItem> = if use_frecency {
providers
.borrow_mut()
.search_with_frecency(&parsed.query, max_results, &filter.borrow(), &frecency.borrow(), frecency_weight, parsed.tag_filter.as_deref())
.into_iter()
.map(|(item, _)| item)
.collect()
} else {
providers
.borrow()
.search_filtered(&parsed.query, max_results, &filter.borrow())
.into_iter()
.map(|(item, _)| item)
.collect()
};
// Clear existing results
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
// Cancel any pending debounced search
if let Some(source_id) = debounce_source.borrow_mut().take() {
source_id.remove();
}
// Lazy loading: store all results but only display initial batch
let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Clone references for the debounced closure
let backend = backend.clone();
let results_list = results_list.clone();
let config = config.clone();
let current_results = current_results.clone();
let filter = filter.clone();
let lazy_state = lazy_state.clone();
let debounce_source_for_closure = debounce_source.clone();
// Display only initial batch
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
results_list.append(&row);
}
// Schedule debounced search
let source_id = gtk4::glib::timeout_add_local_once(
std::time::Duration::from_millis(SEARCH_DEBOUNCE_MS),
move || {
// Clear the source ID since we're now executing
*debounce_source_for_closure.borrow_mut() = None;
if let Some(first_row) = results_list.row_at_index(0) {
results_list.select_row(Some(&first_row));
}
let cfg = config.borrow();
let max_results = cfg.general.max_results;
drop(cfg);
// current_results holds only what's displayed (for selection/activation)
*current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
let results = backend.borrow_mut().search_with_tag(
&parsed.query,
max_results,
&filter.borrow(),
&config.borrow(),
parsed.tag_filter.as_deref(),
);
// Clear existing results
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
// Lazy loading: store all results but only display initial batch
let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Display only initial batch
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
results_list.append(&row);
}
if let Some(first_row) = results_list.row_at_index(0) {
results_list.select_row(Some(&first_row));
}
// current_results holds only what's displayed (for selection/activation)
*current_results.borrow_mut() =
results.into_iter().take(initial_count).collect();
},
);
*debounce_source.borrow_mut() = Some(source_id);
});
// Entry activate signal (Enter key in search entry)
let results_list_for_activate = self.results_list.clone();
let current_results_for_activate = self.current_results.clone();
let config_for_activate = self.config.clone();
let frecency_for_activate = self.frecency.clone();
let providers_for_activate = self.providers.clone();
let backend_for_activate = self.backend.clone();
let window_for_activate = self.window.clone();
let submenu_state_for_activate = self.submenu_state.clone();
let mode_label_for_activate = self.mode_label.clone();
let hints_label_for_activate = self.hints_label.clone();
let search_entry_for_activate = self.search_entry.clone();
let is_dmenu_mode_for_activate = self.is_dmenu_mode;
self.search_entry.connect_activate(move |entry| {
let selected = results_list_for_activate
.selected_row()
.or_else(|| results_list_for_activate.row_at_index(0));
// Handle the case where we have a selected item
if let Some(row) = selected {
let index = row.index() as usize;
let results = current_results_for_activate.borrow();
if let Some(item) = results.get(index) {
// Check if this is a submenu item and query the plugin for actions
let submenu_result = if submenu::is_submenu_item(item) {
if let Some((plugin_id, data)) = submenu::parse_submenu_command(&item.command) {
if let Some((plugin_id, data)) =
submenu::parse_submenu_command(&item.command)
{
// Clone values before dropping borrow
let plugin_id = plugin_id.to_string();
let data = data.to_string();
let display_name = item.name.clone();
drop(results); // Release borrow before querying
providers_for_activate
.borrow()
.query_submenu_actions(&plugin_id, &data, &display_name)
backend_for_activate.borrow_mut().query_submenu_actions(
&plugin_id,
&data,
&display_name,
)
} else {
drop(results);
None
@@ -751,10 +791,13 @@ impl MainWindow {
let should_close = Self::handle_item_action(
&item,
&config_for_activate.borrow(),
&frecency_for_activate,
&providers_for_activate,
&backend_for_activate,
);
if should_close {
// In dmenu mode, exit with success code
if is_dmenu_mode_for_activate {
std::process::exit(0);
}
window_for_activate.close();
} else {
// Trigger search refresh for updated widget state
@@ -762,6 +805,16 @@ impl MainWindow {
}
}
}
return;
}
}
// No item selected/matched - in dmenu mode, output the typed text
if is_dmenu_mode_for_activate {
let text = entry.text();
if !text.is_empty() {
println!("{}", text);
std::process::exit(0);
}
}
});
@@ -802,13 +855,17 @@ impl MainWindow {
let hints_label = self.hints_label.clone();
let submenu_state = self.submenu_state.clone();
let tab_order = self.tab_order.clone();
let is_dmenu_mode = self.is_dmenu_mode;
key_controller.connect_key_pressed(move |_, key, _, modifiers| {
let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let shift = modifiers.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
#[cfg(feature = "dev-logging")]
debug!("[UI] Key pressed: {:?} (ctrl={}, shift={})", key, ctrl, shift);
debug!(
"[UI] Key pressed: {:?} (ctrl={}, shift={})",
key, ctrl, shift
);
match key {
Key::Escape => {
@@ -824,6 +881,10 @@ impl MainWindow {
);
gtk4::glib::Propagation::Stop
} else {
// In dmenu mode, exit with cancel code (1)
if is_dmenu_mode {
std::process::exit(1);
}
window.close();
gtk4::glib::Propagation::Stop
}
@@ -841,6 +902,10 @@ impl MainWindow {
);
gtk4::glib::Propagation::Stop
} else {
// In dmenu mode, exit with cancel code (1)
if is_dmenu_mode {
std::process::exit(1);
}
window.close();
gtk4::glib::Propagation::Stop
}
@@ -863,10 +928,11 @@ impl MainWindow {
if let Some(selected) = results_list.selected_row() {
let prev_index = selected.index() - 1;
if prev_index >= 0
&& let Some(prev_row) = results_list.row_at_index(prev_index) {
results_list.select_row(Some(&prev_row));
Self::scroll_to_row(&scrolled, &results_list, &prev_row);
}
&& let Some(prev_row) = results_list.row_at_index(prev_index)
{
results_list.select_row(Some(&prev_row));
Self::scroll_to_row(&scrolled, &results_list, &prev_row);
}
}
gtk4::glib::Propagation::Stop
}
@@ -898,8 +964,17 @@ impl MainWindow {
gtk4::glib::Propagation::Stop
}
// Ctrl+1-9 toggle specific providers based on tab order (only when not in submenu)
Key::_1 | Key::_2 | Key::_3 | Key::_4 | Key::_5 |
Key::_6 | Key::_7 | Key::_8 | Key::_9 if ctrl => {
Key::_1
| Key::_2
| Key::_3
| Key::_4
| Key::_5
| Key::_6
| Key::_7
| Key::_8
| Key::_9
if ctrl =>
{
info!("[UI] Ctrl+number detected: {:?}", key);
if !submenu_state.borrow().active {
let idx = match key {
@@ -925,7 +1000,11 @@ impl MainWindow {
&mode_label,
);
} else {
info!("[UI] No provider at index {}, tab_order len={}", idx, tab_order.len());
info!(
"[UI] No provider at index {}, tab_order len={}",
idx,
tab_order.len()
);
}
}
gtk4::glib::Propagation::Stop
@@ -939,8 +1018,7 @@ impl MainWindow {
// Double-click to launch
let current_results = self.current_results.clone();
let config = self.config.clone();
let frecency = self.frecency.clone();
let providers = self.providers.clone();
let backend = self.backend.clone();
let window = self.window.clone();
let submenu_state = self.submenu_state.clone();
let results_list_for_click = self.results_list.clone();
@@ -960,8 +1038,8 @@ impl MainWindow {
let data = data.to_string();
let display_name = item.name.clone();
drop(results);
providers
.borrow()
backend
.borrow_mut()
.query_submenu_actions(&plugin_id, &data, &display_name)
} else {
drop(results);
@@ -987,7 +1065,8 @@ impl MainWindow {
let results = current_results.borrow();
if let Some(item) = results.get(index).cloned() {
drop(results);
let should_close = Self::handle_item_action(&item, &config.borrow(), &frecency, &providers);
let should_close =
Self::handle_item_action(&item, &config.borrow(), &backend);
if should_close {
window.close();
} else {
@@ -1034,7 +1113,11 @@ impl MainWindow {
}
} else if current.len() == 1 {
let idx = tab_order.iter().position(|p| p == &current[0]).unwrap_or(0);
let at_boundary = if forward { idx == tab_order.len() - 1 } else { idx == 0 };
let at_boundary = if forward {
idx == tab_order.len() - 1
} else {
idx == 0
};
if at_boundary {
// At boundary, go back to "All" mode
@@ -1103,26 +1186,14 @@ impl MainWindow {
fn update_results(&self, query: &str) {
let cfg = self.config.borrow();
let max_results = cfg.general.max_results;
let frecency_weight = cfg.providers.frecency_weight;
let use_frecency = cfg.providers.frecency;
drop(cfg);
// Fetch all matching results (up to max_results)
let results: Vec<LaunchItem> = if use_frecency {
self.providers
.borrow_mut()
.search_with_frecency(query, max_results, &self.filter.borrow(), &self.frecency.borrow(), frecency_weight, None)
.into_iter()
.map(|(item, _)| item)
.collect()
} else {
self.providers
.borrow()
.search_filtered(query, max_results, &self.filter.borrow())
.into_iter()
.map(|(item, _)| item)
.collect()
};
let results = self.backend.borrow_mut().search(
query,
max_results,
&self.filter.borrow(),
&self.config.borrow(),
);
// Clear existing results
while let Some(child) = self.results_list.first_child() {
@@ -1221,26 +1292,32 @@ impl MainWindow {
fn handle_item_action(
item: &LaunchItem,
config: &Config,
frecency: &Rc<RefCell<FrecencyStore>>,
providers: &Rc<RefCell<ProviderManager>>,
backend: &Rc<RefCell<SearchBackend>>,
) -> bool {
// Check for plugin internal commands (format: PLUGIN_ID:action)
// These are handled by the plugin itself, not launched as shell commands
if providers.borrow().execute_plugin_action(&item.command) {
if backend.borrow_mut().execute_plugin_action(&item.command) {
// Plugin handled the action - don't close window
// User might want to see updated state (e.g., pomodoro timer)
return false;
}
// Regular item launch
Self::launch_item(item, config, frecency);
Self::launch_item(item, config, backend);
true
}
fn launch_item(item: &LaunchItem, config: &Config, frecency: &Rc<RefCell<FrecencyStore>>) {
// Record this launch for frecency tracking
fn launch_item(item: &LaunchItem, config: &Config, backend: &Rc<RefCell<SearchBackend>>) {
// dmenu mode: print selection to stdout instead of executing
if matches!(item.provider, ProviderType::Dmenu) {
println!("{}", item.name);
return;
}
// Record this launch for frecency tracking (via backend)
if config.providers.frecency {
frecency.borrow_mut().record_launch(&item.id);
let provider_str = item.provider.to_string();
backend.borrow_mut().record_launch(&item.id, &provider_str);
#[cfg(feature = "dev-logging")]
debug!("[UI] Recorded frecency launch for: {}", item.id);
}
@@ -1248,18 +1325,101 @@ impl MainWindow {
info!("Launching: {} ({})", item.name, item.command);
#[cfg(feature = "dev-logging")]
debug!("[UI] Launch details: terminal={}, provider={:?}", item.terminal, item.provider);
debug!(
"[UI] Launch details: terminal={}, provider={:?}, id={}",
item.terminal, item.provider, item.id
);
let cmd = if item.terminal {
let terminal = config.general.terminal_command.as_deref().unwrap_or("xterm");
format!("{} -e {}", terminal, item.command)
// Check if this is a desktop application (has .desktop file as ID)
let is_desktop_app =
matches!(item.provider, ProviderType::Application) && item.id.ends_with(".desktop");
// Desktop files should be launched via proper launchers that implement the
// freedesktop Desktop Entry spec (D-Bus activation, field codes, env vars, etc.)
// We delegate to: uwsm (if configured), gio launch, or gtk-launch as fallback.
//
// Non-desktop items (commands, plugins) use sh -c for shell execution.
let result = if is_desktop_app {
Self::launch_desktop_file(&item.id, config)
} else {
item.command.clone()
Self::launch_command(&item.command, item.terminal, config)
};
// Detect if this is a shell command vs an application launch
// Shell commands: playerctl, dbus-send, systemctl, journalctl, or anything with shell operators
let is_shell_command = cmd.starts_with("playerctl ")
if let Err(e) = result {
let msg = format!("Failed to launch '{}': {}", item.name, e);
log::error!("{}", msg);
owlry_core::notify::notify("Launch failed", &msg);
}
}
/// Launch a .desktop file.
///
/// When `use_uwsm` is enabled in config, launches via `uwsm app -- <file>`
/// which starts the app in a proper systemd user session.
///
/// Otherwise, uses `gio launch` which is always available (part of glib2/GTK4)
/// and handles D-Bus activation, field codes, Terminal flag, etc.
fn launch_desktop_file(
desktop_path: &str,
config: &Config,
) -> std::io::Result<std::process::Child> {
use std::path::Path;
// Check if desktop file exists
if !Path::new(desktop_path).exists() {
let msg = format!("Desktop file not found: {}", desktop_path);
log::error!("{}", msg);
owlry_core::notify::notify("Launch failed", &msg);
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg));
}
if config.general.use_uwsm {
// Check if uwsm is available
let uwsm_available = Command::new("which")
.arg("uwsm")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !uwsm_available {
let msg = "uwsm is enabled in config but not installed";
log::error!("{}", msg);
owlry_core::notify::notify("Launch failed", msg);
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg));
}
info!("Launching via uwsm: {}", desktop_path);
Command::new("uwsm")
.args(["app", "--", desktop_path])
.spawn()
} else {
info!("Launching via gio: {}", desktop_path);
Command::new("gio").args(["launch", desktop_path]).spawn()
}
}
/// Launch a shell command (for non-desktop items like PATH commands, plugins, etc.)
fn launch_command(
command: &str,
terminal: bool,
config: &Config,
) -> std::io::Result<std::process::Child> {
let cmd = if terminal {
let terminal_cmd = config
.general
.terminal_command
.as_deref()
.unwrap_or("xterm");
format!("{} -e {}", terminal_cmd, command)
} else {
command.to_string()
};
// Shell/system commands run directly without uwsm wrapper
// (they're typically short-lived or system utilities)
let is_system_command = cmd.starts_with("playerctl ")
|| cmd.starts_with("dbus-send ")
|| cmd.starts_with("systemctl ")
|| cmd.starts_with("journalctl ")
@@ -1269,28 +1429,14 @@ impl MainWindow {
|| cmd.contains(" > ")
|| cmd.contains(" < ");
// Use launch wrapper if configured (uwsm, hyprctl, etc.)
// But skip wrapper for shell commands - they need sh -c
let result = match &config.general.launch_wrapper {
Some(wrapper) if !wrapper.is_empty() && !is_shell_command => {
info!("Using launch wrapper: {}", wrapper);
// Split wrapper into command and args (e.g., "uwsm app --" -> ["uwsm", "app", "--"])
let mut wrapper_parts: Vec<&str> = wrapper.split_whitespace().collect();
if wrapper_parts.is_empty() {
Command::new("sh").arg("-c").arg(&cmd).spawn()
} else {
let wrapper_cmd = wrapper_parts.remove(0);
Command::new(wrapper_cmd)
.args(&wrapper_parts)
.arg(&cmd)
.spawn()
}
}
_ => Command::new("sh").arg("-c").arg(&cmd).spawn(),
};
if let Err(e) = result {
log::error!("Failed to launch '{}': {}", item.name, e);
// Use uwsm for regular commands if enabled (and not a system command)
if config.general.use_uwsm && !is_system_command {
info!("Launching command via uwsm: {}", cmd);
Command::new("uwsm")
.args(["app", "--", "sh", "-c", &cmd])
.spawn()
} else {
Command::new("sh").arg("-c").arg(&cmd).spawn()
}
}
}

View File

@@ -1,6 +1,6 @@
use crate::providers::LaunchItem;
use gtk4::prelude::*;
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget};
use owlry_core::providers::LaunchItem;
#[allow(dead_code)]
pub struct ResultRow {
@@ -81,11 +81,13 @@ impl ResultRow {
} else {
// Default icon based on provider type (only core types, plugins should provide icons)
let default_icon = match &item.provider {
crate::providers::ProviderType::Application => "application-x-executable-symbolic",
crate::providers::ProviderType::Command => "utilities-terminal-symbolic",
crate::providers::ProviderType::Dmenu => "view-list-symbolic",
owlry_core::providers::ProviderType::Application => {
"application-x-executable-symbolic"
}
owlry_core::providers::ProviderType::Command => "utilities-terminal-symbolic",
owlry_core::providers::ProviderType::Dmenu => "view-list-symbolic",
// Plugins should provide their own icon; fallback to generic addon icon
crate::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic",
owlry_core::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic",
};
let img = Image::from_icon_name(default_icon);
img.set_pixel_size(32);
@@ -134,9 +136,7 @@ impl ResultRow {
.build();
for tag in item.tags.iter().take(3) {
let tag_label = Label::builder()
.label(tag)
.build();
let tag_label = Label::builder().label(tag).build();
tag_label.add_css_class("owlry-tag-badge");
tags_box.append(&tag_label);
}

View File

@@ -46,7 +46,7 @@
//! }
//! ```
use crate::providers::LaunchItem;
use owlry_core::providers::LaunchItem;
/// Parse a submenu command and extract plugin_id and data
/// Returns (plugin_id, data) if command matches SUBMENU: format
@@ -66,7 +66,7 @@ pub fn is_submenu_item(item: &LaunchItem) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::ProviderType;
use owlry_core::providers::ProviderType;
#[test]
fn test_parse_submenu_command() {

View File

@@ -17,22 +17,47 @@
# │ Runtimes: /usr/lib/owlry/runtimes/*.so Lua/Rune runtimes │
# └─────────────────────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════════════════════════
# DMENU MODE
# ═══════════════════════════════════════════════════════════════════════
#
# Dmenu mode provides interactive selection from piped input.
# The selected item is printed to stdout (not executed), so pipe
# the output to execute it:
#
# ┌─────────────────────────────────────────────────────────────────────┐
# │ # Screenshot menu │
# │ printf '%s\n' \ │
# │ "grimblast --notify copy screen" \ │
# │ "grimblast --notify copy area" \ │
# │ | owlry -m dmenu -p "Screenshot" \ │
# │ | sh │
# │ │
# │ # Git branch checkout │
# │ git branch | owlry -m dmenu -p "checkout" | xargs git checkout │
# │ │
# │ # Package search │
# │ pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S │
# └─────────────────────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════════════════════════
# GENERAL
# ═══════════════════════════════════════════════════════════════════════
[general]
show_icons = true
max_results = 10
max_results = 100
# Terminal emulator for SSH, scripts, etc.
# Auto-detection order: $TERMINAL → xdg-terminal-exec → DE-native → Wayland → X11 → xterm
# Uncomment to override:
# terminal_command = "kitty"
# Launch wrapper for app execution (auto-detected for uwsm/Hyprland)
# Examples: "uwsm app --", "hyprctl dispatch exec --", ""
# launch_wrapper = "uwsm app --"
# Enable uwsm (Universal Wayland Session Manager) for launching apps.
# When enabled, apps are launched via "uwsm app --" which starts them
# in a proper systemd user session for better process management.
# Requires: uwsm to be installed
# use_uwsm = true
# Header tabs - providers shown as toggle buttons (Ctrl+1, Ctrl+2, etc.)
# Values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
@@ -62,22 +87,54 @@ border_radius = 12
# text_secondary = "#565f89"
# accent = "#7aa2f7"
# accent_bright = "#89b4fa"
#
# Provider badge colors (optional)
# badge_app = "#7aa2f7"
# badge_cmd = "#9ece6a"
# badge_bookmark = "#e0af68"
# badge_calc = "#bb9af7"
# badge_clip = "#7dcfff"
# badge_dmenu = "#c0caf5"
# badge_emoji = "#f7768e"
# badge_file = "#73daca"
# badge_script = "#ff9e64"
# badge_ssh = "#2ac3de"
# badge_sys = "#f7768e"
# badge_uuctl = "#9ece6a"
# badge_web = "#7aa2f7"
# badge_media = "#bb9af7"
# badge_weather = "#7dcfff"
# badge_pomo = "#f7768e"
# ═══════════════════════════════════════════════════════════════════════
# PLUGINS
# ═══════════════════════════════════════════════════════════════════════
#
# All installed plugins are loaded by default. Use 'disabled' to blacklist.
# All installed plugins are loaded by default. Use 'disabled_plugins' to blacklist.
# Plugin IDs: calculator, system, ssh, clipboard, emoji, scripts, bookmarks,
# websearch, filesearch, systemd, weather, media, pomodoro
[plugins]
enabled = true # Master switch for all plugins
# Plugins to disable (by ID)
disabled = []
disabled_plugins = []
# Examples:
# disabled = ["emoji", "pomodoro"] # Disable specific plugins
# disabled = ["weather", "media"] # Disable widget plugins
# disabled_plugins = ["emoji", "pomodoro"] # Disable specific plugins
# disabled_plugins = ["weather", "media"] # Disable widget plugins
# Custom plugin registry URL (defaults to official registry)
# registry_url = "https://my-registry.example.com/plugins.json"
# ─────────────────────────────────────────────────────────────────────────
# Sandbox settings (for Lua/Rune script plugins)
# ─────────────────────────────────────────────────────────────────────────
# [plugins.sandbox]
# allow_filesystem = false # Allow file system access beyond plugin dir
# allow_network = false # Allow network requests
# allow_commands = false # Allow shell command execution
# memory_limit = 67108864 # Memory limit in bytes (64 MB default)
# ═══════════════════════════════════════════════════════════════════════
# PROVIDERS
@@ -112,10 +169,26 @@ calculator = true # Calculator (= expression)
websearch = true # Web search (? query)
# ─────────────────────────────────────────────────────────────────────────
# Plugin settings
# Widget providers (displayed at top of results)
# ─────────────────────────────────────────────────────────────────────────
media = true # MPRIS media player controls
weather = false # Weather widget (disabled by default)
pomodoro = false # Pomodoro timer (disabled by default)
# ─────────────────────────────────────────────────────────────────────────
# Provider settings
# ─────────────────────────────────────────────────────────────────────────
# Web search engine
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
# Or custom URL: "https://search.example.com/?q={query}"
search_engine = "duckduckgo"
# Weather settings (when weather = true)
# weather_provider = "wttr.in" # Options: wttr.in, openweathermap, open-meteo
# weather_location = "Berlin" # City name or coordinates
# weather_api_key = "" # Required for openweathermap
# Pomodoro settings (when pomodoro = true)
# pomodoro_work_mins = 25 # Work session duration
# pomodoro_break_mins = 5 # Break duration

View File

@@ -1,318 +0,0 @@
# Available Plugins
Owlry's functionality is provided through a modular plugin system. This document describes all available plugins.
## Plugin Categories
### Static Providers
Static providers load their items once at startup (and on manual refresh). They're best for data that doesn't change frequently.
### Dynamic Providers
Dynamic providers evaluate queries in real-time. Each keystroke triggers a new query, making them ideal for calculations, searches, and other interactive features.
### Widget Providers
Widget providers display persistent information at the top of results (weather, media controls, timers).
---
## Core Plugins
### owlry-plugin-calculator
**Type:** Dynamic
**Prefix:** `:calc`, `=`, `calc `
**Package:** `owlry-plugin-calculator`
Evaluate mathematical expressions in real-time.
**Examples:**
```
= 5 + 3 → 8
= sqrt(16) → 4
= sin(pi/2) → 1
= 2^10 → 1024
= (1 + 0.05)^12 → 1.7958...
```
**Supported operations:**
- Basic: `+`, `-`, `*`, `/`, `^` (power), `%` (modulo)
- Functions: `sin`, `cos`, `tan`, `asin`, `acos`, `atan`
- Functions: `sqrt`, `abs`, `floor`, `ceil`, `round`
- Functions: `ln`, `log`, `log10`, `exp`
- Constants: `pi`, `e`
---
### owlry-plugin-system
**Type:** Static
**Prefix:** `:sys`
**Package:** `owlry-plugin-system`
System power and session management commands.
**Actions:**
| Name | Description | Command |
|------|-------------|---------|
| Shutdown | Power off | `systemctl poweroff` |
| Reboot | Restart | `systemctl reboot` |
| Reboot into BIOS | UEFI setup | `systemctl reboot --firmware-setup` |
| Suspend | Sleep (RAM) | `systemctl suspend` |
| Hibernate | Sleep (disk) | `systemctl hibernate` |
| Lock Screen | Lock session | `loginctl lock-session` |
| Log Out | End session | `loginctl terminate-session self` |
---
### owlry-plugin-ssh
**Type:** Static
**Prefix:** `:ssh`
**Package:** `owlry-plugin-ssh`
SSH hosts parsed from `~/.ssh/config`.
**Features:**
- Parses `Host` entries from SSH config
- Ignores wildcards (`Host *`)
- Opens connections in your configured terminal
---
### owlry-plugin-clipboard
**Type:** Static
**Prefix:** `:clip`
**Package:** `owlry-plugin-clipboard`
**Dependencies:** `cliphist`, `wl-clipboard`
Clipboard history integration with cliphist.
**Features:**
- Shows last 50 clipboard entries
- Previews text content (truncated to 80 chars)
- Select to copy back to clipboard
---
### owlry-plugin-emoji
**Type:** Static
**Prefix:** `:emoji`
**Package:** `owlry-plugin-emoji`
**Dependencies:** `wl-clipboard`
400+ searchable emoji with keywords.
**Examples:**
```
:emoji heart → ❤️ 💙 💚 💜 ...
:emoji smile → 😀 😃 😄 😁 ...
:emoji fire → 🔥
```
---
### owlry-plugin-scripts
**Type:** Static
**Prefix:** `:script`
**Package:** `owlry-plugin-scripts`
User scripts from `~/.local/share/owlry/scripts/`.
**Setup:**
```bash
mkdir -p ~/.local/share/owlry/scripts
cat > ~/.local/share/owlry/scripts/backup.sh << 'EOF'
#!/bin/bash
rsync -av ~/Documents /backup/
notify-send "Backup complete"
EOF
chmod +x ~/.local/share/owlry/scripts/backup.sh
```
---
### owlry-plugin-bookmarks
**Type:** Static
**Prefix:** `:bm`
**Package:** `owlry-plugin-bookmarks`
Browser bookmarks from Firefox and Chromium-based browsers.
**Supported browsers:**
- Firefox (reads places.sqlite)
- Google Chrome
- Brave
- Microsoft Edge
- Vivaldi
- Chromium
---
### owlry-plugin-websearch
**Type:** Dynamic
**Prefix:** `:web`, `?`, `web `
**Package:** `owlry-plugin-websearch`
Web search with configurable search engine.
**Examples:**
```
? rust programming → Search for "rust programming"
web linux tips → Search for "linux tips"
```
**Configuration:**
```toml
[providers]
search_engine = "duckduckgo" # or: google, bing, startpage
# custom_search_url = "https://search.example.com/?q={}"
```
---
### owlry-plugin-filesearch
**Type:** Dynamic
**Prefix:** `:file`, `/`, `find `
**Package:** `owlry-plugin-filesearch`
**Dependencies:** `fd` (recommended) or `mlocate`
Real-time file search.
**Examples:**
```
/ .bashrc → Find files matching ".bashrc"
find config → Find files matching "config"
```
**Configuration:**
```toml
[providers]
file_search_max_results = 50
# file_search_paths = ["/home", "/etc"] # Custom search paths
```
---
### owlry-plugin-systemd
**Type:** Static (with submenu)
**Prefix:** `:uuctl`
**Package:** `owlry-plugin-systemd`
**Dependencies:** `systemd`
User systemd services with action submenus.
**Features:**
- Lists user services (`systemctl --user`)
- Shows service status (running/stopped/failed)
- Submenu actions: start, stop, restart, enable, disable, status
**Usage:**
1. Search `:uuctl docker`
2. Select a service
3. Choose action from submenu
---
## Widget Plugins
### owlry-plugin-weather
**Type:** Widget (Static)
**Package:** `owlry-plugin-weather`
Current weather displayed at the top of results.
**Supported APIs:**
- wttr.in (default, no API key required)
- OpenWeatherMap (requires API key)
- Open-Meteo (no API key required)
**Note:** Weather configuration is currently embedded in the plugin. Future versions will support runtime configuration.
**Features:**
- Temperature, condition, humidity, wind speed
- Weather icons from Weather Icons font
- 15-minute cache
- Click to open detailed forecast
---
### owlry-plugin-media
**Type:** Widget (Static)
**Package:** `owlry-plugin-media`
MPRIS media player controls.
**Features:**
- Shows currently playing track
- Artist, title, album art
- Play/pause, next, previous controls
- Works with Spotify, Firefox, VLC, etc.
---
### owlry-plugin-pomodoro
**Type:** Widget (Static)
**Package:** `owlry-plugin-pomodoro`
Pomodoro timer with work/break cycles.
**Features:**
- Configurable work session duration
- Configurable break duration
- Session counter
- Desktop notifications on phase completion
- Persistent state across sessions
**Controls:**
- Start/Pause timer
- Skip to next phase
- Reset timer and sessions
---
## Bundle Packages
For convenience, plugins are available in bundle meta-packages:
| Bundle | Plugins |
|--------|---------|
| `owlry-meta-essentials` | calculator, system, ssh, scripts, bookmarks |
| `owlry-meta-widgets` | weather, media, pomodoro |
| `owlry-meta-tools` | clipboard, emoji, websearch, filesearch, systemd |
| `owlry-meta-full` | All of the above |
```bash
# Install everything
yay -S owlry-meta-full
# Or pick a bundle
yay -S owlry-meta-essentials owlry-meta-widgets
```
---
## Runtime Packages
For custom user plugins written in Lua or Rune:
| Package | Description |
|---------|-------------|
| `owlry-lua` | Lua 5.4 runtime for user plugins |
| `owlry-rune` | Rune runtime for user plugins |
User plugins are placed in `~/.config/owlry/plugins/`.
See [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) for creating custom plugins.

View File

@@ -1,571 +0,0 @@
# Plugin Development Guide
This guide covers creating plugins for Owlry. There are three ways to extend Owlry:
1. **Native plugins** (Rust) — Best performance, ABI-stable interface
2. **Lua plugins** — Easy scripting, requires `owlry-lua` runtime
3. **Rune plugins** — Safe scripting with Rust-like syntax, requires `owlry-rune` runtime
---
## Quick Start
### Native Plugin (Rust)
```bash
# Create a new plugin crate
cargo new --lib owlry-plugin-myplugin
cd owlry-plugin-myplugin
```
Edit `Cargo.toml`:
```toml
[package]
name = "owlry-plugin-myplugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
owlry-plugin-api = { git = "https://somegit.dev/Owlibou/owlry" }
abi_stable = "0.11"
```
Edit `src/lib.rs`:
```rust
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo,
ProviderKind, ProviderPosition, API_VERSION,
};
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from("myplugin"),
name: RString::from("My Plugin"),
version: RString::from(env!("CARGO_PKG_VERSION")),
description: RString::from("A custom plugin"),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from("myplugin"),
name: RString::from("My Plugin"),
prefix: ROption::RSome(RString::from(":my")),
icon: RString::from("application-x-executable"),
provider_type: ProviderKind::Static,
type_id: RString::from("myplugin"),
position: ProviderPosition::Normal,
priority: 0, // Use frecency-based ordering
}].into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
ProviderHandle::null()
}
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
vec![
PluginItem::new("item-1", "Hello World", "echo 'Hello!'")
.with_description("A greeting")
.with_icon("face-smile"),
].into()
}
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
RVec::new()
}
extern "C" fn provider_drop(_handle: ProviderHandle) {}
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
```
Build and install:
```bash
cargo build --release
sudo cp target/release/libowlry_plugin_myplugin.so /usr/lib/owlry/plugins/
```
### Lua Plugin
```bash
# Requires owlry-lua runtime
yay -S owlry-lua
# Create plugin directory
mkdir -p ~/.config/owlry/plugins/my-lua-plugin
```
Create `~/.config/owlry/plugins/my-lua-plugin/plugin.toml`:
```toml
[plugin]
id = "my-lua-plugin"
name = "My Lua Plugin"
version = "0.1.0"
description = "A custom Lua plugin"
entry_point = "init.lua"
[[providers]]
id = "myluaprovider"
name = "My Lua Provider"
prefix = ":mylua"
icon = "application-x-executable"
type = "static"
type_id = "mylua"
```
Create `~/.config/owlry/plugins/my-lua-plugin/init.lua`:
```lua
local owlry = require("owlry")
-- Called once at startup for static providers
function refresh()
return {
owlry.item("item-1", "Hello from Lua", "echo 'Hello Lua!'")
:description("A Lua greeting")
:icon("face-smile"),
}
end
-- Called per-keystroke for dynamic providers
function query(q)
return {}
end
```
---
## Native Plugin API
### Plugin VTable
Every native plugin must export a function that returns a vtable:
```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(provider_id: RStr<'_>) -> ProviderHandle,
pub provider_refresh: extern "C" fn(handle: ProviderHandle) -> RVec<PluginItem>,
pub provider_query: extern "C" fn(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem>,
pub provider_drop: extern "C" fn(handle: ProviderHandle),
}
```
Use the `owlry_plugin!` macro to generate the export:
```rust
owlry_plugin! {
info: my_info_fn,
providers: my_providers_fn,
init: my_init_fn,
refresh: my_refresh_fn,
query: my_query_fn,
drop: my_drop_fn,
}
```
### PluginInfo
```rust
pub struct PluginInfo {
pub id: RString, // Unique ID (e.g., "calculator")
pub name: RString, // Display name
pub version: RString, // Semantic version
pub description: RString, // Short description
pub api_version: u32, // Must match API_VERSION
}
```
### ProviderInfo
```rust
pub struct ProviderInfo {
pub id: RString, // Provider ID within plugin
pub name: RString, // Display name
pub prefix: ROption<RString>, // Activation prefix (e.g., ":calc")
pub icon: RString, // Default icon name
pub provider_type: ProviderKind, // Static or Dynamic
pub type_id: RString, // Short ID for badges
pub position: ProviderPosition, // Normal or Widget
pub priority: i32, // Result ordering (higher = first)
}
pub enum ProviderKind {
Static, // Items loaded at startup via refresh()
Dynamic, // Items computed per-query via query()
}
pub enum ProviderPosition {
Normal, // Standard results (sorted by score/frecency)
Widget, // Displayed at top when query is empty
}
```
### PluginItem
```rust
pub struct PluginItem {
pub id: RString, // Unique item ID
pub name: RString, // Display name
pub description: ROption<RString>, // Optional description
pub icon: ROption<RString>, // Optional icon
pub command: RString, // Command to execute
pub terminal: bool, // Run in terminal?
pub keywords: RVec<RString>, // Search keywords
pub score_boost: i32, // Frecency boost
}
// Builder pattern
let item = PluginItem::new("id", "Name", "command")
.with_description("Description")
.with_icon("icon-name")
.with_terminal(true)
.with_keywords(vec!["tag1".to_string(), "tag2".to_string()])
.with_score_boost(100);
```
### ProviderHandle
For stateful providers, use `ProviderHandle` to store state:
```rust
struct MyState {
items: Vec<PluginItem>,
cache: HashMap<String, String>,
}
extern "C" fn provider_init(_: RStr<'_>) -> ProviderHandle {
let state = Box::new(MyState {
items: Vec::new(),
cache: HashMap::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 MyState) };
state.items = load_items();
state.items.clone().into()
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
unsafe { handle.drop_as::<MyState>(); }
}
}
```
### Host API
Plugins can use host-provided functions:
```rust
use owlry_plugin_api::{notify, notify_with_icon, log_info, log_warn, log_error};
// Send notifications
notify("Title", "Body text");
notify_with_icon("Title", "Body", "dialog-information");
// Logging
log_info("Plugin loaded successfully");
log_warn("Cache miss, fetching data");
log_error("Failed to connect to API");
```
### Submenu Support
Plugins can provide submenus for detailed actions:
```rust
// Return an item that opens a submenu
PluginItem::new(
"service-docker",
"Docker",
"SUBMENU:systemd:docker.service", // Special command format
)
// Handle submenu query (query starts with "?SUBMENU:")
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
let q = query.as_str();
if let Some(data) = q.strip_prefix("?SUBMENU:") {
// Return submenu actions
return vec![
PluginItem::new("start", "Start", format!("systemctl start {}", data)),
PluginItem::new("stop", "Stop", format!("systemctl stop {}", data)),
].into();
}
RVec::new()
}
```
---
## Lua Plugin API
### Plugin Manifest (plugin.toml)
```toml
[plugin]
id = "my-plugin"
name = "My Plugin"
version = "1.0.0"
description = "Plugin description"
entry_point = "init.lua"
owlry_version = ">=0.4.0" # Optional version constraint
[permissions]
fs = ["read"] # File system access
http = true # HTTP requests
process = true # Spawn processes
[[providers]]
id = "provider1"
name = "Provider Name"
prefix = ":prefix"
icon = "icon-name"
type = "static" # or "dynamic"
type_id = "shortid"
```
### Lua API
```lua
local owlry = require("owlry")
-- Create items
local item = owlry.item(id, name, command)
:description("Description")
:icon("icon-name")
:terminal(false)
:keywords({"tag1", "tag2"})
-- Notifications
owlry.notify("Title", "Body")
owlry.notify_icon("Title", "Body", "icon-name")
-- Logging
owlry.log.info("Message")
owlry.log.warn("Warning")
owlry.log.error("Error")
-- File operations (requires fs permission)
local content = owlry.fs.read("/path/to/file")
local files = owlry.fs.list("/path/to/dir")
local exists = owlry.fs.exists("/path")
-- HTTP requests (requires http permission)
local response = owlry.http.get("https://api.example.com/data")
local json = owlry.json.decode(response)
-- Process execution (requires process permission)
local output = owlry.process.run("ls", {"-la"})
-- Cache (persistent across sessions)
owlry.cache.set("key", value, ttl_seconds)
local value = owlry.cache.get("key")
```
### Provider Functions
```lua
-- Static provider: called once at startup
function refresh()
return {
owlry.item("id1", "Item 1", "command1"),
owlry.item("id2", "Item 2", "command2"),
}
end
-- Dynamic provider: called on each keystroke
function query(q)
if q == "" then
return {}
end
return {
owlry.item("result", "Result for: " .. q, "echo " .. q),
}
end
```
---
## Rune Plugin API
Rune plugins use a Rust-like syntax with memory safety.
### Plugin Manifest
```toml
[plugin]
id = "my-rune-plugin"
name = "My Rune Plugin"
version = "1.0.0"
entry_point = "main.rn"
[[providers]]
id = "runeprovider"
name = "Rune Provider"
type = "static"
```
### Rune API
```rune
use owlry::{Item, log, notify};
pub fn refresh() {
let items = [];
items.push(Item::new("id", "Name", "command")
.description("Description")
.icon("icon-name"));
items
}
pub fn query(q) {
if q.is_empty() {
return [];
}
log::info(`Query: {q}`);
[Item::new("result", `Result: {q}`, `echo {q}`)]
}
```
---
## Best Practices
### Performance
1. **Static providers**: Do expensive work in `refresh()`, not `items()`
2. **Dynamic providers**: Keep `query()` fast (<50ms)
3. **Cache data**: Use persistent cache for API responses
4. **Lazy loading**: Don't load all items if only a few are needed
### Error Handling
```rust
// Native: Return empty vec on error, log the issue
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
match load_data() {
Ok(items) => items.into(),
Err(e) => {
log_error(&format!("Failed to load: {}", e));
RVec::new()
}
}
}
```
```lua
-- Lua: Wrap in pcall for safety
function refresh()
local ok, result = pcall(function()
return load_items()
end)
if not ok then
owlry.log.error("Failed: " .. result)
return {}
end
return result
end
```
### Icons
Use freedesktop icon names for consistency:
- `application-x-executable` — Generic executable
- `folder` — Directories
- `text-x-generic` — Text files
- `face-smile` — Emoji/reactions
- `system-shutdown` — Power actions
- `network-server` — SSH/network
- `edit-paste` — Clipboard
### Testing
```bash
# Build and test native plugin
cargo build --release -p owlry-plugin-myplugin
cargo test -p owlry-plugin-myplugin
# Install for testing
sudo cp target/release/libowlry_plugin_myplugin.so /usr/lib/owlry/plugins/
# Test with verbose logging
RUST_LOG=debug owlry
```
---
## Publishing to AUR
### PKGBUILD Template
```bash
# Maintainer: Your Name <email@example.com>
pkgname=owlry-plugin-myplugin
pkgver=0.1.0
pkgrel=1
pkgdesc="My custom Owlry plugin"
arch=('x86_64')
url="https://github.com/you/owlry-plugin-myplugin"
license=('GPL-3.0-or-later')
depends=('owlry')
makedepends=('rust' 'cargo')
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
sha256sums=('...')
build() {
cd "$pkgname-$pkgver"
cargo build --release
}
package() {
cd "$pkgname-$pkgver"
install -Dm755 "target/release/lib${pkgname//-/_}.so" \
"$pkgdir/usr/lib/owlry/plugins/lib${pkgname//-/_}.so"
}
```
---
## Example Plugins
The owlry repository includes 13 native plugins as reference implementations:
| Plugin | Type | Highlights |
|--------|------|------------|
| `owlry-plugin-calculator` | Dynamic | Math parsing, expression evaluation |
| `owlry-plugin-weather` | Static/Widget | HTTP API, JSON parsing, caching |
| `owlry-plugin-systemd` | Static | Submenu actions, service management |
| `owlry-plugin-pomodoro` | Static/Widget | State persistence, notifications |
| `owlry-plugin-clipboard` | Static | External process integration |
Browse the source at `crates/owlry-plugin-*/` for implementation details.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,458 @@
# Owlry Architecture Split — Design Spec
**Date:** 2026-03-26
**Status:** Draft
**Goal:** Split owlry into two repos with a client/daemon architecture, enabling independent release cadence and clean architectural separation.
---
## 1. Overview
Currently owlry is a monorepo with 18 crates that all share version `0.4.10`. Changing any single package requires bumping all versions and rebuilding every AUR package from the same source. The architecture also couples the GTK UI directly to plugin loading and provider management, meaning the UI blocks during initialization.
This redesign:
- Splits into two repositories (core + plugins)
- Extracts backend logic into `owlry-core` daemon
- Makes `owlry` a thin GTK4 client communicating over Unix socket IPC
- Enables independent versioning and release cadence per package
- Makes third-party plugin development straightforward
---
## 2. Repository Layout
### Repo 1 — `owlry/` (core, changes infrequently)
```
owlry/
├── Cargo.toml # Workspace: owlry, owlry-core, owlry-plugin-api
├── crates/
│ ├── owlry/ # GTK4 frontend binary (thin UI client)
│ │ └── src/
│ │ ├── main.rs # Entry point, CLI parsing
│ │ ├── app.rs # GTK Application setup, CSS/theme loading
│ │ ├── cli.rs # Argument parsing (--mode, --profile, --dmenu)
│ │ ├── client.rs # IPC client (connects to daemon socket)
│ │ ├── ui/
│ │ │ ├── mod.rs
│ │ │ ├── main_window.rs # GTK window, Layer Shell overlay
│ │ │ ├── result_row.rs # Result rendering
│ │ │ └── submenu.rs # Submenu rendering
│ │ ├── theme.rs # CSS loading priority
│ │ └── providers/
│ │ └── dmenu.rs # Self-contained dmenu mode (bypasses daemon)
│ ├── owlry-core/ # Daemon binary
│ │ └── src/
│ │ ├── main.rs # Daemon entry point
│ │ ├── server.rs # Unix socket IPC server
│ │ ├── config/
│ │ │ └── mod.rs # Config loading (~/.config/owlry/config.toml)
│ │ ├── data/
│ │ │ ├── mod.rs
│ │ │ └── frecency.rs # FrecencyStore
│ │ ├── filter.rs # ProviderFilter, prefix/query parsing
│ │ ├── providers/
│ │ │ ├── mod.rs # ProviderManager
│ │ │ ├── application.rs # Desktop application provider
│ │ │ ├── command.rs # PATH command provider
│ │ │ ├── native_provider.rs # Bridge: native plugin → provider
│ │ │ └── lua_provider.rs # Bridge: lua/rune runtime → provider
│ │ ├── plugins/
│ │ │ ├── mod.rs
│ │ │ ├── native_loader.rs # Loads .so plugins
│ │ │ ├── runtime_loader.rs # Loads lua/rune runtimes
│ │ │ ├── loader.rs
│ │ │ ├── manifest.rs
│ │ │ ├── registry.rs
│ │ │ ├── commands.rs
│ │ │ ├── error.rs
│ │ │ ├── runtime.rs
│ │ │ └── api/ # Lua/Rune scripting API surface
│ │ │ ├── mod.rs
│ │ │ ├── provider.rs
│ │ │ ├── process.rs
│ │ │ ├── cache.rs
│ │ │ ├── math.rs
│ │ │ ├── action.rs
│ │ │ ├── theme.rs
│ │ │ ├── http.rs
│ │ │ ├── hook.rs
│ │ │ └── utils.rs
│ │ ├── notify.rs # Notification dispatch
│ │ └── paths.rs # XDG path helpers
│ └── owlry-plugin-api/ # ABI-stable plugin interface (published crate)
│ └── src/
│ └── lib.rs # PluginVTable, PluginInfo, ProviderInfo, etc.
├── aur/
│ ├── owlry/ # UI package
│ ├── owlry-core/ # Daemon package (new)
│ ├── owlry-meta-essentials/
│ ├── owlry-meta-full/
│ ├── owlry-meta-tools/
│ └── owlry-meta-widgets/
├── resources/ # CSS, base themes, icons
├── systemd/
│ ├── owlry-core.service # Systemd user service
│ └── owlry-core.socket # Socket activation (optional)
├── justfile
├── README.md
└── docs/
```
### Repo 2 — `owlry-plugins/` (plugins, releases independently)
```
owlry-plugins/
├── Cargo.toml # Workspace: all plugins + runtimes
├── crates/
│ ├── owlry-plugin-calculator/
│ ├── owlry-plugin-clipboard/
│ ├── owlry-plugin-emoji/
│ ├── owlry-plugin-bookmarks/
│ ├── owlry-plugin-ssh/
│ ├── owlry-plugin-scripts/
│ ├── owlry-plugin-system/
│ ├── owlry-plugin-websearch/
│ ├── owlry-plugin-filesearch/
│ ├── owlry-plugin-weather/
│ ├── owlry-plugin-media/
│ ├── owlry-plugin-pomodoro/
│ ├── owlry-plugin-systemd/
│ ├── owlry-lua/ # Lua runtime (cdylib)
│ └── owlry-rune/ # Rune runtime (cdylib)
├── aur/
│ ├── owlry-plugin-*/ # Individual plugin PKGBUILDs
│ ├── owlry-lua/
│ └── owlry-rune/
├── justfile
├── README.md
└── docs/
├── PLUGIN_DEVELOPMENT.md
└── PLUGINS.md
```
---
## 3. Daemon Architecture (`owlry-core`)
### Responsibilities
- Load and manage all native plugins from:
- `/usr/lib/owlry/plugins/*.so` (system-installed)
- `~/.local/lib/owlry/plugins/*.so` (user-installed native)
- Load script runtimes from:
- `/usr/lib/owlry/runtimes/*.so` (system)
- `~/.config/owlry/plugins/` (user lua/rune scripts)
- Maintain `ProviderManager` with all providers initialized
- Own `FrecencyStore` (persists across UI open/close cycles)
- Load and watch config from `~/.config/owlry/config.toml`
- Listen on Unix socket at `$XDG_RUNTIME_DIR/owlry/owlry.sock`
### Systemd Integration
```ini
# owlry-core.service
[Unit]
Description=Owlry application launcher daemon
Documentation=https://somegit.dev/Owlibou/owlry
[Service]
Type=simple
ExecStart=/usr/bin/owlry-core
Restart=on-failure
Environment=RUST_LOG=warn
[Install]
WantedBy=default.target
```
```ini
# owlry-core.socket (optional socket activation)
[Unit]
Description=Owlry launcher socket
[Socket]
ListenStream=%t/owlry/owlry.sock
DirectoryMode=0700
[Install]
WantedBy=sockets.target
```
Users can either:
- `exec-once owlry-core` in compositor config
- `systemctl --user enable --now owlry-core.service`
- Rely on socket activation (daemon starts on first UI connection)
---
## 4. IPC Protocol
JSON messages over Unix domain socket, newline-delimited (`\n`). Each message is a single JSON object on one line.
### Client → Server
```json
{"type": "query", "text": "fire", "modes": ["app", "cmd"]}
```
Query providers. `modes` is optional — omit to query all. Server streams back results.
```json
{"type": "launch", "item_id": "firefox.desktop", "provider": "app"}
```
Notify daemon that an item was launched (updates frecency).
```json
{"type": "providers"}
```
List all available providers (for UI tab rendering).
```json
{"type": "refresh", "provider": "clipboard"}
```
Force a specific provider to refresh its data.
```json
{"type": "toggle"}
```
Used for toggle behavior — if UI is already open, close it.
```json
{"type": "submenu", "plugin_id": "systemd", "data": "docker.service"}
```
Request submenu items for a plugin.
### Server → Client
```json
{"type": "results", "items": [{"id": "firefox.desktop", "title": "Firefox", "description": "Web Browser", "icon": "firefox", "provider": "app", "score": 95}]}
```
```json
{"type": "providers", "list": [{"id": "app", "name": "Applications", "prefix": ":app", "icon": "application-x-executable", "position": "normal"}]}
```
```json
{"type": "submenu_items", "items": [...]}
```
```json
{"type": "error", "message": "..."}
```
```json
{"type": "ack"}
```
---
## 5. UI Client (`owlry`)
### CLI Interface
```
owlry # Open with all providers
owlry -m app,cmd,calc # Open with specific modes
owlry --profile default # Open with named profile from config
owlry -m dmenu -p "Pick:" # dmenu mode (bypasses daemon, reads stdin)
```
**Removed flags:**
- `-p` / `--providers` — removed, was confusing overlap with `-m`
Note: `-p` is repurposed as the dmenu prompt flag (short for `--prompt`), matching dmenu/rofi convention.
### Profiles (in `config.toml`)
```toml
[profiles.default]
modes = ["app", "cmd", "calc"]
[profiles.utils]
modes = ["clip", "emoji", "ssh", "sys"]
[profiles.dev]
modes = ["app", "cmd", "ssh", "file"]
```
Usage: `owlry --profile utils`
### Toggle Behavior
When `owlry` is invoked while already open:
1. Client connects to daemon socket
2. Sends `{"type": "toggle"}`
3. If an instance is already showing, it closes
4. If no instance is showing, opens normally
Implementation: the daemon tracks whether a UI client is currently connected and visible.
### Auto-Start Fallback
If the UI tries to connect to the daemon socket and it doesn't exist:
1. Attempt to start via systemd: `systemctl --user start owlry-core`
2. If systemd activation fails, fork `owlry-core` directly
3. Retry connection with brief backoff (100ms, 200ms, 400ms — 3 attempts max)
4. If still unreachable, exit with clear error message
### dmenu Mode
Completely self-contained in the UI binary:
- Reads items from stdin
- Renders GTK picker
- Prints selected item to stdout
- No daemon connection, no frecency, no plugins
- `-p "prompt"` sets the prompt text
---
## 6. Plugin API Decoupling
### `owlry-plugin-api` as Published Crate
- Published to crates.io (or referenced as git dep from core repo)
- Versioned independently — bumped only when ABI changes
- Current `API_VERSION = 3` continues
- Third-party plugin authors: `cargo add owlry-plugin-api`, implement `owlry_plugin_vtable`, build as cdylib
### Plugin Dependencies in `owlry-plugins` Repo
Each plugin's `Cargo.toml`:
```toml
[dependencies]
owlry-plugin-api = "0.5" # from crates.io
# or
owlry-plugin-api = { git = "https://somegit.dev/Owlibou/owlry.git", tag = "plugin-api-v0.5.0" }
```
No path dependencies between repos.
### Plugin Discovery (daemon)
Auto-discovers all `.so` files in plugin directories. No explicit enable list needed.
Disabling in `config.toml`:
```toml
[plugins]
disabled = ["pomodoro", "media"]
```
---
## 7. Versioning Strategy
### Core Repo (`owlry`)
| Crate | Versioning | When to bump |
|-------|-----------|-------------|
| `owlry-plugin-api` | Independent semver | ABI changes only (rare) |
| `owlry-core` | Independent semver | Daemon logic, IPC, provider changes |
| `owlry` | Independent semver | UI changes, CLI changes |
These can share a workspace version if convenient, but are not required to stay in lockstep.
### Plugins Repo (`owlry-plugins`)
| Crate | Versioning | When to bump |
|-------|-----------|-------------|
| Each plugin | Independent semver | That plugin's code changes |
| `owlry-lua` | Independent semver | Lua runtime changes |
| `owlry-rune` | Independent semver | Rune runtime changes |
A calculator bugfix bumps only `owlry-plugin-calculator`. Nothing else changes.
### AUR Packages
- Core PKGBUILDs source from `owlry` repo tags
- Plugin PKGBUILDs source from `owlry-plugins` repo, each with their own tag
- Meta-packages stay at `1.0.0`, only `pkgrel` changes when included packages update
---
## 8. Dependency Refresh
Before starting implementation, update all external crate dependencies to latest stable versions:
- Run `cargo update` to refresh `Cargo.lock`
- Review and bump version constraints in `Cargo.toml` files where appropriate (e.g., `gtk4`, `abi_stable`, `reqwest`, `mlua`, `rune`, etc.)
- Verify the workspace still builds and tests pass after updates
---
## 9. Migration Phases
### Phase 0 — Dependency Refresh
- Update all external crates to latest stable versions
- Verify build + tests pass
- Commit
### Phase 1 — Extract `owlry-core` Crate (library, no IPC yet)
- Create `crates/owlry-core/` with its own `Cargo.toml`
- Move from `crates/owlry/src/` into `crates/owlry-core/src/`:
- `config/` (config loading)
- `data/` (frecency)
- `filter.rs` (provider filter, prefix parsing)
- `providers/``mod.rs`, `application.rs`, `command.rs`, `native_provider.rs`, `lua_provider.rs`
- `plugins/` — entire directory (native_loader, runtime_loader, manifest, registry, commands, error, runtime, api/)
- `notify.rs`
- `paths.rs`
- `owlry` depends on `owlry-core` as a library (path dep)
- Launcher still works as before — no behavioral change
- Update justfile for new crate
- Update README to reflect new structure
- Commit
### Phase 2 — Add IPC Layer (daemon + client)
- Add `server.rs` to `owlry-core` — Unix socket listener, JSON protocol
- Add `main.rs` to `owlry-core` — daemon entry point
- Add `[[bin]]` target to `owlry-core` alongside the library (`lib` + `bin` in same crate)
- Add `client.rs` to `owlry` — connects to daemon, sends queries
- Wire up `owlry` UI to use IPC client instead of direct library calls
- Implement toggle behavior
- Implement auto-start fallback
- Implement profiles in config
- Add systemd service + socket files
- dmenu mode stays self-contained in `owlry`
- Remove `-p` provider flag, repurpose as `--prompt` for dmenu
- Update justfile
- Update README
- Commit
### Phase 3 — Split Repos
- Create `owlry-plugins/` directory with its own workspace
- Move all `crates/owlry-plugin-*/` into `owlry-plugins/crates/`
- Move `crates/owlry-lua/` and `crates/owlry-rune/` into `owlry-plugins/crates/`
- Update plugin `Cargo.toml` files: path dep → git/crates.io dep for `owlry-plugin-api`
- Move plugin AUR PKGBUILDs into `owlry-plugins/aur/`
- Move runtime AUR PKGBUILDs into `owlry-plugins/aur/`
- Create `owlry-plugins/justfile`
- Move `docs/PLUGIN_DEVELOPMENT.md` and `docs/PLUGINS.md` into `owlry-plugins/docs/`
- Create `owlry-plugins/README.md`
- Update core `owlry/justfile` (remove plugin-related targets)
- Update core `owlry/README.md`
- Add `owlry-core` AUR PKGBUILD
- Update meta-package PKGBUILDs to reflect new source structure
- Update `CLAUDE.md`
- Commit both repos
### Phase 4 — Polish & Verify
- Verify all AUR PKGBUILDs build correctly
- Verify `owlry-core` daemon starts and responds to IPC
- Verify `owlry` UI connects, queries, renders, and launches
- Verify dmenu mode still works standalone
- Verify toggle behavior
- Verify profile loading
- Verify auto-start fallback
- Verify systemd service and socket activation
- Verify plugin loading from both system and user paths
- Clean up any leftover references to old structure
- Final README review
---
## 10. Out of Scope
- Publishing to crates.io (can be done later)
- Pushing updated PKGBUILDs to AUR git repos (done after verification)
- Alternative frontends (TUI, etc.) — the architecture enables this but it's future work
- Plugin hot-reloading in the daemon
- Multi-client support (only one UI at a time for now)

222
justfile
View File

@@ -8,10 +8,22 @@ default:
build:
cargo build --workspace
# Build core binary only
build-core:
# Build UI binary only
build-ui:
cargo build -p owlry
# Build core daemon only
build-daemon:
cargo build -p owlry-core
# Build core daemon release
release-daemon:
cargo build -p owlry-core --release
# Run core daemon
run-daemon *ARGS:
cargo run -p owlry-core -- {{ARGS}}
# Build release
release:
cargo build --workspace --release
@@ -37,52 +49,26 @@ fmt:
clean:
cargo clean
# Build a specific plugin (when plugins exist)
plugin name:
cargo build -p owlry-plugin-{{name}} --release
# Build all plugins
plugins:
cargo build --workspace --release --exclude owlry
# Install locally (core + plugins + runtimes)
# Install locally (core + runtimes)
install-local:
#!/usr/bin/env bash
set -euo pipefail
echo "Building release..."
# Build core without embedded Lua (smaller binary)
# Build UI without embedded Lua (smaller binary)
cargo build -p owlry --release --no-default-features
# Build plugins
cargo build --workspace --release --exclude owlry
# Build core daemon
cargo build -p owlry-core --release
# Build runtimes
cargo build -p owlry-lua -p owlry-rune --release
echo "Creating directories..."
sudo mkdir -p /usr/lib/owlry/plugins
sudo mkdir -p /usr/lib/owlry/runtimes
echo "Cleaning up stale files..."
# Remove runtime files that may have ended up in plugins dir (from old installs)
sudo rm -f /usr/lib/owlry/plugins/libowlry_lua.so /usr/lib/owlry/plugins/libowlry_rune.so
# Remove old short-named plugin files (from old AUR packages before naming standardization)
sudo rm -f /usr/lib/owlry/plugins/libbookmarks.so /usr/lib/owlry/plugins/libcalculator.so \
/usr/lib/owlry/plugins/libclipboard.so /usr/lib/owlry/plugins/libemoji.so \
/usr/lib/owlry/plugins/libfilesearch.so /usr/lib/owlry/plugins/libmedia.so \
/usr/lib/owlry/plugins/libpomodoro.so /usr/lib/owlry/plugins/libscripts.so \
/usr/lib/owlry/plugins/libssh.so /usr/lib/owlry/plugins/libsystem.so \
/usr/lib/owlry/plugins/libsystemd.so /usr/lib/owlry/plugins/libweather.so \
/usr/lib/owlry/plugins/libwebsearch.so
echo "Installing core binary..."
echo "Installing binaries..."
sudo install -Dm755 target/release/owlry /usr/bin/owlry
echo "Installing plugins..."
for plugin in target/release/libowlry_plugin_*.so; do
if [ -f "$plugin" ]; then
name=$(basename "$plugin")
sudo install -Dm755 "$plugin" "/usr/lib/owlry/plugins/$name"
echo "$name"
fi
done
sudo install -Dm755 target/release/owlry-core /usr/bin/owlry-core
echo "Installing runtimes..."
if [ -f "target/release/libowlry_lua.so" ]; then
@@ -94,11 +80,28 @@ install-local:
echo " → librune.so"
fi
echo "Installing systemd service files..."
if [ -f "systemd/owlry-core.service" ]; then
sudo install -Dm644 systemd/owlry-core.service /usr/lib/systemd/user/owlry-core.service
echo " → owlry-core.service"
fi
if [ -f "systemd/owlry-core.socket" ]; then
sudo install -Dm644 systemd/owlry-core.socket /usr/lib/systemd/user/owlry-core.socket
echo " → owlry-core.socket"
fi
echo ""
echo "Installation complete!"
echo " - /usr/bin/owlry"
echo " - $(ls /usr/lib/owlry/plugins/*.so 2>/dev/null | wc -l) plugins"
echo " - /usr/bin/owlry (UI)"
echo " - /usr/bin/owlry-core (daemon)"
echo " - $(ls /usr/lib/owlry/runtimes/*.so 2>/dev/null | wc -l) runtimes"
echo " - systemd: owlry-core.service, owlry-core.socket"
echo ""
echo "To start the daemon:"
echo " systemctl --user enable --now owlry-core.service"
echo " OR add 'exec-once = owlry-core' to your compositor config"
echo ""
echo "Note: Install plugins separately from the owlry-plugins repo."
# === Release Management ===
@@ -126,7 +129,7 @@ show-versions:
crate-version crate:
@grep '^version' crates/{{crate}}/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'
# Bump a specific crate version (usage: just bump-crate owlry-plugin-calculator 0.2.0)
# Bump a specific crate version (usage: just bump-crate owlry-core 0.2.0)
bump-crate crate new_version:
#!/usr/bin/env bash
set -euo pipefail
@@ -147,23 +150,6 @@ bump-crate crate new_version:
git commit -m "chore({{crate}}): bump version to {{new_version}}"
echo "{{crate}} bumped to {{new_version}}"
# Bump all plugins to same version (usage: just bump-plugins 0.2.0)
bump-plugins new_version:
#!/usr/bin/env bash
set -euo pipefail
for toml in crates/owlry-plugin-*/Cargo.toml; do
crate=$(basename $(dirname "$toml"))
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" != "{{new_version}}" ]; then
echo "Bumping $crate from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
fi
done
cargo check --workspace
git add crates/owlry-plugin-*/Cargo.toml Cargo.lock
git commit -m "chore(plugins): bump all plugins to {{new_version}}"
echo "All plugins bumped to {{new_version}}"
# Bump meta-packages (no crate, just AUR version)
bump-meta new_version:
#!/usr/bin/env bash
@@ -179,12 +165,11 @@ bump-meta new_version:
done
echo "Meta-packages bumped to {{new_version}}"
# Bump all non-core crates (plugins + runtimes) to same version
# Bump all crates (core UI + daemon + plugin-api + runtimes) to same version
bump-all new_version:
#!/usr/bin/env bash
set -euo pipefail
# Bump plugins
for toml in crates/owlry-plugin-*/Cargo.toml; do
for toml in crates/*/Cargo.toml; do
crate=$(basename $(dirname "$toml"))
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" != "{{new_version}}" ]; then
@@ -192,21 +177,10 @@ bump-all new_version:
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
fi
done
# Bump runtimes
for toml in crates/owlry-lua/Cargo.toml crates/owlry-rune/Cargo.toml; do
if [ -f "$toml" ]; then
crate=$(basename $(dirname "$toml"))
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$old" != "{{new_version}}" ]; then
echo "Bumping $crate from $old to {{new_version}}"
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
fi
fi
done
cargo check --workspace
git add crates/owlry-plugin-*/Cargo.toml crates/owlry-lua/Cargo.toml crates/owlry-rune/Cargo.toml Cargo.lock
git commit -m "chore: bump all plugins and runtimes to {{new_version}}"
echo "All plugins and runtimes bumped to {{new_version}}"
git add crates/*/Cargo.toml Cargo.lock
git commit -m "chore: bump all crates to {{new_version}}"
echo "All crates bumped to {{new_version}}"
# Bump core version (usage: just bump 0.2.0)
bump new_version:
@@ -236,7 +210,7 @@ tag:
git push origin "v{{version}}"
echo "Tag v{{version}} pushed"
# Update AUR package (core)
# Update AUR package (core UI)
aur-update:
#!/usr/bin/env bash
set -euo pipefail
@@ -264,7 +238,7 @@ aur-update:
echo "AUR package updated. Review changes above."
echo "Run 'just aur-publish' to commit and push."
# Publish AUR package (core)
# Publish AUR package (core UI)
aur-publish:
#!/usr/bin/env bash
set -euo pipefail
@@ -276,7 +250,7 @@ aur-publish:
echo "AUR package v{{version}} published!"
# Test AUR package build locally (core)
# Test AUR package build locally (core UI)
aur-test:
#!/usr/bin/env bash
set -euo pipefail
@@ -291,7 +265,7 @@ aur-test:
# === AUR Package Management (individual packages) ===
# Update a specific AUR package (usage: just aur-update-pkg owlry-plugin-calculator)
# Update a specific AUR package (usage: just aur-update-pkg owlry-core)
aur-update-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
@@ -304,7 +278,7 @@ aur-update-pkg pkg:
url="https://somegit.dev/Owlibou/owlry"
# Determine crate version (unified versioning: all crates share same version)
# Determine crate version
case "{{pkg}}" in
owlry-meta-essentials|owlry-meta-tools|owlry-meta-widgets|owlry-meta-full)
# Meta-packages use static versioning (1.0.0), only bump pkgrel for dep changes
@@ -329,7 +303,7 @@ aur-update-pkg pkg:
sed -i "s/^pkgver=.*/pkgver=$crate_ver/" PKGBUILD
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
# Update checksums (unified versioning: all packages use same version)
# Update checksums
if grep -q "^source=" PKGBUILD; then
echo "Updating checksums..."
b2sum=$(curl -sL "$url/archive/v$crate_ver.tar.gz" | b2sum | cut -d' ' -f1)
@@ -377,38 +351,6 @@ aur-test-pkg pkg:
echo "Package built successfully!"
ls -lh *.pkg.tar.zst
# Update all plugin AUR packages
aur-update-plugins:
#!/usr/bin/env bash
set -euo pipefail
for dir in aur/owlry-plugin-*/; do
pkg=$(basename "$dir")
echo "=== Updating $pkg ==="
just aur-update-pkg "$pkg"
echo ""
done
# Publish all plugin AUR packages
aur-publish-plugins:
#!/usr/bin/env bash
set -euo pipefail
for dir in aur/owlry-plugin-*/; do
pkg=$(basename "$dir")
echo "=== Publishing $pkg ==="
just aur-publish-pkg "$pkg"
echo ""
done
# Publish all meta-packages
aur-publish-meta:
#!/usr/bin/env bash
set -euo pipefail
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
echo "=== Publishing $pkg ==="
just aur-publish-pkg "$pkg"
done
echo "All meta-packages published!"
# List all AUR packages with their versions
aur-status:
#!/usr/bin/env bash
@@ -426,19 +368,15 @@ aur-status:
fi
done
# Update ALL AUR packages (core + plugins + runtimes + meta)
# Update ALL AUR packages (core + daemon + runtimes + meta)
aur-update-all:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Updating core ==="
echo "=== Updating core UI ==="
just aur-update
echo ""
echo "=== Updating plugins ==="
for dir in aur/owlry-plugin-*/; do
pkg=$(basename "$dir")
echo "--- $pkg ---"
just aur-update-pkg "$pkg"
done
echo "=== Updating core daemon ==="
just aur-update-pkg owlry-core
echo ""
echo "=== Updating runtimes ==="
just aur-update-pkg owlry-lua
@@ -456,15 +394,11 @@ aur-update-all:
aur-publish-all:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Publishing core ==="
echo "=== Publishing core UI ==="
just aur-publish
echo ""
echo "=== Publishing plugins ==="
for dir in aur/owlry-plugin-*/; do
pkg=$(basename "$dir")
echo "--- $pkg ---"
just aur-publish-pkg "$pkg"
done
echo "=== Publishing core daemon ==="
just aur-publish-pkg owlry-core
echo ""
echo "=== Publishing runtimes ==="
just aur-publish-pkg owlry-lua
@@ -499,39 +433,3 @@ release-core new_version: (bump new_version)
echo ""
echo "Core release v{{new_version}} prepared!"
echo "Review AUR changes, then run 'just aur-publish'"
# Full release workflow for everything (core + plugins + runtimes)
# Usage: just release-all 0.5.0 0.3.0
# First arg is core version, second is plugins/runtimes version
release-all core_version plugin_version:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Bumping versions ==="
just bump {{core_version}}
just bump-all {{plugin_version}}
echo ""
echo "=== Pushing to origin ==="
git push
echo ""
echo "=== Creating tag ==="
just tag
echo "Waiting for tag to propagate..."
sleep 2
echo ""
echo "=== Updating all AUR packages ==="
just aur-update-all
echo ""
echo "=========================================="
echo "Release prepared!"
echo " Core: v{{core_version}}"
echo " Plugins/Runtimes: v{{plugin_version}}"
echo ""
echo "Review changes with 'just aur-status'"
echo "Then publish with 'just aur-publish-all'"
echo "=========================================="

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