owlry 2.0: single-binary rewrite #6
@@ -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/
|
||||
|
||||
@@ -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 (D1–D21), 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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -6,158 +6,119 @@
|
||||
[](https://gtk.org/)
|
||||
[](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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
@@ -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
@@ -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/"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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 = []
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]");
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 ®istrations {
|
||||
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>() };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
@@ -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
@@ -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
+4
-1
@@ -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");
|
||||
}
|
||||
+4
-9
@@ -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('-');
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+20
-4
@@ -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,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
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user