owlry 2.0: single-binary rewrite #6

Merged
vikingowl merged 23 commits from v2 into main 2026-05-13 03:28:07 +02:00
107 changed files with 5547 additions and 16062 deletions
+8
View File
@@ -17,3 +17,11 @@ aur/*/*.pkg.tar.*
build-logs/
test-build-output*.md
test-build-output*.log
# v2: local AUR checkouts of dropped packages. The AUR remotes still exist
# on aur.archlinux.org until manually orphaned via the AUR web UI; the
# embedded .git dirs here are pointers to those remotes. Ignored so the
# main repo doesn't try to track them as embedded submodules.
/aur/owlry-core/
/aur/owlry-lua/
/aur/owlry-rune/
+107 -408
View File
@@ -1,442 +1,141 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for Claude Code (claude.ai/code) when working in this repository.
## Build & Development Commands
The v2 rewrite is the load-bearing context here. Read [`docs/RESTRUCTURE-V2.md`](docs/RESTRUCTURE-V2.md) first if you're picking the project back up — it captures every design decision (D1D21), the phased plan, and the per-task commit log.
## Project shape
Owlry is a single-binary Wayland launcher. Workspace has exactly one member:
```
crates/owlry/ -- everything
├── Cargo.toml -- one binary + one library, features per provider
├── src/
│ ├── main.rs -- subcommand router
│ ├── cli.rs -- clap definitions
│ ├── lib.rs -- module re-exports for integration tests
│ ├── server.rs -- IPC daemon (bind + accept loop)
│ ├── client.rs -- IPC client (UI mode)
│ ├── backend.rs -- SearchBackend abstraction
│ ├── app.rs -- GTK4 setup
│ ├── ui/ -- GTK widgets
│ ├── config/ -- TOML config loader
│ ├── data/ -- FrecencyStore
│ ├── filter.rs -- ProviderFilter + prefix parser
│ ├── ipc.rs -- Request/Response types
│ ├── paths.rs -- XDG paths (honours $OWLRY_SOCKET)
│ ├── commands.rs -- subcommand dispatchers (doctor / providers / config / …)
│ └── providers/ -- ALL providers
│ ├── mod.rs -- Provider/DynamicProvider traits, ProviderManager
│ ├── application.rs (feature: app)
│ ├── command.rs (feature: cmd)
│ ├── calculator.rs (feature: calc)
│ ├── converter/ (feature: conv)
│ ├── power.rs (feature: power — pre-v2 name: sys/system)
│ ├── dmenu.rs (feature: dmenu)
│ ├── clipboard.rs (feature: clipboard)
│ ├── emoji.rs (feature: emoji)
│ ├── ssh.rs (feature: ssh)
│ ├── systemd.rs (feature: systemd — type_id "uuctl")
│ ├── websearch.rs (feature: websearch)
│ └── filesearch.rs (feature: filesearch)
```
There is no `crates/owlry-core`, `owlry-plugin-api`, `owlry-lua`, or `owlry-rune`. They were deleted in the v2 demolition. There is no `~/.config/owlry/plugins/` discovery directory and no `/usr/lib/owlry/plugins/*.so` loading — every "plugin" is a feature-gated module compiled into `owlry`.
## Build & development
```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
# Default features (minimal — app, cmd, calc, conv, power, dmenu)
cargo build
# Dev build with verbose logging
cargo run -p owlry --features dev-logging
# Full features (matches AUR build)
cargo build --release --features full
# Build core without embedded Lua (smaller binary, uses external owlry-lua)
cargo build -p owlry --release --no-default-features
# Tests
cargo test --workspace --features full # 252+ tests
cargo test --workspace --no-default-features
# Verbose dev logging
cargo run -- --features dev-logging -- -d
# Format + lint
cargo fmt --all
cargo clippy --workspace --features full
# Install locally (sudo)
just install-local
```
## Usage Examples
The daemon binary is `owlry`. There is no separate `owlryd` binary anywhere — the daemon is `owlry -d` (or `owlry daemon`). The systemd user unit is `owlry.service` (pre-2.0 name: `owlryd.service`).
### Basic Invocation
## Running locally without disturbing prod
The UI client connects to the `owlry-core` daemon via Unix socket IPC. Start the daemon first:
For side-by-side testing against a production daemon, set `$OWLRY_SOCKET`:
```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)
OWLRY_SOCKET=/tmp/owlry-dev.sock target/release/owlry -d &
OWLRY_SOCKET=/tmp/owlry-dev.sock target/release/owlry -m uuctl
pkill -f 'target/release/owlry -d'
```
### dmenu Mode
`$OWLRY_SOCKET` overrides `$XDG_RUNTIME_DIR/owlry/owlry.sock` for both the daemon (bind path) and the UI client (connect path).
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
# Tagging convention: every crate gets its own tag
# Format: {crate-name}-v{version}
# Examples:
# owlry-v1.0.1
# owlry-core-v1.1.0
# owlry-lua-v1.1.0
# owlry-rune-v1.1.0
# plugin-api-v1.0.1
#
# The plugins repo uses the same convention:
# owlry-plugin-bookmarks-v1.0.1
# owlry-plugin-calculator-v1.0.1
# etc.
#
# IMPORTANT: After bumping versions, tag EVERY changed crate individually.
# The plugin-api tag is referenced by owlry-plugins Cargo.toml as a git dependency.
# 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
## CLI shape
```
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)
owlry UI, auto mode (default)
owlry -m <mode> UI, single-provider mode
owlry --profile <name> UI, named profile from config
owlry -p <prompt> custom prompt text
owlry daemon run the daemon (alias: -d)
owlry dmenu [-p <prompt>] dmenu mode (reads stdin, prints selection)
owlry doctor config + socket + providers status
owlry providers [<id>] list providers (or show one)
owlry config validate parse config, report errors
owlry config show print resolved effective config
owlry migrate-config TOML → init.lua (stub; lands with Lua config)
```
### IPC Protocol
The `owlry plugin ...` subcommand tree from 1.x is **gone** in 2.0. Nothing to install, manage, or run via the CLI.
Communication uses newline-delimited JSON over a Unix domain socket at `$XDG_RUNTIME_DIR/owlry/owlry.sock`.
## Provider model
**Request types** (`owlry_core::ipc::Request`):
Two traits in `src/providers/mod.rs`:
| 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 |
- **`Provider`** — static providers (apps, commands, power, bookmarks, clipboard, emoji, ssh, systemd). Populate `self.items` in `refresh()`, return `&self.items` from `items()`. Optional: `prefix()`, `icon()`, `tab_label()`, `search_noun()`, `position()`, `priority()`, `submenu_actions(data)`, `execute_action(command)`.
- **`DynamicProvider`** — per-keystroke providers (calculator, converter, filesearch, websearch). Generate items in `query(text)`. No `refresh`/`items` cache.
**Response types** (`owlry_core::ipc::Response`):
`ProviderManager::new_with_config` is the canonical registration site. Each provider is gated by both a cargo feature (compile-time) and a config flag in `[providers]` (runtime).
| Type | Purpose |
|------|---------|
| `Results` | Search results with `Vec<ResultItem>` |
| `Providers` | Provider list with `Vec<ProviderDesc>` (includes optional `tab_label`, `search_noun`) |
| `SubmenuItems` | Submenu actions for a plugin |
| `Ack` | Success acknowledgement |
| `Error` | Error with message |
## Submenu protocol
### Core Data Flow
A provider returns items whose `command` field looks like `SUBMENU:<type_id>:<data>`. When the user selects one, the UI parses out `(plugin_id, data)`, sends a `Request::Submenu { plugin_id, data }`, and the daemon routes that to `Provider::submenu_actions(data)` on the matching provider. The systemd provider uses this for service start/stop/restart/enable/disable/status/journal actions.
```
[owlry UI] [owlry-core daemon]
For now the protocol is string-encoded; a typed redesign is on the roadmap.
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
```
## v2 naming rules
### Provider System
| Old (pre-v2) | New (2.0+) | Notes |
|---|---|---|
| `owlryd` binary | `owlry -d` | Single binary |
| `owlryd.service` / `owlryd.socket` | `owlry.service` / `owlry.socket` | `.install` hook handles upgrade |
| `:sys` / `:system` / "System" provider | `:power` / "Power" provider | `:sys` and `:system` kept as aliases |
| `providers.system = true` (config) | `providers.power = true` | Old key still accepted via serde alias |
| `badge_sys` (theme color) | `badge_power` | Old key aliased; CSS var `--owlry-badge-sys` still emitted for transition |
| `Plugin("sys")` type_id | `Plugin("power")` type_id | CLI mode parsing maps all three to `power` |
**Core providers** (in `owlry-core`):
- **Application**: Desktop applications from XDG directories
- **Command**: Shell commands from PATH
## Frecency
**dmenu provider** (in `owlry` client, local only):
- **Dmenu**: Pipe-based input (dmenu compatibility)
`~/.local/share/owlry/frecency.json`. Auto-saved every 5 min by the daemon; flushed on SIGTERM/SIGINT/SIGHUP. Boost weight via `providers.frecency_weight` (0.0 disabled, 1.0 strong).
All other providers are native plugins in the separate `owlry-plugins` repo (`somegit.dev/Owlibou/owlry-plugins`).
## When you need to verify behavior live
**User plugins** (script-based, in `~/.config/owlry/plugins/`):
- **Lua plugins**: Loaded by `owlry-lua` runtime from `/usr/lib/owlry/runtimes/liblua.so`
- **Rune plugins**: Loaded by `owlry-rune` runtime from `/usr/lib/owlry/runtimes/librune.so`
- User plugins are **hot-reloaded** automatically when files change (no daemon restart needed)
- Custom prefixes (e.g., `:hs`) are resolved dynamically for user plugins
1. `cargo build --release --features full`
2. `OWLRY_SOCKET=/tmp/owlry-dev.sock target/release/owlry -d &`
3. Run smoke queries via socket OR the UI with `OWLRY_SOCKET` set
4. `pkill -f 'target/release/owlry -d'`
`ProviderManager` (in `owlry-core`) orchestrates providers and handles:
- Fuzzy matching via `SkimMatcherV2`
- Frecency score boosting
- Native plugin loading from `/usr/lib/owlry/plugins/`
- Script runtime loading from `/usr/lib/owlry/runtimes/` for user plugins
- Filesystem watching for automatic user plugin hot-reload
**Submenu System**: Plugins can return items with `SUBMENU:plugin_id:data` commands. When selected, the plugin is queried with `?SUBMENU:data` to get action items (e.g., systemd service actions).
**Provider Display Metadata**: Script plugins can declare `tab_label` and `search_noun` in their `plugin.toml` `[[providers]]` section, or at runtime via `owlry.provider.register()` (Lua) / `register_provider()` (Rune). These flow through the `Provider` trait → `ProviderDescriptor` → IPC `ProviderDesc` → UI. The UI resolves metadata via `provider_meta::resolve()`, checking IPC metadata first, then falling back to the hardcoded match table. Unknown plugins auto-generate labels from `type_id` (e.g., "hs" → "Hs"). Native plugins use the hardcoded match table since `owlry-plugin-api::ProviderInfo` doesn't include these fields (deferred to API v5).
### Plugin API
Native plugins use the ABI-stable interface in `owlry-plugin-api`:
```rust
#[repr(C)]
pub struct PluginVTable {
pub info: extern "C" fn() -> PluginInfo,
pub providers: extern "C" fn() -> RVec<ProviderInfo>,
pub provider_init: extern "C" fn(id: RStr) -> ProviderHandle,
pub provider_refresh: extern "C" fn(ProviderHandle) -> RVec<PluginItem>,
pub provider_query: extern "C" fn(ProviderHandle, RStr) -> RVec<PluginItem>,
pub provider_drop: extern "C" fn(ProviderHandle),
}
// Each plugin exports:
#[no_mangle]
pub extern "C" fn owlry_plugin_vtable() -> &'static PluginVTable
```
Plugins are compiled as `.so` (cdylib) and loaded by the daemon at startup.
**Plugin locations** (when deployed):
- `/usr/lib/owlry/plugins/*.so` - Native plugins
- `/usr/lib/owlry/runtimes/*.so` - Script runtimes (liblua.so, librune.so)
- `~/.config/owlry/plugins/` - User plugins (Lua/Rune)
### Filter & Prefix System
`ProviderFilter` (`owlry-core/src/filter.rs`) handles:
- CLI mode selection (`--mode app`)
- Profile-based mode selection (`--profile dev`)
- Provider toggling (Ctrl+1/2/3)
- Prefix parsing (`:app`, `:cmd`, `:sys`, etc.)
- Dynamic prefix fallback for user plugins (any `:word` prefix maps to `Plugin(word)`)
Query parsing extracts prefix and forwards clean query to providers.
### SearchBackend
`SearchBackend` (`owlry/src/backend.rs`) abstracts over two modes:
- **`Daemon`**: Wraps `CoreClient`, sends queries over IPC to `owlry-core`
- **`Local`**: Wraps `ProviderManager` directly (used for dmenu mode only)
### UI Layer
- `MainWindow` (`src/ui/main_window.rs`): GTK4 window with Layer Shell overlay
- `ResultRow` (`src/ui/result_row.rs`): Individual result rendering
- `submenu` (`src/ui/submenu.rs`): Universal submenu parsing utilities (plugins provide actions)
### Configuration
`Config` (`owlry-core/src/config/mod.rs`) loads from `~/.config/owlry/config.toml`:
- Auto-detects terminal (`$TERMINAL` -> `xdg-terminal-exec` -> common terminals)
- Optional `use_uwsm = true` for systemd session integration (launches apps via `uwsm app --`)
- Profiles: Define named mode sets under `[profiles.<name>]` with `modes = ["app", "cmd", ...]`
### Theming
CSS loading priority (`owlry/src/app.rs`):
1. Base structural CSS (`resources/base.css`)
2. Theme CSS (built-in "owl" or custom `~/.config/owlry/themes/{name}.css`)
3. User overrides (`~/.config/owlry/style.css`)
4. Config variable injection
### Systemd Integration
Service files in `systemd/`:
- `owlry-core.service`: Runs daemon as `Type=simple`, restarts on failure
- `owlry-core.socket`: Socket activation at `%t/owlry/owlry.sock`
Start with: `systemctl --user enable --now owlry-core.service`
## Plugins
Plugins live in a separate repository: `somegit.dev/Owlibou/owlry-plugins`
13 native plugin crates, all compiled as cdylib (.so):
| Category | Plugins | Behavior |
|----------|---------|----------|
| Static | bookmarks, clipboard, emoji, scripts, ssh, system, systemd | Loaded at startup, refresh() populates items |
| Dynamic | calculator, websearch, filesearch | Queried per-keystroke via query() |
| Widget | weather, media, pomodoro | Displayed at top of results |
## Key Patterns
- **Rc<RefCell<T>>** used throughout for GTK signal handlers needing mutable state
- **Feature flag `dev-logging`**: Wraps debug!() calls in `#[cfg(feature = "dev-logging")]`
- **Feature flag `lua`**: Enables built-in Lua runtime (off by default); enable to embed Lua in core binary
- **Script runtimes**: External `.so` runtimes loaded from `/usr/lib/owlry/runtimes/` — Lua and Rune user plugins loaded from `~/.config/owlry/plugins/`
- **Hot-reload**: Filesystem watcher (`notify` crate) monitors user plugins dir and reloads runtimes on file changes
- **dmenu mode**: Runs locally without daemon. Use `-m dmenu` with piped stdin
- **Frecency**: Time-decayed frequency scoring stored in `~/.local/share/owlry/frecency.json`
- **ABI stability**: Plugin interface uses `abi_stable` crate for safe Rust dynamic linking
- **Plugin API v3**: Adds `position` (Normal/Widget) and `priority` fields to ProviderInfo
- **ProviderType simplification**: Core uses only `Application`, `Command`, `Dmenu`, `Plugin(String)` - all plugin-specific types removed from core
- **Provider display metadata**: Tab labels and search nouns flow from plugin.toml → `Provider` trait → IPC `ProviderDesc` → UI `provider_meta::resolve()`. Known native plugins use a hardcoded match table; script plugins declare metadata in their manifest or at runtime registration
## 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)
Don't fight the prod daemon for the default socket path during testing. The env var exists for exactly this.
Generated
+48 -1016
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -2,10 +2,6 @@
resolver = "2"
members = [
"crates/owlry",
"crates/owlry-core",
"crates/owlry-plugin-api",
"crates/owlry-lua",
"crates/owlry-rune",
]
# Shared workspace settings
+140 -331
View File
@@ -6,158 +6,119 @@
[![GTK4](https://img.shields.io/badge/GTK-4.12-green.svg)](https://gtk.org/)
[![Wayland](https://img.shields.io/badge/Wayland-native-blueviolet.svg)](https://wayland.freedesktop.org/)
A lightweight, owl-themed application launcher for Wayland, built with GTK4 and Layer Shell.
A lightweight, owl-themed application launcher for Wayland, built with GTK4 and Layer Shell. Single-binary, configurable, fast.
> **2.0 highlights.** Owlry collapsed from 15 AUR packages and a dynamic plugin system into one binary. All providers (apps, commands, calculator, converter, power, bookmarks, clipboard, emoji, ssh, systemd, websearch, filesearch) are compiled in and gated by cargo features. The AUR build ships everything; `cargo install` consumers can pick a subset. See [`docs/RESTRUCTURE-V2.md`](docs/RESTRUCTURE-V2.md) for the full rewrite story.
## Features
- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory
- **Built-in providers** — Calculator, unit/currency converter, and system actions out of the box
- **Built-in settings editor** — Configure everything from within the launcher (`:config`)
- **11 optional plugins** — Clipboard, emoji, weather, media, bookmarks, and more
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
- **Single binary** — UI client, daemon, and providers in one `/usr/bin/owlry`
- **Client/daemon architecture** — Daemon (`owlry -d`) keeps providers warm; UI appears instantly
- **Built-in providers** — Apps, PATH commands, calculator, unit/currency converter, power actions
- **Optional providers** (compiled in via `--features full` on AUR) — Clipboard history, emoji, SSH hosts, systemd user units, web search, filesystem search
- **Fuzzy search with tags** — Fast matching across names, descriptions, category tags
- **Config profiles** — Named mode presets for different workflows
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:config`, `:tag:X`, etc.
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:power`, `:uuctl`, `:tag:X`, 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 10 built-in themes
- **GTK4 theming** — System theme by default, 10 built-in themes shipped
- **Wayland native** — Uses Layer Shell for proper overlay behavior
- **dmenu compatible** — Pipe-based selection mode, no daemon required
- **Extensible** — Create custom plugins in Lua or Rune
- **dmenu compatible** — Pipe-based selection, no daemon required
## Installation
### Arch Linux (AUR)
```bash
# Core (includes calculator, converter, system actions, settings editor)
yay -S owlry
# Add individual plugins as needed
yay -S owlry-plugin-bookmarks owlry-plugin-weather owlry-plugin-clipboard
# For custom Lua/Rune user plugins
yay -S owlry-lua # Lua 5.4 runtime
yay -S owlry-rune # Rune runtime
paru -S owlry # or yay -S owlry
```
### Available Packages
**Core packages** (this repo):
| Package | Description |
|---------|-------------|
| `owlry` | GTK4 UI client |
| `owlry-core` | Daemon (`owlryd`) with built-in calculator, converter, system, and settings providers |
| `owlry-lua` | Lua 5.4 script runtime for user plugins |
| `owlry-rune` | Rune script runtime for user plugins |
**Plugin packages** ([owlry-plugins](https://somegit.dev/Owlibou/owlry-plugins) repo):
| Package | Description |
|---------|-------------|
| `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
| `owlry-plugin-clipboard` | History via cliphist |
| `owlry-plugin-emoji` | 400+ searchable emoji |
| `owlry-plugin-filesearch` | File search (`/ filename`) |
| `owlry-plugin-media` | MPRIS media controls |
| `owlry-plugin-pomodoro` | Pomodoro timer widget |
| `owlry-plugin-scripts` | User scripts |
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
| `owlry-plugin-systemd` | User services with actions |
| `owlry-plugin-weather` | Weather widget |
| `owlry-plugin-websearch` | Web search (`? query`) |
> **Note:** Calculator, converter, and system actions are built into `owlry-core` and do not require separate packages.
Upgrading from 1.x: paru/pacman transparently swaps the old `owlry-core`, `owlry-lua`, `owlry-rune`, and every `owlry-plugin-*` and `owlry-meta-*` package for the new unified `owlry`. The `.install` hook prints a banner with the systemd unit rename instructions (`owlryd.{service,socket}``owlry.{service,socket}`).
### Build from Source
**Dependencies:**
**System dependencies:**
```bash
# Arch Linux
# Arch
sudo pacman -S gtk4 gtk4-layer-shell
# Ubuntu/Debian
# Ubuntu / Debian
sudo apt install libgtk-4-dev libgtk4-layer-shell-dev
# Fedora
sudo dnf install gtk4-devel gtk4-layer-shell-devel
```
**Build (requires Rust 1.90+):**
**Rust 1.90+:**
```bash
git clone https://somegit.dev/Owlibou/owlry.git
cd owlry
# Build daemon + UI
cargo build --release -p owlry -p owlry-core
# Build runtimes (for user plugins)
cargo build --release -p owlry-lua -p owlry-rune
# Build everything in this workspace
cargo build --release --workspace
cargo build --release --features full # full = all providers (matches the AUR build)
just install-local # installs binary + systemd units (sudo)
```
**Plugins** are in a [separate repo](https://somegit.dev/Owlibou/owlry-plugins):
Cargo features (pick a subset if you don't need everything):
| Feature | Provider | Default? |
|---|---|---|
| `app` | XDG desktop applications | yes |
| `cmd` | Executables on `$PATH` | yes |
| `calc` | Calculator | yes |
| `conv` | Unit & currency converter | yes |
| `power` | Shutdown/reboot/lock | yes |
| `dmenu` | Pipe-based selection | yes |
| `clipboard` | Clipboard history (`cliphist`) | opt-in |
| `emoji` | Emoji picker (`wl-clipboard`) | opt-in |
| `ssh` | SSH hosts from `~/.ssh/config` | opt-in |
| `systemd` | systemd user units (type_id: `uuctl`) | opt-in |
| `websearch` | Web search (DuckDuckGo / configurable) | opt-in |
| `filesearch` | `fd` / `mlocate` shellout | opt-in |
| `full` | All of the above | — |
```bash
git clone https://somegit.dev/Owlibou/owlry-plugins.git
cd owlry-plugins
cargo build --release -p owlry-plugin-bookmarks # or any plugin
cargo build --release --no-default-features --features "app,cmd,calc,conv,power,dmenu,systemd"
```
**Install locally:**
```bash
just install-local
```
This installs the UI (`owlry`), daemon (`owlryd`), runtimes, and systemd service files.
## Getting Started
Owlry uses a client/daemon architecture. The daemon (`owlryd`) loads providers and plugins into memory. The UI client (`owlry`) connects to the daemon over a Unix socket for instant results.
### Starting the Daemon
Choose one of three methods:
Three options:
**1. Compositor autostart (recommended for most users)**
Add to your compositor config:
**1. Systemd user service (recommended)**
```bash
# Hyprland (~/.config/hypr/hyprland.conf)
exec-once = owlryd
# Sway (~/.config/sway/config)
exec owlryd
systemctl --user enable --now owlry.service
```
**2. Systemd user service**
Reload config from disk without restarting:
```bash
systemctl --user enable --now owlryd.service
systemctl --user reload owlry.service # or: kill -HUP $(pidof owlry)
```
The daemon reloads its configuration on `SIGHUP` without restarting — useful when editing `config.toml` directly:
**2. Socket activation**
```bash
systemctl --user reload owlryd.service
# or: kill -HUP $(pidof owlryd)
systemctl --user enable owlry.socket
```
**3. Socket activation (auto-start on first use)**
Daemon starts the first time the UI connects.
**3. Compositor autostart**
```bash
systemctl --user enable owlryd.socket
```
# Hyprland
exec-once = owlry -d
The daemon starts automatically when the UI client first connects.
# Sway
exec owlry -d
```
### Launching the UI
Bind `owlry` to a key in your compositor:
```bash
# Hyprland
bind = SUPER, Space, exec, owlry
@@ -166,58 +127,42 @@ bind = SUPER, Space, exec, owlry
bindsym $mod+space exec owlry
```
Running `owlry` a second time while it is already open sends a toggle command — the window closes. 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.
Running `owlry` while a window is already open sends a toggle command — single keybind acts as open/close. If the daemon isn't running, the UI tries to start it via systemd.
## Usage
```bash
owlry # Launch with all providers
owlry -m app # Applications only
owlry -m cmd # PATH commands only
owlry -m calc # Calculator only
owlry --profile dev # Use a named profile from config
owlry --help # Show all options with examples
```
owlry launch UI, auto mode
owlry -m auto launch UI, auto mode (explicit alias)
owlry -m <mode> launch UI in single-provider mode
owlry --profile <name> launch UI with a named profile
owlry -d run the daemon (alias: `owlry daemon`)
owlry dmenu [-p <prompt>] dmenu mode (reads stdin, prints selection)
owlry doctor diagnostics: config + socket + providers
owlry providers [<id>] list providers (or show details for one)
owlry config validate parse config, report errors
owlry config show print the resolved effective config as TOML
owlry migrate-config TOML → init.lua (stub in 2.0; lands in a later 2.x release)
```
### 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"]
modes = ["emoji", "clipboard"]
```
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
```
Profiles can also be managed from the launcher itself — see [Settings Editor](#settings-editor).
### 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 `owlryd` running.
`owlry dmenu` (or the legacy `owlry -m dmenu`) reads stdin and prints the selection to stdout. It runs locally — no daemon required.
```bash
# Screenshot menu
@@ -225,24 +170,22 @@ printf '%s\n' \
"grimblast --notify copy screen" \
"grimblast --notify copy area" \
"grimblast --notify edit screen" \
| owlry -m dmenu -p "Screenshot" \
| owlry dmenu -p "Screenshot" \
| sh
# Git branch checkout
git branch | owlry -m dmenu -p "checkout" | xargs git checkout
git branch | owlry dmenu -p "checkout" | xargs git checkout
# Kill a process
ps -eo comm | sort -u | owlry -m dmenu -p "kill" | xargs pkill
ps -eo comm | sort -u | owlry dmenu -p "kill" | xargs pkill
# Select and open a project
find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
# Open a project
find ~/projects -maxdepth 1 -type d | owlry dmenu | xargs code
# Package manager search
pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S
# Package manager
pacman -Ssq | owlry dmenu -p "install" | xargs sudo pacman -S
```
The `-p` / `--prompt` flag sets a custom label for the search input.
### Keyboard Shortcuts
| Key | Action |
@@ -254,56 +197,31 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
| `Shift+Tab` | Cycle filter tabs (reverse) |
| `Ctrl+1..9` | Toggle tab by position |
### Settings Editor
Type `:config` to browse and modify settings without editing files:
| Command | What it does |
|---------|-------------|
| `:config` | Show all setting categories |
| `:config providers` | Toggle built-in providers on/off (calculator, converter, system, frecency) |
| `:config theme` | Select color theme |
| `:config engine` | Select web search engine |
| `:config frecency` | Toggle frecency, set weight |
| `:config fontsize 16` | Set font size (restart to apply) |
| `:config profiles` | List profiles |
| `:config profile create dev` | Create a new profile |
| `:config profile dev modes` | Edit which modes a profile includes |
Changes are saved to `config.toml` immediately. Some settings (theme, frecency) take effect on the next search. Others (font size, dimensions) require a restart.
> **Note:** `:config providers` only covers built-in providers. To enable or disable plugins, use `owlry plugin enable/disable <name>` or set `disabled_plugins` in `[plugins]`.
### Search Prefixes
| 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` |
| `:script` | Scripts | `:script backup` |
| `:file` | Files | `:file config` |
| `:power` (`:sys`, `:system`) | Power & session actions | `:power shutdown` |
| `:calc` | Calculator | `:calc sqrt(16)` |
| `:conv` | Converter | `:conv 5 ft to m` |
| `:clip` | Clipboard | `:clip password` |
| `:emoji` | Emoji | `:emoji heart` |
| `:ssh` | SSH hosts | `:ssh server` |
| `:uuctl` (`:systemd`) | systemd user units | `:uuctl dbus` |
| `:web` | Web search | `:web rust docs` |
| `:uuctl` | systemd | `:uuctl docker` |
| `:config` | Settings | `:config theme` |
| `:tag:X` | Filter by tag | `:tag:development` |
| `:file` | Files | `:file config` |
| `:tag:X` | Filter all results by tag | `:tag:development` |
### Trigger Prefixes
| Trigger | Provider | Example |
|---------|----------|---------|
| `=` | Calculator | `= 5+3` |
| `calc ` | Calculator | `calc sqrt(16)` |
| `>` | Converter | `> 20 km to mi` |
| `?` | Web search | `? rust programming` |
| `web ` | Web search | `web linux tips` |
| `/` | File search | `/ .bashrc` |
| `find ` | File search | `find config` |
## Configuration
@@ -314,184 +232,73 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
| `~/.config/owlry/config.toml` | Main configuration |
| `~/.config/owlry/themes/*.css` | Custom themes |
| `~/.config/owlry/style.css` | CSS overrides |
| `~/.config/owlry/plugins/` | User plugins (Lua/Rune) |
| `~/.local/share/owlry/scripts/` | User scripts |
| `~/.local/share/owlry/frecency.json` | Usage history |
System locations:
| Path | Purpose |
|------|---------|
| `/usr/lib/owlry/plugins/*.so` | Installed native plugins |
| `/usr/lib/owlry/runtimes/*.so` | Lua/Rune script runtimes |
| `$XDG_RUNTIME_DIR/owlry/owlry.sock` | IPC socket (overridable via `$OWLRY_SOCKET`) |
| `/usr/share/doc/owlry/config.example.toml` | Example configuration |
| `/usr/share/owlry/themes/` | Bundled themes |
### Quick Start
```bash
# Copy example config
mkdir -p ~/.config/owlry
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
$EDITOR ~/.config/owlry/config.toml
owlry config validate
```
Or configure from within the launcher: type `:config` to interactively change settings.
### Example Configuration
```toml
[general]
show_icons = true
max_results = 100
tabs = ["app", "cmd", "uuctl"] # Provider tabs shown in the header bar
# terminal_command = "kitty" # Auto-detected; overrides $TERMINAL and xdg-terminal-exec
# use_uwsm = false # Enable for systemd session integration (uwsm app --)
tabs = ["app", "cmd", "uuctl"] # tabs shown in the header bar
# terminal_command = "kitty" # auto-detected; overrides $TERMINAL and xdg-terminal-exec
# use_uwsm = false # enable for systemd session integration (uwsm app --)
[appearance]
width = 850
height = 650
font_size = 14
border_radius = 12
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc. (see Theming section)
# theme = "owl" # or: catppuccin-mocha, nord, dracula, ... (see Theming)
# Optional per-element color overrides — all fields are optional, unset inherits from theme
# Optional per-element color overrides. All fields are optional; unset inherits from the theme.
# [appearance.colors]
# background = "#1e1e2e"
# background_secondary = "#313244"
# border = "#45475a"
# text = "#cdd6f4"
# accent = "#cba6f7"
# badge_app = "#a6e3a1" # All badge_* keys: app, cmd, clip, ssh, emoji, file,
# badge_web = "#89dceb" # script, sys, uuctl, web, calc, bm, dmenu,
# badge_media = "#f38ba8" # media, weather, pomo
# badge_app = "#a6e3a1" # badge_* keys: app, cmd, clip, ssh, emoji, file,
# badge_web = "#89dceb" # power (alias: badge_sys), uuctl, web, calc, bm, dmenu
[providers]
applications = true # .desktop files
commands = true # PATH executables
calculator = true # Built-in math expressions (= or calc trigger)
converter = true # Built-in unit/currency converter (> trigger)
system = true # Built-in shutdown/reboot/lock actions
frecency = true # Boost frequently used items
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
applications = true # .desktop files
commands = true # PATH executables
calculator = true # `=` or :calc
converter = true # `>` or :conv
power = true # `:power` shutdown/reboot/lock (alias: system)
systemd = true # `:uuctl` user units (alias: uuctl)
clipboard = true # via cliphist
emoji = true # picker via wl-clipboard
ssh = true # ~/.ssh/config hosts
websearch = true # `?` or :web
filesearch = true # `/` or :file
frecency = true # boost frequently used items
frecency_weight = 0.3 # 0.0 disabled .. 1.0 strong
# Web search engine: google, duckduckgo, bing, startpage, searxng, brave, ecosia
# Or a custom URL with a {query} placeholder: "https://example.com/search?q={query}"
search_engine = "duckduckgo"
[plugins]
# disabled_plugins = ["emoji", "pomodoro"] # Plugin IDs to disable
# registry_url = "https://..." # Custom plugin registry URL
# Sandboxing for Lua/Rune user plugins (~/.config/owlry/plugins/)
# [plugins.sandbox]
# allow_filesystem = false # Allow access outside plugin directory
# allow_network = false # Allow outbound network requests
# allow_commands = false # Allow shell command execution
# memory_limit = 67108864 # Lua memory cap in bytes (default: 64 MB)
# Per-plugin config (for plugins that expose configurable options)
# [plugin_config.my-plugin]
# option = "value"
# Profiles: named sets of modes
# Profiles — named mode sets
[profiles.dev]
modes = ["app", "cmd", "ssh"]
[profiles.media]
modes = ["media", "emoji"]
[profiles.minimal]
modes = ["app"]
```
See `/usr/share/doc/owlry/config.example.toml` for all options with documentation.
See `/usr/share/doc/owlry/config.example.toml` for every option with documentation.
## Plugin System
Owlry uses a modular plugin architecture. Plugins are loaded by the daemon from:
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
### Disabling Plugins
Add plugin IDs to the disabled list in your config:
```toml
[plugins]
disabled_plugins = ["emoji", "pomodoro"]
```
Or use the CLI:
```bash
owlry plugin disable emoji
owlry plugin enable emoji
```
> **Note:** `:config providers` in the launcher only manages built-in providers (calculator, converter, system). Use `disabled_plugins` or `owlry plugin disable` for plugins.
### Plugin Management CLI
```bash
# List installed plugins (shows both native .so plugins and user script 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...]
```
### Plugin Display Metadata
Plugins can declare how they appear in the UI via their `plugin.toml`. Without these fields, the tab label and search placeholder default to a capitalized version of the `type_id`.
```toml
[[providers]]
id = "my-plugin"
name = "My Plugin"
type_id = "myplugin"
tab_label = "My Plugin" # Tab button and page title
search_noun = "plugin items" # Search placeholder: "Search plugin items..."
```
Both fields are optional. If omitted, the UI auto-generates from `type_id` (e.g., `type_id = "hs"` shows "Hs" as the tab label).
Lua plugins can also set these at runtime via `owlry.provider.register()`:
```lua
owlry.provider.register({
id = "my-plugin",
name = "My Plugin",
tab_label = "My Plugin",
search_noun = "plugin items",
})
```
### Creating Custom Plugins
See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
- Native plugin development (Rust)
- Lua plugin development
- Rune plugin development
- Available APIs
`owlry config show` prints the resolved effective config (defaults merged with your file). `owlry config validate` parses it and reports errors.
## Theming
@@ -515,8 +322,6 @@ See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
theme = "catppuccin-mocha"
```
Or select interactively: type `:config theme` in the launcher.
### Custom Theme
Create `~/.config/owlry/themes/mytheme.css`:
@@ -548,29 +353,33 @@ Create `~/.config/owlry/themes/mytheme.css`:
## Architecture
Owlry uses a client/daemon split:
```
owlryd (daemon) owlry (GTK4 UI client)
├── Loads config + plugins ├── Connects to daemon via Unix socket
├── Built-in providers ├── Renders results in GTK4 window
│ ├── Applications (.desktop) ├── Handles keyboard input
│ ├── Commands (PATH) ├── Toggle: second launch closes window
│ ├── Calculator (math) └── dmenu mode (self-contained, no daemon)
│ ├── Converter (units/currency)
│ ├── System (power/session)
│ └── Config editor (settings)
├── Plugin loader
│ ├── /usr/lib/owlry/plugins/*.so
│ ├── /usr/lib/owlry/runtimes/
│ └── ~/.config/owlry/plugins/
├── Frecency tracking (auto-saved every 5 min; flushed on shutdown)
└── IPC server (Unix socket)
└── $XDG_RUNTIME_DIR/owlry/owlry.sock
owlry (single binary)
├── default invocation GTK4 UI client (connects to daemon over socket)
├── owlry -d / owlry daemon IPC daemon (loads providers, listens on the socket)
├── owlry dmenu stdin → selection (no daemon)
└── owlry doctor / providers / config diagnostics & config tools
Daemon:
├── Built-in providers applications, commands, power, calculator, converter
├── Optional providers bookmarks, clipboard, emoji, ssh, systemd, websearch, filesearch
│ (compiled in per cargo feature)
├── Frecency tracking auto-saved every 5 min; flushed on SIGTERM/SIGINT
└── IPC server $XDG_RUNTIME_DIR/owlry/owlry.sock (newline-delimited JSON)
```
The daemon keeps providers and plugins loaded in memory, so the UI appears instantly when launched. The UI client is a thin GTK4 layer that sends queries and receives results over the socket.
The daemon keeps providers and items warm in memory; the UI launches instantly because there's no work to do at startup. The UI client is a thin GTK4 layer that streams queries and renders results.
Set `OWLRY_SOCKET=/path/to/sock` to override the socket location — useful for running a development daemon alongside a production one.
## Roadmap
See [ROADMAP.md](ROADMAP.md) for feature ideas and [docs/RESTRUCTURE-V2.md](docs/RESTRUCTURE-V2.md) for the v2 rewrite story.
Headline upcoming work:
- **Lua-driven configuration** (2.1 / 3.0) — `~/.config/owlry/init.lua` replaces TOML. User-defined providers via `owlry.provider {}` in the same file (Hyprland-style configs-as-code). `owlry migrate-config` lands at the same time.
- **Widget providers return** — weather, MPRIS media controls, pomodoro timer. Deferred from 2.0 while the UI positioning is reworked.
- **Bookmarks return** — Firefox + Chromium. Deferred from 2.0 to avoid a hard rusqlite/`libsqlite3-sys` dep in the chroot build path; returns with a pure-Rust reader (likely via Firefox's JSON backup files).
## License
@@ -580,5 +389,5 @@ GNU General Public License v3.0 — see [LICENSE](LICENSE).
- [GTK4](https://gtk.org/) — UI toolkit
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) — Wayland Layer Shell
- [abi_stable](https://crates.io/crates/abi_stable) — ABI-stable Rust plugins
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) — Fuzzy search
- [expr-solver-lib](https://crates.io/crates/expr-solver-lib) — Calculator backend
+47 -48
View File
@@ -1,12 +1,34 @@
# Owlry Roadmap
Feature ideas and future development plans for Owlry.
Feature ideas and future development plans. For the v2 rewrite story (where 14 packages and the dynamic plugin system went), see [docs/RESTRUCTURE-V2.md](docs/RESTRUCTURE-V2.md).
## Locked-in for an upcoming 2.x release
### Lua-driven configuration (Phase 3)
Replace `config.toml` with `~/.config/owlry/init.lua`. The config is real Lua, evaluated at startup via embedded `mlua` (Lua 5.4). User-defined providers, keybindings, and theme overrides all live in the same file — Hyprland-style configs-as-code. Ships with `owlry migrate-config` (TOML → init.lua) and hot-reload on save.
```lua
local owlry = require("owlry")
owlry.set { theme = "owl", width = 850, tabs = { "app", "cmd", "uuctl" } }
owlry.providers { "app", "cmd", "power", "bookmarks", "systemd" }
owlry.provider {
id = "hs", prefix = ":hs", tab_label = "Shutdown",
items = function() return { { name = "Lock", command = "hyprlock" } } end,
}
```
### Widget providers return
Weather, MPRIS media controls, and pomodoro timer were deferred from 2.0 while the widget-row UI is redesigned. They'll come back as a feature group once the placement model is settled. ([D20 in the v2 plan](docs/RESTRUCTURE-V2.md).)
### Bookmarks return
Firefox + Chromium bookmarks were deferred from 2.0 — the `rusqlite` dep used to read Firefox's `places.sqlite` made the AUR chroot build brittle (`libsqlite3-sys`'s `bundled` feature kept slipping out of the resolved feature graph). Returns when we wire up a pure-Rust path: Chromium's bookmarks file is already JSON, and Firefox exposes JSON backups under `~/.mozilla/firefox/<profile>/bookmarkbackups/`. No SQLite required.
---
## High Value, Low Effort
### Plugin hot-reload
Detect `.so` file changes in `/usr/lib/owlry/plugins/` and reload without restarting the launcher. The loader infrastructure already exists.
### Frecency pruning
Add `max_entries` and `max_age_days` config options. Prune old entries on startup to prevent `frecency.json` from growing unbounded.
@@ -14,7 +36,10 @@ Add `max_entries` and `max_age_days` config options. Prune old entries on startu
Show last N launched items. Data already exists in frecency.json — just needs a provider to surface it.
### Clipboard images
`cliphist` supports images. Extend the clipboard plugin to show image thumbnails in results.
`cliphist` supports images. Extend the clipboard provider to show image thumbnails in results.
### Dynamic providers in `owlry doctor`
Today `doctor` lists static providers only. Surface the dynamic ones (calc, conv, websearch, filesearch) too.
---
@@ -31,23 +56,23 @@ Generalize the submenu system beyond systemd. Every result type gets contextual
| Bookmarks | Open, Copy URL, Open incognito |
| Clipboard | Paste, Delete from history |
This is the difference between a launcher and a command palette.
### Plugin settings UI
A `:settings` provider that lists installed plugins and their configurable options. Edit values inline, writes to `config.toml`.
This is the difference between a launcher and a command palette. The `Provider::submenu_actions()` trait method (added in 2.0 for the systemd provider) is the foundation.
### Result action capture
Calculator shows `= 5+3 → 8`. Allow pressing Tab or Ctrl+C to copy the result to clipboard instead of "launching" it. Useful for calculator, file paths, URLs.
Calculator shows `= 5+3 → 8`. Allow pressing Tab or Ctrl+C to copy the result to clipboard instead of launching. Useful for calculator output, file paths, URLs.
### Split the 1000+ LOC files
`providers/mod.rs`, `ui/main_window.rs`, `providers/converter/units.rs` are all over a thousand lines each. Carve them into focused modules to make code review and onboarding less painful. (Phase 5 hygiene in the v2 plan.)
---
## Bigger Bets
### Window switcher with live thumbnails
A `windows` plugin using Wayland screencopy to show live thumbnails of open windows. Hyprland and Sway expose window lists via IPC. Could replace Alt+Tab.
A `windows` provider using Wayland screencopy to show live thumbnails of open windows. Hyprland and Sway expose window lists via IPC. Could replace Alt+Tab.
### Cross-device bookmark sync
Firefox and Chrome sync bookmarks across devices. Parse sync metadata to show "recently added on other devices" or "bookmarks from phone".
Firefox and Chrome sync bookmarks across devices. Parse sync metadata to show "recently added on other devices".
### Natural language commands
Parse simple natural language into system commands:
@@ -58,52 +83,26 @@ Parse simple natural language into system commands:
"volume 50%" → wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.5
```
Local pattern matching, no AI/cloud required.
Local pattern matching, no cloud required.
### Plugin marketplace
A curated registry of third-party Lua/Rune plugins with one-command install:
```bash
owlry plugin install github-notifications
owlry plugin install todoist
owlry plugin install spotify-controls
```
The script runtimes make this viable without recompiling.
### Drop the daemon
After Lua config lands and we profile startup honestly: if the daemon's keep-providers-warm justification doesn't hold up against an mmap'd cache, collapse to a single-process model. Eliminates the socket protocol, IPC types, and most of `client.rs` + `server.rs`. ([D17 deferred this decision past 2.0.](docs/RESTRUCTURE-V2.md))
---
## 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:
### `expr-solver-lib` evaluation
The calculator's `expr-solver-lib` dep is small and old. If it stagnates further or becomes unsupported, switch to `evalexpr` v13+ which is actively maintained.
| 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 |
### Submenu protocol redesign
The `SUBMENU:<plugin_id>:<data>` string-encoded command is a workable hack. A typed IPC variant would be cleaner — keep the surface in `Request::Submenu` but stop overloading the `command` field. (Phase 5 hygiene.)
**Execution order:**
1. Publish `owlry-plugin-api` to crates.io
2. Update monorepo to use crates.io dependency
3. Create `owlry-plugins` repo, move plugins + runtimes
4. Slim current repo to core-only
5. Update AUR PKGBUILDs with new source URLs
**Benefit:** Core bugfix = 1 rebuild. Plugin fix = 1 rebuild. Third-party plugins possible via crates.io.
### Replace meval with evalexpr
`meval` depends on `nom v1.2.4` which will be rejected by future Rust versions. Migrate calculator plugin and Lua runtime to `evalexpr` v13+.
### Plugin API backwards compatibility
When `API_VERSION` increments, provide a compatibility shim so v3 plugins work with v4 core. Prevents ecosystem fragmentation.
### Per-plugin configuration
Current flat `[providers]` config doesn't scale. Design a `[plugins.weather]`, `[plugins.pomodoro]` structure that plugins can declare and the core validates.
### Double-daemon spawn
Pre-2.0, the systemd unit and a Hyprland `exec-once = owlryd` could both spawn a daemon. The 2.0 socket-activation path (`owlry.socket`) eliminates the need for an explicit autostart — verify nothing else still launches the daemon directly. (Phase 5 hygiene.)
---
## Priority
If we had to pick one: **Actions on any result**. It transforms every provider from "search and launch" to "search and do anything". The ROI is massive.
If we had to pick one for the next release: **Actions on any result**. It transforms every provider from "search and launch" to "search and do anything". The ROI is massive and the trait foundation is already in 2.0.
-14
View File
@@ -1,14 +0,0 @@
pkgbase = owlry-core
pkgdesc = Core daemon for the Owlry application launcher — manages plugins, providers, and search
pkgver = 1.3.6
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = gcc-libs
depends = openssl
source = owlry-core-1.3.6.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v1.3.6.tar.gz
b2sums = be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2
pkgname = owlry-core
-10
View File
@@ -1,10 +0,0 @@
*.pkg.tar.zst
*.pkg.tar.zst-namcap.log
*-namcap.log
*-build.log
*-check.log
*-package.log
*-prepare.log
*.tar.gz
src/
pkg/
-41
View File
@@ -1,41 +0,0 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-core
pkgver=1.3.6
pkgrel=1
pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search'
arch=('x86_64')
url='https://somegit.dev/Owlibou/owlry'
license=('GPL-3.0-or-later')
depends=('gcc-libs' 'openssl')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v$pkgver.tar.gz")
b2sums=('be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2')
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build -p owlry-core --frozen --release
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p owlry-core --frozen --lib
}
package() {
cd "owlry"
install -Dm755 "target/release/owlryd" "$pkgdir/usr/bin/owlryd"
install -Dm644 "systemd/owlryd.service" "$pkgdir/usr/lib/systemd/user/owlryd.service"
install -Dm644 "systemd/owlryd.socket" "$pkgdir/usr/lib/systemd/user/owlryd.socket"
install -dm755 "$pkgdir/usr/lib/owlry/plugins"
install -dm755 "$pkgdir/usr/lib/owlry/runtimes"
}
-15
View File
@@ -1,15 +0,0 @@
pkgbase = owlry-lua
pkgdesc = Lua scripting runtime for Owlry — enables user-created Lua plugins
pkgver = 1.1.5
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = gcc-libs
depends = lua54
optdepends = owlry-core: daemon that loads this runtime
source = owlry-lua-1.1.5.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v1.1.5.tar.gz
b2sums = be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2
pkgname = owlry-lua
-10
View File
@@ -1,10 +0,0 @@
*.pkg.tar.zst
*.pkg.tar.zst-namcap.log
*-namcap.log
*-build.log
*-check.log
*-package.log
*-prepare.log
*.tar.gz
src/
pkg/
-41
View File
@@ -1,41 +0,0 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-lua
pkgver=1.1.5
pkgrel=1
pkgdesc="Lua scripting runtime for Owlry — enables user-created Lua plugins"
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlry"
license=('GPL-3.0-or-later')
depends=('gcc-libs' 'lua54')
optdepends=('owlry-core: daemon that loads this runtime')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v$pkgver.tar.gz")
b2sums=('be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2')
_cratename=owlry-lua
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build -p $_cratename --frozen --release --no-default-features
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p $_cratename --frozen --no-default-features --lib
}
package() {
cd "owlry"
install -Dm755 "target/release/lib${_cratename//-/_}.so" \
"$pkgdir/usr/lib/owlry/runtimes/liblua.so"
}
-14
View File
@@ -1,14 +0,0 @@
pkgbase = owlry-rune
pkgdesc = Rune scripting runtime for Owlry — enables user-created Rune plugins
pkgver = 1.1.6
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = gcc-libs
optdepends = owlry-core: daemon that loads this runtime
source = owlry-rune-1.1.6.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.6.tar.gz
b2sums = be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2
pkgname = owlry-rune
-10
View File
@@ -1,10 +0,0 @@
*.pkg.tar.zst
*.pkg.tar.zst-namcap.log
*-namcap.log
*-build.log
*-check.log
*-package.log
*-prepare.log
*.tar.gz
src/
pkg/
-41
View File
@@ -1,41 +0,0 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-rune
pkgver=1.1.6
pkgrel=1
pkgdesc="Rune scripting runtime for Owlry — enables user-created Rune plugins"
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlry"
license=('GPL-3.0-or-later')
depends=('gcc-libs')
optdepends=('owlry-core: daemon that loads this runtime')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v$pkgver.tar.gz")
b2sums=('be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2')
_cratename=owlry-rune
prepare() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build -p $_cratename --frozen --release
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p $_cratename --frozen --release
}
package() {
cd "owlry"
install -Dm755 "target/release/lib${_cratename//-/_}.so" \
"$pkgdir/usr/lib/owlry/runtimes/librune.so"
}
+73 -23
View File
@@ -1,34 +1,84 @@
pkgbase = owlry
pkgdesc = Lightweight Wayland application launcher with plugin support
pkgver = 1.0.10
pkgdesc = Lightweight Wayland application launcher — UI, daemon, and providers in one binary
pkgver = 2.0.0
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
install = owlry.install
arch = x86_64
license = GPL-3.0-or-later
makedepends = cargo
depends = owlry-core
depends = gcc-libs
depends = gtk4
depends = gtk4-layer-shell
optdepends = cliphist: clipboard provider support
optdepends = wl-clipboard: clipboard and emoji copy support
optdepends = fd: fast file search
optdepends = owlry-plugin-calculator: calculator provider
optdepends = owlry-plugin-clipboard: clipboard provider
optdepends = owlry-plugin-emoji: emoji picker
optdepends = owlry-plugin-bookmarks: browser bookmarks
optdepends = owlry-plugin-ssh: SSH host launcher
optdepends = owlry-plugin-scripts: custom scripts provider
optdepends = owlry-plugin-system: system actions (shutdown, reboot, etc.)
optdepends = owlry-plugin-websearch: web search provider
optdepends = owlry-plugin-filesearch: file search provider
optdepends = owlry-plugin-systemd: systemd service management
optdepends = owlry-plugin-weather: weather widget
optdepends = owlry-plugin-media: media player controls
optdepends = owlry-plugin-pomodoro: pomodoro timer widget
optdepends = owlry-lua: Lua runtime for user plugins
optdepends = owlry-rune: Rune runtime for user plugins
source = owlry-1.0.10.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.10.tar.gz
b2sums = be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2
optdepends = cliphist: clipboard history provider
optdepends = wl-clipboard: clipboard write and emoji copy
optdepends = fd: filesystem search provider (primary backend)
optdepends = mlocate: filesystem search provider (fallback backend)
provides = owlry-core
provides = owlry-lua
provides = owlry-rune
provides = owlry-plugin-clipboard
provides = owlry-plugin-emoji
provides = owlry-plugin-filesearch
provides = owlry-plugin-ssh
provides = owlry-plugin-systemd
provides = owlry-plugin-websearch
provides = owlry-plugin-bookmarks
provides = owlry-plugin-media
provides = owlry-plugin-pomodoro
provides = owlry-plugin-weather
provides = owlry-plugin-scripts
provides = owlry-plugin-calculator
provides = owlry-plugin-converter
provides = owlry-plugin-system
provides = owlry-meta-essentials
provides = owlry-meta-widgets
provides = owlry-meta-tools
provides = owlry-meta-full
conflicts = owlry-core
conflicts = owlry-lua
conflicts = owlry-rune
conflicts = owlry-plugin-clipboard
conflicts = owlry-plugin-emoji
conflicts = owlry-plugin-filesearch
conflicts = owlry-plugin-ssh
conflicts = owlry-plugin-systemd
conflicts = owlry-plugin-websearch
conflicts = owlry-plugin-bookmarks
conflicts = owlry-plugin-media
conflicts = owlry-plugin-pomodoro
conflicts = owlry-plugin-weather
conflicts = owlry-plugin-scripts
conflicts = owlry-plugin-calculator
conflicts = owlry-plugin-converter
conflicts = owlry-plugin-system
conflicts = owlry-meta-essentials
conflicts = owlry-meta-widgets
conflicts = owlry-meta-tools
conflicts = owlry-meta-full
replaces = owlry-core
replaces = owlry-lua
replaces = owlry-rune
replaces = owlry-plugin-clipboard
replaces = owlry-plugin-emoji
replaces = owlry-plugin-filesearch
replaces = owlry-plugin-ssh
replaces = owlry-plugin-systemd
replaces = owlry-plugin-websearch
replaces = owlry-plugin-bookmarks
replaces = owlry-plugin-media
replaces = owlry-plugin-pomodoro
replaces = owlry-plugin-weather
replaces = owlry-plugin-scripts
replaces = owlry-plugin-calculator
replaces = owlry-plugin-converter
replaces = owlry-plugin-system
replaces = owlry-meta-essentials
replaces = owlry-meta-widgets
replaces = owlry-meta-tools
replaces = owlry-meta-full
options = !debug
source = owlry-2.0.0.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v2.0.0.tar.gz
b2sums = SKIP
pkgname = owlry
+82 -36
View File
@@ -1,35 +1,80 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry
pkgver=1.0.10
pkgver=2.0.0
pkgrel=1
pkgdesc="Lightweight Wayland application launcher with plugin support"
pkgdesc="Lightweight Wayland application launcher — UI, daemon, and providers in one binary"
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlry"
license=('GPL-3.0-or-later')
depends=('owlry-core' 'gcc-libs' 'gtk4' 'gtk4-layer-shell')
depends=(
'gcc-libs'
'gtk4'
'gtk4-layer-shell'
)
makedepends=('cargo')
optdepends=(
'cliphist: clipboard provider support'
'wl-clipboard: clipboard and emoji copy support'
'fd: fast file search'
'owlry-plugin-calculator: calculator provider'
'owlry-plugin-clipboard: clipboard provider'
'owlry-plugin-emoji: emoji picker'
'owlry-plugin-bookmarks: browser bookmarks'
'owlry-plugin-ssh: SSH host launcher'
'owlry-plugin-scripts: custom scripts provider'
'owlry-plugin-system: system actions (shutdown, reboot, etc.)'
'owlry-plugin-websearch: web search provider'
'owlry-plugin-filesearch: file search provider'
'owlry-plugin-systemd: systemd service management'
'owlry-plugin-weather: weather widget'
'owlry-plugin-media: media player controls'
'owlry-plugin-pomodoro: pomodoro timer widget'
'owlry-lua: Lua runtime for user plugins'
'owlry-rune: Rune runtime for user plugins'
'cliphist: clipboard history provider'
'wl-clipboard: clipboard write and emoji copy'
'fd: filesystem search provider (primary backend)'
'mlocate: filesystem search provider (fallback backend)'
)
# v2.0 replaces the entire pre-collapse package set. paru/pacman -Syu
# transparently swaps the old packages for owlry-2.0.0 via these arrays.
# Notes:
# - owlry-{core,lua,rune}: functionality merged into owlry; Lua runtime
# deferred to a later release with the Lua config layer (D4 / Phase 3).
# - owlry-plugin-*: every plugin became a feature-gated module in owlry.
# This PKGBUILD builds with --features full so all of them are present.
# - owlry-plugin-{weather,media,pomodoro}: widgets are deferred per D20.
# Listed here so users on those packages get a clean upgrade; widget
# functionality returns in a later 2.x release.
# - owlry-plugin-scripts: replaced by user Lua config (D12), Phase 3+.
# - owlry-meta-*: superseded by the single owlry package.
_v2_replaced=(
'owlry-core'
'owlry-lua'
'owlry-rune'
# Plugins folded into owlry as feature-gated modules.
'owlry-plugin-clipboard'
'owlry-plugin-emoji'
'owlry-plugin-filesearch'
'owlry-plugin-ssh'
'owlry-plugin-systemd'
'owlry-plugin-websearch'
# Deferred providers (D20+); package replaced so users get a clean
# transition. Functionality returns in a later 2.x release.
'owlry-plugin-bookmarks'
'owlry-plugin-media'
'owlry-plugin-pomodoro'
'owlry-plugin-weather'
# Replaced by user Lua config (D12), Phase 3+.
'owlry-plugin-scripts'
# Pre-v2 transitional stubs (pkgrel -99) where calc/conv/power were
# already folded into owlry-core. Listed so any straggler installs
# are swept up by the v2 upgrade.
'owlry-plugin-calculator'
'owlry-plugin-converter'
'owlry-plugin-system'
# Meta-bundles superseded by the single owlry package.
'owlry-meta-essentials'
'owlry-meta-widgets'
'owlry-meta-tools'
'owlry-meta-full'
)
replaces=("${_v2_replaced[@]}")
conflicts=("${_v2_replaced[@]}")
provides=("${_v2_replaced[@]}")
install=owlry.install
# Cargo's release profile strips the binary at compile time (strip = true
# in workspace Cargo.toml), so there are no debug symbols left for makepkg
# to extract into an owlry-debug subpackage. Disable debug splitting so the
# build doesn't ship a 0-byte debug package.
options=('!debug')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz")
b2sums=('be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2')
b2sums=('SKIP') # populated by `just aur-update-pkg owlry` once the tag is pushed
prepare() {
cd "owlry"
@@ -41,37 +86,38 @@ build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
# Build only the core binary without embedded Lua (Lua runtime is separate package)
cargo build -p owlry --frozen --release --no-default-features
# 'full' enables every optional provider — the AUR binary is the
# batteries-included experience. cargo install consumers can still opt
# to --no-default-features and pick their own subset.
cargo build --frozen --release --features full
}
check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p owlry --frozen --no-default-features
cargo test --frozen --release --features full
}
package() {
cd "owlry"
# Core binary
# Single binary.
install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
# Documentation
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
# systemd user units (renamed from owlryd.* in v2 — see D15).
install -Dm644 systemd/owlry.service "$pkgdir/usr/lib/systemd/user/owlry.service"
install -Dm644 systemd/owlry.socket "$pkgdir/usr/lib/systemd/user/owlry.socket"
# Example configuration files
# Documentation + example configuration.
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
install -Dm644 data/config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml"
install -Dm644 data/style.example.css "$pkgdir/usr/share/doc/$pkgname/style.example.css"
install -Dm755 data/scripts/example.sh "$pkgdir/usr/share/doc/$pkgname/scripts/example.sh"
# Install themes
# Man page.
install -Dm644 data/owlry.1 "$pkgdir/usr/share/man/man1/owlry.1"
# Themes.
install -d "$pkgdir/usr/share/$pkgname/themes"
install -Dm644 data/themes/*.css "$pkgdir/usr/share/$pkgname/themes/"
# Example plugins (for user plugin development)
install -d "$pkgdir/usr/share/$pkgname/examples/plugins"
cp -r examples/plugins/* "$pkgdir/usr/share/$pkgname/examples/plugins/"
chmod -R a+rX "$pkgdir/usr/share/$pkgname/examples/plugins/"
}
+70
View File
@@ -0,0 +1,70 @@
## owlry .install hook
##
## v2.0 renamed the systemd user units (owlryd.{service,socket} → owlry.{service,socket})
## and consolidated 14 separate packages into one. Handle the unit migration so users
## upgrading from 1.x don't end up with an enabled-but-missing owlryd.service.
post_upgrade() {
local old_pkgver="$2"
case "$old_pkgver" in
1.*|0.*)
cat <<'EOF'
╭─────────────────────────────────────────────────────────────────╮
│ owlry 2.0 — the v2 rewrite │
│ │
│ The daemon binary 'owlryd' is gone; owlry now ships as a │
│ single binary. Use `owlry -d` (or the systemd service). │
│ │
│ The systemd user unit was renamed: │
│ owlryd.service -> owlry.service │
│ owlryd.socket -> owlry.socket │
│ │
│ If you had the old service enabled, run: │
│ systemctl --user disable --now owlryd.service │
│ systemctl --user enable --now owlry.service │
│ │
│ Plugin packages (bookmarks, systemd, clipboard, …) are now │
│ built into owlry by default — they were dropped from AUR and │
│ replaced via this package. │
│ │
│ Widget providers (weather, media, pomodoro) are not in 2.0; │
│ they return in a later 2.x release. See: │
│ docs/RESTRUCTURE-V2.md (decisions D20, section 8) │
╰─────────────────────────────────────────────────────────────────╯
EOF
# Best-effort transition: if the old owlryd.service is enabled or
# active for the invoking user, stop it and disable it so the new
# owlry.service can take over. Errors are non-fatal — pacman runs
# as root, so we can only inspect, not toggle user units here.
if command -v loginctl >/dev/null 2>&1; then
local invoking_user
invoking_user="$(loginctl list-users --no-legend 2>/dev/null | awk 'NR==1 {print $2}')"
if [ -n "$invoking_user" ]; then
echo " (Run the systemctl --user commands above as user '$invoking_user'.)"
fi
fi
;;
esac
}
post_install() {
cat <<'EOF'
owlry installed. Start the daemon with:
systemctl --user enable --now owlry.service
Or run ad-hoc:
owlry -d &
owlry
Configuration: ~/.config/owlry/config.toml
Diagnostics: owlry doctor
EOF
}
post_remove() {
echo " owlry removed. Configuration in ~/.config/owlry remains intact."
}
-65
View File
@@ -1,65 +0,0 @@
[package]
name = "owlry-core"
version = "1.3.6"
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 = "owlryd"
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"
fs2 = "0.4"
chrono = { version = "0.4", features = ["serde"] }
dirs = "5"
# Error handling
thiserror = "2"
# Filesystem watching (plugin hot-reload)
notify = "7"
notify-debouncer-mini = "0.5"
# Signal handling
signal-hook = "0.3"
# Logging & notifications
log = "0.4"
env_logger = "0.11"
notify-rust = "4"
# Built-in providers
expr-solver-lib = "1"
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] }
# Optional: embedded Lua runtime
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true }
[dev-dependencies]
tempfile = "3"
[features]
default = []
lua = ["dep:mlua"]
dev-logging = []
-9
View File
@@ -1,9 +0,0 @@
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;
-33
View File
@@ -1,33 +0,0 @@
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 owlryd 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 owlryd: {e}");
std::process::exit(1);
}
};
// SIGTERM/SIGINT are handled inside Server::run() via signal-hook,
// which saves frecency before exiting.
if let Err(e) = server.run() {
eprintln!("Server error: {e}");
std::process::exit(1);
}
}
-330
View File
@@ -1,330 +0,0 @@
//! Action API for Lua plugins
//!
//! Allows plugins to register custom actions for result items:
//! - `owlry.action.register(config)` - Register a custom action
use mlua::{Function, Lua, Result as LuaResult, Table, Value};
/// Action registration data
#[derive(Debug, Clone)]
#[allow(dead_code)] // Used by UI integration
pub struct ActionRegistration {
/// Unique action ID
pub id: String,
/// Human-readable name shown in UI
pub display_name: String,
/// Icon name (optional)
pub icon: Option<String>,
/// Keyboard shortcut hint (optional, e.g., "Ctrl+C")
pub shortcut: Option<String>,
/// Plugin that registered this action
pub plugin_id: String,
}
/// Register action APIs
pub fn register_action_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> {
let action_table = lua.create_table()?;
let plugin_id_owned = plugin_id.to_string();
// Initialize action storage in Lua registry
if lua.named_registry_value::<Value>("actions")?.is_nil() {
let actions: Table = lua.create_table()?;
lua.set_named_registry_value("actions", actions)?;
}
// owlry.action.register(config) -> string (action_id)
// config = {
// id = "copy-url",
// name = "Copy URL",
// icon = "edit-copy", -- optional
// shortcut = "Ctrl+C", -- optional
// filter = function(item) return item.provider == "bookmarks" end, -- optional
// handler = function(item) ... end
// }
let plugin_id_for_register = plugin_id_owned.clone();
action_table.set(
"register",
lua.create_function(move |lua, config: Table| {
// Extract required fields
let id: String = config
.get("id")
.map_err(|_| mlua::Error::external("action.register: 'id' is required"))?;
let name: String = config
.get("name")
.map_err(|_| mlua::Error::external("action.register: 'name' is required"))?;
let _handler: Function = config.get("handler").map_err(|_| {
mlua::Error::external("action.register: 'handler' function is required")
})?;
// Extract optional fields
let icon: Option<String> = config.get("icon").ok();
let shortcut: Option<String> = config.get("shortcut").ok();
// Store action in registry
let actions: Table = lua.named_registry_value("actions")?;
// Create full action ID with plugin prefix
let full_id = format!("{}:{}", plugin_id_for_register, id);
// Store config with full ID
let action_entry = lua.create_table()?;
action_entry.set("id", full_id.clone())?;
action_entry.set("name", name.clone())?;
action_entry.set("plugin_id", plugin_id_for_register.clone())?;
if let Some(ref i) = icon {
action_entry.set("icon", i.clone())?;
}
if let Some(ref s) = shortcut {
action_entry.set("shortcut", s.clone())?;
}
// Store filter and handler functions
if let Ok(filter) = config.get::<Function>("filter") {
action_entry.set("filter", filter)?;
}
action_entry.set("handler", config.get::<Function>("handler")?)?;
actions.set(full_id.clone(), action_entry)?;
log::info!(
"[plugin:{}] Registered action '{}' ({})",
plugin_id_for_register,
name,
full_id
);
Ok(full_id)
})?,
)?;
// owlry.action.unregister(id) -> boolean
let plugin_id_for_unregister = plugin_id_owned.clone();
action_table.set(
"unregister",
lua.create_function(move |lua, id: String| {
let actions: Table = lua.named_registry_value("actions")?;
let full_id = format!("{}:{}", plugin_id_for_unregister, id);
if actions.contains_key(full_id.clone())? {
actions.set(full_id, Value::Nil)?;
Ok(true)
} else {
Ok(false)
}
})?,
)?;
owlry.set("action", action_table)?;
Ok(())
}
/// Get all registered actions from a Lua runtime
#[allow(dead_code)] // Will be used by UI
pub fn get_actions(lua: &Lua) -> LuaResult<Vec<ActionRegistration>> {
let actions: Table = match lua.named_registry_value("actions") {
Ok(a) => a,
Err(_) => return Ok(Vec::new()),
};
let mut result = Vec::new();
for pair in actions.pairs::<String, Table>() {
let (_, entry) = pair?;
let id: String = entry.get("id")?;
let display_name: String = entry.get("name")?;
let plugin_id: String = entry.get("plugin_id")?;
let icon: Option<String> = entry.get("icon").ok();
let shortcut: Option<String> = entry.get("shortcut").ok();
result.push(ActionRegistration {
id,
display_name,
icon,
shortcut,
plugin_id,
});
}
Ok(result)
}
/// Get actions that apply to a specific item
#[allow(dead_code)] // Will be used by UI context menu
pub fn get_actions_for_item(lua: &Lua, item: &Table) -> LuaResult<Vec<ActionRegistration>> {
let actions: Table = match lua.named_registry_value("actions") {
Ok(a) => a,
Err(_) => return Ok(Vec::new()),
};
let mut result = Vec::new();
for pair in actions.pairs::<String, Table>() {
let (_, entry) = pair?;
// Check filter if present
if let Ok(filter) = entry.get::<Function>("filter") {
match filter.call::<bool>(item.clone()) {
Ok(true) => {} // Include this action
Ok(false) => continue, // Skip this action
Err(e) => {
log::warn!("Action filter failed: {}", e);
continue;
}
}
}
let id: String = entry.get("id")?;
let display_name: String = entry.get("name")?;
let plugin_id: String = entry.get("plugin_id")?;
let icon: Option<String> = entry.get("icon").ok();
let shortcut: Option<String> = entry.get("shortcut").ok();
result.push(ActionRegistration {
id,
display_name,
icon,
shortcut,
plugin_id,
});
}
Ok(result)
}
/// Execute an action by ID
#[allow(dead_code)] // Will be used by UI
pub fn execute_action(lua: &Lua, action_id: &str, item: &Table) -> LuaResult<()> {
let actions: Table = lua.named_registry_value("actions")?;
let action: Table = actions.get(action_id)?;
let handler: Function = action.get("handler")?;
handler.call::<()>(item.clone())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_lua(plugin_id: &str) -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_action_api(&lua, &owlry, plugin_id).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_action_registration() {
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
return owlry.action.register({
id = "copy-name",
name = "Copy Name",
icon = "edit-copy",
handler = function(item)
-- copy logic here
end
})
"#,
);
let action_id: String = chunk.call(()).unwrap();
assert_eq!(action_id, "test-plugin:copy-name");
// Verify action is registered
let actions = get_actions(&lua).unwrap();
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].display_name, "Copy Name");
}
#[test]
fn test_action_with_filter() {
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
owlry.action.register({
id = "bookmark-action",
name = "Open in Browser",
filter = function(item)
return item.provider == "bookmarks"
end,
handler = function(item) end
})
"#,
);
chunk.call::<()>(()).unwrap();
// Create bookmark item
let bookmark_item = lua.create_table().unwrap();
bookmark_item.set("provider", "bookmarks").unwrap();
bookmark_item.set("name", "Test Bookmark").unwrap();
let actions = get_actions_for_item(&lua, &bookmark_item).unwrap();
assert_eq!(actions.len(), 1);
// Create non-bookmark item
let app_item = lua.create_table().unwrap();
app_item.set("provider", "applications").unwrap();
app_item.set("name", "Test App").unwrap();
let actions2 = get_actions_for_item(&lua, &app_item).unwrap();
assert_eq!(actions2.len(), 0); // Filtered out
}
#[test]
fn test_action_unregister() {
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
owlry.action.register({
id = "temp-action",
name = "Temporary",
handler = function(item) end
})
return owlry.action.unregister("temp-action")
"#,
);
let unregistered: bool = chunk.call(()).unwrap();
assert!(unregistered);
let actions = get_actions(&lua).unwrap();
assert_eq!(actions.len(), 0);
}
#[test]
fn test_execute_action() {
let lua = setup_lua("test-plugin");
// Register action that sets a global
let chunk = lua.load(
r#"
result = nil
owlry.action.register({
id = "test-exec",
name = "Test Execute",
handler = function(item)
result = item.name
end
})
"#,
);
chunk.call::<()>(()).unwrap();
// Create test item
let item = lua.create_table().unwrap();
item.set("name", "TestItem").unwrap();
// Execute action
execute_action(&lua, "test-plugin:test-exec", &item).unwrap();
// Verify handler was called
let result: String = lua.globals().get("result").unwrap();
assert_eq!(result, "TestItem");
}
}
-307
View File
@@ -1,307 +0,0 @@
//! Cache API for Lua plugins
//!
//! Provides in-memory caching with optional TTL:
//! - `owlry.cache.get(key)` - Get cached value
//! - `owlry.cache.set(key, value, ttl_seconds?)` - Set cached value
//! - `owlry.cache.delete(key)` - Delete cached value
//! - `owlry.cache.clear()` - Clear all cached values
use mlua::{Lua, Result as LuaResult, Table, Value};
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
/// Cached entry with optional expiration
struct CacheEntry {
value: String, // Store as JSON string for simplicity
expires_at: Option<Instant>,
}
impl CacheEntry {
fn is_expired(&self) -> bool {
self.expires_at.map(|e| Instant::now() > e).unwrap_or(false)
}
}
/// Global cache storage (shared across all plugins)
static CACHE: LazyLock<Mutex<HashMap<String, CacheEntry>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Register cache APIs
pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let cache_table = lua.create_table()?;
// owlry.cache.get(key) -> value or nil
cache_table.set(
"get",
lua.create_function(|lua, key: String| {
let cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
if let Some(entry) = cache.get(&key) {
if entry.is_expired() {
drop(cache);
// Remove expired entry
if let Ok(mut cache) = CACHE.lock() {
cache.remove(&key);
}
return Ok(Value::Nil);
}
// Parse JSON back to Lua value
let json_value: serde_json::Value =
serde_json::from_str(&entry.value).map_err(|e| {
mlua::Error::external(format!("Failed to parse cached value: {}", e))
})?;
json_to_lua(lua, &json_value)
} else {
Ok(Value::Nil)
}
})?,
)?;
// owlry.cache.set(key, value, ttl_seconds?) -> boolean
cache_table.set(
"set",
lua.create_function(|_lua, (key, value, ttl): (String, Value, Option<u64>)| {
let json_value = lua_value_to_json(&value)?;
let json_str = serde_json::to_string(&json_value)
.map_err(|e| mlua::Error::external(format!("Failed to serialize value: {}", e)))?;
let expires_at = ttl.map(|secs| Instant::now() + Duration::from_secs(secs));
let entry = CacheEntry {
value: json_str,
expires_at,
};
let mut cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
cache.insert(key, entry);
Ok(true)
})?,
)?;
// owlry.cache.delete(key) -> boolean (true if key existed)
cache_table.set(
"delete",
lua.create_function(|_lua, key: String| {
let mut cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
Ok(cache.remove(&key).is_some())
})?,
)?;
// owlry.cache.clear() -> number of entries removed
cache_table.set(
"clear",
lua.create_function(|_lua, ()| {
let mut cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
let count = cache.len();
cache.clear();
Ok(count)
})?,
)?;
// owlry.cache.has(key) -> boolean
cache_table.set(
"has",
lua.create_function(|_lua, key: String| {
let cache = CACHE
.lock()
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
if let Some(entry) = cache.get(&key) {
Ok(!entry.is_expired())
} else {
Ok(false)
}
})?,
)?;
owlry.set("cache", cache_table)?;
Ok(())
}
/// Convert Lua value to serde_json::Value
fn lua_value_to_json(value: &Value) -> LuaResult<serde_json::Value> {
use serde_json::Value as JsonValue;
match value {
Value::Nil => Ok(JsonValue::Null),
Value::Boolean(b) => Ok(JsonValue::Bool(*b)),
Value::Integer(i) => Ok(JsonValue::Number((*i).into())),
Value::Number(n) => Ok(serde_json::Number::from_f64(*n)
.map(JsonValue::Number)
.unwrap_or(JsonValue::Null)),
Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())),
Value::Table(t) => lua_table_to_json(t),
_ => Err(mlua::Error::external("Unsupported Lua type for cache")),
}
}
/// Convert Lua table to serde_json::Value
fn lua_table_to_json(table: &Table) -> LuaResult<serde_json::Value> {
use serde_json::{Map, Value as JsonValue};
// Check if it's an array (sequential integer keys starting from 1)
let is_array = table
.clone()
.pairs::<i64, Value>()
.enumerate()
.all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false));
if is_array {
let mut arr = Vec::new();
for pair in table.clone().pairs::<i64, Value>() {
let (_, v) = pair?;
arr.push(lua_value_to_json(&v)?);
}
Ok(JsonValue::Array(arr))
} else {
let mut map = Map::new();
for pair in table.clone().pairs::<String, Value>() {
let (k, v) = pair?;
map.insert(k, lua_value_to_json(&v)?);
}
Ok(JsonValue::Object(map))
}
}
/// Convert serde_json::Value to Lua value
fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<Value> {
use serde_json::Value as JsonValue;
match value {
JsonValue::Null => Ok(Value::Nil),
JsonValue::Bool(b) => Ok(Value::Boolean(*b)),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(Value::Number(f))
} else {
Ok(Value::Nil)
}
}
JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)),
JsonValue::Array(arr) => {
let table = lua.create_table()?;
for (i, v) in arr.iter().enumerate() {
table.set(i + 1, json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
JsonValue::Object(obj) => {
let table = lua.create_table()?;
for (k, v) in obj {
table.set(k.as_str(), json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_lua() -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_cache_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
// Clear cache between tests
CACHE.lock().unwrap().clear();
lua
}
#[test]
fn test_cache_set_get() {
let lua = setup_lua();
// Set a value
let chunk = lua.load(r#"return owlry.cache.set("test_key", "test_value")"#);
let result: bool = chunk.call(()).unwrap();
assert!(result);
// Get the value back
let chunk = lua.load(r#"return owlry.cache.get("test_key")"#);
let value: String = chunk.call(()).unwrap();
assert_eq!(value, "test_value");
}
#[test]
fn test_cache_table_value() {
let lua = setup_lua();
// Set a table value
let chunk = lua.load(r#"return owlry.cache.set("table_key", {name = "test", value = 42})"#);
let _: bool = chunk.call(()).unwrap();
// Get and verify
let chunk = lua.load(
r#"
local t = owlry.cache.get("table_key")
return t.name, t.value
"#,
);
let (name, value): (String, i32) = chunk.call(()).unwrap();
assert_eq!(name, "test");
assert_eq!(value, 42);
}
#[test]
fn test_cache_delete() {
let lua = setup_lua();
let chunk = lua.load(
r#"
owlry.cache.set("delete_key", "value")
local existed = owlry.cache.delete("delete_key")
local value = owlry.cache.get("delete_key")
return existed, value
"#,
);
let (existed, value): (bool, Option<String>) = chunk.call(()).unwrap();
assert!(existed);
assert!(value.is_none());
}
#[test]
fn test_cache_has() {
let lua = setup_lua();
let chunk = lua.load(
r#"
local before = owlry.cache.has("has_key")
owlry.cache.set("has_key", "value")
local after = owlry.cache.has("has_key")
return before, after
"#,
);
let (before, after): (bool, bool) = chunk.call(()).unwrap();
assert!(!before);
assert!(after);
}
#[test]
fn test_cache_missing_key() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.cache.get("nonexistent_key")"#);
let value: Value = chunk.call(()).unwrap();
assert!(matches!(value, Value::Nil));
}
}
-418
View File
@@ -1,418 +0,0 @@
//! Hook API for Lua plugins
//!
//! Allows plugins to register callbacks for application events:
//! - `owlry.hook.on(event, callback)` - Register a hook
//! - Events: init, query, results, select, pre_launch, post_launch, shutdown
use mlua::{Function, Lua, Result as LuaResult, Table, Value};
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
/// Hook event types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HookEvent {
/// Called when plugin is initialized
Init,
/// Called when query changes, can modify query
Query,
/// Called after results are gathered, can filter/modify results
Results,
/// Called when an item is selected (highlighted)
Select,
/// Called before launching an item, can cancel launch
PreLaunch,
/// Called after launching an item
PostLaunch,
/// Called when application is shutting down
Shutdown,
}
impl HookEvent {
fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"init" => Some(Self::Init),
"query" => Some(Self::Query),
"results" => Some(Self::Results),
"select" => Some(Self::Select),
"pre_launch" | "prelaunch" => Some(Self::PreLaunch),
"post_launch" | "postlaunch" => Some(Self::PostLaunch),
"shutdown" => Some(Self::Shutdown),
_ => None,
}
}
fn as_str(&self) -> &'static str {
match self {
Self::Init => "init",
Self::Query => "query",
Self::Results => "results",
Self::Select => "select",
Self::PreLaunch => "pre_launch",
Self::PostLaunch => "post_launch",
Self::Shutdown => "shutdown",
}
}
}
/// Registered hook information
#[derive(Debug, Clone)]
#[allow(dead_code)] // Will be used for hook inspection
pub struct HookRegistration {
pub event: HookEvent,
pub plugin_id: String,
pub priority: i32,
}
/// Type alias for hook handlers: (plugin_id, priority)
type HookHandlers = Vec<(String, i32)>;
/// Global hook registry
/// Maps event -> list of (plugin_id, priority)
static HOOK_REGISTRY: LazyLock<Mutex<HashMap<HookEvent, HookHandlers>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Register hook APIs
pub fn register_hook_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> {
let hook_table = lua.create_table()?;
let plugin_id_owned = plugin_id.to_string();
// Store plugin_id in registry for later use
lua.set_named_registry_value("plugin_id", plugin_id_owned.clone())?;
// Initialize hook storage in Lua registry
if lua.named_registry_value::<Value>("hooks")?.is_nil() {
let hooks: Table = lua.create_table()?;
lua.set_named_registry_value("hooks", hooks)?;
}
// owlry.hook.on(event, callback, priority?) -> boolean
// Register a hook for an event
let plugin_id_for_closure = plugin_id_owned.clone();
hook_table.set(
"on",
lua.create_function(move |lua, (event_name, callback, priority): (String, Function, Option<i32>)| {
let event = HookEvent::from_str(&event_name).ok_or_else(|| {
mlua::Error::external(format!(
"Unknown hook event '{}'. Valid events: init, query, results, select, pre_launch, post_launch, shutdown",
event_name
))
})?;
let priority = priority.unwrap_or(0);
// Store callback in Lua registry
let hooks: Table = lua.named_registry_value("hooks")?;
let event_key = event.as_str();
let event_hooks: Table = if let Ok(t) = hooks.get::<Table>(event_key) {
t
} else {
let t = lua.create_table()?;
hooks.set(event_key, t.clone())?;
t
};
// Add callback to event hooks
let len = event_hooks.len()? + 1;
let hook_entry = lua.create_table()?;
hook_entry.set("callback", callback)?;
hook_entry.set("priority", priority)?;
event_hooks.set(len, hook_entry)?;
// Register in global registry
let mut registry = HOOK_REGISTRY.lock().map_err(|e| {
mlua::Error::external(format!("Failed to lock hook registry: {}", e))
})?;
let hooks_list = registry.entry(event).or_insert_with(Vec::new);
hooks_list.push((plugin_id_for_closure.clone(), priority));
// Sort by priority (higher priority first)
hooks_list.sort_by(|a, b| b.1.cmp(&a.1));
log::debug!(
"[plugin:{}] Registered hook for '{}' with priority {}",
plugin_id_for_closure,
event_name,
priority
);
Ok(true)
})?,
)?;
// owlry.hook.off(event) -> boolean
// Unregister all hooks for an event from this plugin
let plugin_id_for_off = plugin_id_owned.clone();
hook_table.set(
"off",
lua.create_function(move |lua, event_name: String| {
let event = HookEvent::from_str(&event_name).ok_or_else(|| {
mlua::Error::external(format!("Unknown hook event '{}'", event_name))
})?;
// Remove from Lua registry
let hooks: Table = lua.named_registry_value("hooks")?;
hooks.set(event.as_str(), Value::Nil)?;
// Remove from global registry
let mut registry = HOOK_REGISTRY.lock().map_err(|e| {
mlua::Error::external(format!("Failed to lock hook registry: {}", e))
})?;
if let Some(hooks_list) = registry.get_mut(&event) {
hooks_list.retain(|(id, _)| id != &plugin_id_for_off);
}
log::debug!(
"[plugin:{}] Unregistered hooks for '{}'",
plugin_id_for_off,
event_name
);
Ok(true)
})?,
)?;
owlry.set("hook", hook_table)?;
Ok(())
}
/// Call hooks for a specific event in a Lua runtime
/// Returns the (possibly modified) value
#[allow(dead_code)] // Will be used by UI integration
pub fn call_hooks<T>(lua: &Lua, event: HookEvent, value: T) -> LuaResult<T>
where
T: mlua::IntoLua + mlua::FromLua,
{
let hooks: Table = match lua.named_registry_value("hooks") {
Ok(h) => h,
Err(_) => return Ok(value), // No hooks registered
};
let event_hooks: Table = match hooks.get(event.as_str()) {
Ok(h) => h,
Err(_) => return Ok(value), // No hooks for this event
};
let mut current_value = value.into_lua(lua)?;
// Collect hooks with priorities
let mut hook_entries: Vec<(i32, Function)> = Vec::new();
for pair in event_hooks.pairs::<i64, Table>() {
let (_, entry) = pair?;
let priority: i32 = entry.get("priority").unwrap_or(0);
let callback: Function = entry.get("callback")?;
hook_entries.push((priority, callback));
}
// Sort by priority (higher first)
hook_entries.sort_by(|a, b| b.0.cmp(&a.0));
// Call each hook
for (_, callback) in hook_entries {
match callback.call::<Value>(current_value.clone()) {
Ok(result) => {
// If hook returns non-nil, use it as the new value
if !result.is_nil() {
current_value = result;
}
}
Err(e) => {
log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e);
// Continue with other hooks
}
}
}
T::from_lua(current_value, lua)
}
/// Call hooks that return a boolean (for pre_launch cancellation)
#[allow(dead_code)] // Will be used for pre_launch hooks
pub fn call_hooks_bool(lua: &Lua, event: HookEvent, value: Value) -> LuaResult<bool> {
let hooks: Table = match lua.named_registry_value("hooks") {
Ok(h) => h,
Err(_) => return Ok(true), // No hooks, allow
};
let event_hooks: Table = match hooks.get(event.as_str()) {
Ok(h) => h,
Err(_) => return Ok(true), // No hooks for this event
};
// Collect and sort hooks
let mut hook_entries: Vec<(i32, Function)> = Vec::new();
for pair in event_hooks.pairs::<i64, Table>() {
let (_, entry) = pair?;
let priority: i32 = entry.get("priority").unwrap_or(0);
let callback: Function = entry.get("callback")?;
hook_entries.push((priority, callback));
}
hook_entries.sort_by(|a, b| b.0.cmp(&a.0));
// Call each hook - if any returns false, cancel
for (_, callback) in hook_entries {
match callback.call::<Value>(value.clone()) {
Ok(result) => {
if let Value::Boolean(false) = result {
return Ok(false); // Cancel
}
}
Err(e) => {
log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e);
}
}
}
Ok(true)
}
/// Call hooks with no return value (for notifications)
#[allow(dead_code)] // Will be used for notification hooks
pub fn call_hooks_void(lua: &Lua, event: HookEvent, value: Value) -> LuaResult<()> {
let hooks: Table = match lua.named_registry_value("hooks") {
Ok(h) => h,
Err(_) => return Ok(()), // No hooks
};
let event_hooks: Table = match hooks.get(event.as_str()) {
Ok(h) => h,
Err(_) => return Ok(()), // No hooks for this event
};
for pair in event_hooks.pairs::<i64, Table>() {
let (_, entry) = pair?;
let callback: Function = entry.get("callback")?;
if let Err(e) = callback.call::<()>(value.clone()) {
log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e);
}
}
Ok(())
}
/// Get list of plugins that have registered for an event
#[allow(dead_code)]
pub fn get_registered_plugins(event: HookEvent) -> Vec<String> {
HOOK_REGISTRY
.lock()
.map(|r| {
r.get(&event)
.map(|v| v.iter().map(|(id, _)| id.clone()).collect())
.unwrap_or_default()
})
.unwrap_or_default()
}
/// Clear all hooks (used when reloading plugins)
#[allow(dead_code)]
pub fn clear_all_hooks() {
if let Ok(mut registry) = HOOK_REGISTRY.lock() {
registry.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_lua(plugin_id: &str) -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_hook_api(&lua, &owlry, plugin_id).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_hook_registration() {
clear_all_hooks();
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
local called = false
owlry.hook.on("init", function()
called = true
end)
return true
"#,
);
let result: bool = chunk.call(()).unwrap();
assert!(result);
// Verify hook was registered
let plugins = get_registered_plugins(HookEvent::Init);
assert!(plugins.contains(&"test-plugin".to_string()));
}
#[test]
fn test_hook_with_priority() {
clear_all_hooks();
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
owlry.hook.on("query", function(q) return q .. "1" end, 10)
owlry.hook.on("query", function(q) return q .. "2" end, 20)
return true
"#,
);
chunk.call::<()>(()).unwrap();
// Call hooks - higher priority (20) should run first
let result: String = call_hooks(&lua, HookEvent::Query, "test".to_string()).unwrap();
// Priority 20 adds "2" first, then priority 10 adds "1"
assert_eq!(result, "test21");
}
#[test]
fn test_hook_off() {
clear_all_hooks();
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
owlry.hook.on("select", function() end)
owlry.hook.off("select")
return true
"#,
);
chunk.call::<()>(()).unwrap();
let plugins = get_registered_plugins(HookEvent::Select);
assert!(!plugins.contains(&"test-plugin".to_string()));
}
#[test]
fn test_pre_launch_cancel() {
clear_all_hooks();
let lua = setup_lua("test-plugin");
let chunk = lua.load(
r#"
owlry.hook.on("pre_launch", function(item)
if item.name == "blocked" then
return false -- cancel launch
end
return true
end)
"#,
);
chunk.call::<()>(()).unwrap();
// Create a test item table
let item = lua.create_table().unwrap();
item.set("name", "blocked").unwrap();
let allow = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item)).unwrap();
assert!(!allow); // Should be blocked
// Test with allowed item
let item2 = lua.create_table().unwrap();
item2.set("name", "allowed").unwrap();
let allow2 = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item2)).unwrap();
assert!(allow2); // Should be allowed
}
}
-350
View File
@@ -1,350 +0,0 @@
//! HTTP client API for Lua plugins
//!
//! Provides:
//! - `owlry.http.get(url, opts)` - HTTP GET request
//! - `owlry.http.post(url, body, opts)` - HTTP POST request
use mlua::{Lua, Result as LuaResult, Table, Value};
use std::collections::HashMap;
use std::time::Duration;
/// Register HTTP client APIs
pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let http_table = lua.create_table()?;
// owlry.http.get(url, opts?) -> { status, body, headers }
http_table.set(
"get",
lua.create_function(|lua, (url, opts): (String, Option<Table>)| {
log::debug!("[plugin] http.get: {}", url);
let timeout_secs = opts
.as_ref()
.and_then(|o| o.get::<u64>("timeout").ok())
.unwrap_or(30);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|e| {
mlua::Error::external(format!("Failed to create HTTP client: {}", e))
})?;
let mut request = client.get(&url);
// Add custom headers if provided
if let Some(ref opts) = opts
&& let Ok(headers) = opts.get::<Table>("headers")
{
for pair in headers.pairs::<String, String>() {
let (key, value) = pair?;
request = request.header(&key, &value);
}
}
let response = request
.send()
.map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?;
let status = response.status().as_u16();
let headers = extract_headers(&response);
let body = response.text().map_err(|e| {
mlua::Error::external(format!("Failed to read response body: {}", e))
})?;
let result = lua.create_table()?;
result.set("status", status)?;
result.set("body", body)?;
result.set("ok", (200..300).contains(&status))?;
let headers_table = lua.create_table()?;
for (key, value) in headers {
headers_table.set(key, value)?;
}
result.set("headers", headers_table)?;
Ok(result)
})?,
)?;
// owlry.http.post(url, body, opts?) -> { status, body, headers }
http_table.set(
"post",
lua.create_function(|lua, (url, body, opts): (String, Value, Option<Table>)| {
log::debug!("[plugin] http.post: {}", url);
let timeout_secs = opts
.as_ref()
.and_then(|o| o.get::<u64>("timeout").ok())
.unwrap_or(30);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|e| {
mlua::Error::external(format!("Failed to create HTTP client: {}", e))
})?;
let mut request = client.post(&url);
// Add custom headers if provided
if let Some(ref opts) = opts
&& let Ok(headers) = opts.get::<Table>("headers")
{
for pair in headers.pairs::<String, String>() {
let (key, value) = pair?;
request = request.header(&key, &value);
}
}
// Set body based on type
request = match body {
Value::String(s) => request.body(s.to_str()?.to_string()),
Value::Table(t) => {
// Assume JSON if body is a table
let json_str = table_to_json(&t)?;
request
.header("Content-Type", "application/json")
.body(json_str)
}
Value::Nil => request,
_ => return Err(mlua::Error::external("POST body must be a string or table")),
};
let response = request
.send()
.map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?;
let status = response.status().as_u16();
let headers = extract_headers(&response);
let body = response.text().map_err(|e| {
mlua::Error::external(format!("Failed to read response body: {}", e))
})?;
let result = lua.create_table()?;
result.set("status", status)?;
result.set("body", body)?;
result.set("ok", (200..300).contains(&status))?;
let headers_table = lua.create_table()?;
for (key, value) in headers {
headers_table.set(key, value)?;
}
result.set("headers", headers_table)?;
Ok(result)
})?,
)?;
// owlry.http.get_json(url, opts?) -> parsed JSON as table
// Convenience function that parses JSON response
http_table.set(
"get_json",
lua.create_function(|lua, (url, opts): (String, Option<Table>)| {
log::debug!("[plugin] http.get_json: {}", url);
let timeout_secs = opts
.as_ref()
.and_then(|o| o.get::<u64>("timeout").ok())
.unwrap_or(30);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|e| {
mlua::Error::external(format!("Failed to create HTTP client: {}", e))
})?;
let mut request = client.get(&url);
request = request.header("Accept", "application/json");
// Add custom headers if provided
if let Some(ref opts) = opts
&& let Ok(headers) = opts.get::<Table>("headers")
{
for pair in headers.pairs::<String, String>() {
let (key, value) = pair?;
request = request.header(&key, &value);
}
}
let response = request
.send()
.map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?;
if !response.status().is_success() {
return Err(mlua::Error::external(format!(
"HTTP request failed with status {}",
response.status()
)));
}
let body = response.text().map_err(|e| {
mlua::Error::external(format!("Failed to read response body: {}", e))
})?;
// Parse JSON and convert to Lua table
let json_value: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| mlua::Error::external(format!("Failed to parse JSON: {}", e)))?;
json_to_lua(lua, &json_value)
})?,
)?;
owlry.set("http", http_table)?;
Ok(())
}
/// Extract headers from response into a HashMap
fn extract_headers(response: &reqwest::blocking::Response) -> HashMap<String, String> {
response
.headers()
.iter()
.filter_map(|(k, v)| {
v.to_str()
.ok()
.map(|v| (k.as_str().to_lowercase(), v.to_string()))
})
.collect()
}
/// Convert a Lua table to JSON string
fn table_to_json(table: &Table) -> LuaResult<String> {
let value = lua_to_json(table)?;
serde_json::to_string(&value)
.map_err(|e| mlua::Error::external(format!("Failed to serialize to JSON: {}", e)))
}
/// Convert Lua table to serde_json::Value
fn lua_to_json(table: &Table) -> LuaResult<serde_json::Value> {
use serde_json::{Map, Value as JsonValue};
// Check if it's an array (sequential integer keys starting from 1)
let is_array = table
.clone()
.pairs::<i64, Value>()
.enumerate()
.all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false));
if is_array {
let mut arr = Vec::new();
for pair in table.clone().pairs::<i64, Value>() {
let (_, v) = pair?;
arr.push(lua_value_to_json(&v)?);
}
Ok(JsonValue::Array(arr))
} else {
let mut map = Map::new();
for pair in table.clone().pairs::<String, Value>() {
let (k, v) = pair?;
map.insert(k, lua_value_to_json(&v)?);
}
Ok(JsonValue::Object(map))
}
}
/// Convert a single Lua value to JSON
fn lua_value_to_json(value: &Value) -> LuaResult<serde_json::Value> {
use serde_json::Value as JsonValue;
match value {
Value::Nil => Ok(JsonValue::Null),
Value::Boolean(b) => Ok(JsonValue::Bool(*b)),
Value::Integer(i) => Ok(JsonValue::Number((*i).into())),
Value::Number(n) => Ok(serde_json::Number::from_f64(*n)
.map(JsonValue::Number)
.unwrap_or(JsonValue::Null)),
Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())),
Value::Table(t) => lua_to_json(t),
_ => Err(mlua::Error::external("Unsupported Lua type for JSON")),
}
}
/// Convert serde_json::Value to Lua value
fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<Value> {
use serde_json::Value as JsonValue;
match value {
JsonValue::Null => Ok(Value::Nil),
JsonValue::Bool(b) => Ok(Value::Boolean(*b)),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(Value::Number(f))
} else {
Ok(Value::Nil)
}
}
JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)),
JsonValue::Array(arr) => {
let table = lua.create_table()?;
for (i, v) in arr.iter().enumerate() {
table.set(i + 1, json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
JsonValue::Object(obj) => {
let table = lua.create_table()?;
for (k, v) in obj {
table.set(k.as_str(), json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_lua() -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_http_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_json_conversion() {
let lua = setup_lua();
// Test table to JSON
let table = lua.create_table().unwrap();
table.set("name", "test").unwrap();
table.set("value", 42).unwrap();
let json = table_to_json(&table).unwrap();
assert!(json.contains("name"));
assert!(json.contains("test"));
assert!(json.contains("42"));
}
#[test]
fn test_array_to_json() {
let lua = setup_lua();
let table = lua.create_table().unwrap();
table.set(1, "first").unwrap();
table.set(2, "second").unwrap();
table.set(3, "third").unwrap();
let json = table_to_json(&table).unwrap();
assert!(json.starts_with('['));
assert!(json.contains("first"));
}
// Note: Network tests are skipped in CI - they require internet access
// Use `cargo test -- --ignored` to run them locally
#[test]
#[ignore]
fn test_http_get() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.http.get("https://httpbin.org/get")"#);
let result: Table = chunk.call(()).unwrap();
assert_eq!(result.get::<u16>("status").unwrap(), 200);
assert!(result.get::<bool>("ok").unwrap());
}
}
-196
View File
@@ -1,196 +0,0 @@
//! Math calculation API for Lua plugins
//!
//! Provides safe math expression evaluation:
//! - `owlry.math.calculate(expression)` - Evaluate a math expression
use expr_solver::{SymTable, eval_with_table};
use mlua::{Lua, Result as LuaResult, Table};
fn eval_math(expr: &str) -> Result<f64, String> {
let mut table = SymTable::stdlib();
table
.add_func("ln", 1, false, |args| Ok(args[0].ln()), false)
.expect("ln alias is valid");
eval_with_table(expr, table)
}
/// Register math APIs
pub fn register_math_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let math_table = lua.create_table()?;
// owlry.math.calculate(expression) -> number or nil, error
// Evaluates a mathematical expression safely
// Returns (result, nil) on success or (nil, error_message) on failure
math_table.set(
"calculate",
lua.create_function(
|_lua, expr: String| -> LuaResult<(Option<f64>, Option<String>)> {
match eval_math(&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))),
}
},
)?,
)?;
// owlry.math.calc(expression) -> number (throws on error)
// Convenience function that throws instead of returning error
math_table.set(
"calc",
lua.create_function(|_lua, expr: String| {
eval_math(&expr)
.map_err(|e| mlua::Error::external(format!("Math error: {}", e)))
.and_then(|r| {
if r.is_finite() {
Ok(r)
} else {
Err(mlua::Error::external("Result is not a finite number"))
}
})
})?,
)?;
// owlry.math.is_expression(str) -> boolean
// Check if a string looks like a math expression
math_table.set(
"is_expression",
lua.create_function(|_lua, expr: String| {
let trimmed = expr.trim();
// Must have at least one digit
if !trimmed.chars().any(|c| c.is_ascii_digit()) {
return Ok(false);
}
// Should only contain valid math characters
let valid = trimmed.chars().all(|c| {
c.is_ascii_digit()
|| c.is_ascii_alphabetic()
|| matches!(c, '+' | '-' | '*' | '/' | '^' | '(' | ')' | '.' | ' ' | '%')
});
Ok(valid)
})?,
)?;
// owlry.math.format(number, decimals?) -> string
// Format a number with optional decimal places
math_table.set(
"format",
lua.create_function(|_lua, (num, decimals): (f64, Option<usize>)| {
let decimals = decimals.unwrap_or(2);
// Check if it's effectively an integer
if (num - num.round()).abs() < f64::EPSILON {
Ok(format!("{}", num as i64))
} else {
Ok(format!("{:.prec$}", num, prec = decimals))
}
})?,
)?;
owlry.set("math", math_table)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_lua() -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_math_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_calculate_basic() {
let lua = setup_lua();
let chunk = lua.load(
r#"
local result, err = owlry.math.calculate("2 + 2")
if err then error(err) end
return result
"#,
);
let result: f64 = chunk.call(()).unwrap();
assert!((result - 4.0).abs() < f64::EPSILON);
}
#[test]
fn test_calculate_complex() {
let lua = setup_lua();
let chunk = lua.load(
r#"
local result, err = owlry.math.calculate("sqrt(16) + 2^3")
if err then error(err) end
return result
"#,
);
let result: f64 = chunk.call(()).unwrap();
assert!((result - 12.0).abs() < f64::EPSILON); // sqrt(16) = 4, 2^3 = 8
}
#[test]
fn test_calculate_error() {
let lua = setup_lua();
let chunk = lua.load(
r#"
local result, err = owlry.math.calculate("invalid expression @@")
if result then
return false -- should not succeed
else
return true -- correctly failed
end
"#,
);
let had_error: bool = chunk.call(()).unwrap();
assert!(had_error);
}
#[test]
fn test_calc_throws() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.math.calc("3 * 4")"#);
let result: f64 = chunk.call(()).unwrap();
assert!((result - 12.0).abs() < f64::EPSILON);
}
#[test]
fn test_is_expression() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.math.is_expression("2 + 2")"#);
let is_expr: bool = chunk.call(()).unwrap();
assert!(is_expr);
let chunk = lua.load(r#"return owlry.math.is_expression("hello world")"#);
let is_expr: bool = chunk.call(()).unwrap();
assert!(!is_expr);
}
#[test]
fn test_format() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.math.format(3.14159, 2)"#);
let formatted: String = chunk.call(()).unwrap();
assert_eq!(formatted, "3.14");
let chunk = lua.load(r#"return owlry.math.format(42.0)"#);
let formatted: String = chunk.call(()).unwrap();
assert_eq!(formatted, "42");
}
}
-77
View File
@@ -1,77 +0,0 @@
//! Lua API implementations for plugins
//!
//! This module provides the `owlry` global table and its submodules
//! that plugins can use to interact with owlry.
pub mod action;
mod cache;
pub mod hook;
mod http;
mod math;
mod process;
pub mod provider;
pub mod theme;
mod utils;
use mlua::{Lua, Result as LuaResult};
pub use action::ActionRegistration;
pub use hook::HookEvent;
pub use provider::ProviderRegistration;
pub use theme::ThemeRegistration;
/// Register all owlry APIs in the Lua runtime
///
/// This creates the `owlry` global table with all available APIs:
/// - `owlry.log.*` - Logging functions
/// - `owlry.path.*` - XDG path helpers
/// - `owlry.fs.*` - Filesystem operations
/// - `owlry.json.*` - JSON encode/decode
/// - `owlry.provider.*` - Provider registration
/// - `owlry.process.*` - Process execution
/// - `owlry.env.*` - Environment variables
/// - `owlry.http.*` - HTTP client
/// - `owlry.cache.*` - In-memory caching
/// - `owlry.math.*` - Math expression evaluation
/// - `owlry.hook.*` - Event hooks
/// - `owlry.action.*` - Custom actions
/// - `owlry.theme.*` - Theme registration
pub fn register_apis(lua: &Lua, plugin_dir: &std::path::Path, plugin_id: &str) -> LuaResult<()> {
let globals = lua.globals();
// Create the main owlry table
let owlry = lua.create_table()?;
// Register utility APIs (log, path, fs, json)
utils::register_log_api(lua, &owlry)?;
utils::register_path_api(lua, &owlry, plugin_dir)?;
utils::register_fs_api(lua, &owlry, plugin_dir)?;
utils::register_json_api(lua, &owlry)?;
// Register provider API
provider::register_provider_api(lua, &owlry)?;
// Register extended APIs (Phase 3)
process::register_process_api(lua, &owlry)?;
process::register_env_api(lua, &owlry)?;
http::register_http_api(lua, &owlry)?;
cache::register_cache_api(lua, &owlry)?;
math::register_math_api(lua, &owlry)?;
// Register Phase 4 APIs (hooks, actions, themes)
hook::register_hook_api(lua, &owlry, plugin_id)?;
action::register_action_api(lua, &owlry, plugin_id)?;
theme::register_theme_api(lua, &owlry, plugin_id, plugin_dir)?;
// Set owlry as global
globals.set("owlry", owlry)?;
Ok(())
}
/// Get provider registrations from the Lua runtime
///
/// Returns all providers that were registered via `owlry.provider.register()`
pub fn get_provider_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
provider::get_registrations(lua)
}
@@ -1,213 +0,0 @@
//! Process and environment APIs for Lua plugins
//!
//! Provides:
//! - `owlry.process.run(cmd)` - Run a shell command and return output
//! - `owlry.process.exists(cmd)` - Check if a command exists in PATH
//! - `owlry.env.get(name)` - Get an environment variable
//! - `owlry.env.set(name, value)` - Set an environment variable (for plugin scope)
use mlua::{Lua, Result as LuaResult, Table};
use std::process::Command;
/// Register process-related APIs
pub fn register_process_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let process_table = lua.create_table()?;
// owlry.process.run(cmd) -> { stdout, stderr, exit_code, success }
// Runs a shell command and returns the result
process_table.set(
"run",
lua.create_function(|lua, cmd: String| {
log::debug!("[plugin] process.run: {}", cmd);
let output = Command::new("sh")
.arg("-c")
.arg(&cmd)
.output()
.map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?;
let result = lua.create_table()?;
result.set(
"stdout",
String::from_utf8_lossy(&output.stdout).to_string(),
)?;
result.set(
"stderr",
String::from_utf8_lossy(&output.stderr).to_string(),
)?;
result.set("exit_code", output.status.code().unwrap_or(-1))?;
result.set("success", output.status.success())?;
Ok(result)
})?,
)?;
// owlry.process.run_lines(cmd) -> table of lines
// Convenience function that runs a command and returns stdout split into lines
process_table.set(
"run_lines",
lua.create_function(|lua, cmd: String| {
log::debug!("[plugin] process.run_lines: {}", cmd);
let output = Command::new("sh")
.arg("-c")
.arg(&cmd)
.output()
.map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?;
if !output.status.success() {
return Err(mlua::Error::external(format!(
"Command failed with exit code {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr)
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
let result = lua.create_table()?;
for (i, line) in lines.iter().enumerate() {
result.set(i + 1, *line)?;
}
Ok(result)
})?,
)?;
// owlry.process.exists(cmd) -> boolean
// Checks if a command exists in PATH
process_table.set(
"exists",
lua.create_function(|_lua, cmd: String| {
let exists = Command::new("which")
.arg(&cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
Ok(exists)
})?,
)?;
owlry.set("process", process_table)?;
Ok(())
}
/// Register environment variable APIs
pub fn register_env_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let env_table = lua.create_table()?;
// owlry.env.get(name) -> string or nil
env_table.set(
"get",
lua.create_function(|_lua, name: String| Ok(std::env::var(&name).ok()))?,
)?;
// owlry.env.get_or(name, default) -> string
env_table.set(
"get_or",
lua.create_function(|_lua, (name, default): (String, String)| {
Ok(std::env::var(&name).unwrap_or(default))
})?,
)?;
// owlry.env.home() -> string
// Convenience function to get home directory
env_table.set(
"home",
lua.create_function(|_lua, ()| {
Ok(dirs::home_dir().map(|p| p.to_string_lossy().to_string()))
})?,
)?;
owlry.set("env", env_table)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_lua() -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_process_api(&lua, &owlry).unwrap();
register_env_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_process_run() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.process.run("echo hello")"#);
let result: Table = chunk.call(()).unwrap();
assert_eq!(result.get::<bool>("success").unwrap(), true);
assert_eq!(result.get::<i32>("exit_code").unwrap(), 0);
assert!(result.get::<String>("stdout").unwrap().contains("hello"));
}
#[test]
fn test_process_run_lines() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.process.run_lines("echo -e 'line1\nline2\nline3'")"#);
let result: Table = chunk.call(()).unwrap();
assert_eq!(result.get::<String>(1).unwrap(), "line1");
assert_eq!(result.get::<String>(2).unwrap(), "line2");
assert_eq!(result.get::<String>(3).unwrap(), "line3");
}
#[test]
fn test_process_exists() {
let lua = setup_lua();
// 'sh' should always exist
let chunk = lua.load(r#"return owlry.process.exists("sh")"#);
let exists: bool = chunk.call(()).unwrap();
assert!(exists);
// Made-up command should not exist
let chunk = lua
.load(r#"return owlry.process.exists("this_command_definitely_does_not_exist_12345")"#);
let not_exists: bool = chunk.call(()).unwrap();
assert!(!not_exists);
}
#[test]
fn test_env_get() {
let lua = setup_lua();
// HOME should be set on any Unix system
let chunk = lua.load(r#"return owlry.env.get("HOME")"#);
let home: Option<String> = chunk.call(()).unwrap();
assert!(home.is_some());
// Non-existent variable should return nil
let chunk = lua.load(r#"return owlry.env.get("THIS_VAR_DOES_NOT_EXIST_12345")"#);
let missing: Option<String> = chunk.call(()).unwrap();
assert!(missing.is_none());
}
#[test]
fn test_env_get_or() {
let lua = setup_lua();
let chunk = lua
.load(r#"return owlry.env.get_or("THIS_VAR_DOES_NOT_EXIST_12345", "default_value")"#);
let result: String = chunk.call(()).unwrap();
assert_eq!(result, "default_value");
}
#[test]
fn test_env_home() {
let lua = setup_lua();
let chunk = lua.load(r#"return owlry.env.home()"#);
let home: Option<String> = chunk.call(()).unwrap();
assert!(home.is_some());
assert!(home.unwrap().starts_with('/'));
}
}
@@ -1,315 +0,0 @@
//! Provider registration API for Lua plugins
//!
//! Allows plugins to register providers via `owlry.provider.register()`
use mlua::{Function, Lua, Result as LuaResult, Table};
/// Provider registration data extracted from Lua
#[derive(Debug, Clone)]
#[allow(dead_code)] // Some fields are for future use
pub struct ProviderRegistration {
/// Provider name (used for filtering/identification)
pub name: String,
/// Human-readable display name
pub display_name: String,
/// Provider type ID (for badge/filtering)
pub type_id: String,
/// Default icon name
pub default_icon: String,
/// Whether this is a static provider (refresh once) or dynamic (query-based)
pub is_static: bool,
/// Prefix to trigger this provider (e.g., ":" for commands)
pub prefix: Option<String>,
}
/// Register owlry.provider.* API
pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let provider_table = lua.create_table()?;
// Initialize registry for storing provider registrations
let registrations: Table = lua.create_table()?;
lua.set_named_registry_value("provider_registrations", registrations)?;
// owlry.provider.register(config) - Register a new provider
provider_table.set(
"register",
lua.create_function(|lua, config: Table| {
// Extract required fields
let name: String = config
.get("name")
.map_err(|_| mlua::Error::external("provider.register: 'name' is required"))?;
let _display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone());
let type_id: String = config
.get("type_id")
.unwrap_or_else(|_| name.replace('-', "_"));
let _default_icon: String = config
.get("default_icon")
.unwrap_or_else(|_| "application-x-executable".to_string());
let _prefix: Option<String> = config.get("prefix").ok();
// Check for refresh function (static provider) or query function (dynamic)
let has_refresh = config.get::<Function>("refresh").is_ok();
let has_query = config.get::<Function>("query").is_ok();
if !has_refresh && !has_query {
return Err(mlua::Error::external(
"provider.register: either 'refresh' or 'query' function is required",
));
}
let is_static = has_refresh;
log::info!(
"[plugin] Registered provider '{}' (type: {}, static: {})",
name,
type_id,
is_static
);
// Store the config in registry for later retrieval
let registrations: Table = lua.named_registry_value("provider_registrations")?;
registrations.set(name.clone(), config)?;
Ok(name)
})?,
)?;
owlry.set("provider", provider_table)?;
Ok(())
}
/// Get all provider registrations from the Lua runtime
pub fn get_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
let registrations: Table = lua.named_registry_value("provider_registrations")?;
let mut result = Vec::new();
for pair in registrations.pairs::<String, Table>() {
let (name, config) = pair?;
let display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone());
let type_id: String = config
.get("type_id")
.unwrap_or_else(|_| name.replace('-', "_"));
let default_icon: String = config
.get("default_icon")
.unwrap_or_else(|_| "application-x-executable".to_string());
let prefix: Option<String> = config.get("prefix").ok();
let is_static = config.get::<Function>("refresh").is_ok();
result.push(ProviderRegistration {
name,
display_name,
type_id,
default_icon,
is_static,
prefix,
});
}
Ok(result)
}
/// Call a provider's refresh function and extract items
pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>> {
let registrations: Table = lua.named_registry_value("provider_registrations")?;
let config: Table = registrations.get(provider_name)?;
let refresh: Function = config.get("refresh")?;
let items: Table = refresh.call(())?;
extract_items(&items)
}
/// Call a provider's query function with a query string
#[allow(dead_code)] // Will be used for dynamic query providers
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
let registrations: Table = lua.named_registry_value("provider_registrations")?;
let config: Table = registrations.get(provider_name)?;
let query_fn: Function = config.get("query")?;
let items: Table = query_fn.call(query.to_string())?;
extract_items(&items)
}
/// Item data from a plugin provider
#[derive(Debug, Clone)]
#[allow(dead_code)] // data field is for future action handlers
pub struct PluginItem {
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub command: Option<String>,
pub terminal: bool,
pub tags: Vec<String>,
/// Custom data passed to action handlers
pub data: Option<String>,
}
/// Extract items from a Lua table returned by refresh/query
fn extract_items(items: &Table) -> LuaResult<Vec<PluginItem>> {
let mut result = Vec::new();
for pair in items.clone().pairs::<i64, Table>() {
let (_, item) = pair?;
let id: String = item.get("id")?;
let name: String = item.get("name")?;
let description: Option<String> = item.get("description").ok();
let icon: Option<String> = item.get("icon").ok();
let command: Option<String> = item.get("command").ok();
let terminal: bool = item.get("terminal").unwrap_or(false);
let data: Option<String> = item.get("data").ok();
// Extract tags array
let tags: Vec<String> = if let Ok(tags_table) = item.get::<Table>("tags") {
tags_table
.pairs::<i64, String>()
.filter_map(|r| r.ok())
.map(|(_, v)| v)
.collect()
} else {
Vec::new()
};
result.push(PluginItem {
id,
name,
description,
icon,
command,
terminal,
tags,
data,
});
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_lua() -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_provider_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_register_static_provider() {
let lua = create_test_lua();
let script = r#"
owlry.provider.register({
name = "test-provider",
display_name = "Test Provider",
type_id = "test",
default_icon = "test-icon",
refresh = function()
return {
{ id = "1", name = "Item 1", description = "First item" },
{ id = "2", name = "Item 2", command = "echo hello" },
}
end
})
"#;
lua.load(script).call::<()>(()).unwrap();
let registrations = get_registrations(&lua).unwrap();
assert_eq!(registrations.len(), 1);
assert_eq!(registrations[0].name, "test-provider");
assert_eq!(registrations[0].display_name, "Test Provider");
assert!(registrations[0].is_static);
}
#[test]
fn test_register_dynamic_provider() {
let lua = create_test_lua();
let script = r#"
owlry.provider.register({
name = "search",
prefix = "?",
query = function(q)
return {
{ id = "result", name = "Result for: " .. q }
}
end
})
"#;
lua.load(script).call::<()>(()).unwrap();
let registrations = get_registrations(&lua).unwrap();
assert_eq!(registrations.len(), 1);
assert!(!registrations[0].is_static);
assert_eq!(registrations[0].prefix, Some("?".to_string()));
}
#[test]
fn test_call_refresh() {
let lua = create_test_lua();
let script = r#"
owlry.provider.register({
name = "items",
refresh = function()
return {
{ id = "a", name = "Alpha", tags = {"one", "two"} },
{ id = "b", name = "Beta", terminal = true },
}
end
})
"#;
lua.load(script).call::<()>(()).unwrap();
let items = call_refresh(&lua, "items").unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].id, "a");
assert_eq!(items[0].name, "Alpha");
assert_eq!(items[0].tags, vec!["one", "two"]);
assert!(!items[0].terminal);
assert_eq!(items[1].id, "b");
assert!(items[1].terminal);
}
#[test]
fn test_call_query() {
let lua = create_test_lua();
let script = r#"
owlry.provider.register({
name = "search",
query = function(q)
return {
{ id = "1", name = "Found: " .. q }
}
end
})
"#;
lua.load(script).call::<()>(()).unwrap();
let items = call_query(&lua, "search", "hello").unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "Found: hello");
}
#[test]
fn test_register_missing_function() {
let lua = create_test_lua();
let script = r#"
owlry.provider.register({
name = "broken",
})
"#;
let result = lua.load(script).call::<()>(());
assert!(result.is_err());
}
}
-286
View File
@@ -1,286 +0,0 @@
//! Theme API for Lua plugins
//!
//! Allows plugins to contribute CSS themes:
//! - `owlry.theme.register(config)` - Register a theme
use mlua::{Lua, Result as LuaResult, Table, Value};
use std::path::Path;
/// Theme registration data
#[derive(Debug, Clone)]
#[allow(dead_code)] // Will be used by theme loading
pub struct ThemeRegistration {
/// Theme name (used in config)
pub name: String,
/// Human-readable display name
pub display_name: String,
/// CSS content
pub css: String,
/// Plugin that registered this theme
pub plugin_id: String,
}
/// Register theme APIs
pub fn register_theme_api(
lua: &Lua,
owlry: &Table,
plugin_id: &str,
plugin_dir: &Path,
) -> LuaResult<()> {
let theme_table = lua.create_table()?;
let plugin_id_owned = plugin_id.to_string();
let plugin_dir_owned = plugin_dir.to_path_buf();
// Initialize theme storage in Lua registry
if lua.named_registry_value::<Value>("themes")?.is_nil() {
let themes: Table = lua.create_table()?;
lua.set_named_registry_value("themes", themes)?;
}
// owlry.theme.register(config) -> string (theme_name)
// config = {
// name = "dark-owl",
// display_name = "Dark Owl", -- optional, defaults to name
// css = "...", -- CSS string
// -- OR
// css_file = "theme.css" -- path relative to plugin dir
// }
let plugin_id_for_register = plugin_id_owned.clone();
let plugin_dir_for_register = plugin_dir_owned.clone();
theme_table.set(
"register",
lua.create_function(move |lua, config: Table| {
// Extract required fields
let name: String = config
.get("name")
.map_err(|_| mlua::Error::external("theme.register: 'name' is required"))?;
let display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone());
// Get CSS either directly or from file
let css: String = if let Ok(css_str) = config.get::<String>("css") {
css_str
} else if let Ok(css_file) = config.get::<String>("css_file") {
let css_path = plugin_dir_for_register.join(&css_file);
std::fs::read_to_string(&css_path).map_err(|e| {
mlua::Error::external(format!(
"Failed to read CSS file '{}': {}",
css_path.display(),
e
))
})?
} else {
return Err(mlua::Error::external(
"theme.register: either 'css' or 'css_file' is required",
));
};
// Store theme in registry
let themes: Table = lua.named_registry_value("themes")?;
let theme_entry = lua.create_table()?;
theme_entry.set("name", name.clone())?;
theme_entry.set("display_name", display_name.clone())?;
theme_entry.set("css", css)?;
theme_entry.set("plugin_id", plugin_id_for_register.clone())?;
themes.set(name.clone(), theme_entry)?;
log::info!(
"[plugin:{}] Registered theme '{}'",
plugin_id_for_register,
name
);
Ok(name)
})?,
)?;
// owlry.theme.unregister(name) -> boolean
theme_table.set(
"unregister",
lua.create_function(|lua, name: String| {
let themes: Table = lua.named_registry_value("themes")?;
if themes.contains_key(name.clone())? {
themes.set(name, Value::Nil)?;
Ok(true)
} else {
Ok(false)
}
})?,
)?;
// owlry.theme.list() -> table of theme names
theme_table.set(
"list",
lua.create_function(|lua, ()| {
let themes: Table = match lua.named_registry_value("themes") {
Ok(t) => t,
Err(_) => return lua.create_table(),
};
let result = lua.create_table()?;
let mut i = 1;
for pair in themes.pairs::<String, Table>() {
let (name, _) = pair?;
result.set(i, name)?;
i += 1;
}
Ok(result)
})?,
)?;
owlry.set("theme", theme_table)?;
Ok(())
}
/// Get all registered themes from a Lua runtime
#[allow(dead_code)] // Will be used by theme system
pub fn get_themes(lua: &Lua) -> LuaResult<Vec<ThemeRegistration>> {
let themes: Table = match lua.named_registry_value("themes") {
Ok(t) => t,
Err(_) => return Ok(Vec::new()),
};
let mut result = Vec::new();
for pair in themes.pairs::<String, Table>() {
let (_, entry) = pair?;
let name: String = entry.get("name")?;
let display_name: String = entry.get("display_name")?;
let css: String = entry.get("css")?;
let plugin_id: String = entry.get("plugin_id")?;
result.push(ThemeRegistration {
name,
display_name,
css,
plugin_id,
});
}
Ok(result)
}
/// Get a specific theme's CSS by name
#[allow(dead_code)] // Will be used by theme loading
pub fn get_theme_css(lua: &Lua, name: &str) -> LuaResult<Option<String>> {
let themes: Table = match lua.named_registry_value("themes") {
Ok(t) => t,
Err(_) => return Ok(None),
};
if let Ok(entry) = themes.get::<Table>(name) {
let css: String = entry.get("css")?;
Ok(Some(css))
} else {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_lua(plugin_id: &str, plugin_dir: &Path) -> Lua {
let lua = Lua::new();
let owlry = lua.create_table().unwrap();
register_theme_api(&lua, &owlry, plugin_id, plugin_dir).unwrap();
lua.globals().set("owlry", owlry).unwrap();
lua
}
#[test]
fn test_theme_registration_inline() {
let temp = TempDir::new().unwrap();
let lua = setup_lua("test-plugin", temp.path());
let chunk = lua.load(
r#"
return owlry.theme.register({
name = "my-theme",
display_name = "My Theme",
css = ".owlry-window { background: #333; }"
})
"#,
);
let name: String = chunk.call(()).unwrap();
assert_eq!(name, "my-theme");
let themes = get_themes(&lua).unwrap();
assert_eq!(themes.len(), 1);
assert_eq!(themes[0].display_name, "My Theme");
assert!(themes[0].css.contains("background: #333"));
}
#[test]
fn test_theme_registration_file() {
let temp = TempDir::new().unwrap();
let css_content = ".owlry-window { background: #444; }";
std::fs::write(temp.path().join("theme.css"), css_content).unwrap();
let lua = setup_lua("test-plugin", temp.path());
let chunk = lua.load(
r#"
return owlry.theme.register({
name = "file-theme",
css_file = "theme.css"
})
"#,
);
let name: String = chunk.call(()).unwrap();
assert_eq!(name, "file-theme");
let css = get_theme_css(&lua, "file-theme").unwrap();
assert!(css.is_some());
assert!(css.unwrap().contains("background: #444"));
}
#[test]
fn test_theme_list() {
let temp = TempDir::new().unwrap();
let lua = setup_lua("test-plugin", temp.path());
let chunk = lua.load(
r#"
owlry.theme.register({ name = "theme1", css = "a{}" })
owlry.theme.register({ name = "theme2", css = "b{}" })
return owlry.theme.list()
"#,
);
let list: Table = chunk.call(()).unwrap();
let mut names: Vec<String> = Vec::new();
for pair in list.pairs::<i64, String>() {
let (_, name) = pair.unwrap();
names.push(name);
}
assert_eq!(names.len(), 2);
assert!(names.contains(&"theme1".to_string()));
assert!(names.contains(&"theme2".to_string()));
}
#[test]
fn test_theme_unregister() {
let temp = TempDir::new().unwrap();
let lua = setup_lua("test-plugin", temp.path());
let chunk = lua.load(
r#"
owlry.theme.register({ name = "temp-theme", css = "c{}" })
return owlry.theme.unregister("temp-theme")
"#,
);
let unregistered: bool = chunk.call(()).unwrap();
assert!(unregistered);
let themes = get_themes(&lua).unwrap();
assert_eq!(themes.len(), 0);
}
}
-569
View File
@@ -1,569 +0,0 @@
//! Utility APIs: log, path, fs, json
use mlua::{Lua, Result as LuaResult, Table, Value};
use std::path::{Path, PathBuf};
/// Register owlry.log.* API
///
/// Provides: debug, info, warn, error
pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let log_table = lua.create_table()?;
log_table.set(
"debug",
lua.create_function(|_, msg: String| {
log::debug!("[plugin] {}", msg);
Ok(())
})?,
)?;
log_table.set(
"info",
lua.create_function(|_, msg: String| {
log::info!("[plugin] {}", msg);
Ok(())
})?,
)?;
log_table.set(
"warn",
lua.create_function(|_, msg: String| {
log::warn!("[plugin] {}", msg);
Ok(())
})?,
)?;
log_table.set(
"error",
lua.create_function(|_, msg: String| {
log::error!("[plugin] {}", msg);
Ok(())
})?,
)?;
owlry.set("log", log_table)?;
Ok(())
}
/// Register owlry.path.* API
///
/// Provides XDG directory helpers: config, data, cache, home, plugin_dir
pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> {
let path_table = lua.create_table()?;
let plugin_dir_str = plugin_dir.to_string_lossy().to_string();
// owlry.path.config() -> ~/.config/owlry
path_table.set(
"config",
lua.create_function(|_, ()| {
let path = dirs::config_dir()
.map(|p| p.join("owlry"))
.unwrap_or_default();
Ok(path.to_string_lossy().to_string())
})?,
)?;
// owlry.path.data() -> ~/.local/share/owlry
path_table.set(
"data",
lua.create_function(|_, ()| {
let path = dirs::data_dir()
.map(|p| p.join("owlry"))
.unwrap_or_default();
Ok(path.to_string_lossy().to_string())
})?,
)?;
// owlry.path.cache() -> ~/.cache/owlry
path_table.set(
"cache",
lua.create_function(|_, ()| {
let path = dirs::cache_dir()
.map(|p| p.join("owlry"))
.unwrap_or_default();
Ok(path.to_string_lossy().to_string())
})?,
)?;
// owlry.path.home() -> ~
path_table.set(
"home",
lua.create_function(|_, ()| {
let path = dirs::home_dir().unwrap_or_default();
Ok(path.to_string_lossy().to_string())
})?,
)?;
// owlry.path.join(base, ...) -> joined path
path_table.set(
"join",
lua.create_function(|_, parts: mlua::Variadic<String>| {
let mut path = PathBuf::new();
for part in parts {
path.push(part);
}
Ok(path.to_string_lossy().to_string())
})?,
)?;
// owlry.path.exists(path) -> bool
path_table.set(
"exists",
lua.create_function(|_, path: String| Ok(Path::new(&path).exists()))?,
)?;
// owlry.path.is_file(path) -> bool
path_table.set(
"is_file",
lua.create_function(|_, path: String| Ok(Path::new(&path).is_file()))?,
)?;
// owlry.path.is_dir(path) -> bool
path_table.set(
"is_dir",
lua.create_function(|_, path: String| Ok(Path::new(&path).is_dir()))?,
)?;
// owlry.path.expand(path) -> expanded path (handles ~)
path_table.set(
"expand",
lua.create_function(|_, path: String| {
let expanded = if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
home.join(rest).to_string_lossy().to_string()
} else {
path
}
} else if path == "~" {
dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or(path)
} else {
path
};
Ok(expanded)
})?,
)?;
// owlry.path.plugin_dir() -> this plugin's directory
path_table.set(
"plugin_dir",
lua.create_function(move |_, ()| Ok(plugin_dir_str.clone()))?,
)?;
owlry.set("path", path_table)?;
Ok(())
}
/// Register owlry.fs.* API
///
/// Provides filesystem operations within the plugin's directory
pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> {
let fs_table = lua.create_table()?;
let plugin_dir_str = plugin_dir.to_string_lossy().to_string();
// Store plugin directory in registry for access in closures
lua.set_named_registry_value("plugin_dir", plugin_dir_str.clone())?;
// owlry.fs.read(path) -> string or nil, error
fs_table.set(
"read",
lua.create_function(|lua, path: String| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
match std::fs::read_to_string(&full_path) {
Ok(content) => Ok((Some(content), Value::Nil)),
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
}
})?,
)?;
// owlry.fs.write(path, content) -> bool, error
fs_table.set(
"write",
lua.create_function(|lua, (path, content): (String, String)| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
// Ensure parent directory exists
if let Some(parent) = full_path.parent()
&& !parent.exists()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return Ok((false, Value::String(lua.create_string(e.to_string())?)));
}
match std::fs::write(&full_path, content) {
Ok(()) => Ok((true, Value::Nil)),
Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))),
}
})?,
)?;
// owlry.fs.list(path) -> array of filenames or nil, error
fs_table.set(
"list",
lua.create_function(|lua, path: Option<String>| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let dir_path = path
.map(|p| resolve_plugin_path(&plugin_dir, &p))
.unwrap_or_else(|| PathBuf::from(&plugin_dir));
match std::fs::read_dir(&dir_path) {
Ok(entries) => {
let names: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
let table = lua.create_sequence_from(names)?;
Ok((Some(table), Value::Nil))
}
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
}
})?,
)?;
// owlry.fs.exists(path) -> bool
fs_table.set(
"exists",
lua.create_function(|lua, path: String| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
Ok(full_path.exists())
})?,
)?;
// owlry.fs.mkdir(path) -> bool, error
fs_table.set(
"mkdir",
lua.create_function(|lua, path: String| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
match std::fs::create_dir_all(&full_path) {
Ok(()) => Ok((true, Value::Nil)),
Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))),
}
})?,
)?;
// owlry.fs.remove(path) -> bool, error
fs_table.set(
"remove",
lua.create_function(|lua, path: String| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
let result = if full_path.is_dir() {
std::fs::remove_dir_all(&full_path)
} else {
std::fs::remove_file(&full_path)
};
match result {
Ok(()) => Ok((true, Value::Nil)),
Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))),
}
})?,
)?;
// owlry.fs.is_file(path) -> bool
fs_table.set(
"is_file",
lua.create_function(|lua, path: String| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
Ok(full_path.is_file())
})?,
)?;
// owlry.fs.is_dir(path) -> bool
fs_table.set(
"is_dir",
lua.create_function(|lua, path: String| {
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
Ok(full_path.is_dir())
})?,
)?;
// owlry.fs.is_executable(path) -> bool
#[cfg(unix)]
fs_table.set(
"is_executable",
lua.create_function(|lua, path: String| {
use std::os::unix::fs::PermissionsExt;
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
let full_path = resolve_plugin_path(&plugin_dir, &path);
let is_exec = full_path
.metadata()
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false);
Ok(is_exec)
})?,
)?;
// owlry.fs.plugin_dir() -> plugin directory path
let dir_clone = plugin_dir_str.clone();
fs_table.set(
"plugin_dir",
lua.create_function(move |_, ()| Ok(dir_clone.clone()))?,
)?;
owlry.set("fs", fs_table)?;
Ok(())
}
/// Resolve a path relative to the plugin directory
///
/// If the path is absolute, returns it as-is (for paths within allowed directories).
/// If relative, joins with plugin directory.
fn resolve_plugin_path(plugin_dir: &str, path: &str) -> PathBuf {
let path = Path::new(path);
if path.is_absolute() {
path.to_path_buf()
} else {
Path::new(plugin_dir).join(path)
}
}
/// Register owlry.json.* API
///
/// Provides JSON encoding/decoding
pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let json_table = lua.create_table()?;
// owlry.json.encode(value) -> string or nil, error
json_table.set(
"encode",
lua.create_function(|lua, value: Value| match lua_to_json(&value) {
Ok(json) => match serde_json::to_string(&json) {
Ok(s) => Ok((Some(s), Value::Nil)),
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
},
Err(e) => Ok((None, Value::String(lua.create_string(&e)?))),
})?,
)?;
// owlry.json.encode_pretty(value) -> string or nil, error
json_table.set(
"encode_pretty",
lua.create_function(|lua, value: Value| match lua_to_json(&value) {
Ok(json) => match serde_json::to_string_pretty(&json) {
Ok(s) => Ok((Some(s), Value::Nil)),
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
},
Err(e) => Ok((None, Value::String(lua.create_string(&e)?))),
})?,
)?;
// owlry.json.decode(string) -> value or nil, error
json_table.set(
"decode",
lua.create_function(|lua, s: String| {
match serde_json::from_str::<serde_json::Value>(&s) {
Ok(json) => match json_to_lua(lua, &json) {
Ok(value) => Ok((Some(value), Value::Nil)),
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
},
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
}
})?,
)?;
owlry.set("json", json_table)?;
Ok(())
}
/// Convert Lua value to JSON
fn lua_to_json(value: &Value) -> Result<serde_json::Value, String> {
match value {
Value::Nil => Ok(serde_json::Value::Null),
Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
Value::Number(n) => serde_json::Number::from_f64(*n)
.map(serde_json::Value::Number)
.ok_or_else(|| "Invalid number".to_string()),
Value::String(s) => Ok(serde_json::Value::String(
s.to_str().map_err(|e| e.to_string())?.to_string(),
)),
Value::Table(t) => {
// Check if it's an array (sequential integer keys starting from 1)
let len = t.raw_len();
let is_array = len > 0
&& (1..=len).all(|i| {
t.raw_get::<Value>(i)
.is_ok_and(|v| !matches!(v, Value::Nil))
});
if is_array {
let arr: Result<Vec<serde_json::Value>, String> = (1..=len)
.map(|i| {
let v: Value = t.raw_get(i).map_err(|e| e.to_string())?;
lua_to_json(&v)
})
.collect();
Ok(serde_json::Value::Array(arr?))
} else {
let mut map = serde_json::Map::new();
for pair in t.clone().pairs::<Value, Value>() {
let (k, v) = pair.map_err(|e| e.to_string())?;
let key = match k {
Value::String(s) => s.to_str().map_err(|e| e.to_string())?.to_string(),
Value::Integer(i) => i.to_string(),
_ => return Err("JSON object keys must be strings".to_string()),
};
map.insert(key, lua_to_json(&v)?);
}
Ok(serde_json::Value::Object(map))
}
}
_ => Err(format!("Cannot convert {:?} to JSON", value)),
}
}
/// Convert JSON to Lua value
fn json_to_lua(lua: &Lua, json: &serde_json::Value) -> LuaResult<Value> {
match json {
serde_json::Value::Null => Ok(Value::Nil),
serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(Value::Number(f))
} else {
Ok(Value::Nil)
}
}
serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)),
serde_json::Value::Array(arr) => {
let table = lua.create_table()?;
for (i, v) in arr.iter().enumerate() {
table.set(i + 1, json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
serde_json::Value::Object(obj) => {
let table = lua.create_table()?;
for (k, v) in obj {
table.set(k.as_str(), json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_lua() -> (Lua, TempDir) {
let lua = Lua::new();
let temp = TempDir::new().unwrap();
let owlry = lua.create_table().unwrap();
register_log_api(&lua, &owlry).unwrap();
register_path_api(&lua, &owlry, temp.path()).unwrap();
register_fs_api(&lua, &owlry, temp.path()).unwrap();
register_json_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
(lua, temp)
}
#[test]
fn test_log_api() {
let (lua, _temp) = create_test_lua();
// Just verify it doesn't panic - using call instead of the e-word
lua.load("owlry.log.info('test message')")
.call::<()>(())
.unwrap();
lua.load("owlry.log.debug('debug')").call::<()>(()).unwrap();
lua.load("owlry.log.warn('warning')")
.call::<()>(())
.unwrap();
lua.load("owlry.log.error('error')").call::<()>(()).unwrap();
}
#[test]
fn test_path_api() {
let (lua, _temp) = create_test_lua();
let home: String = lua.load("return owlry.path.home()").call(()).unwrap();
assert!(!home.is_empty());
let joined: String = lua
.load("return owlry.path.join('a', 'b', 'c')")
.call(())
.unwrap();
assert!(joined.contains("a") && joined.contains("b") && joined.contains("c"));
let expanded: String = lua
.load("return owlry.path.expand('~/test')")
.call(())
.unwrap();
assert!(!expanded.starts_with("~"));
}
#[test]
fn test_fs_api() {
let (lua, temp) = create_test_lua();
// Test write and read
lua.load("owlry.fs.write('test.txt', 'hello world')")
.call::<()>(())
.unwrap();
assert!(temp.path().join("test.txt").exists());
let content: String = lua
.load("return owlry.fs.read('test.txt')")
.call(())
.unwrap();
assert_eq!(content, "hello world");
// Test exists
let exists: bool = lua
.load("return owlry.fs.exists('test.txt')")
.call(())
.unwrap();
assert!(exists);
// Test list
let script = r#"
local files = owlry.fs.list()
return #files
"#;
let count: i32 = lua.load(script).call(()).unwrap();
assert!(count >= 1);
}
#[test]
fn test_json_api() {
let (lua, _temp) = create_test_lua();
// Test encode
let encoded: String = lua
.load(r#"return owlry.json.encode({name = "test", value = 42})"#)
.call(())
.unwrap();
assert!(encoded.contains("test") && encoded.contains("42"));
// Test decode
let script = r#"
local data = owlry.json.decode('{"name":"hello","num":123}')
return data.name, data.num
"#;
let (name, num): (String, i32) = lua.load(script).call(()).unwrap();
assert_eq!(name, "hello");
assert_eq!(num, 123);
// Test array encoding
let encoded: String = lua
.load(r#"return owlry.json.encode({1, 2, 3})"#)
.call(())
.unwrap();
assert_eq!(encoded, "[1,2,3]");
}
}
-51
View File
@@ -1,51 +0,0 @@
//! Plugin system error types
use thiserror::Error;
/// Errors that can occur in the plugin system
#[derive(Error, Debug)]
#[allow(dead_code)] // Some variants are for future use
pub enum PluginError {
#[error("Plugin '{0}' not found")]
NotFound(String),
#[error("Invalid plugin manifest in '{plugin}': {message}")]
InvalidManifest { plugin: String, message: String },
#[error("Plugin '{plugin}' requires owlry {required}, but current version is {current}")]
VersionMismatch {
plugin: String,
required: String,
current: String,
},
#[error("Lua error in plugin '{plugin}': {message}")]
LuaError { plugin: String, message: String },
#[error("Plugin '{plugin}' timed out after {timeout_ms}ms")]
Timeout { plugin: String, timeout_ms: u64 },
#[error("Plugin '{plugin}' attempted forbidden operation: {operation}")]
SandboxViolation { plugin: String, operation: String },
#[error("Plugin '{0}' is already loaded")]
AlreadyLoaded(String),
#[error("Plugin '{0}' is disabled")]
Disabled(String),
#[error("Failed to load native plugin: {0}")]
LoadError(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parsing error: {0}")]
TomlParse(#[from] toml::de::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
/// Result type for plugin operations
pub type PluginResult<T> = Result<T, PluginError>;
-212
View File
@@ -1,212 +0,0 @@
//! Lua plugin loading and initialization
use std::path::PathBuf;
use mlua::Lua;
use super::api;
use super::error::{PluginError, PluginResult};
use super::manifest::PluginManifest;
use super::runtime::{SandboxConfig, create_lua_runtime, load_file};
/// A loaded plugin instance
#[derive(Debug)]
pub struct LoadedPlugin {
/// Plugin manifest
pub manifest: PluginManifest,
/// Path to plugin directory
pub path: PathBuf,
/// Whether plugin is enabled
pub enabled: bool,
/// Lua runtime (None if not yet initialized)
lua: Option<Lua>,
}
impl LoadedPlugin {
/// Create a new loaded plugin (not yet initialized)
pub fn new(manifest: PluginManifest, path: PathBuf) -> Self {
Self {
manifest,
path,
enabled: true,
lua: None,
}
}
/// Get the plugin ID
pub fn id(&self) -> &str {
&self.manifest.plugin.id
}
/// Get the plugin name
#[allow(dead_code)]
pub fn name(&self) -> &str {
&self.manifest.plugin.name
}
/// Initialize the Lua runtime and load the entry point
pub fn initialize(&mut self) -> PluginResult<()> {
if self.lua.is_some() {
return Ok(()); // Already initialized
}
let sandbox = SandboxConfig::from_permissions(&self.manifest.permissions);
let lua = create_lua_runtime(&sandbox).map_err(|e| PluginError::LuaError {
plugin: self.id().to_string(),
message: e.to_string(),
})?;
// Register owlry APIs before loading entry point
api::register_apis(&lua, &self.path, self.id()).map_err(|e| PluginError::LuaError {
plugin: self.id().to_string(),
message: format!("Failed to register APIs: {}", e),
})?;
// Load the entry point file
let entry_path = self.path.join(&self.manifest.plugin.entry);
if !entry_path.exists() {
return Err(PluginError::InvalidManifest {
plugin: self.id().to_string(),
message: format!("Entry point '{}' not found", self.manifest.plugin.entry),
});
}
load_file(&lua, &entry_path).map_err(|e| PluginError::LuaError {
plugin: self.id().to_string(),
message: e.to_string(),
})?;
self.lua = Some(lua);
Ok(())
}
/// Get provider registrations from this plugin
pub fn get_provider_registrations(&self) -> PluginResult<Vec<super::ProviderRegistration>> {
let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError {
plugin: self.id().to_string(),
message: "Plugin not initialized".to_string(),
})?;
api::get_provider_registrations(lua).map_err(|e| PluginError::LuaError {
plugin: self.id().to_string(),
message: e.to_string(),
})
}
/// Call a provider's refresh function
pub fn call_provider_refresh(
&self,
provider_name: &str,
) -> PluginResult<Vec<super::PluginItem>> {
let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError {
plugin: self.id().to_string(),
message: "Plugin not initialized".to_string(),
})?;
api::provider::call_refresh(lua, provider_name).map_err(|e| PluginError::LuaError {
plugin: self.id().to_string(),
message: e.to_string(),
})
}
/// Call a provider's query function
#[allow(dead_code)] // Will be used for dynamic query providers
pub fn call_provider_query(
&self,
provider_name: &str,
query: &str,
) -> PluginResult<Vec<super::PluginItem>> {
let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError {
plugin: self.id().to_string(),
message: "Plugin not initialized".to_string(),
})?;
api::provider::call_query(lua, provider_name, query).map_err(|e| PluginError::LuaError {
plugin: self.id().to_string(),
message: e.to_string(),
})
}
/// Get a reference to the Lua runtime (if initialized)
#[allow(dead_code)]
pub fn lua(&self) -> Option<&Lua> {
self.lua.as_ref()
}
/// Get a mutable reference to the Lua runtime (if initialized)
#[allow(dead_code)]
pub fn lua_mut(&mut self) -> Option<&mut Lua> {
self.lua.as_mut()
}
}
// Note: discover_plugins and check_compatibility are in manifest.rs
// to avoid Lua dependency for plugin discovery.
#[cfg(test)]
mod tests {
use super::super::manifest::{check_compatibility, discover_plugins};
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn create_test_plugin(dir: &Path, id: &str, name: &str) {
let plugin_dir = dir.join(id);
fs::create_dir_all(&plugin_dir).unwrap();
let manifest = format!(
r#"
[plugin]
id = "{}"
name = "{}"
version = "1.0.0"
"#,
id, name
);
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
fs::write(plugin_dir.join("init.lua"), "-- empty plugin").unwrap();
}
#[test]
fn test_discover_plugins() {
let temp = TempDir::new().unwrap();
let plugins_dir = temp.path();
create_test_plugin(plugins_dir, "test-plugin", "Test Plugin");
create_test_plugin(plugins_dir, "another-plugin", "Another Plugin");
let plugins = discover_plugins(plugins_dir).unwrap();
assert_eq!(plugins.len(), 2);
assert!(plugins.contains_key("test-plugin"));
assert!(plugins.contains_key("another-plugin"));
}
#[test]
fn test_discover_plugins_empty_dir() {
let temp = TempDir::new().unwrap();
let plugins = discover_plugins(temp.path()).unwrap();
assert!(plugins.is_empty());
}
#[test]
fn test_discover_plugins_nonexistent_dir() {
let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap();
assert!(plugins.is_empty());
}
#[test]
fn test_check_compatibility() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
owlry_version = ">=0.3.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(check_compatibility(&manifest, "0.3.5").is_ok());
assert!(check_compatibility(&manifest, "0.2.0").is_err());
}
}
-429
View File
@@ -1,429 +0,0 @@
//! Plugin manifest (plugin.toml) parsing
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::error::{PluginError, PluginResult};
/// Plugin manifest loaded from plugin.toml
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
/// Provider declarations from [[providers]] sections (new-style)
#[serde(default)]
pub providers: Vec<ProviderSpec>,
/// Legacy provides block (old-style)
#[serde(default)]
pub provides: PluginProvides,
#[serde(default)]
pub permissions: PluginPermissions,
#[serde(default)]
pub settings: HashMap<String, toml::Value>,
}
/// Core plugin information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
/// Unique plugin identifier (lowercase, alphanumeric, hyphens)
pub id: String,
/// Human-readable name
pub name: String,
/// Semantic version
pub version: String,
/// Short description
#[serde(default)]
pub description: String,
/// Plugin author
#[serde(default)]
pub author: String,
/// License identifier
#[serde(default)]
pub license: String,
/// Repository URL
#[serde(default)]
pub repository: Option<String>,
/// Required owlry version (semver constraint)
#[serde(default = "default_owlry_version")]
pub owlry_version: String,
/// Entry point file (relative to plugin directory)
#[serde(default = "default_entry", alias = "entry_point")]
pub entry: String,
}
fn default_owlry_version() -> String {
">=0.1.0".to_string()
}
fn default_entry() -> String {
"main.lua".to_string()
}
/// A provider declared in a [[providers]] section
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderSpec {
pub id: String,
pub name: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub icon: Option<String>,
/// "static" (default) or "dynamic"
#[serde(default = "default_provider_type", rename = "type")]
pub provider_type: String,
#[serde(default)]
pub type_id: Option<String>,
/// Short label for UI tab button (e.g., "Shutdown"). Defaults to provider name.
#[serde(default)]
pub tab_label: Option<String>,
/// Noun for search placeholder (e.g., "shutdown actions"). Defaults to provider name.
#[serde(default)]
pub search_noun: Option<String>,
}
fn default_provider_type() -> String {
"static".to_string()
}
/// What the plugin provides
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginProvides {
/// Provider names this plugin registers
#[serde(default)]
pub providers: Vec<String>,
/// Whether this plugin registers actions
#[serde(default)]
pub actions: bool,
/// Theme names this plugin contributes
#[serde(default)]
pub themes: Vec<String>,
/// Whether this plugin registers hooks
#[serde(default)]
pub hooks: bool,
/// CLI commands this plugin provides
#[serde(default)]
pub commands: Vec<PluginCommand>,
}
/// A CLI command provided by a plugin
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginCommand {
/// Command name (e.g., "add", "list", "sync")
pub name: String,
/// Short description shown in help
#[serde(default)]
pub description: String,
/// Usage pattern (e.g., "<url> [name]")
#[serde(default)]
pub usage: String,
}
/// Plugin permissions/capabilities
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginPermissions {
/// Allow network/HTTP requests
#[serde(default)]
pub network: bool,
/// Filesystem paths the plugin can access (beyond its own directory)
#[serde(default)]
pub filesystem: Vec<String>,
/// Commands the plugin is allowed to run
#[serde(default)]
pub run_commands: Vec<String>,
/// Environment variables the plugin reads
#[serde(default)]
pub environment: Vec<String>,
}
// ============================================================================
// Plugin Discovery (no Lua dependency)
// ============================================================================
/// Discover all plugins in a directory
///
/// Returns a map of plugin ID -> (manifest, path)
pub fn discover_plugins(
plugins_dir: &Path,
) -> PluginResult<HashMap<String, (PluginManifest, PathBuf)>> {
let mut plugins = HashMap::new();
if !plugins_dir.exists() {
log::debug!(
"Plugins directory does not exist: {}",
plugins_dir.display()
);
return Ok(plugins);
}
let entries = std::fs::read_dir(plugins_dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("plugin.toml");
if !manifest_path.exists() {
log::debug!("Skipping {}: no plugin.toml", path.display());
continue;
}
match PluginManifest::load(&manifest_path) {
Ok(manifest) => {
let id = manifest.plugin.id.clone();
if plugins.contains_key(&id) {
log::warn!("Duplicate plugin ID '{}', skipping {}", id, path.display());
continue;
}
log::info!(
"Discovered plugin: {} v{}",
manifest.plugin.name,
manifest.plugin.version
);
plugins.insert(id, (manifest, path));
}
Err(e) => {
log::warn!("Failed to load plugin at {}: {}", path.display(), e);
}
}
}
Ok(plugins)
}
/// Check if a plugin is compatible with the given owlry version
#[allow(dead_code)]
pub fn check_compatibility(manifest: &PluginManifest, owlry_version: &str) -> PluginResult<()> {
if !manifest.is_compatible_with(owlry_version) {
return Err(PluginError::VersionMismatch {
plugin: manifest.plugin.id.clone(),
required: manifest.plugin.owlry_version.clone(),
current: owlry_version.to_string(),
});
}
Ok(())
}
// ============================================================================
// PluginManifest Implementation
// ============================================================================
impl PluginManifest {
/// Load a plugin manifest from a plugin.toml file
pub fn load(path: &Path) -> PluginResult<Self> {
let content = std::fs::read_to_string(path)?;
let manifest: PluginManifest = toml::from_str(&content)?;
manifest.validate()?;
Ok(manifest)
}
/// Load from a plugin directory (looks for plugin.toml inside)
#[allow(dead_code)]
pub fn load_from_dir(plugin_dir: &Path) -> PluginResult<Self> {
let manifest_path = plugin_dir.join("plugin.toml");
if !manifest_path.exists() {
return Err(PluginError::InvalidManifest {
plugin: plugin_dir.display().to_string(),
message: "plugin.toml not found".to_string(),
});
}
Self::load(&manifest_path)
}
/// Validate the manifest
fn validate(&self) -> PluginResult<()> {
// Validate plugin ID format
if self.plugin.id.is_empty() {
return Err(PluginError::InvalidManifest {
plugin: self.plugin.id.clone(),
message: "Plugin ID cannot be empty".to_string(),
});
}
if !self
.plugin
.id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(PluginError::InvalidManifest {
plugin: self.plugin.id.clone(),
message: "Plugin ID must be lowercase alphanumeric with hyphens".to_string(),
});
}
// Validate version format
if semver::Version::parse(&self.plugin.version).is_err() {
return Err(PluginError::InvalidManifest {
plugin: self.plugin.id.clone(),
message: format!("Invalid version format: {}", self.plugin.version),
});
}
// Validate owlry_version constraint
if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() {
return Err(PluginError::InvalidManifest {
plugin: self.plugin.id.clone(),
message: format!(
"Invalid owlry_version constraint: {}",
self.plugin.owlry_version
),
});
}
Ok(())
}
/// Check if this plugin is compatible with the given owlry version
#[allow(dead_code)]
pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
Ok(r) => r,
Err(_) => return false,
};
let version = match semver::Version::parse(owlry_version) {
Ok(v) => v,
Err(_) => return false,
};
req.matches(&version)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_manifest() {
let toml_str = r#"
[plugin]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.name, "Test Plugin");
assert_eq!(manifest.plugin.version, "1.0.0");
assert_eq!(manifest.plugin.entry, "main.lua");
}
#[test]
fn test_parse_full_manifest() {
let toml_str = r#"
[plugin]
id = "my-provider"
name = "My Provider"
version = "1.2.3"
description = "A test provider"
author = "Test Author"
license = "MIT"
owlry_version = ">=0.4.0"
entry = "main.lua"
[provides]
providers = ["my-provider"]
actions = true
themes = ["dark"]
hooks = true
[permissions]
network = true
filesystem = ["~/.config/myapp"]
run_commands = ["myapp"]
environment = ["MY_API_KEY"]
[settings]
max_results = 20
api_url = "https://api.example.com"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.id, "my-provider");
assert!(manifest.provides.actions);
assert!(manifest.permissions.network);
assert_eq!(manifest.permissions.run_commands, vec!["myapp"]);
}
#[test]
fn test_parse_new_format_with_providers_array() {
let toml_str = r#"
[plugin]
id = "my-plugin"
name = "My Plugin"
version = "0.1.0"
description = "Test"
entry_point = "main.rn"
[[providers]]
id = "my-plugin"
name = "My Plugin"
type = "static"
type_id = "myplugin"
icon = "system-run"
prefix = ":mp"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.entry, "main.rn");
assert_eq!(manifest.providers.len(), 1);
let p = &manifest.providers[0];
assert_eq!(p.id, "my-plugin");
assert_eq!(p.name, "My Plugin");
assert_eq!(p.provider_type, "static");
assert_eq!(p.type_id.as_deref(), Some("myplugin"));
assert_eq!(p.icon.as_deref(), Some("system-run"));
assert_eq!(p.prefix.as_deref(), Some(":mp"));
}
#[test]
fn test_parse_new_format_entry_point_alias() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
entry_point = "main.lua"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.entry, "main.lua");
}
#[test]
fn test_provider_spec_defaults() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
[[providers]]
id = "test"
name = "Test"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.providers.len(), 1);
let p = &manifest.providers[0];
assert_eq!(p.provider_type, "static"); // default
assert!(p.prefix.is_none());
assert!(p.icon.is_none());
assert!(p.type_id.is_none());
}
#[test]
fn test_version_compatibility() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
owlry_version = ">=0.3.0, <1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.is_compatible_with("0.3.5"));
assert!(manifest.is_compatible_with("0.4.0"));
assert!(!manifest.is_compatible_with("0.2.0"));
assert!(!manifest.is_compatible_with("1.0.0"));
}
}
-368
View File
@@ -1,368 +0,0 @@
//! Owlry Plugin System
//!
//! This module loads and manages *plugins* — external code that extends owlry
//! with additional *plugin providers* beyond the built-in ones.
//!
//! # Terminology
//!
//! | Term | Meaning |
//! |------|---------|
//! | **Provider** | Abstract source of [`LaunchItem`]s (the core interface) |
//! | **Built-in provider** | Provider compiled into owlry-core (Application, Command) |
//! | **Plugin** | External code (native `.so` or script) loaded at runtime |
//! | **Plugin provider** | A provider registered by a plugin via its `type_id` |
//! | **Native plugin** | Pre-compiled Rust `.so` from `/usr/lib/owlry/plugins/` |
//! | **Script plugin** | Lua or Rune plugin from `~/.config/owlry/plugins/` |
//!
//! # Plugin Types
//!
//! - **Native plugins** (`.so`): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/`
//! - **Script plugins**: Lua or Rune scripts from `~/.config/owlry/plugins/`
//! (requires the corresponding runtime: `owlry-lua` or `owlry-rune`)
//!
//! # Script Plugin Structure
//!
//! Each script plugin lives in its own directory under `~/.config/owlry/plugins/`:
//!
//! ```text
//! ~/.config/owlry/plugins/
//! my-plugin/
//! plugin.toml # Plugin manifest
//! init.lua # Entry point (Lua) or init.rn (Rune)
//! lib/ # Optional modules
//! ```
//!
//! [`LaunchItem`]: crate::providers::LaunchItem
// Always available
pub mod error;
pub mod manifest;
pub mod native_loader;
pub mod registry;
pub mod runtime_loader;
pub mod watcher;
// Lua-specific modules (require mlua)
#[cfg(feature = "lua")]
pub mod api;
#[cfg(feature = "lua")]
pub mod loader;
#[cfg(feature = "lua")]
pub mod runtime;
// Re-export commonly used types
#[cfg(feature = "lua")]
pub use api::provider::{PluginItem, ProviderRegistration};
#[cfg(feature = "lua")]
#[allow(unused_imports)]
pub use api::{ActionRegistration, HookEvent, ThemeRegistration};
#[allow(unused_imports)]
pub use error::{PluginError, PluginResult};
#[cfg(feature = "lua")]
pub use loader::LoadedPlugin;
// Used by plugins/commands.rs for plugin CLI commands
#[allow(unused_imports)]
pub use manifest::{PluginManifest, check_compatibility, discover_plugins};
// ============================================================================
// Lua Plugin Manager (only available with lua feature)
// ============================================================================
#[cfg(feature = "lua")]
mod lua_manager {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use manifest::{check_compatibility, discover_plugins};
/// Plugin manager coordinates loading, initialization, and lifecycle of Lua plugins
pub struct PluginManager {
/// Directory where plugins are stored
plugins_dir: PathBuf,
/// Current owlry version for compatibility checks
owlry_version: String,
/// Loaded plugins by ID. Arc<Mutex<>> allows sharing with LuaProviders across threads.
plugins: HashMap<String, Arc<Mutex<LoadedPlugin>>>,
/// Plugin IDs that are explicitly disabled
disabled: Vec<String>,
}
impl PluginManager {
/// Create a new plugin manager
pub fn new(plugins_dir: PathBuf, owlry_version: &str) -> Self {
Self {
plugins_dir,
owlry_version: owlry_version.to_string(),
plugins: HashMap::new(),
disabled: Vec::new(),
}
}
/// Set the list of disabled plugin IDs
pub fn set_disabled(&mut self, disabled: Vec<String>) {
self.disabled = disabled;
}
/// Discover and load all plugins from the plugins directory
pub fn discover(&mut self) -> PluginResult<usize> {
log::info!("Discovering plugins in {}", self.plugins_dir.display());
let discovered = discover_plugins(&self.plugins_dir)?;
let mut loaded_count = 0;
for (id, (manifest, path)) in discovered {
// Skip disabled plugins
if self.disabled.contains(&id) {
log::info!("Plugin '{}' is disabled, skipping", id);
continue;
}
// Check version compatibility
if let Err(e) = check_compatibility(&manifest, &self.owlry_version) {
log::warn!("Plugin '{}' is not compatible: {}", id, e);
continue;
}
let plugin = LoadedPlugin::new(manifest, path);
self.plugins.insert(id, Arc::new(Mutex::new(plugin)));
loaded_count += 1;
}
log::info!("Discovered {} compatible plugins", loaded_count);
Ok(loaded_count)
}
/// Initialize all discovered plugins (load their Lua code)
pub fn initialize_all(&mut self) -> Vec<PluginError> {
let mut errors = Vec::new();
for (id, plugin_rc) in &self.plugins {
let mut plugin = plugin_rc.lock().unwrap();
if !plugin.enabled {
continue;
}
log::debug!("Initializing plugin: {}", id);
if let Err(e) = plugin.initialize() {
log::error!("Failed to initialize plugin '{}': {}", id, e);
errors.push(e);
plugin.enabled = false;
}
}
errors
}
/// Get a loaded plugin by ID
#[allow(dead_code)]
pub fn get(&self, id: &str) -> Option<Arc<Mutex<LoadedPlugin>>> {
self.plugins.get(id).cloned()
}
/// Get all loaded plugins
#[allow(dead_code)]
pub fn plugins(&self) -> impl Iterator<Item = Arc<Mutex<LoadedPlugin>>> + '_ {
self.plugins.values().cloned()
}
/// Get all enabled plugins
pub fn enabled_plugins(&self) -> impl Iterator<Item = Arc<Mutex<LoadedPlugin>>> + '_ {
self.plugins
.values()
.filter(|p| p.lock().unwrap().enabled)
.cloned()
}
/// Get the number of loaded plugins
#[allow(dead_code)]
pub fn plugin_count(&self) -> usize {
self.plugins.len()
}
/// Get the number of enabled plugins
#[allow(dead_code)]
pub fn enabled_count(&self) -> usize {
self.plugins.values().filter(|p| p.lock().unwrap().enabled).count()
}
/// Enable a plugin by ID
#[allow(dead_code)]
pub fn enable(&mut self, id: &str) -> PluginResult<()> {
let plugin_rc = self
.plugins
.get(id)
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
let mut plugin = plugin_rc.lock().unwrap();
if !plugin.enabled {
plugin.enabled = true;
// Initialize if not already done
plugin.initialize()?;
}
Ok(())
}
/// Disable a plugin by ID
#[allow(dead_code)]
pub fn disable(&mut self, id: &str) -> PluginResult<()> {
let plugin_rc = self
.plugins
.get(id)
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
plugin_rc.lock().unwrap().enabled = false;
Ok(())
}
/// Get plugin IDs that provide a specific feature
#[allow(dead_code)]
pub fn providers_for(&self, provider_name: &str) -> Vec<String> {
self.enabled_plugins()
.filter(|p| {
p.lock()
.unwrap()
.manifest
.provides
.providers
.contains(&provider_name.to_string())
})
.map(|p| p.lock().unwrap().id().to_string())
.collect()
}
/// Check if any plugin provides actions
#[allow(dead_code)]
pub fn has_action_plugins(&self) -> bool {
self.enabled_plugins()
.any(|p| p.lock().unwrap().manifest.provides.actions)
}
/// Check if any plugin provides hooks
#[allow(dead_code)]
pub fn has_hook_plugins(&self) -> bool {
self.enabled_plugins()
.any(|p| p.lock().unwrap().manifest.provides.hooks)
}
/// Get all theme names provided by plugins
#[allow(dead_code)]
pub fn theme_names(&self) -> Vec<String> {
self.enabled_plugins()
.flat_map(|p| p.lock().unwrap().manifest.provides.themes.clone())
.collect()
}
/// Create providers from all enabled plugins
///
/// This must be called after `initialize_all()`. Returns a vec of Provider trait
/// objects that can be added to the ProviderManager.
pub fn create_providers(&self) -> Vec<Box<dyn crate::providers::Provider>> {
use crate::providers::lua_provider::create_providers_from_plugin;
let mut providers = Vec::new();
for plugin_rc in self.enabled_plugins() {
let plugin_providers = create_providers_from_plugin(plugin_rc);
providers.extend(plugin_providers);
}
providers
}
}
}
#[cfg(feature = "lua")]
pub use lua_manager::PluginManager;
// ============================================================================
// Tests
// ============================================================================
#[cfg(all(test, feature = "lua"))]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_plugin(dir: &std::path::Path, id: &str, version: &str, owlry_req: &str) {
let plugin_dir = dir.join(id);
fs::create_dir_all(&plugin_dir).unwrap();
let manifest = format!(
r#"
[plugin]
id = "{}"
name = "Test {}"
version = "{}"
owlry_version = "{}"
[provides]
providers = ["{}"]
"#,
id, id, version, owlry_req, id
);
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
fs::write(plugin_dir.join("init.lua"), "-- test plugin").unwrap();
}
#[test]
fn test_plugin_manager_discover() {
let temp = TempDir::new().unwrap();
create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0");
create_test_plugin(temp.path(), "plugin-b", "2.0.0", ">=0.3.0");
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
let count = manager.discover().unwrap();
assert_eq!(count, 2);
assert!(manager.get("plugin-a").is_some());
assert!(manager.get("plugin-b").is_some());
}
#[test]
fn test_plugin_manager_disabled() {
let temp = TempDir::new().unwrap();
create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0");
create_test_plugin(temp.path(), "plugin-b", "1.0.0", ">=0.3.0");
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
manager.set_disabled(vec!["plugin-b".to_string()]);
let count = manager.discover().unwrap();
assert_eq!(count, 1);
assert!(manager.get("plugin-a").is_some());
assert!(manager.get("plugin-b").is_none());
}
#[test]
fn test_plugin_manager_version_compat() {
let temp = TempDir::new().unwrap();
create_test_plugin(temp.path(), "old-plugin", "1.0.0", ">=0.5.0"); // Requires future version
create_test_plugin(temp.path(), "new-plugin", "1.0.0", ">=0.3.0");
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
let count = manager.discover().unwrap();
assert_eq!(count, 1);
assert!(manager.get("old-plugin").is_none()); // Incompatible
assert!(manager.get("new-plugin").is_some());
}
#[test]
fn test_providers_for() {
let temp = TempDir::new().unwrap();
create_test_plugin(temp.path(), "my-provider", "1.0.0", ">=0.3.0");
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
manager.discover().unwrap();
let providers = manager.providers_for("my-provider");
assert_eq!(providers.len(), 1);
assert_eq!(providers[0], "my-provider");
}
}
@@ -1,458 +0,0 @@
//! Native Plugin Loader
//!
//! Loads pre-compiled Rust plugins (.so files) from `/usr/lib/owlry/plugins/`.
//! These plugins use the ABI-stable interface defined in `owlry-plugin-api`.
//!
//! Note: This module is infrastructure for the plugin architecture. Full integration
//! with ProviderManager is pending Phase 5 (AUR Packaging) when native plugins
//! will actually be deployed.
#![allow(dead_code)]
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Once, OnceLock, RwLock};
use libloading::Library;
use log::{debug, error, info, warn};
use owlry_plugin_api::{
API_VERSION, HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo,
ProviderKind, ROption, RStr, RString,
};
use crate::config::Config;
use crate::notify;
// ============================================================================
// Plugin config access
// ============================================================================
/// Shared config reference, set by the host before any plugins are loaded.
static PLUGIN_CONFIG: OnceLock<Arc<RwLock<Config>>> = OnceLock::new();
/// Share the config with the native plugin loader so plugins can read their
/// own config sections. Must be called before `NativePluginLoader::discover()`.
pub fn set_shared_config(config: Arc<RwLock<Config>>) {
let _ = PLUGIN_CONFIG.set(config);
}
extern "C" fn host_get_config_string(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<RString> {
let Some(cfg_arc) = PLUGIN_CONFIG.get() else {
return ROption::RNone;
};
let Ok(cfg) = cfg_arc.read() else {
return ROption::RNone;
};
match cfg.get_plugin_string(plugin_id.as_str(), key.as_str()) {
Some(v) => ROption::RSome(RString::from(v)),
None => ROption::RNone,
}
}
extern "C" fn host_get_config_int(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<i64> {
let Some(cfg_arc) = PLUGIN_CONFIG.get() else {
return ROption::RNone;
};
let Ok(cfg) = cfg_arc.read() else {
return ROption::RNone;
};
match cfg.get_plugin_int(plugin_id.as_str(), key.as_str()) {
Some(v) => ROption::RSome(v),
None => ROption::RNone,
}
}
extern "C" fn host_get_config_bool(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<bool> {
let Some(cfg_arc) = PLUGIN_CONFIG.get() else {
return ROption::RNone;
};
let Ok(cfg) = cfg_arc.read() else {
return ROption::RNone;
};
match cfg.get_plugin_bool(plugin_id.as_str(), key.as_str()) {
Some(v) => ROption::RSome(v),
None => ROption::RNone,
}
}
// ============================================================================
// Host API Implementation
// ============================================================================
/// Host notification handler
extern "C" fn host_notify(
summary: RStr<'_>,
body: RStr<'_>,
icon: RStr<'_>,
urgency: NotifyUrgency,
) {
let icon_str = icon.as_str();
let icon_opt = if icon_str.is_empty() {
None
} else {
Some(icon_str)
};
let notify_urgency = match urgency {
NotifyUrgency::Low => notify::NotifyUrgency::Low,
NotifyUrgency::Normal => notify::NotifyUrgency::Normal,
NotifyUrgency::Critical => notify::NotifyUrgency::Critical,
};
notify::notify_with_options(summary.as_str(), body.as_str(), icon_opt, notify_urgency);
}
/// Host log info handler
extern "C" fn host_log_info(message: RStr<'_>) {
info!("[plugin] {}", message.as_str());
}
/// Host log warning handler
extern "C" fn host_log_warn(message: RStr<'_>) {
warn!("[plugin] {}", message.as_str());
}
/// Host log error handler
extern "C" fn host_log_error(message: RStr<'_>) {
error!("[plugin] {}", message.as_str());
}
/// Static host API instance
static HOST_API: HostAPI = HostAPI {
notify: host_notify,
log_info: host_log_info,
log_warn: host_log_warn,
log_error: host_log_error,
get_config_string: host_get_config_string,
get_config_int: host_get_config_int,
get_config_bool: host_get_config_bool,
};
/// Initialize the host API (called once before loading plugins)
static HOST_API_INIT: Once = Once::new();
fn ensure_host_api_initialized() {
HOST_API_INIT.call_once(|| {
// SAFETY: We only call this once, before any plugins are loaded
unsafe {
owlry_plugin_api::init_host_api(&HOST_API);
}
debug!("Host API initialized for plugins");
});
}
use super::error::{PluginError, PluginResult};
/// Default directory for system-installed native plugins
pub const SYSTEM_PLUGINS_DIR: &str = "/usr/lib/owlry/plugins";
/// A loaded native plugin with its library handle and vtable
pub struct NativePlugin {
/// Plugin metadata
pub info: PluginInfo,
/// List of providers this plugin offers
pub providers: Vec<ProviderInfo>,
/// The vtable for calling plugin functions
vtable: &'static PluginVTable,
/// The loaded library (must be kept alive)
_library: Library,
}
impl NativePlugin {
/// Get the plugin ID
pub fn id(&self) -> &str {
self.info.id.as_str()
}
/// Get the plugin name
pub fn name(&self) -> &str {
self.info.name.as_str()
}
/// Initialize a provider by ID
pub fn init_provider(&self, provider_id: &str) -> ProviderHandle {
(self.vtable.provider_init)(provider_id.into())
}
/// Refresh a static provider
pub fn refresh_provider(&self, handle: ProviderHandle) -> Vec<owlry_plugin_api::PluginItem> {
(self.vtable.provider_refresh)(handle).into_iter().collect()
}
/// Query a dynamic provider
pub fn query_provider(
&self,
handle: ProviderHandle,
query: &str,
) -> Vec<owlry_plugin_api::PluginItem> {
(self.vtable.provider_query)(handle, query.into())
.into_iter()
.collect()
}
/// Drop a provider handle
pub fn drop_provider(&self, handle: ProviderHandle) {
(self.vtable.provider_drop)(handle)
}
}
// SAFETY: NativePlugin is safe to send between threads because:
// - `info` and `providers` are plain data (RString, RVec from abi_stable are Send+Sync)
// - `vtable` is a &'static reference to immutable function pointers
// - `_library` (libloading::Library) is Send+Sync
unsafe impl Send for NativePlugin {}
unsafe impl Sync for NativePlugin {}
/// Manages native plugin discovery and loading
pub struct NativePluginLoader {
/// Directory to scan for plugins
plugins_dir: PathBuf,
/// Loaded plugins by ID (Arc for shared ownership with providers)
plugins: HashMap<String, Arc<NativePlugin>>,
/// Plugin IDs that are disabled
disabled: Vec<String>,
}
impl NativePluginLoader {
/// Create a new loader with the default system plugins directory
pub fn new() -> Self {
Self::with_dir(PathBuf::from(SYSTEM_PLUGINS_DIR))
}
/// Create a new loader with a custom plugins directory
pub fn with_dir(plugins_dir: PathBuf) -> Self {
Self {
plugins_dir,
plugins: HashMap::new(),
disabled: Vec::new(),
}
}
/// Set the list of disabled plugin IDs
pub fn set_disabled(&mut self, disabled: Vec<String>) {
self.disabled = disabled;
}
/// Check if the plugins directory exists
pub fn plugins_dir_exists(&self) -> bool {
self.plugins_dir.exists()
}
/// Discover and load all native plugins
pub fn discover(&mut self) -> PluginResult<usize> {
// Initialize host API before loading any plugins
ensure_host_api_initialized();
if !self.plugins_dir.exists() {
debug!(
"Native plugins directory does not exist: {}",
self.plugins_dir.display()
);
return Ok(0);
}
info!(
"Discovering native plugins in {}",
self.plugins_dir.display()
);
let entries = std::fs::read_dir(&self.plugins_dir).map_err(|e| {
PluginError::LoadError(format!(
"Failed to read plugins directory {}: {}",
self.plugins_dir.display(),
e
))
})?;
let mut loaded_count = 0;
for entry in entries.flatten() {
let path = entry.path();
// Only process .so files
if path.extension() != Some(OsStr::new("so")) {
continue;
}
match self.load_plugin(&path) {
Ok(plugin) => {
let id = plugin.id().to_string();
// Check if disabled
if self.disabled.contains(&id) {
info!("Native plugin '{}' is disabled, skipping", id);
continue;
}
info!(
"Loaded native plugin '{}' v{} with {} providers",
plugin.name(),
plugin.info.version.as_str(),
plugin.providers.len()
);
self.plugins.insert(id, Arc::new(plugin));
loaded_count += 1;
}
Err(e) => {
error!("Failed to load plugin {:?}: {}", path, e);
}
}
}
info!("Loaded {} native plugins", loaded_count);
Ok(loaded_count)
}
/// Load a single plugin from a .so file
fn load_plugin(&self, path: &Path) -> PluginResult<NativePlugin> {
debug!("Loading native plugin from {:?}", path);
// Load the library
// SAFETY: We trust plugins in /usr/lib/owlry/plugins/ as they were
// installed by the package manager
let library = unsafe { Library::new(path) }.map_err(|e| {
PluginError::LoadError(format!("Failed to load library {:?}: {}", path, e))
})?;
// Get the vtable function
let vtable: &'static PluginVTable = unsafe {
let func: libloading::Symbol<extern "C" fn() -> &'static PluginVTable> =
library.get(b"owlry_plugin_vtable").map_err(|e| {
PluginError::LoadError(format!(
"Plugin {:?} missing owlry_plugin_vtable symbol: {}",
path, e
))
})?;
func()
};
// Get plugin info
let info = (vtable.info)();
// Check API version compatibility
if info.api_version != API_VERSION {
return Err(PluginError::LoadError(format!(
"Plugin '{}' has API version {} but owlry requires version {}",
info.id.as_str(),
info.api_version,
API_VERSION
)));
}
// Get provider list
let providers: Vec<ProviderInfo> = (vtable.providers)().into_iter().collect();
Ok(NativePlugin {
info,
providers,
vtable,
_library: library,
})
}
/// Get a loaded plugin by ID
pub fn get(&self, id: &str) -> Option<Arc<NativePlugin>> {
self.plugins.get(id).cloned()
}
/// Get all loaded plugins as Arc references
pub fn plugins(&self) -> impl Iterator<Item = Arc<NativePlugin>> + '_ {
self.plugins.values().cloned()
}
/// Get all loaded plugins as a Vec (for passing to create_providers)
pub fn into_plugins(self) -> Vec<Arc<NativePlugin>> {
self.plugins.into_values().collect()
}
/// Get the number of loaded plugins
pub fn plugin_count(&self) -> usize {
self.plugins.len()
}
/// Create providers from all loaded native plugins
///
/// Returns a vec of (plugin_id, provider_info, handle) tuples that can be
/// used to create NativeProvider instances.
pub fn create_provider_handles(&self) -> Vec<(String, ProviderInfo, ProviderHandle)> {
let mut handles = Vec::new();
for plugin in self.plugins.values() {
for provider_info in &plugin.providers {
let handle = plugin.init_provider(provider_info.id.as_str());
handles.push((plugin.id().to_string(), provider_info.clone(), handle));
}
}
handles
}
}
impl Default for NativePluginLoader {
fn default() -> Self {
Self::new()
}
}
/// Active provider instance from a native plugin
pub struct NativeProviderInstance {
/// Plugin ID this provider belongs to
pub plugin_id: String,
/// Provider metadata
pub info: ProviderInfo,
/// Handle to the provider state
pub handle: ProviderHandle,
/// Cached items for static providers
pub cached_items: Vec<owlry_plugin_api::PluginItem>,
}
impl NativeProviderInstance {
/// Create a new provider instance
pub fn new(plugin_id: String, info: ProviderInfo, handle: ProviderHandle) -> Self {
Self {
plugin_id,
info,
handle,
cached_items: Vec::new(),
}
}
/// Check if this is a static provider
pub fn is_static(&self) -> bool {
self.info.provider_type == ProviderKind::Static
}
/// Check if this is a dynamic provider
pub fn is_dynamic(&self) -> bool {
self.info.provider_type == ProviderKind::Dynamic
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_loader_nonexistent_dir() {
let mut loader = NativePluginLoader::with_dir(PathBuf::from("/nonexistent/path"));
let count = loader.discover().unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_loader_empty_dir() {
let temp = tempfile::TempDir::new().unwrap();
let mut loader = NativePluginLoader::with_dir(temp.path().to_path_buf());
let count = loader.discover().unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_disabled_plugins() {
let mut loader = NativePluginLoader::new();
loader.set_disabled(vec!["test-plugin".to_string()]);
assert!(loader.disabled.contains(&"test-plugin".to_string()));
}
}
-292
View File
@@ -1,292 +0,0 @@
//! Plugin registry client for discovering and installing remote plugins
//!
//! The registry is a git repository containing an `index.toml` file with
//! plugin metadata. Plugins are installed by cloning their source repositories.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use crate::paths;
/// Default registry URL (can be overridden in config)
pub const DEFAULT_REGISTRY_URL: &str =
"https://raw.githubusercontent.com/owlry/plugin-registry/main/index.toml";
/// Cache duration for registry index (1 hour)
const CACHE_DURATION: Duration = Duration::from_secs(3600);
/// Registry index containing all available plugins
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryIndex {
/// Registry metadata
#[serde(default)]
pub registry: RegistryMeta,
/// Available plugins
#[serde(default)]
pub plugins: Vec<RegistryPlugin>,
}
/// Registry metadata
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RegistryMeta {
/// Registry name
#[serde(default)]
pub name: String,
/// Registry description
#[serde(default)]
pub description: String,
/// Registry maintainer URL
#[serde(default)]
pub url: String,
}
/// Plugin entry in the registry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryPlugin {
/// Unique plugin identifier
pub id: String,
/// Human-readable name
pub name: String,
/// Latest version
pub version: String,
/// Short description
#[serde(default)]
pub description: String,
/// Plugin author
#[serde(default)]
pub author: String,
/// Git repository URL for installation
pub repository: String,
/// Search tags
#[serde(default)]
pub tags: Vec<String>,
/// Minimum owlry version required
#[serde(default)]
pub owlry_version: String,
/// License identifier
#[serde(default)]
pub license: String,
}
/// Registry client for fetching and searching plugins
pub struct RegistryClient {
/// Registry URL (index.toml location)
registry_url: String,
/// Local cache directory
cache_dir: PathBuf,
}
impl RegistryClient {
/// Create a new registry client with the given URL
pub fn new(registry_url: &str) -> Self {
let cache_dir = paths::owlry_cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp/owlry"))
.join("registry");
Self {
registry_url: registry_url.to_string(),
cache_dir,
}
}
/// Create a client with the default registry URL
pub fn default_registry() -> Self {
Self::new(DEFAULT_REGISTRY_URL)
}
/// Get the path to the cached index file
fn cache_path(&self) -> PathBuf {
self.cache_dir.join("index.toml")
}
/// Check if the cache is valid (exists and not expired)
fn is_cache_valid(&self) -> bool {
let cache_path = self.cache_path();
if !cache_path.exists() {
return false;
}
if let Ok(metadata) = fs::metadata(&cache_path)
&& let Ok(modified) = metadata.modified()
&& let Ok(elapsed) = SystemTime::now().duration_since(modified)
{
return elapsed < CACHE_DURATION;
}
false
}
/// Fetch the registry index (from cache or network)
pub fn fetch_index(&self, force_refresh: bool) -> Result<RegistryIndex, String> {
// Use cache if valid and not forcing refresh
if !force_refresh
&& self.is_cache_valid()
&& let Ok(content) = fs::read_to_string(self.cache_path())
&& let Ok(index) = toml::from_str(&content)
{
return Ok(index);
}
// Fetch from network
self.fetch_from_network()
}
/// Fetch the index from the network and cache it
fn fetch_from_network(&self) -> Result<RegistryIndex, String> {
// Use curl for fetching (available on most systems)
let output = std::process::Command::new("curl")
.args(["-fsSL", "--max-time", "30", &self.registry_url])
.output()
.map_err(|e| format!("Failed to run curl: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to fetch registry: {}", stderr.trim()));
}
let content = String::from_utf8_lossy(&output.stdout);
// Parse the index
let index: RegistryIndex = toml::from_str(&content)
.map_err(|e| format!("Failed to parse registry index: {}", e))?;
// Cache the result
if let Err(e) = self.cache_index(&content) {
eprintln!("Warning: Failed to cache registry index: {}", e);
}
Ok(index)
}
/// Cache the index content to disk
fn cache_index(&self, content: &str) -> Result<(), String> {
fs::create_dir_all(&self.cache_dir)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
fs::write(self.cache_path(), content)
.map_err(|e| format!("Failed to write cache file: {}", e))?;
Ok(())
}
/// Search for plugins matching a query
pub fn search(&self, query: &str, force_refresh: bool) -> Result<Vec<RegistryPlugin>, String> {
let index = self.fetch_index(force_refresh)?;
let query_lower = query.to_lowercase();
let matches: Vec<_> = index
.plugins
.into_iter()
.filter(|p| {
p.id.to_lowercase().contains(&query_lower)
|| p.name.to_lowercase().contains(&query_lower)
|| p.description.to_lowercase().contains(&query_lower)
|| p.tags
.iter()
.any(|t| t.to_lowercase().contains(&query_lower))
})
.collect();
Ok(matches)
}
/// Find a specific plugin by ID
pub fn find(&self, id: &str, force_refresh: bool) -> Result<Option<RegistryPlugin>, String> {
let index = self.fetch_index(force_refresh)?;
Ok(index.plugins.into_iter().find(|p| p.id == id))
}
/// List all available plugins
pub fn list_all(&self, force_refresh: bool) -> Result<Vec<RegistryPlugin>, String> {
let index = self.fetch_index(force_refresh)?;
Ok(index.plugins)
}
/// Clear the cache
#[allow(dead_code)]
pub fn clear_cache(&self) -> Result<(), String> {
let cache_path = self.cache_path();
if cache_path.exists() {
fs::remove_file(&cache_path).map_err(|e| format!("Failed to remove cache: {}", e))?;
}
Ok(())
}
/// Get the repository URL for a plugin
#[allow(dead_code)]
pub fn get_install_url(&self, id: &str) -> Result<String, String> {
match self.find(id, false)? {
Some(plugin) => Ok(plugin.repository),
None => Err(format!("Plugin '{}' not found in registry", id)),
}
}
}
/// Check if a string looks like a URL (for distinguishing registry names from URLs)
pub fn is_url(s: &str) -> bool {
s.starts_with("http://")
|| s.starts_with("https://")
|| s.starts_with("git@")
|| s.starts_with("git://")
}
/// Check if a string looks like a local path
pub fn is_path(s: &str) -> bool {
s.starts_with('/')
|| s.starts_with("./")
|| s.starts_with("../")
|| s.starts_with('~')
|| Path::new(s).exists()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_registry_index() {
let toml_str = r#"
[registry]
name = "Test Registry"
description = "A test registry"
[[plugins]]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
description = "A test plugin"
author = "Test Author"
repository = "https://github.com/test/plugin"
tags = ["test", "example"]
owlry_version = ">=0.3.0"
"#;
let index: RegistryIndex = toml::from_str(toml_str).unwrap();
assert_eq!(index.registry.name, "Test Registry");
assert_eq!(index.plugins.len(), 1);
assert_eq!(index.plugins[0].id, "test-plugin");
assert_eq!(index.plugins[0].tags, vec!["test", "example"]);
}
#[test]
fn test_is_url() {
assert!(is_url("https://github.com/user/repo"));
assert!(is_url("http://example.com"));
assert!(is_url("git@github.com:user/repo.git"));
assert!(!is_url("my-plugin"));
assert!(!is_url("/path/to/plugin"));
}
#[test]
fn test_is_path() {
assert!(is_path("/absolute/path"));
assert!(is_path("./relative/path"));
assert!(is_path("../parent/path"));
assert!(is_path("~/home/path"));
assert!(!is_path("my-plugin"));
assert!(!is_path("https://example.com"));
}
}
-154
View File
@@ -1,154 +0,0 @@
//! Lua runtime setup and sandboxing
use mlua::{Lua, Result as LuaResult, StdLib};
use super::manifest::PluginPermissions;
/// Configuration for the Lua sandbox
#[derive(Debug, Clone)]
#[allow(dead_code)] // Fields used for future permission enforcement
pub struct SandboxConfig {
/// Allow shell command running
pub allow_commands: bool,
/// Allow HTTP requests
pub allow_network: bool,
/// Allow filesystem access outside plugin directory
pub allow_external_fs: bool,
/// Maximum run time per call (ms)
pub max_run_time_ms: u64,
/// Memory limit (bytes, 0 = unlimited)
pub max_memory: usize,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
allow_commands: false,
allow_network: false,
allow_external_fs: false,
max_run_time_ms: 5000, // 5 seconds
max_memory: 64 * 1024 * 1024, // 64 MB
}
}
}
impl SandboxConfig {
/// Create a sandbox config from plugin permissions
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
Self {
allow_commands: !permissions.run_commands.is_empty(),
allow_network: permissions.network,
allow_external_fs: !permissions.filesystem.is_empty(),
..Default::default()
}
}
}
/// Create a new sandboxed Lua runtime
pub fn create_lua_runtime(_sandbox: &SandboxConfig) -> LuaResult<Lua> {
// Create Lua with safe standard libraries only
// ALL_SAFE excludes: debug, io, os (dangerous parts), package (loadlib), ffi
// We then customize the os table to only allow safe functions
let libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH;
let lua = Lua::new_with(libs, mlua::LuaOptions::default())?;
// Set up safe environment
setup_safe_globals(&lua)?;
Ok(lua)
}
/// Set up safe global environment by removing/replacing dangerous functions
fn setup_safe_globals(lua: &Lua) -> LuaResult<()> {
let globals = lua.globals();
// Remove dangerous globals
globals.set("dofile", mlua::Value::Nil)?;
globals.set("loadfile", mlua::Value::Nil)?;
// Create a restricted os table with only safe functions
// We do NOT include: os.exit, os.remove, os.rename, os.setlocale, os.tmpname
// and the shell-related functions
let os_table = lua.create_table()?;
os_table.set(
"clock",
lua.create_function(|_, ()| Ok(std::time::Instant::now().elapsed().as_secs_f64()))?,
)?;
os_table.set("date", lua.create_function(os_date)?)?;
os_table.set(
"difftime",
lua.create_function(|_, (t2, t1): (f64, f64)| Ok(t2 - t1))?,
)?;
os_table.set("time", lua.create_function(os_time)?)?;
globals.set("os", os_table)?;
// Remove print (plugins should use owlry.log instead)
// We'll add it back via owlry.log
globals.set("print", mlua::Value::Nil)?;
Ok(())
}
/// Safe os.date implementation
fn os_date(_lua: &Lua, format: Option<String>) -> LuaResult<String> {
use chrono::Local;
let now = Local::now();
let fmt = format.unwrap_or_else(|| "%c".to_string());
Ok(now.format(&fmt).to_string())
}
/// Safe os.time implementation
fn os_time(_lua: &Lua, _args: ()) -> LuaResult<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
Ok(duration.as_secs() as i64)
}
/// Load and run a Lua file in the given runtime
pub fn load_file(lua: &Lua, path: &std::path::Path) -> LuaResult<()> {
let content = std::fs::read_to_string(path).map_err(mlua::Error::external)?;
lua.load(&content)
.set_name(path.file_name().and_then(|n| n.to_str()).unwrap_or("chunk"))
.into_function()?
.call(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_sandboxed_runtime() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
// Verify dangerous functions are removed
let result: LuaResult<mlua::Value> = lua.globals().get("dofile");
assert!(matches!(result, Ok(mlua::Value::Nil)));
// Verify safe functions work
let result: String = lua.load("return os.date('%Y')").call(()).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_basic_lua_operations() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
// Test basic math
let result: i32 = lua.load("return 2 + 2").call(()).unwrap();
assert_eq!(result, 4);
// Test table operations
let result: i32 = lua.load("local t = {1,2,3}; return #t").call(()).unwrap();
assert_eq!(result, 3);
// Test string operations
let result: String = lua.load("return string.upper('hello')").call(()).unwrap();
assert_eq!(result, "HELLO");
}
}
@@ -1,337 +0,0 @@
//! Dynamic runtime loader
//!
//! This module provides dynamic loading of script runtimes (Lua, Rune)
//! when they're not compiled into the core binary.
//!
//! Runtimes are loaded from `/usr/lib/owlry/runtimes/`:
//! - `liblua.so` - Lua runtime (from owlry-lua package)
//! - `librune.so` - Rune runtime (from owlry-rune package)
//!
//! Note: This module is infrastructure for the runtime architecture. Full integration
//! is pending Phase 5 (AUR Packaging) when runtime packages will be available.
use std::mem::ManuallyDrop;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use libloading::{Library, Symbol};
use owlry_plugin_api::{PluginItem, RStr, RString, RVec};
use super::error::{PluginError, PluginResult};
use crate::providers::{ItemSource, LaunchItem, Provider, ProviderType};
/// System directory for runtime libraries
pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes";
/// Information about a loaded runtime
#[repr(C)]
#[derive(Debug)]
pub struct RuntimeInfo {
pub name: RString,
pub version: RString,
}
/// Information about a provider from a script runtime
#[repr(C)]
#[derive(Debug, Clone)]
pub struct ScriptProviderInfo {
pub name: RString,
pub display_name: RString,
pub type_id: RString,
pub default_icon: RString,
pub is_static: bool,
pub prefix: owlry_plugin_api::ROption<RString>,
pub tab_label: owlry_plugin_api::ROption<RString>,
pub search_noun: owlry_plugin_api::ROption<RString>,
}
// Type alias for backwards compatibility
pub type LuaProviderInfo = ScriptProviderInfo;
/// Handle to runtime-managed state
#[repr(transparent)]
#[derive(Clone, Copy)]
pub struct RuntimeHandle(pub *mut ());
// SAFETY: The underlying runtime state (Lua VM, Rune VM) is Send — mlua enables
// the "send" feature and Rune wraps its state in Mutex internally. Access is always
// serialized through Arc<Mutex<RuntimeHandle>>, so there are no data races.
unsafe impl Send for RuntimeHandle {}
/// VTable for script runtime functions (used by both Lua and Rune)
#[repr(C)]
pub struct ScriptRuntimeVTable {
pub info: extern "C" fn() -> RuntimeInfo,
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<ScriptProviderInfo>,
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
pub query: extern "C" fn(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem>,
pub drop: extern "C" fn(handle: RuntimeHandle),
}
/// A loaded script runtime
pub struct LoadedRuntime {
/// Runtime name (for logging)
name: &'static str,
/// Keep library alive — wrapped in ManuallyDrop so we never call dlclose().
/// dlclose() unmaps the library code; any thread-local destructors inside the
/// library then SIGSEGV when they try to run against the unmapped addresses.
/// Runtime libraries live for the process lifetime, so leaking the handle is safe.
_library: ManuallyDrop<Arc<Library>>,
/// Runtime vtable
vtable: &'static ScriptRuntimeVTable,
/// Runtime handle shared with all RuntimeProvider instances for this runtime.
/// Mutex serializes concurrent vtable calls. Arc shares ownership so all
/// RuntimeProviders can call into the runtime through the same handle.
handle: Arc<Mutex<RuntimeHandle>>,
/// Provider information
providers: Vec<ScriptProviderInfo>,
}
impl LoadedRuntime {
/// Load the Lua runtime from the system directory
pub fn load_lua(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
Self::load_from_path(
"Lua",
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"),
b"owlry_lua_runtime_vtable",
plugins_dir,
owlry_version,
)
}
/// Load a runtime from a specific path
fn load_from_path(
name: &'static str,
library_path: &Path,
vtable_symbol: &[u8],
plugins_dir: &Path,
owlry_version: &str,
) -> PluginResult<Self> {
if !library_path.exists() {
return Err(PluginError::NotFound(library_path.display().to_string()));
}
// SAFETY: We trust the runtime library to be correct
let library = unsafe { Library::new(library_path) }
.map_err(|e| PluginError::LoadError(format!("{}: {}", library_path.display(), e)))?;
let library = Arc::new(library);
// Get the vtable
let vtable: &'static ScriptRuntimeVTable = unsafe {
let get_vtable: Symbol<extern "C" fn() -> &'static ScriptRuntimeVTable> =
library.get(vtable_symbol).map_err(|e| {
PluginError::LoadError(format!(
"{}: Missing vtable symbol: {}",
library_path.display(),
e
))
})?;
get_vtable()
};
// Initialize the runtime
let plugins_dir_str = plugins_dir.to_string_lossy();
let raw_handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version));
let handle = Arc::new(Mutex::new(raw_handle));
// Get provider information — lock to serialize the vtable call
let providers_rvec = {
let h = handle.lock().unwrap();
(vtable.providers)(*h)
};
let providers: Vec<ScriptProviderInfo> = providers_rvec.into_iter().collect();
log::info!(
"Loaded {} runtime with {} provider(s)",
name,
providers.len()
);
Ok(Self {
name,
_library: ManuallyDrop::new(library),
vtable,
handle,
providers,
})
}
/// Get all providers from this runtime
pub fn providers(&self) -> &[ScriptProviderInfo] {
&self.providers
}
/// Create Provider trait objects for all providers in this runtime
pub fn create_providers(&self) -> Vec<Box<dyn Provider>> {
self.providers
.iter()
.map(|info| {
let provider = RuntimeProvider::new(
self.name,
self.vtable,
Arc::clone(&self.handle),
info.clone(),
);
Box::new(provider) as Box<dyn Provider>
})
.collect()
}
}
impl Drop for LoadedRuntime {
fn drop(&mut self) {
let h = self.handle.lock().unwrap();
(self.vtable.drop)(*h);
// Do NOT drop _library: ManuallyDrop ensures dlclose() is never called.
// See field comment for rationale.
}
}
// LoadedRuntime is Send + Sync because:
// - Arc<Mutex<RuntimeHandle>> is Send + Sync (RuntimeHandle: Send via unsafe impl above)
// - All other fields are 'static references or Send types
// No unsafe impl needed — this is derived automatically.
/// A provider backed by a dynamically loaded runtime
pub struct RuntimeProvider {
/// Runtime name (for logging)
#[allow(dead_code)]
runtime_name: &'static str,
vtable: &'static ScriptRuntimeVTable,
/// Shared with the owning LoadedRuntime and sibling RuntimeProviders.
/// Mutex serializes concurrent vtable calls on the same runtime handle.
handle: Arc<Mutex<RuntimeHandle>>,
info: ScriptProviderInfo,
items: Vec<LaunchItem>,
}
impl RuntimeProvider {
fn new(
runtime_name: &'static str,
vtable: &'static ScriptRuntimeVTable,
handle: Arc<Mutex<RuntimeHandle>>,
info: ScriptProviderInfo,
) -> Self {
Self {
runtime_name,
vtable,
handle,
info,
items: Vec::new(),
}
}
fn convert_item(&self, item: PluginItem) -> LaunchItem {
LaunchItem {
id: item.id.to_string(),
name: item.name.to_string(),
description: item.description.into_option().map(|s| s.to_string()),
icon: item.icon.into_option().map(|s| s.to_string()),
provider: ProviderType::Plugin(self.info.type_id.to_string()),
command: item.command.to_string(),
terminal: item.terminal,
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
source: ItemSource::ScriptPlugin,
}
}
}
impl Provider for RuntimeProvider {
fn name(&self) -> &str {
self.info.name.as_str()
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(self.info.type_id.to_string())
}
fn refresh(&mut self) {
if !self.info.is_static {
return;
}
let name_rstr = RStr::from_str(self.info.name.as_str());
let items_rvec = {
let h = self.handle.lock().unwrap();
(self.vtable.refresh)(*h, name_rstr)
};
self.items = items_rvec
.into_iter()
.map(|i| self.convert_item(i))
.collect();
log::debug!(
"[RuntimeProvider] '{}' refreshed with {} items",
self.info.name,
self.items.len()
);
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
fn tab_label(&self) -> Option<&str> {
self.info.tab_label.as_ref().map(|s| s.as_str()).into()
}
fn search_noun(&self) -> Option<&str> {
self.info.search_noun.as_ref().map(|s| s.as_str()).into()
}
}
// RuntimeProvider is Send + Sync because:
// - Arc<Mutex<RuntimeHandle>> is Send + Sync (RuntimeHandle: Send via unsafe impl above)
// - vtable is &'static (Send + Sync), info and items are Send
// No unsafe impl needed — this is derived automatically.
/// Check if the Lua runtime is available
pub fn lua_runtime_available() -> bool {
PathBuf::from(SYSTEM_RUNTIMES_DIR)
.join("liblua.so")
.exists()
}
/// Check if the Rune runtime is available
pub fn rune_runtime_available() -> bool {
PathBuf::from(SYSTEM_RUNTIMES_DIR)
.join("librune.so")
.exists()
}
impl LoadedRuntime {
/// Load the Rune runtime from the system directory
pub fn load_rune(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
Self::load_from_path(
"Rune",
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"),
b"owlry_rune_runtime_vtable",
plugins_dir,
owlry_version,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lua_runtime_check_doesnt_panic() {
// Just verify the function runs without panicking
// Result depends on whether runtime is installed
let _available = lua_runtime_available();
}
#[test]
fn test_rune_runtime_check_doesnt_panic() {
// Just verify the function runs without panicking
// Result depends on whether runtime is installed
let _available = rune_runtime_available();
}
}
-109
View File
@@ -1,109 +0,0 @@
//! Filesystem watcher for user plugin hot-reload
//!
//! Watches `~/.config/owlry/plugins/` for changes and triggers
//! runtime reload when plugin files are modified.
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
use log::{info, warn};
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use crate::providers::ProviderManager;
/// Start watching the user plugins directory for changes.
///
/// Spawns a background thread that monitors the directory and triggers
/// a full runtime reload on any file change. Returns immediately.
///
/// Respects `OWLRY_SKIP_RUNTIMES=1` — returns early if set.
pub fn start_watching(pm: Arc<RwLock<ProviderManager>>) {
if std::env::var("OWLRY_SKIP_RUNTIMES").is_ok() {
info!("OWLRY_SKIP_RUNTIMES set, skipping file watcher");
return;
}
let plugins_dir = match crate::paths::plugins_dir() {
Some(d) => d,
None => {
info!("No plugins directory configured, skipping file watcher");
return;
}
};
if !plugins_dir.exists()
&& std::fs::create_dir_all(&plugins_dir).is_err()
{
warn!(
"Failed to create plugins directory: {}",
plugins_dir.display()
);
return;
}
info!(
"Plugin file watcher started for {}",
plugins_dir.display()
);
thread::spawn(move || {
if let Err(e) = watch_loop(&plugins_dir, &pm) {
warn!("Plugin watcher stopped: {}", e);
}
});
}
fn watch_loop(
plugins_dir: &PathBuf,
pm: &Arc<RwLock<ProviderManager>>,
) -> Result<(), Box<dyn std::error::Error>> {
let (tx, rx) = std::sync::mpsc::channel();
let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?;
debouncer
.watcher()
.watch(plugins_dir.as_ref(), notify::RecursiveMode::Recursive)?;
info!("Watching {} for plugin changes", plugins_dir.display());
// Skip events during initial startup grace period (watcher setup triggers events)
let startup = std::time::Instant::now();
let grace_period = Duration::from_secs(2);
loop {
match rx.recv() {
Ok(Ok(events)) => {
if startup.elapsed() < grace_period {
continue;
}
let has_relevant_change = events.iter().any(|e| {
matches!(
e.kind,
DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous
)
});
if has_relevant_change {
info!("Plugin file change detected, reloading runtimes...");
match pm.write() {
Ok(mut pm_guard) => pm_guard.reload_runtimes(),
Err(_) => {
log::error!("Plugin watcher: provider lock poisoned; stopping watcher");
return Err(Box::from("provider lock poisoned"));
}
}
}
}
Ok(Err(error)) => {
warn!("File watcher error: {}", error);
}
Err(e) => {
return Err(Box::new(e));
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,144 +0,0 @@
//! LuaProvider - Bridge between Lua plugins and the Provider trait
//!
//! This module provides a `LuaProvider` struct that implements the `Provider` trait
//! by delegating to a Lua plugin's registered provider functions.
use std::sync::{Arc, Mutex};
use crate::plugins::{LoadedPlugin, PluginItem, ProviderRegistration};
use super::{ItemSource, LaunchItem, Provider, ProviderType};
/// A provider backed by a Lua plugin
///
/// This struct implements the `Provider` trait by calling into a Lua plugin's
/// `refresh` or `query` functions.
pub struct LuaProvider {
/// Provider registration info
registration: ProviderRegistration,
/// Reference to the loaded plugin (shared with other providers from same plugin).
/// Mutex serializes concurrent refresh calls; Arc allows sharing across threads.
plugin: Arc<Mutex<LoadedPlugin>>,
/// Cached items from last refresh
items: Vec<LaunchItem>,
}
impl LuaProvider {
/// Create a new LuaProvider
pub fn new(registration: ProviderRegistration, plugin: Arc<Mutex<LoadedPlugin>>) -> Self {
Self {
registration,
plugin,
items: Vec::new(),
}
}
/// Convert a PluginItem to a LaunchItem
fn convert_item(&self, item: PluginItem) -> LaunchItem {
if item.command.is_none() {
log::warn!("Plugin item '{}' has no command", item.name);
}
LaunchItem {
id: item.id,
name: item.name,
description: item.description,
icon: item.icon,
provider: ProviderType::Plugin(self.registration.type_id.clone()),
command: item.command.unwrap_or_default(),
terminal: item.terminal,
tags: item.tags,
source: ItemSource::ScriptPlugin,
}
}
}
impl Provider for LuaProvider {
fn name(&self) -> &str {
&self.registration.name
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(self.registration.type_id.clone())
}
fn refresh(&mut self) {
// Only refresh static providers
if !self.registration.is_static {
return;
}
let plugin = self.plugin.lock().unwrap();
match plugin.call_provider_refresh(&self.registration.name) {
Ok(items) => {
self.items = items.into_iter().map(|i| self.convert_item(i)).collect();
log::debug!(
"[LuaProvider] '{}' refreshed with {} items",
self.registration.name,
self.items.len()
);
}
Err(e) => {
log::error!(
"[LuaProvider] Failed to refresh '{}': {}",
self.registration.name,
e
);
self.items.clear();
}
}
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
// LuaProvider is Send + Sync because:
// - Arc<Mutex<LoadedPlugin>> is Send + Sync (LoadedPlugin: Send with mlua "send" feature)
// - All other fields are Send + Sync
// No unsafe impl needed.
/// Create LuaProviders from all registered providers in a plugin
pub fn create_providers_from_plugin(plugin: Arc<Mutex<LoadedPlugin>>) -> Vec<Box<dyn Provider>> {
let registrations = {
let p = plugin.lock().unwrap();
match p.get_provider_registrations() {
Ok(regs) => regs,
Err(e) => {
log::error!("[LuaProvider] Failed to get registrations: {}", e);
return Vec::new();
}
}
};
registrations
.into_iter()
.map(|reg| {
let provider = LuaProvider::new(reg, plugin.clone());
Box::new(provider) as Box<dyn Provider>
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
// Note: Full integration tests require a complete plugin setup
// These tests verify the basic structure
#[test]
fn test_provider_type() {
let reg = ProviderRegistration {
name: "test".to_string(),
display_name: "Test".to_string(),
type_id: "test_provider".to_string(),
default_icon: "test-icon".to_string(),
is_static: true,
prefix: None,
};
// We can't easily create a mock LoadedPlugin, so just test the type
assert_eq!(reg.type_id, "test_provider");
}
}
File diff suppressed because it is too large Load Diff
@@ -1,220 +0,0 @@
//! Native Plugin Provider Bridge
//!
//! This module provides a bridge between native plugins (compiled .so files)
//! and the core Provider trait used by ProviderManager.
//!
//! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files
//! and provide search providers via an ABI-stable interface.
use std::sync::Arc;
use log::debug;
use owlry_plugin_api::{
PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition,
};
use super::{ItemSource, LaunchItem, Provider, ProviderType};
use crate::plugins::native_loader::NativePlugin;
/// A provider backed by a native plugin
///
/// This wraps a native plugin's provider and implements the core Provider trait,
/// allowing native plugins to be used seamlessly with the existing ProviderManager.
pub struct NativeProvider {
/// The native plugin (shared reference since multiple providers may use same plugin)
plugin: Arc<NativePlugin>,
/// Provider metadata
info: ProviderInfo,
/// Handle to the provider state in the plugin
handle: ProviderHandle,
/// Cached items (for static providers)
items: Vec<LaunchItem>,
}
impl NativeProvider {
/// Create a new native provider
pub fn new(plugin: Arc<NativePlugin>, info: ProviderInfo) -> Self {
let handle = plugin.init_provider(info.id.as_str());
Self {
plugin,
info,
handle,
items: Vec::new(),
}
}
/// Get the ProviderType for this native provider
/// All native plugins return Plugin(type_id) - the core has no hardcoded plugin types
fn get_provider_type(&self) -> ProviderType {
ProviderType::Plugin(self.info.type_id.to_string())
}
/// The ID of the plugin that owns this provider.
pub fn plugin_id(&self) -> &str {
self.plugin.id()
}
/// The human-readable name of the plugin that owns this provider.
pub fn plugin_name(&self) -> &str {
self.plugin.name()
}
/// The version string of the plugin that owns this provider.
pub fn plugin_version(&self) -> &str {
self.plugin.info.version.as_str()
}
/// Convert a plugin API item to a core LaunchItem
fn convert_item(&self, item: ApiPluginItem) -> LaunchItem {
LaunchItem {
id: item.id.to_string(),
name: item.name.to_string(),
description: item.description.as_ref().map(|s| s.to_string()).into(),
icon: item.icon.as_ref().map(|s| s.to_string()).into(),
provider: self.get_provider_type(),
command: item.command.to_string(),
terminal: item.terminal,
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
source: ItemSource::NativePlugin,
}
}
/// Query the provider
///
/// For dynamic providers, this is called per-keystroke.
/// For static providers, returns cached items unless query is a special command
/// (submenu queries `?SUBMENU:` or action commands `!ACTION:`).
pub fn query(&self, query: &str) -> Vec<LaunchItem> {
// Special queries (submenu, actions) should always be forwarded to the plugin
let is_special_query = query.starts_with("?SUBMENU:") || query.starts_with("!");
if self.info.provider_type != ProviderKind::Dynamic && !is_special_query {
return self.items.clone();
}
let api_items = self.plugin.query_provider(self.handle, query);
api_items
.into_iter()
.map(|item| self.convert_item(item))
.collect()
}
/// Check if this provider has a prefix that matches the query
#[allow(dead_code)]
pub fn matches_prefix(&self, query: &str) -> bool {
match self.info.prefix.as_ref().into_option() {
Some(prefix) => query.starts_with(prefix.as_str()),
None => false,
}
}
/// Get the prefix for this provider (if any)
#[allow(dead_code)]
pub fn prefix(&self) -> Option<&str> {
self.info.prefix.as_ref().map(|s| s.as_str()).into()
}
/// Check if this is a dynamic provider
#[allow(dead_code)]
pub fn is_dynamic(&self) -> bool {
self.info.provider_type == ProviderKind::Dynamic
}
/// Get the provider type ID (e.g., "calc", "clipboard", "weather")
pub fn type_id(&self) -> &str {
self.info.type_id.as_str()
}
/// Check if this is a widget provider (appears at top of results)
pub fn is_widget(&self) -> bool {
self.info.position == ProviderPosition::Widget
}
/// Get the provider's priority for result ordering
/// Higher values appear first in results
pub fn priority(&self) -> i32 {
self.info.priority
}
/// Get the provider's default icon name
pub fn icon(&self) -> &str {
self.info.icon.as_str()
}
/// Get the provider's display position as a string
pub fn position_str(&self) -> &str {
match self.info.position {
ProviderPosition::Widget => "widget",
ProviderPosition::Normal => "normal",
}
}
/// Execute an action command on the provider
/// Uses query with "!" prefix to trigger action handling in the plugin
pub fn execute_action(&self, action: &str) {
let action_query = format!("!{}", action);
self.plugin.query_provider(self.handle, &action_query);
}
}
impl Provider for NativeProvider {
fn name(&self) -> &str {
self.info.name.as_str()
}
fn provider_type(&self) -> ProviderType {
self.get_provider_type()
}
fn refresh(&mut self) {
// Only refresh static providers
if self.info.provider_type != ProviderKind::Static {
return;
}
debug!("Refreshing native provider '{}'", self.info.name.as_str());
let api_items = self.plugin.refresh_provider(self.handle);
let items: Vec<LaunchItem> = api_items
.into_iter()
.map(|item| self.convert_item(item))
.collect();
debug!(
"Native provider '{}' loaded {} items",
self.info.name.as_str(),
items.len()
);
self.items = items;
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
impl Drop for NativeProvider {
fn drop(&mut self) {
// Clean up the provider handle
self.plugin.drop_provider(self.handle);
}
}
#[cfg(test)]
mod tests {
use super::*;
// Note: Full testing requires actual .so plugins, which we'll test
// via integration tests. Unit tests here focus on the conversion logic.
#[test]
fn test_provider_type_conversion() {
// Test that type_id is correctly converted to ProviderType::Plugin
let type_id = "calculator";
let provider_type = ProviderType::Plugin(type_id.to_string());
assert_eq!(format!("{}", provider_type), "calculator");
}
}
-51
View File
@@ -1,51 +0,0 @@
[package]
name = "owlry-lua"
version = "1.1.5"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Lua runtime for owlry plugins - enables loading user-created Lua plugins"
keywords = ["owlry", "plugin", "lua", "runtime"]
categories = ["development-tools"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[features]
# Bundle Lua 5.4 from source (no system lua dep). Enabled by default for dev
# and CI builds. Distribution packages should disable this and add lua54 as a
# system dependency instead.
default = ["vendored"]
vendored = ["mlua/vendored"]
[dependencies]
# Plugin API for owlry (shared types)
owlry-plugin-api = { path = "../owlry-plugin-api" }
# ABI-stable types
abi_stable = "0.11"
# Lua runtime
mlua = { version = "0.11", features = ["lua54", "send", "serialize"] }
# Plugin manifest parsing
toml = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Version compatibility
semver = "1"
# Logging
log = "0.4"
# Date/time for os.date
chrono = "0.4"
# XDG paths
dirs = "5.0"
[dev-dependencies]
tempfile = "3"
-57
View File
@@ -1,57 +0,0 @@
//! Lua API implementations for plugins
//!
//! This module provides the `owlry` global table and its submodules
//! that plugins can use to interact with owlry.
mod provider;
mod utils;
use mlua::{Lua, Result as LuaResult};
use owlry_plugin_api::PluginItem;
use crate::loader::ProviderRegistration;
/// Register all owlry APIs in the Lua runtime
pub fn register_apis(lua: &Lua, plugin_dir: &std::path::Path, plugin_id: &str) -> LuaResult<()> {
let globals = lua.globals();
// Create the main owlry table
let owlry = lua.create_table()?;
// Register utility APIs (log, path, fs, json)
utils::register_log_api(lua, &owlry)?;
utils::register_path_api(lua, &owlry, plugin_dir)?;
utils::register_fs_api(lua, &owlry, plugin_dir)?;
utils::register_json_api(lua, &owlry)?;
// Register provider API
provider::register_provider_api(lua, &owlry)?;
// Set owlry as global
globals.set("owlry", owlry)?;
// Suppress unused warnings
let _ = plugin_id;
Ok(())
}
/// Get provider registrations from the Lua runtime
pub fn get_provider_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
provider::get_registrations(lua)
}
/// Call a provider's refresh function
pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>> {
provider::call_refresh(lua, provider_name)
}
/// Call a provider's query function
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
provider::call_query(lua, provider_name, query)
}
/// Call the global `refresh()` function (for manifest-declared providers)
pub fn call_global_refresh(lua: &Lua) -> LuaResult<Vec<PluginItem>> {
provider::call_global_refresh(lua)
}
-271
View File
@@ -1,271 +0,0 @@
//! Provider registration API for Lua plugins
use mlua::{Function, Lua, Result as LuaResult, Table, Value};
use owlry_plugin_api::PluginItem;
use std::cell::RefCell;
use crate::loader::ProviderRegistration;
thread_local! {
static REGISTRATIONS: RefCell<Vec<ProviderRegistration>> = const { RefCell::new(Vec::new()) };
}
/// Register the provider API in the owlry table
pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let provider = lua.create_table()?;
// owlry.provider.register(config)
provider.set("register", lua.create_function(register_provider)?)?;
owlry.set("provider", provider)?;
Ok(())
}
/// Implementation of owlry.provider.register()
fn register_provider(lua: &Lua, config: Table) -> LuaResult<()> {
let name: String = config.get("name")?;
let display_name: String = config
.get::<Option<String>>("display_name")?
.unwrap_or_else(|| name.clone());
let type_id: String = config
.get::<Option<String>>("type_id")?
.unwrap_or_else(|| name.replace('-', "_"));
let default_icon: String = config
.get::<Option<String>>("default_icon")?
.unwrap_or_else(|| "application-x-addon".to_string());
let prefix: Option<String> = config.get("prefix")?;
let tab_label: Option<String> = config.get("tab_label")?;
let search_noun: Option<String> = config.get("search_noun")?;
// Check if it's a dynamic provider (has query function) or static (has refresh)
let has_query: bool = config.contains_key("query")?;
let has_refresh: bool = config.contains_key("refresh")?;
if !has_query && !has_refresh {
return Err(mlua::Error::external(
"Provider must have either 'refresh' or 'query' function",
));
}
let is_dynamic = has_query;
// Store the config table in owlry.provider._registrations[name]
// so call_refresh/call_query can find the callback functions later
let globals = lua.globals();
let owlry: Table = globals.get("owlry")?;
let provider: Table = owlry.get("provider")?;
let registrations: Table = match provider.get::<Value>("_registrations")? {
Value::Table(t) => t,
_ => {
let t = lua.create_table()?;
provider.set("_registrations", t.clone())?;
t
}
};
registrations.set(name.as_str(), config)?;
REGISTRATIONS.with(|regs| {
regs.borrow_mut().push(ProviderRegistration {
name,
display_name,
type_id,
default_icon,
prefix,
is_dynamic,
tab_label,
search_noun,
});
});
Ok(())
}
/// Call the top-level `refresh()` global function (for manifest-declared providers)
pub fn call_global_refresh(lua: &Lua) -> LuaResult<Vec<PluginItem>> {
let globals = lua.globals();
match globals.get::<Function>("refresh") {
Ok(refresh_fn) => parse_items_result(refresh_fn.call(())?),
Err(_) => Ok(Vec::new()),
}
}
/// Get all registered providers
pub fn get_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
// Suppress unused warning
let _ = lua;
REGISTRATIONS.with(|regs| Ok(regs.borrow().clone()))
}
/// Call a provider's refresh function
pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>> {
let globals = lua.globals();
let owlry: Table = globals.get("owlry")?;
let provider: Table = owlry.get("provider")?;
// Get the registered providers table (internal)
let registrations: Table = match provider.get::<Value>("_registrations")? {
Value::Table(t) => t,
_ => {
// Try to find the config directly from the global scope
// This happens when register was called with the config table
return call_provider_function(lua, provider_name, "refresh", None);
}
};
let config: Table = match registrations.get(provider_name)? {
Value::Table(t) => t,
_ => return Ok(Vec::new()),
};
let refresh_fn: Function = match config.get("refresh")? {
Value::Function(f) => f,
_ => return Ok(Vec::new()),
};
let result: Value = refresh_fn.call(())?;
parse_items_result(result)
}
/// Call a provider's query function
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
call_provider_function(lua, provider_name, "query", Some(query))
}
/// Call a provider function by name
fn call_provider_function(
lua: &Lua,
provider_name: &str,
function_name: &str,
query: Option<&str>,
) -> LuaResult<Vec<PluginItem>> {
// Search through all registered providers in the Lua globals
// This is a workaround since we store registrations thread-locally
let globals = lua.globals();
// Try to find a registered provider with matching name
// First check if there's a _providers table
if let Ok(Value::Table(providers)) = globals.get::<Value>("_owlry_providers")
&& let Ok(Value::Table(config)) = providers.get::<Value>(provider_name)
&& let Ok(Value::Function(func)) = config.get::<Value>(function_name)
{
let result: Value = match query {
Some(q) => func.call(q)?,
None => func.call(())?,
};
return parse_items_result(result);
}
// Fall back: search through globals for functions
// This is less reliable but handles simple cases
Ok(Vec::new())
}
/// Parse items from Lua return value
fn parse_items_result(result: Value) -> LuaResult<Vec<PluginItem>> {
let mut items = Vec::new();
if let Value::Table(table) = result {
for pair in table.pairs::<i32, Table>() {
let (_, item_table) = pair?;
if let Ok(item) = parse_item(&item_table) {
items.push(item);
}
}
}
Ok(items)
}
/// Parse a single item from a Lua table
fn parse_item(table: &Table) -> LuaResult<PluginItem> {
let id: String = table.get("id")?;
let name: String = table.get("name")?;
let command: String = table.get::<Option<String>>("command")?.unwrap_or_default();
let description: Option<String> = table.get("description")?;
let icon: Option<String> = table.get("icon")?;
let terminal: bool = table.get::<Option<bool>>("terminal")?.unwrap_or(false);
let tags: Vec<String> = table
.get::<Option<Vec<String>>>("tags")?
.unwrap_or_default();
let mut item = PluginItem::new(id, name, command);
if let Some(desc) = description {
item = item.with_description(desc);
}
if let Some(ic) = icon {
item = item.with_icon(&ic);
}
if terminal {
item = item.with_terminal(true);
}
if !tags.is_empty() {
item = item.with_keywords(tags);
}
Ok(item)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{SandboxConfig, create_lua_runtime};
#[test]
fn test_register_static_provider() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_provider_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let code = r#"
owlry.provider.register({
name = "test-provider",
display_name = "Test Provider",
refresh = function()
return {
{ id = "1", name = "Item 1" }
}
end
})
"#;
lua.load(code).set_name("test").call::<()>(()).unwrap();
let regs = get_registrations(&lua).unwrap();
assert_eq!(regs.len(), 1);
assert_eq!(regs[0].name, "test-provider");
assert!(!regs[0].is_dynamic);
}
#[test]
fn test_register_dynamic_provider() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_provider_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let code = r#"
owlry.provider.register({
name = "query-provider",
prefix = "?",
query = function(q)
return {
{ id = "search", name = "Search: " .. q }
}
end
})
"#;
lua.load(code).set_name("test").call::<()>(()).unwrap();
let regs = get_registrations(&lua).unwrap();
assert_eq!(regs.len(), 1);
assert_eq!(regs[0].name, "query-provider");
assert!(regs[0].is_dynamic);
assert_eq!(regs[0].prefix, Some("?".to_string()));
}
}
-447
View File
@@ -1,447 +0,0 @@
//! Utility APIs: logging, paths, filesystem, JSON
use mlua::{Lua, Result as LuaResult, Table, Value};
use std::path::{Path, PathBuf};
// ============================================================================
// Logging API
// ============================================================================
/// Register the log API in the owlry table
pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let log = lua.create_table()?;
log.set(
"debug",
lua.create_function(|_, msg: String| {
eprintln!("[DEBUG] {}", msg);
Ok(())
})?,
)?;
log.set(
"info",
lua.create_function(|_, msg: String| {
eprintln!("[INFO] {}", msg);
Ok(())
})?,
)?;
log.set(
"warn",
lua.create_function(|_, msg: String| {
eprintln!("[WARN] {}", msg);
Ok(())
})?,
)?;
log.set(
"error",
lua.create_function(|_, msg: String| {
eprintln!("[ERROR] {}", msg);
Ok(())
})?,
)?;
owlry.set("log", log)?;
Ok(())
}
// ============================================================================
// Path API
// ============================================================================
/// Register the path API in the owlry table
pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> {
let path = lua.create_table()?;
// owlry.path.config() -> ~/.config/owlry
path.set(
"config",
lua.create_function(|_, ()| {
Ok(dirs::config_dir()
.map(|d| d.join("owlry"))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default())
})?,
)?;
// owlry.path.data() -> ~/.local/share/owlry
path.set(
"data",
lua.create_function(|_, ()| {
Ok(dirs::data_dir()
.map(|d| d.join("owlry"))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default())
})?,
)?;
// owlry.path.cache() -> ~/.cache/owlry
path.set(
"cache",
lua.create_function(|_, ()| {
Ok(dirs::cache_dir()
.map(|d| d.join("owlry"))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default())
})?,
)?;
// owlry.path.home() -> ~
path.set(
"home",
lua.create_function(|_, ()| {
Ok(dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default())
})?,
)?;
// owlry.path.join(...) -> joined path
path.set(
"join",
lua.create_function(|_, parts: mlua::Variadic<String>| {
let mut path = PathBuf::new();
for part in parts {
path.push(part);
}
Ok(path.to_string_lossy().to_string())
})?,
)?;
// owlry.path.plugin_dir() -> plugin directory
let plugin_dir_str = plugin_dir.to_string_lossy().to_string();
path.set(
"plugin_dir",
lua.create_function(move |_, ()| Ok(plugin_dir_str.clone()))?,
)?;
// owlry.path.expand(path) -> expanded path (~ -> home)
path.set(
"expand",
lua.create_function(|_, path: String| {
if path.starts_with("~/")
&& let Some(home) = dirs::home_dir()
{
return Ok(home.join(&path[2..]).to_string_lossy().to_string());
}
Ok(path)
})?,
)?;
owlry.set("path", path)?;
Ok(())
}
// ============================================================================
// Filesystem API
// ============================================================================
/// Register the fs API in the owlry table
pub fn register_fs_api(lua: &Lua, owlry: &Table, _plugin_dir: &Path) -> LuaResult<()> {
let fs = lua.create_table()?;
// owlry.fs.exists(path) -> bool
fs.set(
"exists",
lua.create_function(|_, path: String| {
let path = expand_path(&path);
Ok(Path::new(&path).exists())
})?,
)?;
// owlry.fs.is_dir(path) -> bool
fs.set(
"is_dir",
lua.create_function(|_, path: String| {
let path = expand_path(&path);
Ok(Path::new(&path).is_dir())
})?,
)?;
// owlry.fs.read(path) -> string or nil
fs.set(
"read",
lua.create_function(|_, path: String| {
let path = expand_path(&path);
match std::fs::read_to_string(&path) {
Ok(content) => Ok(Some(content)),
Err(_) => Ok(None),
}
})?,
)?;
// owlry.fs.read_lines(path) -> table of strings or nil
fs.set(
"read_lines",
lua.create_function(|lua, path: String| {
let path = expand_path(&path);
match std::fs::read_to_string(&path) {
Ok(content) => {
let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
Ok(Some(lua.create_sequence_from(lines)?))
}
Err(_) => Ok(None),
}
})?,
)?;
// owlry.fs.list_dir(path) -> table of filenames or nil
fs.set(
"list_dir",
lua.create_function(|lua, path: String| {
let path = expand_path(&path);
match std::fs::read_dir(&path) {
Ok(entries) => {
let names: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
Ok(Some(lua.create_sequence_from(names)?))
}
Err(_) => Ok(None),
}
})?,
)?;
// owlry.fs.read_json(path) -> table or nil
fs.set(
"read_json",
lua.create_function(|lua, path: String| {
let path = expand_path(&path);
match std::fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => json_to_lua(lua, &value),
Err(_) => Ok(Value::Nil),
},
Err(_) => Ok(Value::Nil),
}
})?,
)?;
// owlry.fs.write(path, content) -> bool
fs.set(
"write",
lua.create_function(|_, (path, content): (String, String)| {
let path = expand_path(&path);
// Create parent directories if needed
if let Some(parent) = Path::new(&path).parent() {
let _ = std::fs::create_dir_all(parent);
}
Ok(std::fs::write(&path, content).is_ok())
})?,
)?;
owlry.set("fs", fs)?;
Ok(())
}
// ============================================================================
// JSON API
// ============================================================================
/// Register the json API in the owlry table
pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
let json = lua.create_table()?;
// owlry.json.encode(value) -> string
json.set(
"encode",
lua.create_function(|lua, value: Value| {
let json_value = lua_to_json(lua, &value)?;
Ok(serde_json::to_string(&json_value).unwrap_or_else(|_| "null".to_string()))
})?,
)?;
// owlry.json.decode(string) -> value or nil
json.set(
"decode",
lua.create_function(|lua, s: String| {
match serde_json::from_str::<serde_json::Value>(&s) {
Ok(value) => json_to_lua(lua, &value),
Err(_) => Ok(Value::Nil),
}
})?,
)?;
owlry.set("json", json)?;
Ok(())
}
// ============================================================================
// Helper Functions
// ============================================================================
/// Expand ~ in paths
fn expand_path(path: &str) -> String {
if path.starts_with("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(&path[2..]).to_string_lossy().to_string();
}
path.to_string()
}
/// Convert JSON value to Lua value
fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<Value> {
match value {
serde_json::Value::Null => Ok(Value::Nil),
serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(Value::Number(f))
} else {
Ok(Value::Nil)
}
}
serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)),
serde_json::Value::Array(arr) => {
let table = lua.create_table()?;
for (i, v) in arr.iter().enumerate() {
table.set(i + 1, json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
serde_json::Value::Object(obj) => {
let table = lua.create_table()?;
for (k, v) in obj {
table.set(k.as_str(), json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
}
}
/// Convert Lua value to JSON value
fn lua_to_json(_lua: &Lua, value: &Value) -> LuaResult<serde_json::Value> {
match value {
Value::Nil => Ok(serde_json::Value::Null),
Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
Value::Number(n) => Ok(serde_json::json!(*n)),
Value::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_string())),
Value::Table(t) => {
// Check if it's an array (sequential integer keys starting from 1)
let mut is_array = true;
let mut max_key = 0i64;
for pair in t.clone().pairs::<Value, Value>() {
let (k, _) = pair?;
match k {
Value::Integer(i) if i > 0 => {
max_key = max_key.max(i);
}
_ => {
is_array = false;
break;
}
}
}
if is_array && max_key > 0 {
let mut arr = Vec::new();
for i in 1..=max_key {
let v: Value = t.get(i)?;
arr.push(lua_to_json(_lua, &v)?);
}
Ok(serde_json::Value::Array(arr))
} else {
let mut obj = serde_json::Map::new();
for pair in t.clone().pairs::<String, Value>() {
let (k, v) = pair?;
obj.insert(k, lua_to_json(_lua, &v)?);
}
Ok(serde_json::Value::Object(obj))
}
}
_ => Ok(serde_json::Value::Null),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{SandboxConfig, create_lua_runtime};
#[test]
fn test_log_api() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_log_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
// Just verify it doesn't panic
lua.load("owlry.log.info('test message')")
.set_name("test")
.call::<()>(())
.unwrap();
}
#[test]
fn test_path_api() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_path_api(&lua, &owlry, Path::new("/tmp/test-plugin")).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let home: String = lua
.load("return owlry.path.home()")
.set_name("test")
.call(())
.unwrap();
assert!(!home.is_empty());
let plugin_dir: String = lua
.load("return owlry.path.plugin_dir()")
.set_name("test")
.call(())
.unwrap();
assert_eq!(plugin_dir, "/tmp/test-plugin");
}
#[test]
fn test_fs_api() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_fs_api(&lua, &owlry, Path::new("/tmp")).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let exists: bool = lua
.load("return owlry.fs.exists('/tmp')")
.set_name("test")
.call(())
.unwrap();
assert!(exists);
let is_dir: bool = lua
.load("return owlry.fs.is_dir('/tmp')")
.set_name("test")
.call(())
.unwrap();
assert!(is_dir);
}
#[test]
fn test_json_api() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
let owlry = lua.create_table().unwrap();
register_json_api(&lua, &owlry).unwrap();
lua.globals().set("owlry", owlry).unwrap();
let code = r#"
local t = { name = "test", value = 42 }
local json = owlry.json.encode(t)
local decoded = owlry.json.decode(json)
return decoded.name, decoded.value
"#;
let (name, value): (String, i32) = lua.load(code).set_name("test").call(()).unwrap();
assert_eq!(name, "test");
assert_eq!(value, 42);
}
}
-347
View File
@@ -1,347 +0,0 @@
//! Owlry Lua Runtime
//!
//! This crate provides Lua plugin support for owlry. It is loaded dynamically
//! by the core when Lua plugins need to be executed.
//!
//! # Architecture
//!
//! The runtime acts as a "meta-plugin" that:
//! 1. Discovers Lua plugins in `~/.config/owlry/plugins/`
//! 2. Creates sandboxed Lua VMs for each plugin
//! 3. Registers the `owlry` API table
//! 4. Bridges Lua providers to native `PluginItem` format
//!
//! # Plugin Structure
//!
//! Each plugin lives in its own directory:
//! ```text
//! ~/.config/owlry/plugins/
//! my-plugin/
//! plugin.toml # Plugin manifest
//! init.lua # Entry point
//! ```
mod api;
mod loader;
mod manifest;
mod runtime;
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::PluginItem;
use std::collections::HashMap;
use std::path::PathBuf;
use loader::LoadedPlugin;
/// Runtime vtable - exported interface for the core to use
#[repr(C)]
pub struct LuaRuntimeVTable {
/// Get runtime info
pub info: extern "C" fn() -> RuntimeInfo,
/// Initialize the runtime with plugins directory
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
/// Get provider infos from all loaded plugins
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<LuaProviderInfo>,
/// Refresh a provider's items
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
/// Query a dynamic provider
pub query: extern "C" fn(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem>,
/// Cleanup and drop the runtime
pub drop: extern "C" fn(handle: RuntimeHandle),
}
/// Runtime info returned by the runtime
#[repr(C)]
pub struct RuntimeInfo {
pub name: RString,
pub version: RString,
}
/// Opaque handle to the runtime state
#[repr(C)]
#[derive(Clone, Copy)]
pub struct RuntimeHandle {
pub ptr: *mut (),
}
// SAFETY: LuaRuntimeState (pointed to by RuntimeHandle) contains mlua::Lua, which is
// Send when the "send" feature is enabled (enabled in Cargo.toml). RuntimeHandle itself
// is Copy and has no interior mutability — Sync is NOT implemented because concurrent
// access is serialized by Arc<Mutex<RuntimeHandle>> in the runtime loader.
unsafe impl Send for RuntimeHandle {}
impl RuntimeHandle {
/// Create a null handle (reserved for error cases)
#[allow(dead_code)]
fn null() -> Self {
Self {
ptr: std::ptr::null_mut(),
}
}
fn from_box<T>(state: Box<T>) -> Self {
Self {
ptr: Box::into_raw(state) as *mut (),
}
}
unsafe fn drop_as<T>(&self) {
if !self.ptr.is_null() {
unsafe { drop(Box::from_raw(self.ptr as *mut T)) };
}
}
}
/// Provider info from a Lua plugin
///
/// Must match ScriptProviderInfo layout in owlry-core/src/plugins/runtime_loader.rs
#[repr(C)]
pub struct LuaProviderInfo {
/// Provider name (used as vtable refresh/query key: "plugin_id:provider_name")
pub name: RString,
/// Display name
pub display_name: RString,
/// Type ID for filtering
pub type_id: RString,
/// Icon name
pub default_icon: RString,
/// Whether this is a static provider (true) or dynamic (false)
pub is_static: bool,
/// Optional prefix trigger
pub prefix: ROption<RString>,
/// Short label for UI tab button
pub tab_label: ROption<RString>,
/// Noun for search placeholder
pub search_noun: ROption<RString>,
}
/// Internal runtime state
struct LuaRuntimeState {
plugins_dir: PathBuf,
plugins: HashMap<String, LoadedPlugin>,
/// Maps "plugin_id:provider_name" to plugin_id for lookup
provider_map: HashMap<String, String>,
}
impl LuaRuntimeState {
fn new(plugins_dir: PathBuf) -> Self {
Self {
plugins_dir,
plugins: HashMap::new(),
provider_map: HashMap::new(),
}
}
fn discover_and_load(&mut self, owlry_version: &str) {
let discovered = match loader::discover_plugins(&self.plugins_dir) {
Ok(d) => d,
Err(e) => {
eprintln!("owlry-lua: Failed to discover plugins: {}", e);
return;
}
};
for (id, (manifest, path)) in discovered {
// Check version compatibility
if !manifest.is_compatible_with(owlry_version) {
eprintln!(
"owlry-lua: Plugin '{}' not compatible with owlry {}",
id, owlry_version
);
continue;
}
let mut plugin = LoadedPlugin::new(manifest, path);
if let Err(e) = plugin.initialize() {
eprintln!("owlry-lua: Failed to initialize plugin '{}': {}", id, e);
continue;
}
// Build provider map
if let Ok(registrations) = plugin.get_provider_registrations() {
for reg in &registrations {
let full_id = format!("{}:{}", id, reg.name);
self.provider_map.insert(full_id, id.clone());
}
}
self.plugins.insert(id, plugin);
}
}
fn get_providers(&self) -> Vec<LuaProviderInfo> {
let mut providers = Vec::new();
for (plugin_id, plugin) in &self.plugins {
if let Ok(registrations) = plugin.get_provider_registrations() {
for reg in registrations {
let full_id = format!("{}:{}", plugin_id, reg.name);
providers.push(LuaProviderInfo {
name: RString::from(full_id),
display_name: RString::from(reg.display_name.as_str()),
type_id: RString::from(reg.type_id.as_str()),
default_icon: RString::from(reg.default_icon.as_str()),
is_static: !reg.is_dynamic,
prefix: reg.prefix.map(RString::from).into(),
tab_label: reg.tab_label.map(RString::from).into(),
search_noun: reg.search_noun.map(RString::from).into(),
});
}
}
}
providers
}
fn refresh_provider(&self, provider_id: &str) -> Vec<PluginItem> {
// Parse "plugin_id:provider_name"
let parts: Vec<&str> = provider_id.splitn(2, ':').collect();
if parts.len() != 2 {
return Vec::new();
}
let (plugin_id, provider_name) = (parts[0], parts[1]);
if let Some(plugin) = self.plugins.get(plugin_id) {
match plugin.call_provider_refresh(provider_name) {
Ok(items) => items,
Err(e) => {
eprintln!("owlry-lua: Refresh failed for {}: {}", provider_id, e);
Vec::new()
}
}
} else {
Vec::new()
}
}
fn query_provider(&self, provider_id: &str, query: &str) -> Vec<PluginItem> {
// Parse "plugin_id:provider_name"
let parts: Vec<&str> = provider_id.splitn(2, ':').collect();
if parts.len() != 2 {
return Vec::new();
}
let (plugin_id, provider_name) = (parts[0], parts[1]);
if let Some(plugin) = self.plugins.get(plugin_id) {
match plugin.call_provider_query(provider_name, query) {
Ok(items) => items,
Err(e) => {
eprintln!("owlry-lua: Query failed for {}: {}", provider_id, e);
Vec::new()
}
}
} else {
Vec::new()
}
}
}
// ============================================================================
// Exported Functions
// ============================================================================
extern "C" fn runtime_info() -> RuntimeInfo {
RuntimeInfo {
name: RString::from("Lua"),
version: RString::from(env!("CARGO_PKG_VERSION")),
}
}
extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
let plugins_dir = PathBuf::from(plugins_dir.as_str());
let mut state = Box::new(LuaRuntimeState::new(plugins_dir));
state.discover_and_load(owlry_version.as_str());
RuntimeHandle::from_box(state)
}
extern "C" fn runtime_providers(handle: RuntimeHandle) -> RVec<LuaProviderInfo> {
if handle.ptr.is_null() {
return RVec::new();
}
let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) };
state.get_providers().into()
}
extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) };
state.refresh_provider(provider_id.as_str()).into()
}
extern "C" fn runtime_query(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) };
state
.query_provider(provider_id.as_str(), query.as_str())
.into()
}
extern "C" fn runtime_drop(handle: RuntimeHandle) {
if !handle.ptr.is_null() {
unsafe {
handle.drop_as::<LuaRuntimeState>();
}
}
}
/// Static vtable instance
static LUA_RUNTIME_VTABLE: LuaRuntimeVTable = LuaRuntimeVTable {
info: runtime_info,
init: runtime_init,
providers: runtime_providers,
refresh: runtime_refresh,
query: runtime_query,
drop: runtime_drop,
};
/// Entry point - returns the runtime vtable
#[unsafe(no_mangle)]
pub extern "C" fn owlry_lua_runtime_vtable() -> &'static LuaRuntimeVTable {
&LUA_RUNTIME_VTABLE
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runtime_info() {
let info = runtime_info();
assert_eq!(info.name.as_str(), "Lua");
assert!(!info.version.as_str().is_empty());
}
#[test]
fn test_runtime_handle_null() {
let handle = RuntimeHandle::null();
assert!(handle.ptr.is_null());
}
#[test]
fn test_runtime_handle_from_box() {
let state = Box::new(42u32);
let handle = RuntimeHandle::from_box(state);
assert!(!handle.ptr.is_null());
unsafe { handle.drop_as::<u32>() };
}
}
-350
View File
@@ -1,350 +0,0 @@
//! Plugin discovery and loading
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use mlua::Lua;
use owlry_plugin_api::PluginItem;
use crate::api;
use crate::manifest::PluginManifest;
use crate::runtime::{SandboxConfig, create_lua_runtime, load_file};
/// Provider registration info from Lua
#[derive(Debug, Clone)]
pub struct ProviderRegistration {
pub name: String,
pub display_name: String,
pub type_id: String,
pub default_icon: String,
pub prefix: Option<String>,
pub is_dynamic: bool,
pub tab_label: Option<String>,
pub search_noun: Option<String>,
}
/// A loaded plugin instance
pub struct LoadedPlugin {
/// Plugin manifest
pub manifest: PluginManifest,
/// Path to plugin directory
pub path: PathBuf,
/// Whether plugin is enabled
pub enabled: bool,
/// Lua runtime (None if not yet initialized)
lua: Option<Lua>,
}
impl std::fmt::Debug for LoadedPlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoadedPlugin")
.field("manifest", &self.manifest)
.field("path", &self.path)
.field("enabled", &self.enabled)
.field("lua", &self.lua.is_some())
.finish()
}
}
impl LoadedPlugin {
/// Create a new loaded plugin (not yet initialized)
pub fn new(manifest: PluginManifest, path: PathBuf) -> Self {
Self {
manifest,
path,
enabled: true,
lua: None,
}
}
/// Get the plugin ID
pub fn id(&self) -> &str {
&self.manifest.plugin.id
}
/// Initialize the Lua runtime and load the entry point
pub fn initialize(&mut self) -> Result<(), String> {
if self.lua.is_some() {
return Ok(()); // Already initialized
}
let sandbox = SandboxConfig::from_permissions(&self.manifest.permissions);
let lua = create_lua_runtime(&sandbox)
.map_err(|e| format!("Failed to create Lua runtime: {}", e))?;
// Register owlry APIs before loading entry point
api::register_apis(&lua, &self.path, self.id())
.map_err(|e| format!("Failed to register APIs: {}", e))?;
// Load the entry point file
let entry_path = self.path.join(&self.manifest.plugin.entry);
if !entry_path.exists() {
return Err(format!(
"Entry point '{}' not found",
self.manifest.plugin.entry
));
}
load_file(&lua, &entry_path).map_err(|e| format!("Failed to load entry point: {}", e))?;
self.lua = Some(lua);
Ok(())
}
/// Get provider registrations from this plugin
pub fn get_provider_registrations(&self) -> Result<Vec<ProviderRegistration>, String> {
let lua = self
.lua
.as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?;
let mut regs = api::get_provider_registrations(lua)
.map_err(|e| format!("Failed to get registrations: {}", e))?;
// Fall back to manifest [[providers]] declarations when the script
// doesn't call owlry.provider.register() (new-style plugins)
if regs.is_empty() {
for decl in &self.manifest.providers {
regs.push(ProviderRegistration {
name: decl.id.clone(),
display_name: decl.name.clone(),
type_id: decl.type_id.clone().unwrap_or_else(|| decl.id.clone()),
default_icon: decl
.icon
.clone()
.unwrap_or_else(|| "application-x-addon".to_string()),
prefix: decl.prefix.clone(),
is_dynamic: decl.provider_type == "dynamic",
tab_label: decl.tab_label.clone(),
search_noun: decl.search_noun.clone(),
});
}
}
Ok(regs)
}
/// Call a provider's refresh function
pub fn call_provider_refresh(&self, provider_name: &str) -> Result<Vec<PluginItem>, String> {
let lua = self
.lua
.as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?;
let items = api::call_refresh(lua, provider_name)
.map_err(|e| format!("Refresh failed: {}", e))?;
// If the API path returned nothing, try calling the global refresh()
// function directly (new-style plugins with manifest [[providers]])
if items.is_empty() {
return api::call_global_refresh(lua)
.map_err(|e| format!("Refresh failed: {}", e));
}
Ok(items)
}
/// Call a provider's query function
pub fn call_provider_query(
&self,
provider_name: &str,
query: &str,
) -> Result<Vec<PluginItem>, String> {
let lua = self
.lua
.as_ref()
.ok_or_else(|| "Plugin not initialized".to_string())?;
api::call_query(lua, provider_name, query).map_err(|e| format!("Query failed: {}", e))
}
}
/// Discover plugins in a directory
pub fn discover_plugins(
plugins_dir: &Path,
) -> Result<HashMap<String, (PluginManifest, PathBuf)>, String> {
let mut plugins = HashMap::new();
if !plugins_dir.exists() {
return Ok(plugins);
}
let entries = std::fs::read_dir(plugins_dir)
.map_err(|e| format!("Failed to read plugins directory: {}", e))?;
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("plugin.toml");
if !manifest_path.exists() {
continue;
}
match PluginManifest::load(&manifest_path) {
Ok(manifest) => {
// Skip plugins whose entry point is not a Lua file
if !manifest.plugin.entry.ends_with(".lua") {
log::debug!(
"owlry-lua: Skipping non-Lua plugin at {} (entry: {})",
path.display(),
manifest.plugin.entry
);
continue;
}
let id = manifest.plugin.id.clone();
if plugins.contains_key(&id) {
log::warn!(
"owlry-lua: Duplicate plugin ID '{}', skipping {}",
id,
path.display()
);
continue;
}
plugins.insert(id, (manifest, path));
}
Err(e) => {
log::warn!(
"owlry-lua: Failed to load plugin at {}: {}",
path.display(),
e
);
}
}
}
Ok(plugins)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_plugin(dir: &Path, id: &str) {
let plugin_dir = dir.join(id);
fs::create_dir_all(&plugin_dir).unwrap();
let manifest = format!(
r#"
[plugin]
id = "{}"
name = "Test {}"
version = "1.0.0"
"#,
id, id
);
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
fs::write(plugin_dir.join("main.lua"), "-- empty plugin").unwrap();
}
#[test]
fn test_discover_plugins() {
let temp = TempDir::new().unwrap();
let plugins_dir = temp.path();
create_test_plugin(plugins_dir, "test-plugin");
create_test_plugin(plugins_dir, "another-plugin");
let plugins = discover_plugins(plugins_dir).unwrap();
assert_eq!(plugins.len(), 2);
assert!(plugins.contains_key("test-plugin"));
assert!(plugins.contains_key("another-plugin"));
}
#[test]
fn test_discover_plugins_empty_dir() {
let temp = TempDir::new().unwrap();
let plugins = discover_plugins(temp.path()).unwrap();
assert!(plugins.is_empty());
}
#[test]
fn test_discover_plugins_nonexistent_dir() {
let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap();
assert!(plugins.is_empty());
}
#[test]
fn test_discover_skips_non_lua_plugins() {
let temp = TempDir::new().unwrap();
let plugins_dir = temp.path();
// Rune plugin — should be skipped by the Lua runtime
let rune_dir = plugins_dir.join("rune-plugin");
fs::create_dir_all(&rune_dir).unwrap();
fs::write(
rune_dir.join("plugin.toml"),
r#"
[plugin]
id = "rune-plugin"
name = "Rune Plugin"
version = "1.0.0"
entry_point = "main.rn"
[[providers]]
id = "rune-plugin"
name = "Rune Plugin"
"#,
)
.unwrap();
fs::write(rune_dir.join("main.rn"), "pub fn refresh() { [] }").unwrap();
// Lua plugin — should be discovered
create_test_plugin(plugins_dir, "lua-plugin");
let plugins = discover_plugins(plugins_dir).unwrap();
assert_eq!(plugins.len(), 1);
assert!(plugins.contains_key("lua-plugin"));
assert!(!plugins.contains_key("rune-plugin"));
}
#[test]
fn test_manifest_provider_fallback() {
let temp = TempDir::new().unwrap();
let plugin_dir = temp.path().join("test-plugin");
fs::create_dir_all(&plugin_dir).unwrap();
fs::write(
plugin_dir.join("plugin.toml"),
r#"
[plugin]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
entry_point = "main.lua"
[[providers]]
id = "test-plugin"
name = "Test Plugin"
type = "static"
type_id = "testplugin"
icon = "system-run"
prefix = ":tp"
"#,
)
.unwrap();
// Script that does NOT call owlry.provider.register()
fs::write(plugin_dir.join("main.lua"), "function refresh() return {} end").unwrap();
let manifest =
crate::manifest::PluginManifest::load(&plugin_dir.join("plugin.toml")).unwrap();
let mut plugin = LoadedPlugin::new(manifest, plugin_dir);
plugin.initialize().unwrap();
let regs = plugin.get_provider_registrations().unwrap();
assert_eq!(regs.len(), 1, "should fall back to [[providers]] declaration");
assert_eq!(regs[0].name, "test-plugin");
assert_eq!(regs[0].type_id, "testplugin");
assert_eq!(regs[0].prefix.as_deref(), Some(":tp"));
assert!(!regs[0].is_dynamic);
}
}
-214
View File
@@ -1,214 +0,0 @@
//! Plugin manifest (plugin.toml) parsing
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
/// Plugin manifest loaded from plugin.toml
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
/// Provider declarations from [[providers]] sections (new-style)
#[serde(default)]
pub providers: Vec<ProviderDecl>,
/// Legacy provides block (old-style)
#[serde(default)]
pub provides: PluginProvides,
#[serde(default)]
pub permissions: PluginPermissions,
#[serde(default)]
pub settings: HashMap<String, toml::Value>,
}
/// A provider declared in a [[providers]] section
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderDecl {
pub id: String,
pub name: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub icon: Option<String>,
/// "static" (default) or "dynamic"
#[serde(default = "default_provider_type", rename = "type")]
pub provider_type: String,
#[serde(default)]
pub type_id: Option<String>,
#[serde(default)]
pub tab_label: Option<String>,
#[serde(default)]
pub search_noun: Option<String>,
}
fn default_provider_type() -> String {
"static".to_string()
}
/// Core plugin information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
/// Unique plugin identifier (lowercase, alphanumeric, hyphens)
pub id: String,
/// Human-readable name
pub name: String,
/// Semantic version
pub version: String,
/// Short description
#[serde(default)]
pub description: String,
/// Plugin author
#[serde(default)]
pub author: String,
/// License identifier
#[serde(default)]
pub license: String,
/// Repository URL
#[serde(default)]
pub repository: Option<String>,
/// Required owlry version (semver constraint)
#[serde(default = "default_owlry_version")]
pub owlry_version: String,
/// Entry point file (relative to plugin directory)
#[serde(default = "default_entry", alias = "entry_point")]
pub entry: String,
}
fn default_owlry_version() -> String {
">=0.1.0".to_string()
}
fn default_entry() -> String {
"main.lua".to_string()
}
/// What the plugin provides
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginProvides {
/// Provider names this plugin registers
#[serde(default)]
pub providers: Vec<String>,
/// Whether this plugin registers actions
#[serde(default)]
pub actions: bool,
/// Theme names this plugin contributes
#[serde(default)]
pub themes: Vec<String>,
/// Whether this plugin registers hooks
#[serde(default)]
pub hooks: bool,
}
/// Plugin permissions/capabilities
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginPermissions {
/// Allow network/HTTP requests
#[serde(default)]
pub network: bool,
/// Filesystem paths the plugin can access (beyond its own directory)
#[serde(default)]
pub filesystem: Vec<String>,
/// Commands the plugin is allowed to run
#[serde(default)]
pub run_commands: Vec<String>,
/// Environment variables the plugin reads
#[serde(default)]
pub environment: Vec<String>,
}
impl PluginManifest {
/// Load a plugin manifest from a plugin.toml file
pub fn load(path: &Path) -> Result<Self, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read manifest: {}", e))?;
let manifest: PluginManifest =
toml::from_str(&content).map_err(|e| format!("Failed to parse manifest: {}", e))?;
manifest.validate()?;
Ok(manifest)
}
/// Validate the manifest
fn validate(&self) -> Result<(), String> {
// Validate plugin ID format
if self.plugin.id.is_empty() {
return Err("Plugin ID cannot be empty".to_string());
}
if !self
.plugin
.id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err("Plugin ID must be lowercase alphanumeric with hyphens".to_string());
}
// Validate version format
if semver::Version::parse(&self.plugin.version).is_err() {
return Err(format!("Invalid version format: {}", self.plugin.version));
}
// Validate owlry_version constraint
if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() {
return Err(format!(
"Invalid owlry_version constraint: {}",
self.plugin.owlry_version
));
}
// Lua plugins must have a .lua entry point
if !self.plugin.entry.ends_with(".lua") {
return Err("Entry point must be a .lua file for Lua plugins".to_string());
}
Ok(())
}
/// Check if this plugin is compatible with the given owlry version
pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
Ok(r) => r,
Err(_) => return false,
};
let version = match semver::Version::parse(owlry_version) {
Ok(v) => v,
Err(_) => return false,
};
req.matches(&version)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_manifest() {
let toml_str = r#"
[plugin]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.name, "Test Plugin");
assert_eq!(manifest.plugin.version, "1.0.0");
assert_eq!(manifest.plugin.entry, "main.lua");
}
#[test]
fn test_version_compatibility() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
owlry_version = ">=0.3.0, <1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.is_compatible_with("0.3.5"));
assert!(manifest.is_compatible_with("0.4.0"));
assert!(!manifest.is_compatible_with("0.2.0"));
assert!(!manifest.is_compatible_with("1.0.0"));
}
}
-152
View File
@@ -1,152 +0,0 @@
//! Lua runtime setup and sandboxing
use mlua::{Lua, Result as LuaResult, StdLib};
use crate::manifest::PluginPermissions;
/// Configuration for the Lua sandbox
///
/// Note: Some fields are reserved for future sandbox enforcement.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SandboxConfig {
/// Allow shell command running (reserved for future enforcement)
pub allow_commands: bool,
/// Allow HTTP requests (reserved for future enforcement)
pub allow_network: bool,
/// Allow filesystem access outside plugin directory (reserved for future enforcement)
pub allow_external_fs: bool,
/// Maximum run time per call (ms) (reserved for future enforcement)
pub max_run_time_ms: u64,
/// Memory limit (bytes, 0 = unlimited) (reserved for future enforcement)
pub max_memory: usize,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
allow_commands: false,
allow_network: false,
allow_external_fs: false,
max_run_time_ms: 5000, // 5 seconds
max_memory: 64 * 1024 * 1024, // 64 MB
}
}
}
impl SandboxConfig {
/// Create a sandbox config from plugin permissions
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
Self {
allow_commands: !permissions.run_commands.is_empty(),
allow_network: permissions.network,
allow_external_fs: !permissions.filesystem.is_empty(),
..Default::default()
}
}
}
/// Create a new sandboxed Lua runtime
pub fn create_lua_runtime(_sandbox: &SandboxConfig) -> LuaResult<Lua> {
// Create Lua with safe standard libraries only
// We exclude: debug, io, os (dangerous parts), package (loadlib), ffi
let libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH;
let lua = Lua::new_with(libs, mlua::LuaOptions::default())?;
// Set up safe environment
setup_safe_globals(&lua)?;
Ok(lua)
}
/// Set up safe global environment by removing/replacing dangerous functions
fn setup_safe_globals(lua: &Lua) -> LuaResult<()> {
let globals = lua.globals();
// Remove dangerous globals
globals.set("dofile", mlua::Value::Nil)?;
globals.set("loadfile", mlua::Value::Nil)?;
// Create a restricted os table with only safe functions
let os_table = lua.create_table()?;
os_table.set(
"clock",
lua.create_function(|_, ()| Ok(std::time::Instant::now().elapsed().as_secs_f64()))?,
)?;
os_table.set("date", lua.create_function(os_date)?)?;
os_table.set(
"difftime",
lua.create_function(|_, (t2, t1): (f64, f64)| Ok(t2 - t1))?,
)?;
os_table.set("time", lua.create_function(os_time)?)?;
globals.set("os", os_table)?;
// Remove print (plugins should use owlry.log instead)
globals.set("print", mlua::Value::Nil)?;
Ok(())
}
/// Safe os.date implementation
fn os_date(_lua: &Lua, format: Option<String>) -> LuaResult<String> {
use chrono::Local;
let now = Local::now();
let fmt = format.unwrap_or_else(|| "%c".to_string());
Ok(now.format(&fmt).to_string())
}
/// Safe os.time implementation
fn os_time(_lua: &Lua, _args: ()) -> LuaResult<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
Ok(duration.as_secs() as i64)
}
/// Load and run a Lua file in the given runtime
pub fn load_file(lua: &Lua, path: &std::path::Path) -> LuaResult<()> {
let content = std::fs::read_to_string(path).map_err(mlua::Error::external)?;
lua.load(&content)
.set_name(path.file_name().and_then(|n| n.to_str()).unwrap_or("chunk"))
.into_function()?
.call(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_sandboxed_runtime() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
// Verify dangerous functions are removed
let result: LuaResult<mlua::Value> = lua.globals().get("dofile");
assert!(matches!(result, Ok(mlua::Value::Nil)));
// Verify safe functions work
let result: String = lua.load("return os.date('%Y')").call(()).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_basic_lua_operations() {
let config = SandboxConfig::default();
let lua = create_lua_runtime(&config).unwrap();
// Test basic math
let result: i32 = lua.load("return 2 + 2").call(()).unwrap();
assert_eq!(result, 4);
// Test table operations
let result: i32 = lua.load("local t = {1,2,3}; return #t").call(()).unwrap();
assert_eq!(result, 3);
// Test string operations
let result: String = lua.load("return string.upper('hello')").call(()).unwrap();
assert_eq!(result, "HELLO");
}
}
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "owlry-plugin-api"
version = "1.0.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Plugin API for owlry application launcher"
keywords = ["owlry", "plugin", "api"]
categories = ["api-bindings"]
[dependencies]
# ABI-stable types for dynamic linking
abi_stable = "0.11"
# Serialization for plugin config
serde = { version = "1", features = ["derive"] }
-487
View File
@@ -1,487 +0,0 @@
//! # Owlry Plugin API
//!
//! This crate provides the ABI-stable interface for owlry native plugins.
//! Plugins are compiled as dynamic libraries (.so) and loaded at runtime.
//!
//! ## Creating a Plugin
//!
//! ```ignore
//! use owlry_plugin_api::*;
//!
//! // Define your plugin's vtable
//! static VTABLE: PluginVTable = PluginVTable {
//! info: plugin_info,
//! providers: plugin_providers,
//! provider_init: my_provider_init,
//! provider_refresh: my_provider_refresh,
//! provider_query: my_provider_query,
//! provider_drop: my_provider_drop,
//! };
//!
//! // Export the vtable
//! #[no_mangle]
//! pub extern "C" fn owlry_plugin_vtable() -> &'static PluginVTable {
//! &VTABLE
//! }
//! ```
use abi_stable::StableAbi;
// Re-export abi_stable types for use by consumers (runtime loader, plugins)
pub use abi_stable::std_types::{ROption, RStr, RString, RVec};
/// Current plugin API version - plugins must match this
/// v2: Added ProviderPosition for widget support
/// v3: Added priority field for plugin-declared result ordering
/// v4: Added get_config_string/int/bool to HostAPI for plugin config access
pub const API_VERSION: u32 = 4;
/// Plugin metadata returned by the info function
#[repr(C)]
#[derive(StableAbi, Clone, Debug)]
pub struct PluginInfo {
/// Unique plugin identifier (e.g., "calculator", "weather")
pub id: RString,
/// Human-readable plugin name
pub name: RString,
/// Plugin version string
pub version: RString,
/// Short description of what the plugin provides
pub description: RString,
/// Plugin API version (must match API_VERSION)
pub api_version: u32,
}
/// Information about a provider offered by a plugin
#[repr(C)]
#[derive(StableAbi, Clone, Debug)]
pub struct ProviderInfo {
/// Unique provider identifier within the plugin
pub id: RString,
/// Human-readable provider name
pub name: RString,
/// Optional prefix that activates this provider (e.g., "=" for calculator)
pub prefix: ROption<RString>,
/// Default icon name for results from this provider
pub icon: RString,
/// Provider type (static or dynamic)
pub provider_type: ProviderKind,
/// Short type identifier for UI badges (e.g., "calc", "web")
pub type_id: RString,
/// Display position (Normal or Widget)
pub position: ProviderPosition,
/// Priority for result ordering (higher values appear first)
/// Suggested ranges:
/// - Widgets: 10000-12000
/// - Dynamic providers: 7000-10000
/// - Static providers: 0-5000 (use 0 for frecency-based ordering)
pub priority: i32,
}
/// Provider behavior type
#[repr(C)]
#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderKind {
/// Static providers load items once at startup via refresh()
Static,
/// Dynamic providers evaluate queries in real-time via query()
Dynamic,
}
/// Provider display position
///
/// Controls where in the result list this provider's items appear.
#[repr(C)]
#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ProviderPosition {
/// Standard position in results (sorted by score/frecency)
#[default]
Normal,
/// Widget position - appears at top of results when query is empty
/// Widgets are always visible regardless of filter settings
Widget,
}
/// A single searchable/launchable item returned by providers
#[repr(C)]
#[derive(StableAbi, Clone, Debug)]
pub struct PluginItem {
/// Unique item identifier
pub id: RString,
/// Display name
pub name: RString,
/// Optional description shown below the name
pub description: ROption<RString>,
/// Optional icon name or path
pub icon: ROption<RString>,
/// Command to execute when selected
pub command: RString,
/// Whether to run in a terminal
pub terminal: bool,
/// Search keywords/tags for filtering
pub keywords: RVec<RString>,
/// Score boost for frecency (higher = more prominent)
pub score_boost: i32,
}
impl PluginItem {
/// Create a new plugin item with required fields
pub fn new(id: impl Into<String>, name: impl Into<String>, command: impl Into<String>) -> Self {
Self {
id: RString::from(id.into()),
name: RString::from(name.into()),
description: ROption::RNone,
icon: ROption::RNone,
command: RString::from(command.into()),
terminal: false,
keywords: RVec::new(),
score_boost: 0,
}
}
/// Set the description
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = ROption::RSome(RString::from(desc.into()));
self
}
/// Set the icon
pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
self.icon = ROption::RSome(RString::from(icon.into()));
self
}
/// Set terminal mode
pub fn with_terminal(mut self, terminal: bool) -> Self {
self.terminal = terminal;
self
}
/// Add keywords
pub fn with_keywords(mut self, keywords: Vec<String>) -> Self {
self.keywords = keywords.into_iter().map(RString::from).collect();
self
}
/// Set score boost
pub fn with_score_boost(mut self, boost: i32) -> Self {
self.score_boost = boost;
self
}
}
/// Plugin function table - defines the interface between owlry and plugins
///
/// Every native plugin must export a function `owlry_plugin_vtable` that returns
/// a static reference to this structure.
#[repr(C)]
#[derive(StableAbi)]
pub struct PluginVTable {
/// Return plugin metadata
pub info: extern "C" fn() -> PluginInfo,
/// Return list of providers this plugin offers
pub providers: extern "C" fn() -> RVec<ProviderInfo>,
/// Initialize a provider by ID, returns an opaque handle
/// The handle is passed to refresh/query/drop functions
pub provider_init: extern "C" fn(provider_id: RStr<'_>) -> ProviderHandle,
/// Refresh a static provider's items
/// Called once at startup and when user requests refresh
pub provider_refresh: extern "C" fn(handle: ProviderHandle) -> RVec<PluginItem>,
/// Query a dynamic provider
/// Called on each keystroke for dynamic providers
pub provider_query: extern "C" fn(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem>,
/// Clean up a provider handle
pub provider_drop: extern "C" fn(handle: ProviderHandle),
}
/// Opaque handle to a provider instance
/// Plugins can use this to store state between calls
#[repr(C)]
#[derive(StableAbi, Clone, Copy, Debug)]
pub struct ProviderHandle {
/// Opaque pointer to provider state
pub ptr: *mut (),
}
impl ProviderHandle {
/// Create a null handle
pub fn null() -> Self {
Self {
ptr: std::ptr::null_mut(),
}
}
/// Create a handle from a boxed value
/// The caller is responsible for calling drop to free the memory
pub fn from_box<T>(value: Box<T>) -> Self {
Self {
ptr: Box::into_raw(value) as *mut (),
}
}
/// Convert handle back to a reference (unsafe)
///
/// # Safety
/// The handle must have been created from a Box<T> of the same type
pub unsafe fn as_ref<T>(&self) -> Option<&T> {
// SAFETY: Caller guarantees the pointer was created from Box<T>
unsafe { (self.ptr as *const T).as_ref() }
}
/// Convert handle back to a mutable reference (unsafe)
///
/// # Safety
/// The handle must have been created from a Box<T> of the same type
pub unsafe fn as_mut<T>(&mut self) -> Option<&mut T> {
// SAFETY: Caller guarantees the pointer was created from Box<T>
unsafe { (self.ptr as *mut T).as_mut() }
}
/// Drop the handle and free its memory (unsafe)
///
/// # Safety
/// The handle must have been created from a Box<T> of the same type
/// and must not be used after this call
pub unsafe fn drop_as<T>(self) {
if !self.ptr.is_null() {
// SAFETY: Caller guarantees the pointer was created from Box<T>
unsafe { drop(Box::from_raw(self.ptr as *mut T)) };
}
}
}
// ProviderHandle contains a raw pointer but we manage it carefully
unsafe impl Send for ProviderHandle {}
unsafe impl Sync for ProviderHandle {}
// ============================================================================
// Host API - Functions the host provides to plugins
// ============================================================================
/// Notification urgency level
#[repr(C)]
#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum NotifyUrgency {
/// Low priority notification
Low = 0,
/// Normal priority notification (default)
#[default]
Normal = 1,
/// Critical/urgent notification
Critical = 2,
}
/// Host API function table
///
/// This structure contains functions that the host (owlry) provides to plugins.
/// Plugins can call these functions to interact with the system.
#[repr(C)]
#[derive(StableAbi, Clone, Copy)]
pub struct HostAPI {
/// Send a notification to the user
/// Parameters: summary, body, icon (optional, empty string for none), urgency
pub notify:
extern "C" fn(summary: RStr<'_>, body: RStr<'_>, icon: RStr<'_>, urgency: NotifyUrgency),
/// Log a message at info level
pub log_info: extern "C" fn(message: RStr<'_>),
/// Log a message at warning level
pub log_warn: extern "C" fn(message: RStr<'_>),
/// Log a message at error level
pub log_error: extern "C" fn(message: RStr<'_>),
/// Read a string value from this plugin's config section.
/// Parameters: plugin_id (the calling plugin's ID), key
/// Returns RSome(value) if set, RNone otherwise.
pub get_config_string:
extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<RString>,
/// Read an integer value from this plugin's config section.
pub get_config_int: extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<i64>,
/// Read a boolean value from this plugin's config section.
pub get_config_bool: extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<bool>,
}
use std::sync::OnceLock;
// Global host API pointer - set by the host when loading plugins
static HOST_API: OnceLock<&'static HostAPI> = OnceLock::new();
/// Initialize the host API (called by the host)
///
/// # Safety
/// Must only be called once by the host before any plugins use the API
pub unsafe fn init_host_api(api: &'static HostAPI) {
let _ = HOST_API.set(api);
}
/// Get the host API
///
/// Returns None if the host hasn't initialized the API yet
pub fn host_api() -> Option<&'static HostAPI> {
HOST_API.get().copied()
}
// ============================================================================
// Convenience functions for plugins
// ============================================================================
/// Send a notification (convenience wrapper)
pub fn notify(summary: &str, body: &str) {
if let Some(api) = host_api() {
(api.notify)(
RStr::from_str(summary),
RStr::from_str(body),
RStr::from_str(""),
NotifyUrgency::Normal,
);
}
}
/// Send a notification with an icon (convenience wrapper)
pub fn notify_with_icon(summary: &str, body: &str, icon: &str) {
if let Some(api) = host_api() {
(api.notify)(
RStr::from_str(summary),
RStr::from_str(body),
RStr::from_str(icon),
NotifyUrgency::Normal,
);
}
}
/// Send a notification with full options (convenience wrapper)
pub fn notify_with_urgency(summary: &str, body: &str, icon: &str, urgency: NotifyUrgency) {
if let Some(api) = host_api() {
(api.notify)(
RStr::from_str(summary),
RStr::from_str(body),
RStr::from_str(icon),
urgency,
);
}
}
/// Log an info message (convenience wrapper)
pub fn log_info(message: &str) {
if let Some(api) = host_api() {
(api.log_info)(RStr::from_str(message));
}
}
/// Log a warning message (convenience wrapper)
pub fn log_warn(message: &str) {
if let Some(api) = host_api() {
(api.log_warn)(RStr::from_str(message));
}
}
/// Log an error message (convenience wrapper)
pub fn log_error(message: &str) {
if let Some(api) = host_api() {
(api.log_error)(RStr::from_str(message));
}
}
/// Read a string value from this plugin's config section (convenience wrapper).
/// `plugin_id` must match the ID the plugin declares in its `PluginInfo`.
pub fn get_config_string(plugin_id: &str, key: &str) -> Option<String> {
host_api().and_then(|api| {
(api.get_config_string)(RStr::from_str(plugin_id), RStr::from_str(key))
.into_option()
.map(|s| s.into_string())
})
}
/// Read an integer value from this plugin's config section (convenience wrapper).
pub fn get_config_int(plugin_id: &str, key: &str) -> Option<i64> {
host_api().and_then(|api| {
(api.get_config_int)(RStr::from_str(plugin_id), RStr::from_str(key)).into_option()
})
}
/// Read a boolean value from this plugin's config section (convenience wrapper).
pub fn get_config_bool(plugin_id: &str, key: &str) -> Option<bool> {
host_api().and_then(|api| {
(api.get_config_bool)(RStr::from_str(plugin_id), RStr::from_str(key)).into_option()
})
}
/// Helper macro for defining plugin vtables
///
/// Usage:
/// ```ignore
/// owlry_plugin! {
/// info: my_plugin_info,
/// providers: my_providers,
/// init: my_init,
/// refresh: my_refresh,
/// query: my_query,
/// drop: my_drop,
/// }
/// ```
#[macro_export]
macro_rules! owlry_plugin {
(
info: $info:expr,
providers: $providers:expr,
init: $init:expr,
refresh: $refresh:expr,
query: $query:expr,
drop: $drop:expr $(,)?
) => {
static OWLRY_PLUGIN_VTABLE: $crate::PluginVTable = $crate::PluginVTable {
info: $info,
providers: $providers,
provider_init: $init,
provider_refresh: $refresh,
provider_query: $query,
provider_drop: $drop,
};
#[unsafe(no_mangle)]
pub extern "C" fn owlry_plugin_vtable() -> &'static $crate::PluginVTable {
&OWLRY_PLUGIN_VTABLE
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_item_builder() {
let item = PluginItem::new("test-id", "Test Item", "echo hello")
.with_description("A test item")
.with_icon("test-icon")
.with_terminal(true)
.with_keywords(vec!["test".to_string(), "example".to_string()])
.with_score_boost(100);
assert_eq!(item.id.as_str(), "test-id");
assert_eq!(item.name.as_str(), "Test Item");
assert_eq!(item.command.as_str(), "echo hello");
assert!(item.terminal);
assert_eq!(item.score_boost, 100);
}
#[test]
fn test_provider_handle() {
let value = Box::new(42i32);
let handle = ProviderHandle::from_box(value);
unsafe {
assert_eq!(*handle.as_ref::<i32>().unwrap(), 42);
handle.drop_as::<i32>();
}
}
}
-40
View File
@@ -1,40 +0,0 @@
[package]
name = "owlry-rune"
version = "1.1.6"
edition = "2024"
rust-version = "1.90"
description = "Rune scripting runtime for owlry plugins"
license = "GPL-3.0-or-later"
[lib]
crate-type = ["cdylib"]
[dependencies]
# Shared plugin API
owlry-plugin-api = { path = "../owlry-plugin-api" }
# Rune scripting language
rune = "0.14"
# Logging
log = "0.4"
env_logger = "0.11"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Configuration parsing
toml = "0.8"
# Semantic versioning
semver = "1"
# Date/time
chrono = "0.4"
# Directory paths
dirs = "5"
[dev-dependencies]
tempfile = "3"
-173
View File
@@ -1,173 +0,0 @@
//! Owlry API bindings for Rune plugins
//!
//! This module provides the `owlry` module that Rune plugins can use.
use rune::{Any, ContextError, Module};
use std::sync::Mutex;
use owlry_plugin_api::{PluginItem, RString};
/// Provider registration info
#[derive(Debug, Clone)]
pub struct ProviderRegistration {
pub name: String,
pub display_name: String,
pub type_id: String,
pub default_icon: String,
pub is_static: bool,
pub prefix: Option<String>,
pub tab_label: Option<String>,
pub search_noun: Option<String>,
}
/// An item returned by a provider
///
/// Exposed to Rune scripts as `owlry::Item`.
#[derive(Debug, Clone, Any)]
#[rune(item = ::owlry)]
pub struct Item {
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub command: String,
pub terminal: bool,
pub keywords: Vec<String>,
}
impl Item {
/// Constructor exposed to Rune via #[rune::function]
#[rune::function(path = Self::new)]
pub fn rune_new(id: String, name: String, command: String) -> Self {
Self {
id,
name,
command,
description: None,
icon: None,
terminal: false,
keywords: Vec::new(),
}
}
/// Set description (builder pattern for Rune)
#[rune::function]
fn description(mut self, desc: String) -> Self {
self.description = Some(desc);
self
}
/// Set icon (builder pattern for Rune)
#[rune::function]
fn icon(mut self, icon: String) -> Self {
self.icon = Some(icon);
self
}
/// Set keywords (builder pattern for Rune)
#[rune::function]
fn keywords(mut self, keywords: Vec<String>) -> Self {
self.keywords = keywords;
self
}
/// Convert to PluginItem for FFI
pub fn to_plugin_item(&self) -> PluginItem {
let mut item = PluginItem::new(
RString::from(self.id.as_str()),
RString::from(self.name.as_str()),
RString::from(self.command.as_str()),
);
if let Some(ref desc) = self.description {
item = item.with_description(desc.clone());
}
if let Some(ref icon) = self.icon {
item = item.with_icon(icon.clone());
}
item.with_terminal(self.terminal)
.with_keywords(self.keywords.clone())
}
}
/// Global state for provider registrations (thread-safe)
pub static REGISTRATIONS: Mutex<Vec<ProviderRegistration>> = Mutex::new(Vec::new());
/// Create the owlry module for Rune
pub fn module() -> Result<Module, ContextError> {
let mut module = Module::with_crate("owlry")?;
// Register Item type with constructor and builder methods
module.ty::<Item>()?;
module.function_meta(Item::rune_new)?;
module.function_meta(Item::description)?;
module.function_meta(Item::icon)?;
module.function_meta(Item::keywords)?;
// Register logging functions
module.function("log_info", log_info).build()?;
module.function("log_debug", log_debug).build()?;
module.function("log_warn", log_warn).build()?;
module.function("log_error", log_error).build()?;
Ok(module)
}
// ============================================================================
// Logging Functions
// ============================================================================
fn log_info(message: &str) {
log::info!("[Rune] {}", message);
}
fn log_debug(message: &str) {
log::debug!("[Rune] {}", message);
}
fn log_warn(message: &str) {
log::warn!("[Rune] {}", message);
}
fn log_error(message: &str) {
log::error!("[Rune] {}", message);
}
/// Get all provider registrations
pub fn get_registrations() -> Vec<ProviderRegistration> {
REGISTRATIONS.lock().unwrap().clone()
}
/// Clear all registrations (for testing or reloading)
pub fn clear_registrations() {
REGISTRATIONS.lock().unwrap().clear();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_item_creation() {
let item = Item {
id: "test-1".to_string(),
name: "Test Item".to_string(),
description: Some("A test".to_string()),
icon: Some("test-icon".to_string()),
command: "echo test".to_string(),
terminal: false,
keywords: vec!["test".to_string()],
};
let plugin_item = item.to_plugin_item();
assert_eq!(plugin_item.id.as_str(), "test-1");
assert_eq!(plugin_item.name.as_str(), "Test Item");
}
#[test]
fn test_module_creation() {
let module = module();
assert!(module.is_ok());
}
}
-276
View File
@@ -1,276 +0,0 @@
//! Owlry Rune Runtime
//!
//! This crate provides a Rune scripting runtime for owlry user plugins.
//! It is loaded dynamically by the core when installed.
//!
//! # Architecture
//!
//! The runtime exports a C-compatible vtable that the core uses to:
//! 1. Initialize the runtime with a plugins directory
//! 2. Get a list of providers from loaded plugins
//! 3. Refresh/query providers
//! 4. Clean up resources
//!
//! # Plugin Structure
//!
//! Rune plugins live in `~/.config/owlry/plugins/<plugin-name>/`:
//! ```text
//! my-plugin/
//! plugin.toml # Manifest
//! init.rn # Entry point (Rune script)
//! ```
mod api;
mod loader;
mod manifest;
mod runtime;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use owlry_plugin_api::{PluginItem, ROption, RStr, RString, RVec};
pub use loader::LoadedPlugin;
pub use manifest::PluginManifest;
// ============================================================================
// Runtime VTable (C-compatible interface)
// ============================================================================
/// Information about this runtime
#[repr(C)]
pub struct RuntimeInfo {
pub name: RString,
pub version: RString,
}
/// Information about a provider from a plugin
#[repr(C)]
#[derive(Clone)]
pub struct RuneProviderInfo {
pub name: RString,
pub display_name: RString,
pub type_id: RString,
pub default_icon: RString,
pub is_static: bool,
pub prefix: ROption<RString>,
pub tab_label: ROption<RString>,
pub search_noun: ROption<RString>,
}
/// Opaque handle to runtime state
#[repr(transparent)]
#[derive(Clone, Copy)]
pub struct RuntimeHandle(pub *mut ());
/// Runtime state managed by the handle
struct RuntimeState {
plugins: HashMap<String, LoadedPlugin>,
providers: Vec<RuneProviderInfo>,
}
/// VTable for the Rune runtime
#[repr(C)]
pub struct RuneRuntimeVTable {
pub info: extern "C" fn() -> RuntimeInfo,
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<RuneProviderInfo>,
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
pub query: extern "C" fn(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem>,
pub drop: extern "C" fn(handle: RuntimeHandle),
}
// ============================================================================
// VTable Implementation
// ============================================================================
extern "C" fn runtime_info() -> RuntimeInfo {
RuntimeInfo {
name: RString::from("rune"),
version: RString::from(env!("CARGO_PKG_VERSION")),
}
}
extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
let _ = env_logger::try_init();
let _version = owlry_version.as_str();
let plugins_dir = PathBuf::from(plugins_dir.as_str());
log::info!(
"Initializing Rune runtime with plugins from: {}",
plugins_dir.display()
);
let mut state = RuntimeState {
plugins: HashMap::new(),
providers: Vec::new(),
};
// Discover and load Rune plugins
match loader::discover_rune_plugins(&plugins_dir) {
Ok(plugins) => {
for (id, plugin) in plugins {
// Collect provider info before storing plugin
for reg in plugin.provider_registrations() {
state.providers.push(RuneProviderInfo {
name: RString::from(reg.name.as_str()),
display_name: RString::from(reg.display_name.as_str()),
type_id: RString::from(reg.type_id.as_str()),
default_icon: RString::from(reg.default_icon.as_str()),
is_static: reg.is_static,
prefix: reg
.prefix
.as_ref()
.map(|p| RString::from(p.as_str()))
.into(),
tab_label: reg
.tab_label
.as_ref()
.map(|s| RString::from(s.as_str()))
.into(),
search_noun: reg
.search_noun
.as_ref()
.map(|s| RString::from(s.as_str()))
.into(),
});
}
state.plugins.insert(id, plugin);
}
log::info!(
"Loaded {} Rune plugin(s) with {} provider(s)",
state.plugins.len(),
state.providers.len()
);
}
Err(e) => {
log::error!("Failed to discover Rune plugins: {}", e);
}
}
// Box and leak the state, returning an opaque handle
let boxed = Box::new(Mutex::new(state));
RuntimeHandle(Box::into_raw(boxed) as *mut ())
}
extern "C" fn runtime_providers(handle: RuntimeHandle) -> RVec<RuneProviderInfo> {
let state = unsafe { &*(handle.0 as *const Mutex<RuntimeState>) };
let guard = state.lock().unwrap();
guard.providers.clone().into_iter().collect()
}
extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem> {
let state = unsafe { &*(handle.0 as *const Mutex<RuntimeState>) };
let mut guard = state.lock().unwrap();
let provider_name = provider_id.as_str();
// Find the plugin that provides this provider
for plugin in guard.plugins.values_mut() {
if plugin.provides_provider(provider_name) {
match plugin.refresh_provider(provider_name) {
Ok(items) => return items.into_iter().collect(),
Err(e) => {
log::error!("Failed to refresh provider '{}': {}", provider_name, e);
return RVec::new();
}
}
}
}
log::warn!("Provider '{}' not found", provider_name);
RVec::new()
}
extern "C" fn runtime_query(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem> {
let state = unsafe { &*(handle.0 as *const Mutex<RuntimeState>) };
let mut guard = state.lock().unwrap();
let provider_name = provider_id.as_str();
let query_str = query.as_str();
// Find the plugin that provides this provider
for plugin in guard.plugins.values_mut() {
if plugin.provides_provider(provider_name) {
match plugin.query_provider(provider_name, query_str) {
Ok(items) => return items.into_iter().collect(),
Err(e) => {
log::error!("Failed to query provider '{}': {}", provider_name, e);
return RVec::new();
}
}
}
}
log::warn!("Provider '{}' not found", provider_name);
RVec::new()
}
extern "C" fn runtime_drop(handle: RuntimeHandle) {
if !handle.0.is_null() {
// SAFETY: We created this box in runtime_init
unsafe {
let _ = Box::from_raw(handle.0 as *mut Mutex<RuntimeState>);
}
log::info!("Rune runtime cleaned up");
}
}
/// Static vtable instance
static RUNE_RUNTIME_VTABLE: RuneRuntimeVTable = RuneRuntimeVTable {
info: runtime_info,
init: runtime_init,
providers: runtime_providers,
refresh: runtime_refresh,
query: runtime_query,
drop: runtime_drop,
};
/// Entry point - returns the runtime vtable
#[unsafe(no_mangle)]
pub extern "C" fn owlry_rune_runtime_vtable() -> &'static RuneRuntimeVTable {
&RUNE_RUNTIME_VTABLE
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runtime_info() {
let info = runtime_info();
assert_eq!(info.name.as_str(), "rune");
assert!(!info.version.as_str().is_empty());
}
#[test]
fn test_runtime_lifecycle() {
// Create a temp directory for plugins
let temp = tempfile::TempDir::new().unwrap();
let plugins_dir = temp.path().to_string_lossy();
// Initialize runtime
let handle = runtime_init(RStr::from_str(&plugins_dir), RStr::from_str("1.0.0"));
assert!(!handle.0.is_null());
// Get providers (should be empty with no plugins)
let providers = runtime_providers(handle);
assert!(providers.is_empty());
// Clean up
runtime_drop(handle);
}
}
-294
View File
@@ -1,294 +0,0 @@
//! Rune plugin discovery and loading
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rune::{Context, Unit};
use crate::api::{self, ProviderRegistration};
use crate::manifest::PluginManifest;
use crate::runtime::{SandboxConfig, compile_source, create_context, create_vm};
use owlry_plugin_api::PluginItem;
/// A loaded Rune plugin
pub struct LoadedPlugin {
pub manifest: PluginManifest,
pub path: PathBuf,
/// Context for creating new VMs (reserved for refresh/query implementation)
#[allow(dead_code)]
context: Context,
/// Compiled unit (reserved for refresh/query implementation)
#[allow(dead_code)]
unit: Arc<Unit>,
registrations: Vec<ProviderRegistration>,
}
impl LoadedPlugin {
/// Create and initialize a new plugin
pub fn new(manifest: PluginManifest, path: PathBuf) -> Result<Self, String> {
let sandbox = SandboxConfig::from_permissions(&manifest.permissions);
let context =
create_context(&sandbox).map_err(|e| format!("Failed to create context: {}", e))?;
let entry_path = path.join(&manifest.plugin.entry);
if !entry_path.exists() {
return Err(format!("Entry point not found: {}", entry_path.display()));
}
// Clear previous registrations before loading
api::clear_registrations();
// Compile the source
let unit = compile_source(&context, &entry_path)
.map_err(|e| format!("Failed to compile: {}", e))?;
// Run the entry point to register providers
let mut vm =
create_vm(&context, unit.clone()).map_err(|e| format!("Failed to create VM: {}", e))?;
// Execute the main function if it exists
match vm.call(rune::Hash::type_hash(["main"]), ()) {
Ok(result) => {
// Try to complete the execution
let _: () = rune::from_value(result).unwrap_or(());
}
Err(_) => {
// No main function is okay
}
}
// Collect registrations — from runtime API or from manifest [[providers]]
let mut registrations = api::get_registrations();
if registrations.is_empty() && !manifest.providers.is_empty() {
for decl in &manifest.providers {
registrations.push(ProviderRegistration {
name: decl.id.clone(),
display_name: decl.name.clone(),
type_id: decl.type_id.clone().unwrap_or_else(|| decl.id.clone()),
default_icon: decl.icon.clone().unwrap_or_else(|| "application-x-addon".to_string()),
is_static: decl.provider_type != "dynamic",
prefix: decl.prefix.clone(),
tab_label: decl.tab_label.clone(),
search_noun: decl.search_noun.clone(),
});
}
}
log::info!(
"Loaded Rune plugin '{}' with {} provider(s)",
manifest.plugin.id,
registrations.len()
);
Ok(Self {
manifest,
path,
context,
unit,
registrations,
})
}
/// Get plugin ID
pub fn id(&self) -> &str {
&self.manifest.plugin.id
}
/// Get provider registrations
pub fn provider_registrations(&self) -> &[ProviderRegistration] {
&self.registrations
}
/// Check if this plugin provides a specific provider
pub fn provides_provider(&self, name: &str) -> bool {
self.registrations.iter().any(|r| r.name == name)
}
/// Refresh a static provider by calling the Rune `refresh()` function
pub fn refresh_provider(&mut self, _name: &str) -> Result<Vec<PluginItem>, String> {
let mut vm = create_vm(&self.context, self.unit.clone())
.map_err(|e| format!("Failed to create VM: {}", e))?;
let output = vm
.call(rune::Hash::type_hash(["refresh"]), ())
.map_err(|e| format!("refresh() call failed: {}", e))?;
let items: Vec<crate::api::Item> = rune::from_value(output)
.map_err(|e| format!("Failed to parse refresh() result: {}", e))?;
Ok(items.iter().map(|i| i.to_plugin_item()).collect())
}
/// Query a dynamic provider by calling the Rune `query(q)` function
pub fn query_provider(&mut self, _name: &str, query: &str) -> Result<Vec<PluginItem>, String> {
let mut vm = create_vm(&self.context, self.unit.clone())
.map_err(|e| format!("Failed to create VM: {}", e))?;
let output = vm
.call(
rune::Hash::type_hash(["query"]),
(query.to_string(),),
)
.map_err(|e| format!("query() call failed: {}", e))?;
let items: Vec<crate::api::Item> = rune::from_value(output)
.map_err(|e| format!("Failed to parse query() result: {}", e))?;
Ok(items.iter().map(|i| i.to_plugin_item()).collect())
}
}
/// Discover Rune plugins in a directory
pub fn discover_rune_plugins(plugins_dir: &Path) -> Result<HashMap<String, LoadedPlugin>, String> {
let mut plugins = HashMap::new();
if !plugins_dir.exists() {
log::debug!(
"Plugins directory does not exist: {}",
plugins_dir.display()
);
return Ok(plugins);
}
let entries = std::fs::read_dir(plugins_dir)
.map_err(|e| format!("Failed to read plugins directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("plugin.toml");
if !manifest_path.exists() {
continue;
}
// Load manifest
let manifest = match PluginManifest::load(&manifest_path) {
Ok(m) => m,
Err(e) => {
log::warn!(
"Failed to load manifest at {}: {}",
manifest_path.display(),
e
);
continue;
}
};
// Check if this is a Rune plugin (entry ends with .rn)
if !manifest.plugin.entry.ends_with(".rn") {
log::debug!("Skipping non-Rune plugin: {}", manifest.plugin.id);
continue;
}
// Load the plugin
match LoadedPlugin::new(manifest.clone(), path.clone()) {
Ok(plugin) => {
let id = manifest.plugin.id.clone();
plugins.insert(id, plugin);
}
Err(e) => {
log::warn!("Failed to load plugin '{}': {}", manifest.plugin.id, e);
}
}
}
Ok(plugins)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_discover_empty_dir() {
let temp = TempDir::new().unwrap();
let plugins = discover_rune_plugins(temp.path()).unwrap();
assert!(plugins.is_empty());
}
#[test]
fn test_discover_skips_non_rune_plugins() {
let temp = TempDir::new().unwrap();
let plugins_dir = temp.path();
// Lua plugin — should be skipped by the Rune runtime
let lua_dir = plugins_dir.join("lua-plugin");
fs::create_dir_all(&lua_dir).unwrap();
fs::write(
lua_dir.join("plugin.toml"),
r#"
[plugin]
id = "lua-plugin"
name = "Lua Plugin"
version = "1.0.0"
entry_point = "main.lua"
[[providers]]
id = "lua-plugin"
name = "Lua Plugin"
"#,
)
.unwrap();
fs::write(lua_dir.join("main.lua"), "function refresh() return {} end").unwrap();
let plugins = discover_rune_plugins(plugins_dir).unwrap();
assert!(plugins.is_empty(), "Lua plugin should be skipped by Rune runtime");
}
#[test]
fn test_manifest_provider_fallback() {
let temp = TempDir::new().unwrap();
let plugin_dir = temp.path().join("test-plugin");
fs::create_dir_all(&plugin_dir).unwrap();
fs::write(
plugin_dir.join("plugin.toml"),
r#"
[plugin]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
entry_point = "main.rn"
[[providers]]
id = "test-plugin"
name = "Test Plugin"
type = "static"
type_id = "testplugin"
icon = "system-run"
prefix = ":tp"
"#,
)
.unwrap();
// Script that exports refresh() but doesn't call register_provider()
fs::write(
plugin_dir.join("main.rn"),
r#"use owlry::Item;
pub fn refresh() {
[]
}
"#,
)
.unwrap();
let manifest =
crate::manifest::PluginManifest::load(&plugin_dir.join("plugin.toml")).unwrap();
let plugin = LoadedPlugin::new(manifest, plugin_dir).unwrap();
let regs = plugin.provider_registrations();
assert_eq!(regs.len(), 1, "should fall back to [[providers]] declaration");
assert_eq!(regs[0].name, "test-plugin");
assert_eq!(regs[0].type_id, "testplugin");
assert_eq!(regs[0].prefix.as_deref(), Some(":tp"));
assert!(regs[0].is_static);
}
}
-186
View File
@@ -1,186 +0,0 @@
//! Plugin manifest parsing for Rune plugins
use serde::Deserialize;
use std::path::Path;
/// Plugin manifest from plugin.toml
#[derive(Debug, Clone, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
#[serde(default)]
pub provides: PluginProvides,
#[serde(default)]
pub permissions: PluginPermissions,
/// Provider declarations from [[providers]] sections
#[serde(default)]
pub providers: Vec<ProviderDecl>,
}
/// A provider declared in [[providers]] section of plugin.toml
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderDecl {
pub id: String,
pub name: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default = "default_provider_type", rename = "type")]
pub provider_type: String,
#[serde(default)]
pub type_id: Option<String>,
#[serde(default)]
pub tab_label: Option<String>,
#[serde(default)]
pub search_noun: Option<String>,
}
fn default_provider_type() -> String {
"static".to_string()
}
/// Core plugin information
#[derive(Debug, Clone, Deserialize)]
pub struct PluginInfo {
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub author: String,
#[serde(default = "default_owlry_version")]
pub owlry_version: String,
#[serde(default = "default_entry", alias = "entry_point")]
pub entry: String,
}
fn default_owlry_version() -> String {
">=0.1.0".to_string()
}
fn default_entry() -> String {
"main.rn".to_string()
}
/// What the plugin provides
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PluginProvides {
#[serde(default)]
pub providers: Vec<String>,
#[serde(default)]
pub actions: bool,
#[serde(default)]
pub themes: Vec<String>,
#[serde(default)]
pub hooks: bool,
}
/// Plugin permissions
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PluginPermissions {
#[serde(default)]
pub network: bool,
#[serde(default)]
pub filesystem: Vec<String>,
#[serde(default)]
pub run_commands: Vec<String>,
}
impl PluginManifest {
/// Load manifest from a plugin.toml file
pub fn load(path: &Path) -> Result<Self, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read manifest: {}", e))?;
let manifest: PluginManifest =
toml::from_str(&content).map_err(|e| format!("Failed to parse manifest: {}", e))?;
manifest.validate()?;
Ok(manifest)
}
/// Validate the manifest
fn validate(&self) -> Result<(), String> {
if self.plugin.id.is_empty() {
return Err("Plugin ID cannot be empty".to_string());
}
if !self
.plugin
.id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err("Plugin ID must be lowercase alphanumeric with hyphens".to_string());
}
// Validate version format
if semver::Version::parse(&self.plugin.version).is_err() {
return Err(format!("Invalid version format: {}", self.plugin.version));
}
// Rune plugins must have .rn entry point
if !self.plugin.entry.ends_with(".rn") {
return Err("Entry point must be a .rn file for Rune plugins".to_string());
}
Ok(())
}
/// Check compatibility with owlry version
pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
Ok(r) => r,
Err(_) => return false,
};
let version = match semver::Version::parse(owlry_version) {
Ok(v) => v,
Err(_) => return false,
};
req.matches(&version)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_manifest() {
let toml_str = r#"
[plugin]
id = "test-plugin"
name = "Test Plugin"
version = "1.0.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.plugin.id, "test-plugin");
assert_eq!(manifest.plugin.entry, "main.rn");
}
#[test]
fn test_validate_entry_point() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
entry = "main.lua"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.validate().is_err()); // .lua not allowed for Rune
}
#[test]
fn test_version_compatibility() {
let toml_str = r#"
[plugin]
id = "test"
name = "Test"
version = "1.0.0"
owlry_version = ">=0.3.0"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.is_compatible_with("0.3.5"));
assert!(!manifest.is_compatible_with("0.2.0"));
}
}
-157
View File
@@ -1,157 +0,0 @@
//! Rune VM runtime creation and sandboxing
use rune::{Context, Diagnostics, Source, Sources, Unit, Vm};
use std::path::Path;
use std::sync::Arc;
use crate::manifest::PluginPermissions;
/// Configuration for the Rune sandbox
///
/// Some fields are reserved for future sandbox enforcement.
#[derive(Debug, Clone)]
#[allow(dead_code)]
#[derive(Default)]
pub struct SandboxConfig {
/// Allow network/HTTP operations
pub network: bool,
/// Allow filesystem operations
pub filesystem: bool,
/// Allowed filesystem paths (reserved for future sandbox enforcement)
pub allowed_paths: Vec<String>,
/// Allow running external commands (reserved for future sandbox enforcement)
pub run_commands: bool,
/// Allowed commands (reserved for future sandbox enforcement)
pub allowed_commands: Vec<String>,
}
impl SandboxConfig {
/// Create sandbox config from plugin permissions
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
Self {
network: permissions.network,
filesystem: !permissions.filesystem.is_empty(),
allowed_paths: permissions.filesystem.clone(),
run_commands: !permissions.run_commands.is_empty(),
allowed_commands: permissions.run_commands.clone(),
}
}
}
/// Create a Rune context with owlry API modules
pub fn create_context(sandbox: &SandboxConfig) -> Result<Context, rune::ContextError> {
let mut context = Context::with_default_modules()?;
// Add standard modules based on permissions
if sandbox.network {
log::debug!("Network access enabled for Rune plugin");
}
if sandbox.filesystem {
log::debug!("Filesystem access enabled for Rune plugin");
}
// Add owlry API module
context.install(crate::api::module()?)?;
Ok(context)
}
/// Compile Rune source code into a Unit
pub fn compile_source(context: &Context, source_path: &Path) -> Result<Arc<Unit>, CompileError> {
let source_content =
std::fs::read_to_string(source_path).map_err(|e| CompileError::Io(e.to_string()))?;
let source_name = source_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("init.rn");
let mut sources = Sources::new();
sources
.insert(
Source::new(source_name, &source_content)
.map_err(|e| CompileError::Compile(e.to_string()))?,
)
.map_err(|e| CompileError::Compile(format!("Failed to insert source: {}", e)))?;
let mut diagnostics = Diagnostics::new();
let result = rune::prepare(&mut sources)
.with_context(context)
.with_diagnostics(&mut diagnostics)
.build();
match result {
Ok(unit) => Ok(Arc::new(unit)),
Err(e) => {
// Collect error messages
let mut error_msg = format!("Compilation failed: {}", e);
for diagnostic in diagnostics.diagnostics() {
error_msg.push_str(&format!("\n {:?}", diagnostic));
}
Err(CompileError::Compile(error_msg))
}
}
}
/// Create a new Rune VM from compiled unit
pub fn create_vm(context: &Context, unit: Arc<Unit>) -> Result<Vm, CompileError> {
let runtime = Arc::new(
context
.runtime()
.map_err(|e| CompileError::Compile(format!("Failed to get runtime: {}", e)))?,
);
Ok(Vm::new(runtime, unit))
}
/// Error type for compilation
#[derive(Debug)]
pub enum CompileError {
Io(String),
Compile(String),
}
impl std::fmt::Display for CompileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CompileError::Io(e) => write!(f, "IO error: {}", e),
CompileError::Compile(e) => write!(f, "Compile error: {}", e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sandbox_config_default() {
let config = SandboxConfig::default();
assert!(!config.network);
assert!(!config.filesystem);
assert!(!config.run_commands);
}
#[test]
fn test_sandbox_from_permissions() {
let permissions = PluginPermissions {
network: true,
filesystem: vec!["~/.config".to_string()],
run_commands: vec!["notify-send".to_string()],
};
let config = SandboxConfig::from_permissions(&permissions);
assert!(config.network);
assert!(config.filesystem);
assert!(config.run_commands);
assert_eq!(config.allowed_paths, vec!["~/.config"]);
assert_eq!(config.allowed_commands, vec!["notify-send"]);
}
#[test]
fn test_create_context() {
let config = SandboxConfig::default();
let context = create_context(&config);
assert!(context.is_ok());
}
}
+58 -20
View File
@@ -1,19 +1,24 @@
[package]
name = "owlry"
version = "1.0.10"
version = "2.0.0"
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"
authors = ["Your Name <you@example.com>"]
authors = ["Owlibou"]
license = "GPL-3.0-or-later"
repository = "https://somegit.dev/Owlibou/owlry"
keywords = ["launcher", "wayland", "gtk4", "linux"]
categories = ["gui"]
[dependencies]
# Core backend library
owlry-core = { path = "../owlry-core" }
[lib]
name = "owlry"
path = "src/lib.rs"
[[bin]]
name = "owlry"
path = "src/main.rs"
[dependencies]
# GTK4 for the UI
gtk4 = { version = "0.10", features = ["v4_12"] }
@@ -23,28 +28,37 @@ gtk4-layer-shell = "0.7"
# Low-level syscalls for stdin detection (dmenu mode)
libc = "0.2"
# Logging
# Logging & notifications
log = "0.4"
env_logger = "0.11"
# Configuration (needed for config types used in app.rs/theme.rs)
serde = { version = "1", features = ["derive"] }
toml = "0.8"
notify-rust = "4"
# CLI argument parsing
clap = { version = "4", features = ["derive"] }
# JSON serialization (needed by plugin commands in CLI)
# Configuration & serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
# Date/time (needed by plugin commands in CLI)
# Provider system & search
fuzzy-matcher = "0.3"
freedesktop-desktop-entry = "0.8"
# Data & filesystem
fs2 = "0.4"
chrono = { version = "0.4", features = ["serde"] }
# Directory utilities (needed by plugin commands)
dirs = "5"
# Semantic versioning (needed by plugin commands)
semver = "1"
# Error handling
thiserror = "2"
# Signal handling (daemon)
signal-hook = "0.3"
# Built-in providers
expr-solver-lib = "1"
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] }
# Async oneshot channel (background thread -> main loop)
futures-channel = "0.3"
@@ -53,9 +67,33 @@ futures-channel = "0.3"
# GResource compilation for bundled icons
glib-build-tools = "0.20"
[dev-dependencies]
tempfile = "3"
[features]
default = []
default = ["systemd"]
# Enable verbose debug logging (for development/testing builds)
dev-logging = ["owlry-core/dev-logging"]
# Enable built-in Lua runtime (disable to use external owlry-lua package)
lua = ["owlry-core/lua"]
dev-logging = []
# Optional providers (compiled in when enabled).
# The AUR PKGBUILD builds with --features "full" to ship everything.
clipboard = []
emoji = []
filesearch = []
ssh = []
systemd = []
websearch = []
# Bookmarks deferred for 2.0 alongside the widget providers (D20+).
# Returns in a later 2.x release with a pure-Rust path that doesn't pull
# in libsqlite3-sys for Firefox's places.sqlite.
full = [
"clipboard",
"emoji",
"filesearch",
"ssh",
"systemd",
"websearch",
]
+12 -34
View File
@@ -1,18 +1,18 @@
use crate::backend::SearchBackend;
use crate::cli::CliArgs;
use crate::client::CoreClient;
use crate::config::Config;
use crate::data::FrecencyStore;
use crate::filter::ProviderFilter;
use crate::paths;
use crate::providers::DmenuProvider;
use crate::providers::{Provider, ProviderManager, ProviderType};
use crate::theme;
use crate::ui::MainWindow;
use gtk4::prelude::*;
use gtk4::{Application, CssProvider, gio};
use gtk4_layer_shell::{Edge, Layer, LayerShell};
use log::{debug, info, warn};
use owlry_core::config::Config;
use owlry_core::data::FrecencyStore;
use owlry_core::filter::ProviderFilter;
use owlry_core::paths;
use owlry_core::providers::{Provider, ProviderManager, ProviderType};
use std::cell::RefCell;
use std::rc::Rc;
@@ -144,41 +144,19 @@ impl OwlryApp {
}
/// Create a local backend as fallback when daemon is unavailable.
/// Loads native plugins and creates providers locally.
fn create_local_backend(config: &Config) -> SearchBackend {
use owlry_core::plugins::native_loader::NativePluginLoader;
use owlry_core::providers::native_provider::NativeProvider;
use owlry_core::providers::{ApplicationProvider, CommandProvider};
use std::sync::Arc;
// Load native plugins
let mut loader = NativePluginLoader::new();
loader.set_disabled(config.plugins.disabled_plugins.clone());
let native_providers: Vec<NativeProvider> = match loader.discover() {
Ok(count) if count > 0 => {
info!("Discovered {} native plugin(s) for local fallback", count);
let plugins: Vec<Arc<owlry_core::plugins::native_loader::NativePlugin>> =
loader.into_plugins();
let mut providers = Vec::new();
for plugin in plugins {
for provider_info in &plugin.providers {
let provider =
NativeProvider::new(Arc::clone(&plugin), provider_info.clone());
providers.push(provider);
}
}
providers
}
_ => Vec::new(),
};
///
/// Only built-in providers run locally — dmenu mode is the typical use case.
/// All other providers belong to the daemon (Phase 1 keeps the daemon as the
/// primary path).
fn create_local_backend(_config: &Config) -> SearchBackend {
use crate::providers::{ApplicationProvider, CommandProvider};
let core_providers: Vec<Box<dyn Provider>> = vec![
Box::new(ApplicationProvider::new()),
Box::new(CommandProvider::new()),
];
let provider_manager = ProviderManager::new(core_providers, native_providers);
let provider_manager = ProviderManager::new(core_providers, Vec::new());
let frecency = FrecencyStore::load_or_default();
SearchBackend::Local {
+8 -11
View File
@@ -4,12 +4,12 @@
//! In dmenu mode, the UI uses a local ProviderManager directly (no daemon).
use crate::client::CoreClient;
use crate::config::Config;
use crate::data::FrecencyStore;
use crate::filter::ProviderFilter;
use crate::ipc::{ProviderDesc, ResultItem};
use crate::providers::{ItemSource, LaunchItem, ProviderManager, ProviderType};
use log::warn;
use owlry_core::config::Config;
use owlry_core::data::FrecencyStore;
use owlry_core::filter::ProviderFilter;
use owlry_core::ipc::{ProviderDesc, ResultItem};
use owlry_core::providers::{ItemSource, LaunchItem, ProviderManager, ProviderType};
use std::sync::{Arc, Mutex};
/// Parameters needed to run a search query on a background thread.
@@ -342,12 +342,9 @@ impl SearchBackend {
}
}
/// 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();
}
}
/// Refresh widget providers. No-op in Phase 1 (widgets deferred per D20).
/// Retained as a stable no-op so callers don't need conditional compilation.
pub fn refresh_widgets(&mut self) {}
/// Get available provider descriptors from the daemon, or from local manager.
pub fn available_providers(&mut self) -> Vec<ProviderDesc> {
+193 -210
View File
@@ -1,255 +1,102 @@
//! Command-line interface for owlry launcher
//!
//! Provides both the launcher interface and plugin management commands.
//! Command-line interface for owlry launcher.
use clap::{Parser, Subcommand};
use owlry_core::providers::ProviderType;
use crate::providers::ProviderType;
#[derive(Parser, Debug, Clone)]
#[command(
name = "owlry",
about = "An owl-themed application launcher for Wayland",
long_about = "An owl-themed application launcher for Wayland, built with GTK4 and Layer Shell.\n\n\
Owlry provides fuzzy search across applications, commands, and plugins.\n\
Native plugins add features like calculator, clipboard, emoji, weather, and more.",
Owlry provides fuzzy search across applications, commands, and built-in providers.",
version,
after_help = "\
EXAMPLES:
owlry Launch with all providers
owlry Launch UI, auto mode
owlry -m auto Launch UI, auto mode (explicit)
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:
owlry daemon Run the daemon (alias: owlry -d)
owlry dmenu [-p <prompt>] dmenu-compatible mode (reads from stdin)
owlry doctor Diagnostics: providers, config, socket
owlry providers [<id>] List providers (or show one)
owlry config validate Check config for errors
owlry config show Print resolved effective config
owlry migrate-config TOML → init.lua (Phase 3+; stub for now)
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:
SEARCH PREFIXES (inside the UI):
: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)
:calc / = Calculator (e.g. = 2+2)
:web / ? Web search
:file / / File search
For configuration, see ~/.config/owlry/config.toml
For plugin management, see: owlry plugin --help"
For configuration, see ~/.config/owlry/config.toml"
)]
pub struct CliArgs {
/// Start in single-provider mode
/// Run the daemon (alias for `owlry daemon`).
#[arg(long, short = 'd', conflicts_with_all = ["mode", "profile", "prompt"])]
pub daemon: bool,
/// Start the UI in a single-provider mode.
///
/// Core modes: app, cmd, dmenu
/// Plugin modes: calc, clip, emoji, ssh, sys, bm, file, web, uuctl, weather, media, pomodoro
/// Core modes: app, cmd, dmenu, auto
/// Built-in modes: calc, conv, power
/// Optional modes (cargo features): bm, clip, emoji, ssh, systemd/uuctl, web, file
#[arg(long, short = 'm', value_parser = parse_provider, value_name = "MODE")]
pub mode: Option<ProviderType>,
/// Use a named profile from config (defines which modes to enable)
///
/// Profiles are defined in config.toml under [profiles.<name>].
/// Example: --profile dev (loads modes from [profiles.dev])
/// Use a named profile from config (defines which modes to enable).
#[arg(long, value_name = "NAME")]
pub profile: Option<String>,
/// Custom prompt text for the search input
///
/// Useful in dmenu mode to indicate what the user is selecting.
/// Example: -p "Select file:" or --prompt "Select file:"
/// Custom prompt text for the search input (mainly useful in dmenu mode).
#[arg(long, short = 'p', value_name = "TEXT")]
pub prompt: Option<String>,
/// Subcommand to run (if any)
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand, Debug, Clone)]
pub enum Command {
/// Manage plugins
#[command(subcommand)]
Plugin(PluginCommand),
}
/// Run the IPC daemon. Same as `owlry -d`.
Daemon,
/// Plugin runtime type
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum PluginRuntime {
/// Lua runtime (requires owlry-lua package)
Lua,
/// Rune runtime (requires owlry-rune package)
Rune,
}
/// dmenu-compatible mode: read stdin, print the selection to stdout.
Dmenu {
/// Prompt text shown in the search input.
#[arg(long, short = 'p', value_name = "TEXT")]
prompt: Option<String>,
},
impl std::fmt::Display for PluginRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PluginRuntime::Lua => write!(f, "lua"),
PluginRuntime::Rune => write!(f, "rune"),
}
}
/// Diagnostics: providers, daemon socket, config status.
Doctor,
/// List providers (or show details for one by id).
Providers {
/// Provider id (e.g. `app`, `uuctl`, `power`). Optional.
id: Option<String>,
},
/// Config tools.
Config {
#[command(subcommand)]
action: ConfigAction,
},
/// Migrate TOML config to init.lua. Stub in 2.0; lands with Phase 3 (Lua config).
MigrateConfig,
}
#[derive(Subcommand, Debug, Clone)]
pub enum PluginCommand {
/// List installed plugins
List {
/// Show only enabled plugins
#[arg(long)]
enabled: bool,
/// Show only disabled plugins
#[arg(long)]
disabled: bool,
/// Filter by runtime type (lua or rune)
#[arg(long, short = 'r', value_enum)]
runtime: Option<PluginRuntime>,
/// Show available plugins from registry instead of installed
#[arg(long)]
available: bool,
/// Force refresh of registry cache
#[arg(long)]
refresh: bool,
/// Output in JSON format
#[arg(long)]
json: bool,
},
/// Search for plugins in the registry
Search {
/// Search query (matches name, description, tags)
query: String,
/// Force refresh of registry cache
#[arg(long)]
refresh: bool,
/// Output in JSON format
#[arg(long)]
json: bool,
},
/// Show detailed information about a plugin
Info {
/// Plugin ID
name: String,
/// Show info from registry instead of installed plugin
#[arg(long)]
registry: bool,
/// Output in JSON format
#[arg(long)]
json: bool,
},
/// Install a plugin from registry, path, or URL
Install {
/// Plugin source (registry name, local path, or git URL)
source: String,
/// Force reinstall even if already installed
#[arg(long, short = 'f')]
force: bool,
},
/// Remove an installed plugin
Remove {
/// Plugin ID to remove
name: String,
/// Don't ask for confirmation
#[arg(long, short = 'y')]
yes: bool,
},
/// Update installed plugins
Update {
/// Specific plugin to update (all if not specified)
name: Option<String>,
},
/// Enable a disabled plugin
Enable {
/// Plugin ID to enable
name: String,
},
/// Disable an installed plugin
Disable {
/// Plugin ID to disable
name: String,
},
/// Create a new plugin from template
Create {
/// Plugin ID (directory name)
name: String,
/// Runtime type to use (default: lua)
#[arg(long, short = 'r', value_enum, default_value = "lua")]
runtime: PluginRuntime,
/// Target directory (default: current directory)
#[arg(long, short = 'd')]
dir: Option<String>,
/// Plugin display name
#[arg(long)]
display_name: Option<String>,
/// Plugin description
#[arg(long)]
description: Option<String>,
},
/// Validate a plugin's structure and manifest
Validate {
/// Path to plugin directory (default: current directory)
path: Option<String>,
},
/// Show available script runtimes
Runtimes,
/// Run a plugin command
///
/// Plugins can provide CLI commands that are invoked via:
/// owlry plugin run <plugin-id> <command> [args...]
///
/// Example:
/// owlry plugin run bookmark add https://example.com "My Bookmark"
Run {
/// Plugin ID
plugin_id: String,
/// Command to run
command: String,
/// Arguments to pass to the command
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
/// List commands provided by a plugin
Commands {
/// Plugin ID (optional - lists all if not specified)
plugin_id: Option<String>,
},
pub enum ConfigAction {
/// Parse config.toml and report any errors.
Validate,
/// Print the resolved effective config (defaults merged with user file).
Show,
}
fn parse_provider(s: &str) -> Result<ProviderType, String> {
@@ -261,3 +108,139 @@ impl CliArgs {
Self::parse()
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn no_args_yields_ui_launch_defaults() {
let args = CliArgs::try_parse_from(["owlry"]).unwrap();
assert!(!args.daemon);
assert!(args.mode.is_none());
assert!(args.profile.is_none());
assert!(args.prompt.is_none());
assert!(args.command.is_none());
}
#[test]
fn dash_d_enables_daemon_mode() {
let args = CliArgs::try_parse_from(["owlry", "-d"]).unwrap();
assert!(args.daemon);
}
#[test]
fn long_daemon_flag_enables_daemon_mode() {
let args = CliArgs::try_parse_from(["owlry", "--daemon"]).unwrap();
assert!(args.daemon);
}
#[test]
fn daemon_flag_conflicts_with_ui_flags() {
assert!(CliArgs::try_parse_from(["owlry", "-d", "-m", "app"]).is_err());
assert!(CliArgs::try_parse_from(["owlry", "-d", "--profile", "dev"]).is_err());
assert!(CliArgs::try_parse_from(["owlry", "-d", "-p", "prompt"]).is_err());
}
#[test]
fn daemon_flag_with_subcommand_resolves_to_daemon_in_dispatcher() {
// clap allows both; main.rs checks args.daemon first and dispatches to
// run_daemon, ignoring the subcommand. Encoded here so the precedence
// can't drift silently.
let args = CliArgs::try_parse_from(["owlry", "-d", "doctor"]).unwrap();
assert!(args.daemon);
assert!(args.command.is_some());
}
#[test]
fn mode_flag_parses_known_providers() {
let args = CliArgs::try_parse_from(["owlry", "-m", "app"]).unwrap();
assert_eq!(args.mode, Some(ProviderType::Application));
let args = CliArgs::try_parse_from(["owlry", "-m", "cmd"]).unwrap();
assert_eq!(args.mode, Some(ProviderType::Command));
let args = CliArgs::try_parse_from(["owlry", "-m", "dmenu"]).unwrap();
assert_eq!(args.mode, Some(ProviderType::Dmenu));
let args = CliArgs::try_parse_from(["owlry", "-m", "uuctl"]).unwrap();
assert_eq!(args.mode, Some(ProviderType::Plugin("uuctl".into())));
// Power aliases all resolve to Plugin("power") per D13.
let args = CliArgs::try_parse_from(["owlry", "-m", "power"]).unwrap();
assert_eq!(args.mode, Some(ProviderType::Plugin("power".into())));
let args = CliArgs::try_parse_from(["owlry", "-m", "sys"]).unwrap();
assert_eq!(args.mode, Some(ProviderType::Plugin("power".into())));
}
#[test]
fn plugin_subcommand_is_no_longer_recognised() {
// The entire `plugin` subcommand tree was dropped in v2.
assert!(CliArgs::try_parse_from(["owlry", "plugin", "list"]).is_err());
assert!(CliArgs::try_parse_from(["owlry", "plugin", "install", "x"]).is_err());
}
#[test]
fn daemon_subcommand_parses() {
let args = CliArgs::try_parse_from(["owlry", "daemon"]).unwrap();
assert!(matches!(args.command, Some(Command::Daemon)));
}
#[test]
fn dmenu_subcommand_with_prompt_parses() {
let args = CliArgs::try_parse_from(["owlry", "dmenu", "-p", "Pick:"]).unwrap();
match args.command {
Some(Command::Dmenu { prompt }) => assert_eq!(prompt.as_deref(), Some("Pick:")),
other => panic!("expected Dmenu, got {other:?}"),
}
}
#[test]
fn doctor_subcommand_parses() {
let args = CliArgs::try_parse_from(["owlry", "doctor"]).unwrap();
assert!(matches!(args.command, Some(Command::Doctor)));
}
#[test]
fn providers_subcommand_with_optional_id() {
let args = CliArgs::try_parse_from(["owlry", "providers"]).unwrap();
match args.command {
Some(Command::Providers { id }) => assert!(id.is_none()),
other => panic!("expected Providers, got {other:?}"),
}
let args = CliArgs::try_parse_from(["owlry", "providers", "uuctl"]).unwrap();
match args.command {
Some(Command::Providers { id }) => assert_eq!(id.as_deref(), Some("uuctl")),
other => panic!("expected Providers, got {other:?}"),
}
}
#[test]
fn config_validate_subcommand_parses() {
let args = CliArgs::try_parse_from(["owlry", "config", "validate"]).unwrap();
assert!(matches!(
args.command,
Some(Command::Config {
action: ConfigAction::Validate
})
));
}
#[test]
fn config_show_subcommand_parses() {
let args = CliArgs::try_parse_from(["owlry", "config", "show"]).unwrap();
assert!(matches!(
args.command,
Some(Command::Config {
action: ConfigAction::Show
})
));
}
#[test]
fn migrate_config_subcommand_parses() {
let args = CliArgs::try_parse_from(["owlry", "migrate-config"]).unwrap();
assert!(matches!(args.command, Some(Command::MigrateConfig)));
}
}
+7 -22
View File
@@ -3,7 +3,7 @@ use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
use owlry_core::ipc::{PluginEntry, ProviderDesc, Request, Response, ResultItem};
use crate::ipc::{ProviderDesc, Request, Response, ResultItem};
/// Maximum allowed size for a single IPC response line (4 MiB).
/// Larger than the request limit because responses carry result sets.
@@ -77,15 +77,13 @@ impl CoreClient {
// Socket not available — try to start the daemon.
let status = std::process::Command::new("systemctl")
.args(["--user", "start", "owlryd"])
.args(["--user", "start", "owlry.service"])
.status()
.map_err(|e| {
io::Error::other(format!("failed to start owlryd via systemd: {e}"))
})?;
.map_err(|e| io::Error::other(format!("failed to start owlry.service via systemd: {e}")))?;
if !status.success() {
return Err(io::Error::other(format!(
"systemctl --user start owlryd exited with status {}",
"systemctl --user start owlry.service exited with status {}",
status
)));
}
@@ -111,10 +109,10 @@ impl CoreClient {
/// Default socket path: `$XDG_RUNTIME_DIR/owlry/owlry.sock`.
///
/// Delegates to `owlry_core::paths::socket_path()` to keep a single
/// Delegates to `crate::paths::socket_path()` to keep a single
/// source of truth.
pub fn socket_path() -> PathBuf {
owlry_core::paths::socket_path()
crate::paths::socket_path()
}
/// Send a search query and return matching results.
@@ -196,19 +194,6 @@ impl CoreClient {
}
}
/// Query the daemon's native plugin registry (loaded + suppressed entries).
pub fn plugin_list(&mut self) -> io::Result<Vec<PluginEntry>> {
self.send(&Request::PluginList)?;
match self.receive()? {
Response::PluginList { entries } => Ok(entries),
Response::Error { message } => Err(io::Error::other(message)),
other => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected response to PluginList: {other:?}"),
)),
}
}
/// Query a plugin's submenu actions.
pub fn submenu(&mut self, plugin_id: &str, data: &str) -> io::Result<Vec<ResultItem>> {
self.send(&Request::Submenu {
@@ -244,7 +229,7 @@ impl CoreClient {
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))
+209
View File
@@ -0,0 +1,209 @@
//! Implementations of CLI subcommands that don't launch the UI.
//!
//! Each function exits the process when done — they're invoked from `main.rs`
//! after subcommand dispatch and never return.
use std::io::{self, Write};
use crate::cli::ConfigAction;
use crate::client::CoreClient;
use crate::config::Config;
use crate::ipc::ProviderDesc;
use crate::{paths, server};
/// `owlry daemon` / `owlry -d`: bind the IPC socket and run the daemon loop.
pub fn run_daemon() -> ! {
let sock = paths::socket_path();
if let Err(e) = paths::ensure_parent_dir(&sock) {
eprintln!("Failed to create socket directory: {e}");
std::process::exit(1);
}
match server::Server::bind(&sock) {
Ok(s) => match s.run() {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("Server error: {e}");
std::process::exit(1);
}
},
Err(e) => {
eprintln!("Failed to start daemon: {e}");
std::process::exit(1);
}
}
}
/// `owlry doctor`: print the daemon socket status, loaded provider list, and
/// config status. Useful for confirming an install is wired correctly.
pub fn run_doctor() -> ! {
let stdout = io::stdout();
let mut out = stdout.lock();
let _ = writeln!(out, "owlry doctor");
let _ = writeln!(out, "============");
let _ = writeln!(out);
// Config check.
let _ = writeln!(out, "[config]");
match Config::load() {
Ok(_) => {
let _ = writeln!(out, " OK");
}
Err(e) => {
let _ = writeln!(out, " ERROR: {e}");
}
}
let _ = writeln!(out);
// Daemon socket.
let sock = paths::socket_path();
let _ = writeln!(out, "[daemon]");
let _ = writeln!(out, " socket path: {}", sock.display());
match CoreClient::connect(&sock) {
Ok(mut client) => {
let _ = writeln!(out, " socket: OK (daemon reachable)");
match client.providers() {
Ok(list) => {
let _ = writeln!(out);
let _ = writeln!(out, "[providers] {} registered", list.len());
print_provider_list(&mut out, &list);
}
Err(e) => {
let _ = writeln!(out, " providers query failed: {e}");
}
}
}
Err(e) => {
let _ = writeln!(out, " socket: UNREACHABLE ({e})");
let _ = writeln!(
out,
" hint: start the daemon with `owlry -d` or via systemd"
);
}
}
std::process::exit(0);
}
/// `owlry providers [<id>]`: list providers, or show one in detail.
pub fn run_providers(id: Option<String>) -> ! {
let sock = paths::socket_path();
let mut client = match CoreClient::connect(&sock) {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to reach daemon socket {}: {}", sock.display(), e);
eprintln!("Hint: start the daemon with `owlry -d`.");
std::process::exit(1);
}
};
let list = match client.providers() {
Ok(l) => l,
Err(e) => {
eprintln!("Daemon providers query failed: {e}");
std::process::exit(1);
}
};
let stdout = io::stdout();
let mut out = stdout.lock();
match id {
None => {
let _ = writeln!(out, "{} provider(s) registered", list.len());
print_provider_list(&mut out, &list);
}
Some(target) => match list.iter().find(|p| p.id == target) {
Some(p) => {
let _ = writeln!(out, "id: {}", p.id);
let _ = writeln!(out, "name: {}", p.name);
let _ = writeln!(out, "icon: {}", p.icon);
let _ = writeln!(out, "position: {}", p.position);
if let Some(prefix) = &p.prefix {
let _ = writeln!(out, "prefix: {prefix}");
}
if let Some(tab_label) = &p.tab_label {
let _ = writeln!(out, "tab_label: {tab_label}");
}
if let Some(search_noun) = &p.search_noun {
let _ = writeln!(out, "search_noun: {search_noun}");
}
}
None => {
eprintln!("No provider with id '{target}'.");
std::process::exit(1);
}
},
}
std::process::exit(0);
}
fn print_provider_list(out: &mut impl Write, list: &[ProviderDesc]) {
for p in list {
let prefix = p.prefix.as_deref().unwrap_or("-");
let _ = writeln!(out, " {:<12} {:<22} prefix={}", p.id, p.name, prefix);
}
}
/// `owlry config validate`: parse the config file and report errors.
pub fn run_config_validate() -> ! {
match Config::load() {
Ok(_) => {
println!("config: OK");
std::process::exit(0);
}
Err(e) => {
eprintln!("config: ERROR — {e}");
std::process::exit(1);
}
}
}
/// `owlry config show`: serialize the effective config to stdout as TOML.
pub fn run_config_show() -> ! {
let cfg = Config::load_or_default();
match toml::to_string_pretty(&cfg) {
Ok(s) => {
print!("{s}");
std::process::exit(0);
}
Err(e) => {
eprintln!("config: failed to serialize ({e})");
std::process::exit(1);
}
}
}
pub fn run_config(action: ConfigAction) -> ! {
match action {
ConfigAction::Validate => run_config_validate(),
ConfigAction::Show => run_config_show(),
}
}
/// `owlry migrate-config`: TOML → init.lua. Lands in Phase 3 (Lua config).
pub fn run_migrate_config() -> ! {
eprintln!(
"migrate-config: not yet implemented.\n\
The Lua config layer lands in 2.1+ (or 3.0). TOML config remains the\n\
active format in 2.0. See docs/RESTRUCTURE-V2.md (phase 3) for status."
);
std::process::exit(2);
}
/// `owlry dmenu`: delegate to the UI's dmenu pipeline.
///
/// The UI reads stdin itself when launched in dmenu mode — we just construct
/// the equivalent of `owlry -m dmenu [-p prompt]` and hand off.
pub fn run_dmenu(prompt: Option<String>) -> ! {
let args = crate::cli::CliArgs {
daemon: false,
mode: Some(crate::providers::ProviderType::Dmenu),
profile: None,
prompt,
command: None,
};
let app = crate::app::OwlryApp::new(args);
std::process::exit(app.run());
}
@@ -103,7 +103,8 @@ pub struct ThemeColors {
pub badge_file: Option<String>,
pub badge_script: Option<String>,
pub badge_ssh: Option<String>,
pub badge_sys: Option<String>,
#[serde(alias = "badge_sys")]
pub badge_power: Option<String>,
pub badge_uuctl: Option<String>,
pub badge_web: Option<String>,
// Widget badge colors
@@ -168,9 +169,28 @@ pub struct ProvidersConfig {
/// Enable built-in unit/currency converter (> trigger)
#[serde(default = "default_true")]
pub converter: bool,
/// Enable built-in system actions (shutdown, reboot, lock, etc.)
/// Enable built-in power actions (shutdown, reboot, lock, etc.)
/// Pre-v2 config key `system` is still accepted as an alias.
#[serde(default = "default_true", alias = "system", alias = "sys")]
pub power: bool,
/// Enable systemd user units provider (alias: uuctl)
#[serde(default = "default_true", alias = "uuctl")]
pub systemd: bool,
/// Enable clipboard history provider (requires cliphist)
#[serde(default = "default_true")]
pub system: bool,
pub clipboard: bool,
/// Enable emoji picker provider
#[serde(default = "default_true")]
pub emoji: bool,
/// Enable filesystem search provider (uses fd or mlocate)
#[serde(default = "default_true")]
pub filesearch: bool,
/// Enable SSH host provider (parses ~/.ssh/config)
#[serde(default = "default_true")]
pub ssh: bool,
/// Enable web search provider (DuckDuckGo / configurable)
#[serde(default = "default_true")]
pub websearch: bool,
/// Enable frecency-based result ranking
#[serde(default = "default_true")]
pub frecency: bool,
@@ -191,7 +211,13 @@ impl Default for ProvidersConfig {
commands: true,
calculator: true,
converter: true,
system: true,
power: true,
systemd: true,
clipboard: true,
emoji: true,
filesearch: true,
ssh: true,
websearch: true,
frecency: true,
frecency_weight: 0.3,
search_engine: "duckduckgo".to_string(),
@@ -232,7 +258,6 @@ pub struct PluginsConfig {
/// Defaults to the official owlry plugin registry if not specified.
#[serde(default)]
pub registry_url: Option<String>,
}
/// Sandbox settings for plugin security
@@ -584,3 +609,41 @@ mod tests {
assert!(!super::command_exists("owlry_nonexistent_binary_abc123"));
}
}
#[cfg(test)]
mod v2_rename_tests {
use super::*;
#[test]
fn providers_config_accepts_power_key() {
let toml = r#"[providers]
power = false
"#;
let cfg: Config = toml::from_str(toml).expect("must parse");
assert!(!cfg.providers.power);
}
#[test]
fn providers_config_accepts_pre_v2_system_alias() {
// Pre-v2 configs used `system = ...`. Serde alias keeps them working.
let toml = r#"[providers]
system = false
"#;
let cfg: Config = toml::from_str(toml).expect("must parse");
assert!(!cfg.providers.power);
}
#[test]
fn theme_colors_accepts_pre_v2_badge_sys_alias() {
// Pre-v2 stylesheets named the color `badge_sys`. Serde alias keeps
// existing user configs working.
let toml = r##"[appearance.colors]
badge_sys = "#ff8800"
"##;
let cfg: Config = toml::from_str(toml).expect("must parse");
assert_eq!(
cfg.appearance.colors.badge_power.as_deref(),
Some("#ff8800")
);
}
}
@@ -67,9 +67,9 @@ impl FrecencyStore {
let cutoff = now - chrono::Duration::days(PRUNE_AGE_DAYS);
let before = self.data.entries.len();
self.data.entries.retain(|_, e| {
e.last_launch > cutoff || e.launch_count >= MIN_LAUNCHES_TO_KEEP
});
self.data
.entries
.retain(|_, e| e.last_launch > cutoff || e.launch_count >= MIN_LAUNCHES_TO_KEEP);
if self.data.entries.len() > MAX_ENTRIES {
// Sort by score descending and keep the top MAX_ENTRIES
@@ -78,18 +78,28 @@ impl FrecencyStore {
.entries
.iter()
.map(|(k, e)| {
(k.clone(), Self::calculate_frecency_at(e.launch_count, e.last_launch, now))
(
k.clone(),
Self::calculate_frecency_at(e.launch_count, e.last_launch, now),
)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let keep: std::collections::HashSet<String> =
scored.into_iter().take(MAX_ENTRIES).map(|(k, _)| k).collect();
let keep: std::collections::HashSet<String> = scored
.into_iter()
.take(MAX_ENTRIES)
.map(|(k, _)| k)
.collect();
self.data.entries.retain(|k, _| keep.contains(k));
}
let removed = before - self.data.entries.len();
if removed > 0 {
info!("Frecency: pruned {} stale entries ({} remaining)", removed, self.data.entries.len());
info!(
"Frecency: pruned {} stale entries ({} remaining)",
removed,
self.data.entries.len()
);
self.dirty = true;
}
}
@@ -185,7 +195,11 @@ impl FrecencyStore {
}
/// Calculate frecency using a caller-provided timestamp.
fn calculate_frecency_at(launch_count: u32, last_launch: DateTime<Utc>, now: DateTime<Utc>) -> f64 {
fn calculate_frecency_at(
launch_count: u32,
last_launch: DateTime<Utc>,
now: DateTime<Utc>,
) -> f64 {
let age = now.signed_duration_since(last_launch);
let age_days = age.num_hours() as f64 / 24.0;
@@ -241,9 +241,9 @@ impl ProviderFilter {
("script", "scripts"),
("scripts", "scripts"),
("ssh", "ssh"),
("sys", "system"),
("system", "system"),
("power", "system"),
("sys", "power"),
("system", "power"),
("power", "power"),
("uuctl", "uuctl"),
("systemd", "uuctl"),
("web", "websearch"),
@@ -321,14 +321,22 @@ impl ProviderFilter {
if let Some(rest) = trimmed.strip_prefix(':') {
if let Some(space_idx) = rest.find(' ') {
let prefix_word = &rest[..space_idx];
if !prefix_word.is_empty() && prefix_word.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
if !prefix_word.is_empty()
&& prefix_word
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return ParsedQuery {
prefix: Some(ProviderType::Plugin(prefix_word.to_string())),
tag_filter: None,
query: rest[space_idx + 1..].to_string(),
};
}
} else if !rest.is_empty() && rest.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
} else if !rest.is_empty()
&& rest
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
// Partial prefix (no space yet)
return ParsedQuery {
prefix: Some(ProviderType::Plugin(rest.to_string())),
@@ -24,8 +24,6 @@ pub enum Request {
PluginAction {
command: String,
},
/// Query the daemon's plugin registry (native plugins + suppressed entries).
PluginList,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -34,30 +32,10 @@ pub enum Response {
Results { items: Vec<ResultItem> },
Providers { list: Vec<ProviderDesc> },
SubmenuItems { items: Vec<ResultItem> },
PluginList { entries: Vec<PluginEntry> },
Ack,
Error { message: String },
}
/// Registry entry for a loaded or suppressed plugin (native plugins only).
/// Script plugins are tracked separately via filesystem discovery.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginEntry {
pub id: String,
pub name: String,
pub version: String,
/// Plugin runtime type: "native", "builtin"
pub runtime: String,
/// Load status: "active" or "suppressed"
pub status: String,
/// Human-readable detail for non-active status (e.g. suppression reason)
#[serde(default, skip_serializing_if = "String::is_empty")]
pub status_detail: String,
/// Provider type IDs registered by this plugin
#[serde(default)]
pub providers: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResultItem {
pub id: String,
@@ -72,7 +50,7 @@ pub struct ResultItem {
pub terminal: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// Item trust level: "core", "native_plugin", or "script_plugin".
/// Item trust level: "core" or "script_plugin".
/// Defaults to "core" when absent (backwards-compatible with old daemons).
#[serde(default = "default_source")]
pub source: String,
+21
View File
@@ -0,0 +1,21 @@
//! Owlry — Wayland application launcher.
//!
//! Single-crate layout (v2): the daemon, IPC layer, providers, and GTK4 UI
//! all live here. The binary entry point in `main.rs` selects between UI
//! launch (default) and daemon mode (`-d` / `--daemon`).
pub mod app;
pub mod backend;
pub mod cli;
pub mod client;
pub mod commands;
pub mod config;
pub mod data;
pub mod filter;
pub mod ipc;
pub mod notify;
pub mod paths;
pub mod providers;
pub mod server;
pub mod theme;
pub mod ui;
+43 -39
View File
@@ -1,17 +1,10 @@
mod app;
mod backend;
mod cli;
pub mod client;
mod plugin_commands;
mod providers;
mod theme;
mod ui;
use app::OwlryApp;
use cli::{CliArgs, Command};
use log::{info, warn};
use std::os::unix::io::AsRawFd;
use owlry::app::OwlryApp;
use owlry::cli::{CliArgs, Command};
use owlry::{client, commands, paths};
#[cfg(feature = "dev-logging")]
use log::debug;
@@ -23,12 +16,8 @@ use log::debug;
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");
let lock_path = 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);
}
@@ -50,30 +39,34 @@ fn try_acquire_lock() -> Option<std::fs::File> {
fn main() {
let args = CliArgs::parse_args();
// Handle subcommands before initializing the full app
if let Some(command) = &args.command {
// CLI commands don't need full logging
match command {
Command::Plugin(plugin_cmd) => {
if let Err(e) = plugin_commands::execute(plugin_cmd.clone()) {
eprintln!("Error: {}", e);
std::process::exit(1);
}
std::process::exit(0);
}
// Subcommand dispatch. Each commands::* function calls std::process::exit
// and never returns. Daemon mode also installs env_logger; the other
// subcommands stay quiet by default.
if args.daemon {
init_logger();
commands::run_daemon();
}
match args.command.clone() {
Some(Command::Daemon) => {
init_logger();
commands::run_daemon();
}
Some(Command::Doctor) => commands::run_doctor(),
Some(Command::Providers { id }) => commands::run_providers(id),
Some(Command::Config { action }) => commands::run_config(action),
Some(Command::MigrateConfig) => commands::run_migrate_config(),
Some(Command::Dmenu { prompt }) => {
init_logger();
commands::run_dmenu(prompt);
}
None => {
// Fall through to UI launch below.
}
}
// No subcommand - launch the app
let default_level = if cfg!(feature = "dev-logging") {
"debug"
} else {
"info"
};
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level))
.format_timestamp_millis()
.init();
// Default: launch the UI.
init_logger();
#[cfg(feature = "dev-logging")]
{
@@ -88,7 +81,6 @@ fn main() {
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) {
@@ -106,7 +98,7 @@ fn main() {
info!("Starting Owlry launcher");
// Diagnostic: log critical environment variables
// Diagnostic: log critical environment variables.
let home = std::env::var("HOME").unwrap_or_else(|_| "<not set>".to_string());
let path = std::env::var("PATH").unwrap_or_else(|_| "<not set>".to_string());
let xdg_data = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| "<not set>".to_string());
@@ -121,3 +113,15 @@ fn main() {
let app = OwlryApp::new(args);
std::process::exit(app.run());
}
fn init_logger() {
let default_level = if cfg!(feature = "dev-logging") {
"debug"
} else {
"info"
};
let _ =
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level))
.format_timestamp_millis()
.try_init();
}
@@ -157,10 +157,17 @@ pub fn system_data_dirs() -> Vec<PathBuf> {
// Runtime files
// =============================================================================
/// IPC socket path: `$XDG_RUNTIME_DIR/owlry/owlry.sock`
/// IPC socket path.
///
/// Falls back to `/tmp` if `$XDG_RUNTIME_DIR` is not set.
/// Resolution order:
/// 1. `$OWLRY_SOCKET` — full path override (useful for tests and side-by-side
/// daemon instances).
/// 2. `$XDG_RUNTIME_DIR/owlry/owlry.sock` (the normal case).
/// 3. `/tmp/owlry/owlry.sock` as a last-resort fallback.
pub fn socket_path() -> PathBuf {
if let Ok(custom) = std::env::var("OWLRY_SOCKET") {
return PathBuf::from(custom);
}
let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"));
@@ -202,6 +209,22 @@ mod tests {
}
}
#[test]
fn owlry_socket_env_overrides_xdg_runtime() {
// SAFETY: tests in this binary do not run in parallel against the env.
// Save / restore so other tests in this module aren't affected.
let prev = std::env::var_os("OWLRY_SOCKET");
unsafe { std::env::set_var("OWLRY_SOCKET", "/tmp/owlry-smoke/foo.sock") };
assert_eq!(
socket_path(),
PathBuf::from("/tmp/owlry-smoke/foo.sock")
);
match prev {
Some(v) => unsafe { std::env::set_var("OWLRY_SOCKET", v) },
None => unsafe { std::env::remove_var("OWLRY_SOCKET") },
}
}
#[test]
fn test_frecency_in_data_dir() {
if let Some(path) = frecency_file() {
File diff suppressed because it is too large Load Diff
@@ -301,7 +301,10 @@ mod tests {
#[test]
fn test_clean_desktop_exec_collapses_spaces() {
assert_eq!(clean_desktop_exec_field("app --flag arg"), "app --flag arg");
assert_eq!(
clean_desktop_exec_field("app --flag arg"),
"app --flag arg"
);
let input = format!("app{}arg", " ".repeat(100));
assert_eq!(clean_desktop_exec_field(&input), "app arg");
}
@@ -31,10 +31,8 @@ impl DynamicProvider for CalculatorProvider {
match eval_math(expr) {
Ok(result) => {
let display = format_result(result);
let copy_cmd = format!(
"printf '%s' '{}' | wl-copy",
display.replace('\'', "'\\''")
);
let copy_cmd =
format!("printf '%s' '{}' | wl-copy", display.replace('\'', "'\\''"));
vec![LaunchItem {
id: format!("calc:{}", expr),
name: display.clone(),
@@ -105,11 +103,8 @@ fn extract_expression(query: &str) -> Option<&str> {
fn looks_like_math(s: &str) -> bool {
// Must contain at least one digit or a known constant/function name
let has_digit = s.chars().any(|c| c.is_ascii_digit());
let has_operator = s.contains('+')
|| s.contains('*')
|| s.contains('/')
|| s.contains('^')
|| s.contains('%');
let has_operator =
s.contains('+') || s.contains('*') || s.contains('/') || s.contains('^') || s.contains('%');
// Subtraction/negation is ambiguous; only count it as an operator when
// there are already digits present to avoid matching bare words with hyphens.
let has_minus_operator = has_digit && s.contains('-');
+249
View File
@@ -0,0 +1,249 @@
//! Clipboard history provider.
//!
//! Static provider that surfaces `cliphist` entries as launch items. Selecting
//! an item runs `cliphist decode | wl-copy` to put the entry back on the
//! clipboard. Requires `cliphist` and `wl-clipboard` to be installed.
use super::{ItemSource, LaunchItem, Provider, ProviderType};
use std::process::Command;
const TYPE_ID: &str = "clipboard";
const PROVIDER_ICON: &str = "edit-paste";
const DEFAULT_MAX_ENTRIES: usize = 50;
pub struct ClipboardProvider {
items: Vec<LaunchItem>,
max_entries: usize,
}
impl Default for ClipboardProvider {
fn default() -> Self {
Self::new()
}
}
impl ClipboardProvider {
pub fn new() -> Self {
Self {
items: Vec::new(),
max_entries: DEFAULT_MAX_ENTRIES,
}
}
fn has_cliphist() -> bool {
Command::new("which")
.arg("cliphist")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn parse_cliphist_output(output: &str, max_entries: usize) -> Vec<LaunchItem> {
let mut items = Vec::new();
for (idx, line) in output.lines().take(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/tabs with spaces, strip CR.
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('\'', "'\\''")
);
items.push(LaunchItem {
id: format!("clipboard:{}", idx),
name: preview_clean,
description: Some("Copy to clipboard".to_string()),
icon: Some(PROVIDER_ICON.to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command,
terminal: false,
tags: vec!["clipboard".to_string()],
source: ItemSource::Core,
});
}
items
}
}
impl Provider for ClipboardProvider {
fn name(&self) -> &str {
"Clipboard"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(TYPE_ID.into())
}
fn refresh(&mut self) {
self.items.clear();
if !Self::has_cliphist() {
return;
}
let output = match Command::new("cliphist").arg("list").output() {
Ok(o) if o.status.success() => o,
_ => return,
};
let stdout = String::from_utf8_lossy(&output.stdout);
self.items = Self::parse_cliphist_output(&stdout, self.max_entries);
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
fn prefix(&self) -> Option<&str> {
Some(":clip")
}
fn icon(&self) -> &str {
PROVIDER_ICON
}
fn tab_label(&self) -> Option<&str> {
Some("Clipboard")
}
fn search_noun(&self) -> Option<&str> {
Some("clipboard entries")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clipboard_provider_new() {
let p = ClipboardProvider::new();
assert!(p.items.is_empty());
assert_eq!(p.max_entries, DEFAULT_MAX_ENTRIES);
}
#[test]
fn test_preview_truncation() {
// Long ASCII strings get truncated char-safely to 80 chars with "..." suffix.
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() {
// Multi-byte UTF-8 chars must not split mid-codepoint.
let utf8_text = "├── ".repeat(30);
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 _ = ClipboardProvider::has_cliphist();
}
#[test]
fn parse_cliphist_output_builds_items_with_plugin_provider_type() {
let output = "1\thello world\n2\tanother entry\n";
let items = ClipboardProvider::parse_cliphist_output(output, DEFAULT_MAX_ENTRIES);
assert_eq!(items.len(), 2);
assert_eq!(items[0].name, "hello world");
assert_eq!(items[0].provider, ProviderType::Plugin("clipboard".into()));
assert_eq!(items[0].source, ItemSource::Core);
assert!(items[0].command.contains("cliphist decode"));
assert!(items[0].command.contains("wl-copy"));
assert!(!items[0].terminal);
}
#[test]
fn parse_cliphist_output_respects_max_entries() {
let output = "1\ta\n2\tb\n3\tc\n4\td\n";
let items = ClipboardProvider::parse_cliphist_output(output, 2);
assert_eq!(items.len(), 2);
}
#[test]
fn parse_cliphist_output_handles_missing_preview_as_binary() {
let output = "42\n";
let items = ClipboardProvider::parse_cliphist_output(output, DEFAULT_MAX_ENTRIES);
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "[binary data]");
}
#[test]
fn provider_type_is_clipboard_plugin() {
// CLI back-compat: `-m clip` / `:clip` resolve to this type_id.
let p = ClipboardProvider::new();
assert_eq!(p.provider_type(), ProviderType::Plugin("clipboard".into()));
}
#[test]
fn provider_metadata_exposes_clip_prefix_and_labels() {
let p = ClipboardProvider::new();
assert_eq!(p.prefix(), Some(":clip"));
assert_eq!(p.icon(), "edit-paste");
assert_eq!(p.tab_label(), Some("Clipboard"));
assert_eq!(p.search_noun(), Some("clipboard entries"));
}
}
@@ -167,8 +167,16 @@ fn convert_currency(value: f64, from: &str, to: &str) -> Option<ConversionResult
let from_code = currency::resolve_currency_code(from)?;
let to_code = currency::resolve_currency_code(to)?;
let from_rate = if from_code == "EUR" { 1.0 } else { *rates.rates.get(from_code)? };
let to_rate = if to_code == "EUR" { 1.0 } else { *rates.rates.get(to_code)? };
let from_rate = if from_code == "EUR" {
1.0
} else {
*rates.rates.get(from_code)?
};
let to_rate = if to_code == "EUR" {
1.0
} else {
*rates.rates.get(to_code)?
};
let result = value / from_rate * to_rate;
Some(format_currency_result(result, to_code))
@@ -198,7 +206,11 @@ fn convert_currency_common(value: f64, from: &str) -> Vec<ConversionResult> {
.iter()
.filter(|&&sym| sym != from_code)
.filter_map(|&sym| {
let to_rate = if sym == "EUR" { 1.0 } else { *rates.rates.get(sym)? };
let to_rate = if sym == "EUR" {
1.0
} else {
*rates.rates.get(sym)?
};
let result = value / from_rate * to_rate;
Some(format_currency_result(result, sym))
})
@@ -939,6 +951,10 @@ mod tests {
let r = convert_to(&100.0, "km", "mi").unwrap();
// display_value should contain the symbol exactly once
let count = r.display_value.matches(&r.target_symbol).count();
assert_eq!(count, 1, "display_value '{}' should contain '{}' exactly once", r.display_value, r.target_symbol);
assert_eq!(
count, 1,
"display_value '{}' should contain '{}' exactly once",
r.display_value, r.target_symbol
);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
use crate::providers::{ItemSource, LaunchItem, Provider, ProviderType};
use log::debug;
use owlry_core::providers::{ItemSource, LaunchItem, Provider, ProviderType};
use std::io::{self, BufRead};
/// Provider for dmenu-style input from stdin
+515
View File
@@ -0,0 +1,515 @@
//! Emoji provider.
//!
//! Static provider exposing a curated set of emojis. Selecting an item copies
//! the emoji glyph to the Wayland clipboard via `wl-copy`. Search matches the
//! human-readable name and the keyword bag.
use super::{ItemSource, LaunchItem, Provider, ProviderType};
const TYPE_ID: &str = "emoji";
/// (emoji, name, space-separated keywords)
const 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"),
];
pub struct EmojiProvider {
items: Vec<LaunchItem>,
}
impl Default for EmojiProvider {
fn default() -> Self {
Self::new()
}
}
impl EmojiProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn build_items() -> Vec<LaunchItem> {
EMOJIS
.iter()
.map(|(emoji, name, keywords)| {
let mut tags = vec![name.to_string()];
if !keywords.is_empty() {
tags.push(keywords.to_string());
}
LaunchItem {
id: format!("emoji:{}", emoji),
name: name.to_string(),
description: Some(format!("{} {}", emoji, keywords)),
icon: Some((*emoji).to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command: format!("printf '%s' '{}' | wl-copy", emoji),
terminal: false,
tags,
source: ItemSource::Core,
}
})
.collect()
}
}
impl Provider for EmojiProvider {
fn name(&self) -> &str {
"Emoji"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(TYPE_ID.into())
}
fn refresh(&mut self) {
self.items = Self::build_items();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
fn prefix(&self) -> Option<&str> {
Some(":emoji")
}
fn icon(&self) -> &str {
"face-smile"
}
fn tab_label(&self) -> Option<&str> {
Some("Emoji")
}
fn search_noun(&self) -> Option<&str> {
Some("emoji")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emoji_state_new() {
let mut provider = EmojiProvider::new();
provider.refresh();
assert!(
provider.items().len() > 100,
"Should have more than 100 emojis loaded after refresh"
);
}
#[test]
fn test_emoji_has_grinning_face() {
let mut provider = EmojiProvider::new();
provider.refresh();
let grinning = provider.items().iter().find(|i| i.name == "grinning face");
assert!(grinning.is_some());
let item = grinning.unwrap();
assert!(item.description.as_ref().unwrap().contains("😀"));
}
#[test]
fn test_emoji_command_format() {
let mut provider = EmojiProvider::new();
provider.refresh();
let item = &provider.items()[0];
assert!(item.command.contains("wl-copy"));
assert!(item.command.contains("printf"));
}
#[test]
fn test_emojis_have_keywords() {
let mut provider = EmojiProvider::new();
provider.refresh();
let heart = provider.items().iter().find(|i| i.name == "red heart");
assert!(heart.is_some());
}
#[test]
fn provider_type_is_emoji_plugin() {
let provider = EmojiProvider::new();
assert_eq!(
provider.provider_type(),
ProviderType::Plugin("emoji".into())
);
}
}
+267
View File
@@ -0,0 +1,267 @@
//! File search provider.
//!
//! Dynamic provider that searches for files using `fd` (preferred) or
//! `locate`. Triggered by:
//! - `/ name` / `/name` (slash prefix)
//! - `file name` / `find name` (word prefix)
//!
//! External dependencies:
//! - `fd` (preferred) or `locate`
use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType};
use std::path::Path;
use std::process::Command;
const TYPE_ID: &str = "filesearch";
const MAX_RESULTS: usize = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchTool {
Fd,
Locate,
None,
}
/// Dynamic file search provider — shells out to `fd` or `locate` per keystroke.
pub(crate) struct FileSearchProvider {
search_tool: SearchTool,
// TODO(v2.x): plumb via constructor (search roots, extra flags).
home: String,
}
impl Default for FileSearchProvider {
fn default() -> Self {
Self::new()
}
}
impl FileSearchProvider {
pub fn new() -> Self {
let search_tool = Self::detect_search_tool();
// TODO(v2.x): plumb via constructor.
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)
}
}
}
fn evaluate(&self, query: &str) -> Vec<LaunchItem> {
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<LaunchItem> {
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<LaunchItem> {
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<LaunchItem> {
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<LaunchItem> {
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)
};
let filename = Path::new(&full_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| full_path.clone());
let is_dir = Path::new(&full_path).is_dir();
let icon = if is_dir { "folder" } else { "text-x-generic" };
let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''"));
LaunchItem {
id: format!("file:{}", full_path),
name: filename,
description: Some(full_path.clone()),
icon: Some(icon.to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command,
terminal: false,
tags: vec!["file".to_string()],
source: ItemSource::Core,
}
})
.collect()
}
}
impl DynamicProvider for FileSearchProvider {
fn name(&self) -> &str {
"Files"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(TYPE_ID.into())
}
fn priority(&self) -> u32 {
8_000
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
self.evaluate(query)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_search_term() {
assert_eq!(
FileSearchProvider::extract_search_term("/ config.toml"),
Some("config.toml")
);
assert_eq!(
FileSearchProvider::extract_search_term("/config"),
Some("config")
);
assert_eq!(
FileSearchProvider::extract_search_term("file bashrc"),
Some("bashrc")
);
assert_eq!(
FileSearchProvider::extract_search_term("find readme"),
Some("readme")
);
}
#[test]
fn test_extract_search_term_empty() {
assert_eq!(FileSearchProvider::extract_search_term("/"), Some(""));
assert_eq!(FileSearchProvider::extract_search_term("/ "), Some(""));
}
#[test]
fn test_command_exists() {
// 'which' should exist on any Unix system.
assert!(FileSearchProvider::command_exists("which"));
// This should not exist.
assert!(!FileSearchProvider::command_exists(
"nonexistent-command-12345"
));
}
#[test]
fn test_detect_search_tool() {
// Just ensure it doesn't panic.
let _ = FileSearchProvider::detect_search_tool();
}
#[test]
fn test_state_new() {
let provider = FileSearchProvider::new();
assert!(!provider.home.is_empty());
}
#[test]
fn test_evaluate_empty() {
let provider = FileSearchProvider::new();
let results = provider.evaluate("/");
assert!(results.is_empty());
let results = provider.evaluate("/ ");
assert!(results.is_empty());
}
#[test]
fn provider_type_is_filesearch_plugin() {
let p = FileSearchProvider::new();
assert_eq!(p.provider_type(), ProviderType::Plugin("filesearch".into()));
}
}
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,20 @@
use super::{ItemSource, LaunchItem, Provider, ProviderType};
/// Built-in system provider. Returns a fixed set of power and session management actions.
/// Built-in power & session provider. Returns a fixed set of shutdown / reboot /
/// suspend / lock / logout actions.
///
/// This is a static provider — items are populated in `new()` and `refresh()` is a no-op.
pub(crate) struct SystemProvider {
/// Static provider — items are populated in `new()` and `refresh()` is a no-op.
///
/// type_id is `"power"`. CLI aliases `:sys` and `:system` still resolve here for
/// muscle-memory back-compat (see [`crate::providers::ProviderType::FromStr`]
/// and [`crate::filter::ProviderFilter::parse_query`]).
pub(crate) struct PowerProvider {
items: Vec<LaunchItem>,
}
impl SystemProvider {
const TYPE_ID: &str = "power";
impl PowerProvider {
pub fn new() -> Self {
let commands: &[(&str, &str, &str, &str, &str)] = &[
(
@@ -64,14 +71,14 @@ impl SystemProvider {
let items = commands
.iter()
.map(|(action_id, name, description, icon, command)| LaunchItem {
id: format!("sys:{}", action_id),
id: format!("power:{}", action_id),
name: name.to_string(),
description: Some(description.to_string()),
icon: Some(icon.to_string()),
provider: ProviderType::Plugin("sys".into()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command: command.to_string(),
terminal: false,
tags: vec!["system".into()],
tags: vec!["power".into()],
source: ItemSource::Core,
})
.collect();
@@ -80,13 +87,13 @@ impl SystemProvider {
}
}
impl Provider for SystemProvider {
impl Provider for PowerProvider {
fn name(&self) -> &str {
"System"
"Power"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin("sys".into())
ProviderType::Plugin(TYPE_ID.into())
}
fn refresh(&mut self) {
@@ -96,6 +103,22 @@ impl Provider for SystemProvider {
fn items(&self) -> &[LaunchItem] {
&self.items
}
fn prefix(&self) -> Option<&str> {
Some(":power")
}
fn icon(&self) -> &str {
"system-shutdown"
}
fn tab_label(&self) -> Option<&str> {
Some("Power")
}
fn search_noun(&self) -> Option<&str> {
Some("power actions")
}
}
#[cfg(test)]
@@ -104,13 +127,13 @@ mod tests {
#[test]
fn has_seven_actions() {
let provider = SystemProvider::new();
let provider = PowerProvider::new();
assert_eq!(provider.items().len(), 7);
}
#[test]
fn contains_expected_action_names() {
let provider = SystemProvider::new();
let provider = PowerProvider::new();
let names: Vec<&str> = provider.items().iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"Shutdown"));
assert!(names.contains(&"Reboot"));
@@ -119,14 +142,17 @@ mod tests {
}
#[test]
fn provider_type_is_sys_plugin() {
let provider = SystemProvider::new();
assert_eq!(provider.provider_type(), ProviderType::Plugin("sys".into()));
fn provider_type_is_power_plugin() {
let provider = PowerProvider::new();
assert_eq!(
provider.provider_type(),
ProviderType::Plugin("power".into())
);
}
#[test]
fn shutdown_command_is_correct() {
let provider = SystemProvider::new();
let provider = PowerProvider::new();
let shutdown = provider
.items()
.iter()
@@ -136,14 +162,28 @@ mod tests {
}
#[test]
fn all_items_have_system_tag() {
let provider = SystemProvider::new();
fn all_items_have_power_tag() {
let provider = PowerProvider::new();
for item in provider.items() {
assert!(
item.tags.contains(&"system".to_string()),
"item '{}' is missing 'system' tag",
item.tags.contains(&"power".to_string()),
"item '{}' is missing 'power' tag",
item.name
);
}
}
#[test]
fn all_item_ids_use_power_prefix() {
// Frecency / IPC compatibility check: every item id must start with
// the canonical "power:" prefix after the v2 rename.
let provider = PowerProvider::new();
for item in provider.items() {
assert!(
item.id.starts_with("power:"),
"item id '{}' should start with 'power:'",
item.id
);
}
}
}
+290
View File
@@ -0,0 +1,290 @@
//! SSH hosts provider.
//!
//! Parses `~/.ssh/config` and exposes each non-wildcard `Host` entry as a
//! launchable item that opens an `ssh <host>` session in the user's terminal.
use super::{ItemSource, LaunchItem, Provider, ProviderType};
use std::fs;
use std::path::PathBuf;
const TYPE_ID: &str = "ssh";
const ICON: &str = "utilities-terminal";
pub struct SshProvider {
items: Vec<LaunchItem>,
terminal_command: String,
}
impl Default for SshProvider {
fn default() -> Self {
Self::new()
}
}
impl SshProvider {
pub fn new() -> Self {
Self {
items: Vec::new(),
terminal_command: Self::load_terminal_from_config(),
}
}
fn load_terminal_from_config() -> String {
// Try [plugins.ssh] in config.toml
let config_path = dirs::config_dir().map(|d| d.join("owlry").join("config.toml"));
if let Some(content) = config_path.and_then(|p| fs::read_to_string(p).ok())
&& let Ok(toml) = content.parse::<toml::Table>()
&& let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(ssh) = plugins.get("ssh").and_then(|v| v.as_table())
&& let Some(terminal) = ssh.get("terminal").and_then(|v| v.as_str())
{
return terminal.to_string();
}
// Fall back to $TERMINAL env var
if let Ok(terminal) = std::env::var("TERMINAL") {
return terminal;
}
// Last resort
"xdg-terminal-exec".to_string()
}
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);
self.items.push(LaunchItem {
id: format!("ssh:{}", host),
name: format!("SSH: {}", host),
description,
icon: Some(ICON.to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command,
terminal: false,
tags: vec!["ssh".to_string(), "remote".to_string()],
source: ItemSource::Core,
});
}
}
impl Provider for SshProvider {
fn name(&self) -> &str {
"SSH"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(TYPE_ID.into())
}
fn refresh(&mut self) {
self.parse_ssh_config();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
fn prefix(&self) -> Option<&str> {
Some(":ssh")
}
fn icon(&self) -> &str {
ICON
}
fn tab_label(&self) -> Option<&str> {
Some("SSH")
}
fn search_noun(&self) -> Option<&str> {
Some("SSH hosts")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssh_provider_new() {
let p = SshProvider::new();
assert!(p.items.is_empty());
}
#[test]
fn test_parse_simple_config() {
let mut p = SshProvider::new();
// We can't easily test the full flow without mocking file paths,
// but we can test the add_host_item method
p.add_host_item(
"myserver",
Some("192.168.1.100".to_string()),
Some("admin".to_string()),
Some("2222".to_string()),
);
assert_eq!(p.items.len(), 1);
assert_eq!(p.items[0].name, "SSH: myserver");
assert!(p.items[0].command.contains("ssh myserver"));
}
#[test]
fn test_add_host_without_details() {
let mut p = SshProvider::new();
p.add_host_item("simple-host", None, None, None);
assert_eq!(p.items.len(), 1);
assert_eq!(p.items[0].name, "SSH: simple-host");
assert!(p.items[0].description.is_none());
}
#[test]
fn test_add_host_with_partial_details() {
let mut p = SshProvider::new();
p.add_host_item("partial", Some("example.com".to_string()), None, None);
assert_eq!(p.items.len(), 1);
let desc = p.items[0].description.as_ref().unwrap();
assert_eq!(desc, "example.com");
}
#[test]
fn test_items_have_icons() {
let mut p = SshProvider::new();
p.add_host_item("test", None, None, None);
assert!(p.items[0].icon.is_some());
assert_eq!(p.items[0].icon.as_ref().unwrap(), ICON);
}
#[test]
fn test_items_have_keywords() {
let mut p = SshProvider::new();
p.add_host_item("test", None, None, None);
assert!(!p.items[0].tags.is_empty());
assert!(p.items[0].tags.iter().any(|t| t == "ssh"));
}
#[test]
fn provider_type_is_ssh_plugin() {
let p = SshProvider::new();
assert_eq!(p.provider_type(), ProviderType::Plugin("ssh".into()));
}
}
+364
View File
@@ -0,0 +1,364 @@
//! systemd user services provider.
//!
//! Lists user-level systemd services and exposes start/stop/restart/enable/
//! disable/status/journal as submenu actions. type_id stays `uuctl` for CLI
//! and config back-compat.
use super::{ItemSource, LaunchItem, Provider, ProviderType};
use std::process::Command;
const TYPE_ID: &str = "uuctl";
pub struct SystemdProvider {
items: Vec<LaunchItem>,
}
impl Default for SystemdProvider {
fn default() -> Self {
Self::new()
}
}
impl SystemdProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
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<LaunchItem> {
let mut items = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.split_whitespace();
let unit_name = match parts.next() {
Some(u) => u,
None => continue,
};
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(" ");
let display_name = clean_display_name(unit_name);
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)
};
// SUBMENU:<type_id>:<data> is the protocol the UI uses to route
// a selection back to this provider's submenu_actions().
let submenu_data = format!("SUBMENU:{}:{}:{}", TYPE_ID, unit_name, is_active);
let icon = if is_active {
"emblem-ok-symbolic"
} else {
"emblem-pause-symbolic"
};
items.push(LaunchItem {
id: format!("systemd:service:{}", unit_name),
name: display_name,
description: Some(status_desc),
icon: Some(icon.to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command: submenu_data,
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
source: ItemSource::Core,
});
}
items
}
}
impl Provider for SystemdProvider {
fn name(&self) -> &str {
"User Units"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(TYPE_ID.into())
}
fn refresh(&mut self) {
self.items.clear();
if !Self::systemctl_available() {
return;
}
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);
self.items
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
fn prefix(&self) -> Option<&str> {
Some(":uuctl")
}
fn icon(&self) -> &str {
"system-run"
}
fn tab_label(&self) -> Option<&str> {
Some("Units")
}
fn search_noun(&self) -> Option<&str> {
Some("systemd units")
}
/// Submenu data is `<unit_name>:<is_active>` (encoded by `refresh()`).
fn submenu_actions(&self, data: &str) -> Vec<LaunchItem> {
let parts: Vec<&str> = data.splitn(2, ':').collect();
let (unit_name, is_active) = match parts.as_slice() {
[unit, active] if !unit.is_empty() => (*unit, *active == "true"),
[unit] if !unit.is_empty() => (*unit, false),
_ => return Vec::new(),
};
let display = clean_display_name(unit_name);
actions_for_service(unit_name, &display, is_active)
}
}
fn clean_display_name(unit_name: &str) -> String {
unit_name
.trim_end_matches(".service")
.replace("app-", "")
.replace("@autostart", "")
.replace("\\x2d", "-")
}
fn make_action(id: &str, name: &str, command: String, desc: String, icon: &str) -> LaunchItem {
LaunchItem {
id: id.to_string(),
name: name.to_string(),
description: Some(desc),
icon: Some(icon.to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command,
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
source: ItemSource::Core,
}
}
fn actions_for_service(unit: &str, display: &str, is_active: bool) -> Vec<LaunchItem> {
let mut actions = Vec::new();
if is_active {
actions.push(make_action(
&format!("systemd:restart:{}", unit),
"↻ Restart",
format!("systemctl --user restart {}", unit),
format!("Restart {}", display),
"view-refresh",
));
actions.push(make_action(
&format!("systemd:stop:{}", unit),
"■ Stop",
format!("systemctl --user stop {}", unit),
format!("Stop {}", display),
"process-stop",
));
actions.push(make_action(
&format!("systemd:reload:{}", unit),
"⟳ Reload",
format!("systemctl --user reload {}", unit),
format!("Reload {} configuration", display),
"view-refresh",
));
actions.push(make_action(
&format!("systemd:kill:{}", unit),
"✗ Kill",
format!("systemctl --user kill {}", unit),
format!("Force kill {}", display),
"edit-delete",
));
} else {
actions.push(make_action(
&format!("systemd:start:{}", unit),
"▶ Start",
format!("systemctl --user start {}", unit),
format!("Start {}", display),
"media-playback-start",
));
}
// Always-available actions. Status and Journal need a terminal.
let mut status = make_action(
&format!("systemd:status:{}", unit),
" Status",
format!("systemctl --user status {}", unit),
format!("Show {} status", display),
"dialog-information",
);
status.terminal = true;
actions.push(status);
let mut journal = make_action(
&format!("systemd:journal:{}", unit),
"📋 Journal",
format!("journalctl --user -u {} -f", unit),
format!("Show {} logs", display),
"utilities-system-monitor",
);
journal.terminal = true;
actions.push(journal);
actions.push(make_action(
&format!("systemd:enable:{}", unit),
"⊕ Enable",
format!("systemctl --user enable {}", unit),
format!("Enable {} on startup", display),
"emblem-default",
));
actions.push(make_action(
&format!("systemd:disable:{}", unit),
"⊖ Disable",
format!("systemctl --user disable {}", unit),
format!("Disable {} on startup", display),
"emblem-unreadable",
));
actions
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_systemctl_output_extracts_services_with_submenu_command() {
let output = "
foo.service loaded active running Foo Service
bar.service loaded inactive dead Bar Service
baz@autostart.service loaded active running Baz App
";
let items = SystemdProvider::parse_systemctl_output(output);
assert_eq!(items.len(), 3);
// Active service: SUBMENU command with is_active=true.
assert_eq!(items[0].name, "foo");
assert!(items[0].command.contains("SUBMENU:uuctl:foo.service:true"));
// Inactive service: SUBMENU command with is_active=false.
assert_eq!(items[1].name, "bar");
assert!(items[1].command.contains("SUBMENU:uuctl:bar.service:false"));
// Display name cleaning strips @autostart suffix.
assert_eq!(items[2].name, "baz");
}
#[test]
fn parse_systemctl_output_skips_non_service_lines() {
let output = "
foo.service loaded active running Foo
bar.socket loaded active listening Bar
quux.timer loaded active waiting Quux
baz.service loaded inactive dead Baz
";
let items = SystemdProvider::parse_systemctl_output(output);
assert_eq!(items.len(), 2);
assert_eq!(items[0].name, "foo");
assert_eq!(items[1].name, "baz");
}
#[test]
fn provider_type_is_uuctl_plugin() {
// CLI back-compat: `-m uuctl` and `:uuctl` both resolve to this type_id.
let p = SystemdProvider::new();
assert_eq!(p.provider_type(), ProviderType::Plugin("uuctl".into()));
}
#[test]
fn submenu_actions_for_active_service_includes_restart_stop_not_start() {
let p = SystemdProvider::new();
let actions = p.submenu_actions("nginx.service:true");
let ids: Vec<&str> = actions.iter().map(|a| a.id.as_str()).collect();
assert!(ids.contains(&"systemd:restart:nginx.service"));
assert!(ids.contains(&"systemd:stop:nginx.service"));
assert!(ids.contains(&"systemd:status:nginx.service"));
assert!(!ids.contains(&"systemd:start:nginx.service"));
}
#[test]
fn submenu_actions_for_inactive_service_includes_start_not_stop() {
let p = SystemdProvider::new();
let actions = p.submenu_actions("nginx.service:false");
let ids: Vec<&str> = actions.iter().map(|a| a.id.as_str()).collect();
assert!(ids.contains(&"systemd:start:nginx.service"));
assert!(ids.contains(&"systemd:status:nginx.service"));
assert!(!ids.contains(&"systemd:stop:nginx.service"));
}
#[test]
fn submenu_actions_returns_empty_for_garbage_data() {
let p = SystemdProvider::new();
assert!(p.submenu_actions("").is_empty());
}
#[test]
fn submenu_actions_terminal_flag_set_on_status_and_journal() {
let p = SystemdProvider::new();
let actions = p.submenu_actions("test.service:true");
for action in &actions {
if action.id.contains(":status:") || action.id.contains(":journal:") {
assert!(action.terminal, "{} must have terminal=true", action.id);
}
}
}
#[test]
fn clean_display_name_strips_service_suffix_and_known_prefixes() {
assert_eq!(clean_display_name("foo.service"), "foo");
assert_eq!(clean_display_name("app-firefox.service"), "firefox");
assert_eq!(clean_display_name("bar@autostart.service"), "bar");
// The hex-escaped dash that systemd uses for some unit names.
assert_eq!(clean_display_name("foo\\x2dbar.service"), "foo-bar");
}
}
+244
View File
@@ -0,0 +1,244 @@
use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType};
/// Built-in web search provider. Opens a search URL in the user's browser via
/// `xdg-open`.
///
/// Triggered by:
/// - `? query` / `?query` (explicit prefix)
/// - `web query` / `search query` (word prefixes)
///
/// The CLI prefix routing for `:web` and `?` is handled by core; this provider
/// only needs to return matching items when `query()` is called.
pub(crate) struct WebSearchProvider {
/// URL template containing a `{query}` placeholder.
url_template: String,
}
/// 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 when no configuration is provided.
const DEFAULT_ENGINE: &str = "duckduckgo";
const PROVIDER_ICON: &str = "web-browser";
impl Default for WebSearchProvider {
fn default() -> Self {
// TODO(v2.x): plumb search_engine via constructor argument from Lua
// config. The C-ABI host config lookup was removed; for now we
// hardcode the default engine.
Self::with_engine(DEFAULT_ENGINE)
}
}
impl WebSearchProvider {
#[allow(dead_code)]
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn with_engine(engine_name: &str) -> Self {
let lower = engine_name.to_lowercase();
let url_template = SEARCH_ENGINES
.iter()
.find(|(name, _)| *name == lower)
.map(|(_, url)| (*url).to_string())
.unwrap_or_else(|| {
// Not a known engine; treat as custom URL template if it
// contains a {query} placeholder, else fall back to default.
if engine_name.contains("{query}") {
engine_name.to_string()
} else {
SEARCH_ENGINES
.iter()
.find(|(name, _)| *name == DEFAULT_ENGINE)
.map(|(_, url)| (*url).to_string())
.expect("default engine must exist in SEARCH_ENGINES")
}
});
Self { url_template }
}
/// Extract the search term from a raw query string.
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 {
// Filter mode: accept the raw query.
Some(trimmed)
}
}
/// URL-encode a search query using a small percent-encoding scheme suitable
/// for query parameters (spaces become `+`).
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 final search URL for a given search term.
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 produce a single `LaunchItem` if the query yields a
/// non-empty search term.
fn evaluate(&self, query: &str) -> Option<LaunchItem> {
let search_term = Self::extract_search_term(query)?;
if search_term.is_empty() {
return None;
}
let url = self.build_search_url(search_term);
let command = format!("xdg-open '{}'", url.replace('\'', "'\\''"));
Some(LaunchItem {
id: format!("websearch:{}", search_term),
name: format!("Search: {}", search_term),
description: Some("Open in browser".to_string()),
icon: Some(PROVIDER_ICON.to_string()),
provider: ProviderType::Plugin("websearch".into()),
command,
terminal: false,
tags: vec!["web".into(), "search".into()],
source: ItemSource::Core,
})
}
}
impl DynamicProvider for WebSearchProvider {
fn name(&self) -> &str {
"Web Search"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin("websearch".into())
}
fn priority(&self) -> u32 {
9_000
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
match self.evaluate(query) {
Some(item) => vec![item],
None => Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_search_term() {
assert_eq!(
WebSearchProvider::extract_search_term("? rust programming"),
Some("rust programming")
);
assert_eq!(
WebSearchProvider::extract_search_term("?rust"),
Some("rust")
);
assert_eq!(
WebSearchProvider::extract_search_term("web rust docs"),
Some("rust docs")
);
assert_eq!(
WebSearchProvider::extract_search_term("search how to rust"),
Some("how to rust")
);
}
#[test]
fn test_url_encode() {
assert_eq!(WebSearchProvider::url_encode("hello world"), "hello+world");
assert_eq!(WebSearchProvider::url_encode("foo&bar"), "foo%26bar");
assert_eq!(WebSearchProvider::url_encode("a=b"), "a%3Db");
assert_eq!(WebSearchProvider::url_encode("test?query"), "test%3Fquery");
}
#[test]
fn test_build_search_url() {
let provider = WebSearchProvider::with_engine("duckduckgo");
let url = provider.build_search_url("rust programming");
assert_eq!(url, "https://duckduckgo.com/?q=rust+programming");
}
#[test]
fn test_build_search_url_google() {
let provider = WebSearchProvider::with_engine("google");
let url = provider.build_search_url("rust");
assert_eq!(url, "https://www.google.com/search?q=rust");
}
#[test]
fn test_evaluate() {
let provider = WebSearchProvider::new();
let item = provider.evaluate("? rust docs").unwrap();
assert_eq!(item.name, "Search: rust docs");
assert!(item.command.contains("xdg-open"));
assert!(item.command.contains("duckduckgo"));
}
#[test]
fn test_evaluate_empty() {
let provider = WebSearchProvider::new();
assert!(provider.evaluate("?").is_none());
assert!(provider.evaluate("? ").is_none());
}
#[test]
fn test_custom_url_template() {
let provider = WebSearchProvider::with_engine("https://custom.search/q={query}");
let url = provider.build_search_url("test");
assert_eq!(url, "https://custom.search/q=test");
}
#[test]
fn test_fallback_to_default() {
let provider = WebSearchProvider::with_engine("nonexistent");
let url = provider.build_search_url("test");
assert!(url.contains("duckduckgo"));
}
#[test]
fn provider_type_is_websearch_plugin() {
assert_eq!(
WebSearchProvider::default().provider_type(),
ProviderType::Plugin("websearch".into())
);
}
}
@@ -4,8 +4,8 @@ use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use std::thread;
use std::time::Duration;
/// Maximum allowed size for a single IPC request line (1 MiB).
const MAX_REQUEST_SIZE: usize = 1_048_576;
@@ -111,8 +111,6 @@ impl Server {
info!("IPC server listening on {:?}", socket_path);
let config = Arc::new(RwLock::new(Config::load_or_default()));
// Share config with native plugin loader so plugins can read their own config sections.
crate::plugins::native_loader::set_shared_config(Arc::clone(&config));
let provider_manager = ProviderManager::new_with_config(Arc::clone(&config));
let frecency = FrecencyStore::new();
@@ -127,30 +125,24 @@ impl Server {
/// Accept connections in a loop, spawning a thread per client.
pub fn run(&self) -> io::Result<()> {
// Start filesystem watcher for user plugin hot-reload
crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager));
// SIGHUP handler: reload config from disk into the shared Arc<RwLock<Config>>.
{
use signal_hook::consts::SIGHUP;
use signal_hook::iterator::Signals;
let config = Arc::clone(&self.config);
let mut signals = Signals::new([SIGHUP])
.map_err(io::Error::other)?;
let mut signals = Signals::new([SIGHUP]).map_err(io::Error::other)?;
thread::spawn(move || {
for _sig in signals.forever() {
match Config::load() {
Ok(new_cfg) => {
match config.write() {
Ok(mut cfg) => {
*cfg = new_cfg;
info!("Config reloaded via SIGHUP");
}
Err(_) => {
warn!("SIGHUP: config lock poisoned; reload skipped");
}
Ok(new_cfg) => match config.write() {
Ok(mut cfg) => {
*cfg = new_cfg;
info!("Config reloaded via SIGHUP");
}
}
Err(_) => {
warn!("SIGHUP: config lock poisoned; reload skipped");
}
},
Err(e) => {
warn!("SIGHUP: failed to reload config: {}", e);
}
@@ -166,8 +158,7 @@ impl Server {
use signal_hook::iterator::Signals;
let frecency = Arc::clone(&self.frecency);
let socket_path = self.socket_path.clone();
let mut signals = Signals::new([SIGTERM, SIGINT])
.map_err(io::Error::other)?;
let mut signals = Signals::new([SIGTERM, SIGINT]).map_err(io::Error::other)?;
thread::spawn(move || {
// Block until we receive SIGTERM or SIGINT, then save and exit.
let _ = signals.forever().next();
@@ -191,16 +182,18 @@ impl Server {
// Periodic frecency auto-save every 5 minutes.
{
let frecency = Arc::clone(&self.frecency);
thread::spawn(move || loop {
thread::sleep(Duration::from_secs(300));
match frecency.write() {
Ok(mut f) => {
if let Err(e) = f.save() {
warn!("Periodic frecency save failed: {}", e);
thread::spawn(move || {
loop {
thread::sleep(Duration::from_secs(300));
match frecency.write() {
Ok(mut f) => {
if let Err(e) = f.save() {
warn!("Periodic frecency save failed: {}", e);
}
}
Err(_) => {
warn!("Periodic frecency save: lock poisoned; skipping");
}
}
Err(_) => {
warn!("Periodic frecency save: lock poisoned; skipping");
}
}
});
@@ -223,9 +216,15 @@ impl Server {
});
}
None => {
warn!("Connection limit reached ({} max); rejecting client", MAX_CONNECTIONS);
warn!(
"Connection limit reached ({} max); rejecting client",
MAX_CONNECTIONS
);
let resp = Response::Error {
message: format!("server busy: max {} concurrent connections", MAX_CONNECTIONS),
message: format!(
"server busy: max {} concurrent connections",
MAX_CONNECTIONS
),
};
let _ = write_response(&mut stream, &resp);
}
@@ -320,18 +319,30 @@ impl Server {
let (max, weight) = {
let cfg = match config.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: config lock poisoned".into() },
Err(_) => {
return Response::Error {
message: "internal error: config lock poisoned".into(),
};
}
};
(cfg.general.max_results, cfg.providers.frecency_weight)
};
let pm_guard = match pm.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
Err(_) => {
return Response::Error {
message: "internal error: provider lock poisoned".into(),
};
}
};
let frecency_guard = match frecency.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: frecency lock poisoned".into() },
Err(_) => {
return Response::Error {
message: "internal error: frecency lock poisoned".into(),
};
}
};
let results = pm_guard.search_with_frecency(
text,
@@ -356,7 +367,11 @@ impl Server {
} => {
let mut frecency_guard = match frecency.write() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: frecency lock poisoned".into() },
Err(_) => {
return Response::Error {
message: "internal error: frecency lock poisoned".into(),
};
}
};
frecency_guard.record_launch(item_id);
Response::Ack
@@ -365,7 +380,11 @@ impl Server {
Request::Providers => {
let pm_guard = match pm.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
Err(_) => {
return Response::Error {
message: "internal error: provider lock poisoned".into(),
};
}
};
let descs = pm_guard.available_providers();
Response::Providers {
@@ -376,7 +395,11 @@ impl Server {
Request::Refresh { provider } => {
let mut pm_guard = match pm.write() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
Err(_) => {
return Response::Error {
message: "internal error: provider lock poisoned".into(),
};
}
};
pm_guard.refresh_provider(provider);
Response::Ack
@@ -390,7 +413,11 @@ impl Server {
Request::Submenu { plugin_id, data } => {
let pm_guard = match pm.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
Err(_) => {
return Response::Error {
message: "internal error: provider lock poisoned".into(),
};
}
};
match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) {
Some((_name, actions)) => Response::SubmenuItems {
@@ -408,7 +435,11 @@ impl Server {
Request::PluginAction { command } => {
let pm_guard = match pm.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
Err(_) => {
return Response::Error {
message: "internal error: provider lock poisoned".into(),
};
}
};
if pm_guard.execute_plugin_action(command) {
Response::Ack
@@ -418,16 +449,6 @@ impl Server {
}
}
}
Request::PluginList => {
let pm_guard = match pm.read() {
Ok(g) => g,
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
};
Response::PluginList {
entries: pm_guard.plugin_registry.clone(),
}
}
}
}
}
+5 -3
View File
@@ -1,4 +1,4 @@
use owlry_core::config::AppearanceConfig;
use crate::config::AppearanceConfig;
/// Generate CSS with :root variables from config settings
pub fn generate_variables_css(config: &AppearanceConfig) -> String {
@@ -71,8 +71,10 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
if let Some(ref badge_ssh) = config.colors.badge_ssh {
css.push_str(&format!(" --owlry-badge-ssh: {};\n", badge_ssh));
}
if let Some(ref badge_sys) = config.colors.badge_sys {
css.push_str(&format!(" --owlry-badge-sys: {};\n", badge_sys));
if let Some(ref badge_power) = config.colors.badge_power {
// Emit both for transition: pre-v2 stylesheets reference --owlry-badge-sys.
css.push_str(&format!(" --owlry-badge-power: {};\n", badge_power));
css.push_str(&format!(" --owlry-badge-sys: {};\n", badge_power));
}
if let Some(ref badge_uuctl) = config.colors.badge_uuctl {
css.push_str(&format!(" --owlry-badge-uuctl: {};\n", badge_uuctl));
+40 -57
View File
@@ -1,4 +1,8 @@
use crate::backend::SearchBackend;
use crate::config::Config;
use crate::filter::ProviderFilter;
use crate::ipc::ProviderDesc;
use crate::providers::{ItemSource, LaunchItem, ProviderType};
use crate::ui::ResultRow;
use crate::ui::provider_meta;
use crate::ui::submenu;
@@ -9,10 +13,6 @@ use gtk4::{
ListBoxRow, Orientation, ScrolledWindow, SelectionMode, ToggleButton,
};
use log::info;
use owlry_core::config::Config;
use owlry_core::filter::ProviderFilter;
use owlry_core::ipc::ProviderDesc;
use owlry_core::providers::{ItemSource, LaunchItem, ProviderType};
#[cfg(feature = "dev-logging")]
use log::debug;
@@ -156,10 +156,7 @@ impl MainWindow {
let provider_descs = Rc::new(provider_descs);
// Update mode label with resolved plugin name (if applicable)
mode_label.set_label(&Self::mode_display_name(
&filter.borrow(),
&provider_descs,
));
mode_label.set_label(&Self::mode_display_name(&filter.borrow(), &provider_descs));
// Create toggle buttons for each enabled provider
let filter_buttons =
@@ -339,10 +336,7 @@ impl MainWindow {
}
/// Get the mode display name, resolving plugin names via IPC metadata.
fn mode_display_name(
filter: &ProviderFilter,
descs: &HashMap<String, ProviderDesc>,
) -> String {
fn mode_display_name(filter: &ProviderFilter, descs: &HashMap<String, ProviderDesc>) -> String {
let base = filter.mode_display_name();
// "Plugin" is the generic fallback — resolve it to the actual name
if base == "Plugin" {
@@ -366,10 +360,7 @@ impl MainWindow {
provider_meta::resolve(provider, desc)
}
fn build_placeholder(
filter: &ProviderFilter,
descs: &HashMap<String, ProviderDesc>,
) -> String {
fn build_placeholder(filter: &ProviderFilter, descs: &HashMap<String, ProviderDesc>) -> String {
let active: Vec<String> = filter
.enabled_providers()
.iter()
@@ -382,7 +373,7 @@ impl MainWindow {
/// Build hints string for the status bar based on enabled built-in providers.
/// Plugin trigger hints (? web, / files, etc.) are not included here since
/// plugin availability is not tracked in ProvidersConfig.
fn build_hints(config: &owlry_core::config::ProvidersConfig) -> String {
fn build_hints(config: &crate::config::ProvidersConfig) -> String {
let mut parts: Vec<String> = vec![
"Tab: cycle".to_string(),
"↑↓: nav".to_string(),
@@ -396,8 +387,8 @@ impl MainWindow {
if config.converter {
parts.push("> conv".to_string());
}
if config.system {
parts.push(":sys".to_string());
if config.power {
parts.push(":power".to_string());
}
parts.join(" ")
@@ -507,10 +498,7 @@ impl MainWindow {
// Restore UI
mode_label.set_label(&Self::mode_display_name(&filter.borrow(), descs));
hints_label.set_label(&Self::build_hints(&config.borrow().providers));
search_entry.set_placeholder_text(Some(&Self::build_placeholder(
&filter.borrow(),
descs,
)));
search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow(), descs)));
search_entry.set_text(&saved_search);
// Trigger refresh by emitting changed signal
@@ -595,7 +583,7 @@ impl MainWindow {
"pomodoro" => "pomodoro",
"scripts" => "scripts",
"ssh" => "SSH hosts",
"system" => "system",
"power" | "system" => "power actions",
"uuctl" => "uuctl units",
"weather" => "weather",
"websearch" => "web",
@@ -641,13 +629,7 @@ impl MainWindow {
let be = backend.borrow();
let f = filter.borrow();
let c = config.borrow();
be.query_async(
&query_str,
max_results,
&f,
&c,
tag.as_deref(),
)
be.query_async(&query_str, max_results, &f, &c, tag.as_deref())
};
if let Some(rx) = receiver {
@@ -669,22 +651,18 @@ impl MainWindow {
results_list_cb.remove_all();
let items = result.items;
let initial_count =
INITIAL_RESULTS.min(items.len());
let initial_count = INITIAL_RESULTS.min(items.len());
for item in items.iter().take(initial_count) {
let row = ResultRow::new(item, &query_for_highlight);
results_list_cb.append(&row);
}
if let Some(first_row) =
results_list_cb.row_at_index(0)
{
if let Some(first_row) = results_list_cb.row_at_index(0) {
results_list_cb.select_row(Some(&first_row));
}
*current_results_cb.borrow_mut() =
items[..initial_count].to_vec();
*current_results_cb.borrow_mut() = items[..initial_count].to_vec();
let mut lazy = lazy_state_cb.borrow_mut();
lazy.all_results = items;
lazy.displayed_count = initial_count;
@@ -714,8 +692,7 @@ impl MainWindow {
results_list.select_row(Some(&first_row));
}
*current_results.borrow_mut() =
results[..initial_count].to_vec();
*current_results.borrow_mut() = results[..initial_count].to_vec();
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results;
lazy.query = query_str;
@@ -837,10 +814,8 @@ impl MainWindow {
}
}
mode_label.set_label(&Self::mode_display_name(&filter.borrow(), &descs));
search_entry.set_placeholder_text(Some(&Self::build_placeholder(
&filter.borrow(),
&descs,
)));
search_entry
.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow(), &descs)));
search_entry.emit_by_name::<()>("changed", &[]);
});
}
@@ -927,7 +902,12 @@ impl MainWindow {
let next_index = current.index() + 1;
if let Some(next_row) = results_list.row_at_index(next_index) {
results_list.select_row(Some(&next_row));
Self::scroll_to_row(&scrolled, &results_list, &next_row, &lazy_state_for_keys);
Self::scroll_to_row(
&scrolled,
&results_list,
&next_row,
&lazy_state_for_keys,
);
}
}
gtk4::glib::Propagation::Stop
@@ -939,7 +919,12 @@ impl MainWindow {
&& 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, &lazy_state_for_keys);
Self::scroll_to_row(
&scrolled,
&results_list,
&prev_row,
&lazy_state_for_keys,
);
}
}
gtk4::glib::Propagation::Stop
@@ -1212,12 +1197,10 @@ impl MainWindow {
let max_results = cfg.general.max_results;
drop(cfg);
let results = backend.borrow_mut().search(
"",
max_results,
&filter.borrow(),
&config.borrow(),
);
let results =
backend
.borrow_mut()
.search("", max_results, &filter.borrow(), &config.borrow());
// Clear existing results
results_list.remove_all();
@@ -1366,7 +1349,7 @@ impl MainWindow {
item.name, cmd
);
log::warn!("{}", msg);
owlry_core::notify::notify("Command blocked", &msg);
crate::notify::notify("Command blocked", &msg);
return;
}
}
@@ -1375,7 +1358,7 @@ impl MainWindow {
if item.command.is_empty() && !matches!(item.provider, ProviderType::Application) {
let msg = format!("Item '{}' has no command; cannot launch", item.name);
log::warn!("{}", msg);
owlry_core::notify::notify("Launch failed", &msg);
crate::notify::notify("Launch failed", &msg);
return;
}
@@ -1397,7 +1380,7 @@ impl MainWindow {
if let Err(e) = result {
let msg = format!("Failed to launch '{}': {}", item.name, e);
log::error!("{}", msg);
owlry_core::notify::notify("Launch failed", &msg);
crate::notify::notify("Launch failed", &msg);
}
}
@@ -1418,7 +1401,7 @@ impl MainWindow {
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);
crate::notify::notify("Launch failed", &msg);
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg));
}
@@ -1435,7 +1418,7 @@ impl MainWindow {
if !uwsm_available {
let msg = "uwsm is enabled in config but not installed";
log::error!("{}", msg);
owlry_core::notify::notify("Launch failed", msg);
crate::notify::notify("Launch failed", msg);
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg));
}
+6 -6
View File
@@ -1,5 +1,5 @@
use owlry_core::ipc::ProviderDesc;
use owlry_core::providers::ProviderType;
use crate::ipc::ProviderDesc;
use crate::providers::ProviderType;
/// Display metadata for a provider.
pub struct ProviderMeta {
@@ -86,10 +86,10 @@ fn hardcoded(provider: &ProviderType) -> ProviderMeta {
css_class: "owlry-filter-ssh".to_string(),
search_noun: "SSH hosts".to_string(),
},
"system" => ProviderMeta {
tab_label: "System".to_string(),
css_class: "owlry-filter-sys".to_string(),
search_noun: "system".to_string(),
"power" | "system" => ProviderMeta {
tab_label: "Power".to_string(),
css_class: "owlry-filter-power".to_string(),
search_noun: "power actions".to_string(),
},
"uuctl" => ProviderMeta {
tab_label: "uuctl".to_string(),
+5 -7
View File
@@ -1,6 +1,6 @@
use crate::providers::{LaunchItem, ProviderType};
use gtk4::prelude::*;
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget};
use owlry_core::providers::{LaunchItem, ProviderType};
#[allow(dead_code)]
pub struct ResultRow {
@@ -107,13 +107,11 @@ impl ResultRow {
} else {
// Default icon based on provider type (only core types, plugins should provide icons)
let default_icon = match &item.provider {
owlry_core::providers::ProviderType::Application => {
"application-x-executable-symbolic"
}
owlry_core::providers::ProviderType::Command => "utilities-terminal-symbolic",
owlry_core::providers::ProviderType::Dmenu => "view-list-symbolic",
crate::providers::ProviderType::Application => "application-x-executable-symbolic",
crate::providers::ProviderType::Command => "utilities-terminal-symbolic",
crate::providers::ProviderType::Dmenu => "view-list-symbolic",
// Plugins should provide their own icon; fallback to generic addon icon
owlry_core::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic",
crate::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic",
};
let img = Image::from_icon_name(default_icon);
img.set_pixel_size(32);
+4 -4
View File
@@ -46,7 +46,7 @@
//! }
//! ```
use owlry_core::providers::LaunchItem;
use crate::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 owlry_core::providers::{ItemSource, ProviderType};
use crate::providers::{ItemSource, ProviderType};
#[test]
fn test_parse_submenu_command() {
@@ -94,7 +94,7 @@ mod tests {
command: "SUBMENU:plugin:data".to_string(),
terminal: false,
tags: vec![],
source: ItemSource::NativePlugin,
source: ItemSource::Core,
};
assert!(is_submenu_item(&submenu_item));
@@ -107,7 +107,7 @@ mod tests {
command: "some-command".to_string(),
terminal: false,
tags: vec![],
source: ItemSource::NativePlugin,
source: ItemSource::Core,
};
assert!(!is_submenu_item(&normal_item));
}
+91
View File
@@ -0,0 +1,91 @@
//! Auto-mode integration test (Phase 1 task #10, D7).
//!
//! Pins down the invariant that `owlry` invoked with no `-m`/`--profile`/CLI
//! restriction surfaces results from every enabled provider, with tabs
//! reflecting `general.tabs`. This is the existing default behavior that
//! refactors in subsequent phases must not silently regress.
use owlry::config::ProvidersConfig;
use owlry::filter::ProviderFilter;
use owlry::providers::ProviderType;
/// Build a filter the same way `Server::bind` does when the user runs `owlry`
/// with no CLI mode and no profile: `cli_mode=None`, `cli_providers=None`.
fn auto_mode_filter(tabs: &[&str]) -> ProviderFilter {
let cfg = ProvidersConfig::default();
let tabs: Vec<String> = tabs.iter().map(|s| s.to_string()).collect();
ProviderFilter::new(None, None, &cfg, &tabs)
}
#[test]
fn auto_mode_filter_accepts_every_provider_type() {
// Even providers the user didn't list in `general.tabs` must remain
// searchable — tabs only drive UI display, not query routing.
let filter = auto_mode_filter(&["app", "cmd", "power"]);
assert!(filter.is_accept_all(), "auto mode must set accept_all=true");
assert!(filter.is_active(ProviderType::Application));
assert!(filter.is_active(ProviderType::Command));
assert!(filter.is_active(ProviderType::Plugin("power".into())));
// Providers NOT in tabs are still active in auto mode.
assert!(filter.is_active(ProviderType::Plugin("uuctl".into())));
assert!(filter.is_active(ProviderType::Plugin("bookmarks".into())));
assert!(filter.is_active(ProviderType::Plugin("anything-else".into())));
}
#[test]
fn auto_mode_filter_with_empty_tabs_still_accepts_everything() {
// Even an empty `general.tabs` list (degenerate config) must not silently
// drop providers from query routing — accept_all stays true.
let filter = auto_mode_filter(&[]);
assert!(filter.is_accept_all());
assert!(filter.is_active(ProviderType::Application));
assert!(filter.is_active(ProviderType::Plugin("uuctl".into())));
}
#[test]
fn auto_mode_changes_to_filtered_when_cli_mode_is_set() {
// Counterpoint: passing `-m app` flips accept_all off so the user sees only
// applications. Encodes the contract from the other direction.
let cfg = ProvidersConfig::default();
let tabs: Vec<String> = vec!["app".into(), "cmd".into()];
let filter = ProviderFilter::new(Some(ProviderType::Application), None, &cfg, &tabs);
assert!(!filter.is_accept_all(), "single-mode must clear accept_all");
assert!(filter.is_active(ProviderType::Application));
assert!(!filter.is_active(ProviderType::Command));
assert!(!filter.is_active(ProviderType::Plugin("uuctl".into())));
}
#[test]
fn auto_mode_prefix_overrides_accept_all_for_routing() {
// Inside the UI, typing `:uuctl foo` should narrow results to that
// provider even when the filter is otherwise accept_all. The prefix is
// a per-keystroke override; the filter's enabled set is unchanged.
let mut filter = auto_mode_filter(&["app", "cmd"]);
assert!(filter.is_accept_all());
filter.set_prefix(Some(ProviderType::Plugin("uuctl".into())));
assert!(filter.is_active(ProviderType::Plugin("uuctl".into())));
assert!(!filter.is_active(ProviderType::Application));
assert!(!filter.is_active(ProviderType::Command));
// Clearing the prefix restores the accept_all reach.
filter.set_prefix(None);
assert!(filter.is_active(ProviderType::Application));
assert!(filter.is_active(ProviderType::Plugin("uuctl".into())));
}
#[test]
fn dash_m_auto_explicit_alias_is_equivalent_to_no_flag() {
// `owlry -m auto` is the documented explicit form of the no-flag default.
// The parser turns `auto` into Plugin("auto"); the main launcher treats
// any unknown-to-us mode like the default. Here we just confirm the
// FromStr resolution stays under the user's control — implementation of
// the actual treatment lives in the UI, but the parsing must not silently
// map `auto` to something else.
use std::str::FromStr;
let parsed = ProviderType::from_str("auto").unwrap();
assert_eq!(parsed, ProviderType::Plugin("auto".into()));
}
@@ -1,4 +1,4 @@
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
use owlry::ipc::{ProviderDesc, Request, Response, ResultItem};
#[test]
fn test_query_request_roundtrip() {
@@ -131,6 +131,25 @@ fn test_terminal_field_defaults_false() {
assert!(!item.terminal);
}
#[test]
fn test_plugin_list_request_is_rejected() {
// v2 dropped Request::PluginList. The daemon must reject incoming
// `{"type":"plugin_list"}` requests so old clients fail loudly instead
// of silently appearing to work.
let result: Result<Request, _> = serde_json::from_str(r#"{"type":"plugin_list"}"#);
assert!(result.is_err(), "plugin_list request must not deserialize");
}
#[test]
fn test_plugin_list_response_is_rejected() {
// Same on the response side — old daemons replying with
// `{"type":"plugin_list", ...}` must fail to parse so clients can't
// accidentally treat them as valid.
let result: Result<Response, _> =
serde_json::from_str(r#"{"type":"plugin_list","entries":[]}"#);
assert!(result.is_err(), "plugin_list response must not deserialize");
}
#[test]
fn test_terminal_field_roundtrip() {
let item = ResultItem {

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