Compare commits
102 Commits
v0.3.2
...
owlry-rune
| Author | SHA1 | Date | |
|---|---|---|---|
| 91da177f46 | |||
| f5d83f1372 | |||
| 50caa1ff0d | |||
| 0c46082b2b | |||
| a0b65e69a4 | |||
| 938a9ee6f3 | |||
| d4f71cae42 | |||
| 6391711df2 | |||
| 30b2b5b9c0 | |||
| 5be21aadc6 | |||
| 4ed9a9973a | |||
| 18c58ce33d | |||
| f609ce1c13 | |||
| 915dc193d9 | |||
| 71d78ce7df | |||
| 1bce5850a3 | |||
| 182a500596 | |||
| d79c9087fd | |||
| 8494a806bf | |||
| 9db3be6fdc | |||
| a49f5127dc | |||
| c0ea40a393 | |||
| 44f0915ba9 | |||
| a55567b422 | |||
| 707caefadf | |||
| 78895d34b5 | |||
| e6f217f19c | |||
| ff04675417 | |||
| b85f85c4da | |||
| 1aa92ee1e5 | |||
| 9532b3cfde | |||
| 551e5d74ae | |||
| 60eaffb2ab | |||
| 6d8d4a9f89 | |||
| 3ef9398655 | |||
| 46bb4bfb38 | |||
| c8aed5faf5 | |||
| bf8a31af78 | |||
| e23bdf5cee | |||
| 25c4d40d36 | |||
| b36dd2a438 | |||
| 35a0f580c3 | |||
| 7ed36c58c2 | |||
| 7cccd3b512 | |||
| 9f6d0c5935 | |||
| 026a232e0c | |||
| 1557119448 | |||
| b814d07382 | |||
| 0dead603ec | |||
| c1eb5ae2eb | |||
| 07847c76d8 | |||
| 2dfce67f3b | |||
| b1198f4600 | |||
| e6776b803c | |||
| 6e2d60466b | |||
| 8c1cf88474 | |||
| ecaaae39e3 | |||
| 96e9b09a31 | |||
| e053f7d5d5 | |||
| b1f11c076b | |||
| 2d7fb33f30 | |||
| 3b1ff03ff8 | |||
| e1fb63d6c4 | |||
| 33e2f9cb5e | |||
| 6b21602a07 | |||
| 4516865c21 | |||
| 4fbc7fc4c9 | |||
| 536c5c5012 | |||
| abd4df6939 | |||
| 43f7228be2 | |||
| a1b47b8ba0 | |||
| ccce9b8572 | |||
| ffb4c2f127 | |||
| cde599db03 | |||
| cf8e33c976 | |||
| 85a18fc271 | |||
| 67dad9c269 | |||
| 3e8be3a4c5 | |||
| e83feb6ce4 | |||
| bead9e4b4a | |||
| 10722bc016 | |||
| 384dd016a0 | |||
| a582f0181c | |||
| 97c6f655ca | |||
| 8670909480 | |||
| cb12ffbeca | |||
| 892333dbca | |||
| 6d3d69d103 | |||
| bec8fc332b | |||
| a750ef8559 | |||
| 7cbebd324f | |||
| 5519381d8c | |||
| 38025279f9 | |||
| 405b598b9b | |||
| d086995399 | |||
| 7ca8a1f443 | |||
| 2a2a22f72c | |||
| 0eccdc5883 | |||
| 3f7a8950eb | |||
| b38bf082e1 | |||
| 617dbbce3e | |||
| 4ff054afe0 |
14
.gitignore
vendored
@@ -1 +1,15 @@
|
||||
/target
|
||||
CLAUDE.md
|
||||
media.md
|
||||
|
||||
# AUR packages (each is its own git repo for aur.archlinux.org)
|
||||
aur/*/.git/
|
||||
aur/*/pkg/
|
||||
aur/*/src/
|
||||
aur/*/*.tar.zst
|
||||
aur/*/*.tar.gz
|
||||
aur/*/*.tar.xz
|
||||
aur/*/*.pkg.tar.*
|
||||
# Keep PKGBUILD and .SRCINFO tracked
|
||||
.SRCINFO
|
||||
aur/
|
||||
|
||||
417
CLAUDE.md
@@ -1,32 +1,411 @@
|
||||
# Owlry - Claude Code Instructions
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
```bash
|
||||
just build # Debug build (all workspace members)
|
||||
just build-ui # UI binary only
|
||||
just build-daemon # Core daemon only
|
||||
just release # Release build (LTO, stripped)
|
||||
just release-daemon # Release build for daemon only
|
||||
just check # cargo check + clippy
|
||||
just test # Run tests
|
||||
just fmt # Format code
|
||||
just run [ARGS] # Run UI with optional args (e.g., just run --mode app)
|
||||
just run-daemon # Run core daemon
|
||||
just install-local # Install core + daemon + runtimes + systemd units
|
||||
|
||||
# Dev build with verbose logging
|
||||
cargo run -p owlry --features dev-logging
|
||||
|
||||
# Build core without embedded Lua (smaller binary, uses external owlry-lua)
|
||||
cargo build -p owlry --release --no-default-features
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Invocation
|
||||
|
||||
The UI client connects to the `owlry-core` daemon via Unix socket IPC. Start the daemon first:
|
||||
|
||||
```bash
|
||||
# Start daemon (systemd recommended)
|
||||
systemctl --user enable --now owlry-core.service
|
||||
|
||||
# Or run directly
|
||||
owlry-core
|
||||
|
||||
# Then launch UI
|
||||
owlry # Launch with all providers
|
||||
owlry -m app # Applications only
|
||||
owlry -m cmd # PATH commands only
|
||||
owlry --profile dev # Use a named config profile
|
||||
owlry -m calc # Calculator plugin only (if installed)
|
||||
```
|
||||
|
||||
### dmenu Mode
|
||||
|
||||
dmenu mode runs locally without the daemon. Use `-m dmenu` with piped input for interactive selection. The selected item is printed to stdout (not executed), so pipe the output to execute it:
|
||||
|
||||
```bash
|
||||
# Screenshot menu (execute selected command)
|
||||
printf '%s\n' \
|
||||
"grimblast --notify copy screen" \
|
||||
"grimblast --notify copy area" \
|
||||
"grimblast --notify edit screen" \
|
||||
| owlry -m dmenu -p "Screenshot" \
|
||||
| sh
|
||||
|
||||
# Git branch checkout
|
||||
git branch | owlry -m dmenu -p "checkout" | xargs git checkout
|
||||
|
||||
# Kill a process
|
||||
ps -eo comm | sort -u | owlry -m dmenu -p "kill" | xargs pkill
|
||||
|
||||
# Select and open a project
|
||||
find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
|
||||
```
|
||||
|
||||
### CLI Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-m`, `--mode MODE` | Start in single-provider mode (app, cmd, dmenu, calc, etc.) |
|
||||
| `--profile NAME` | Use a named profile from config (defines which modes to enable) |
|
||||
| `-p`, `--prompt TEXT` | Custom prompt text for the search input (dmenu mode) |
|
||||
|
||||
### Available Modes
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `app` | Desktop applications |
|
||||
| `cmd` | PATH commands |
|
||||
| `dmenu` | Pipe-based selection (requires stdin, runs locally) |
|
||||
| `calc` | Calculator (plugin) |
|
||||
| `clip` | Clipboard history (plugin) |
|
||||
| `emoji` | Emoji picker (plugin) |
|
||||
| `ssh` | SSH hosts (plugin) |
|
||||
| `sys` | System actions (plugin) |
|
||||
| `bm` | Bookmarks (plugin) |
|
||||
| `file` | File search (plugin) |
|
||||
| `web` | Web search (plugin) |
|
||||
| `uuctl` | systemd user units (plugin) |
|
||||
|
||||
### Search Prefixes
|
||||
|
||||
Type these in the search box to filter by provider:
|
||||
|
||||
| Prefix | Provider | Example |
|
||||
|--------|----------|---------|
|
||||
| `:app` | Applications | `:app firefox` |
|
||||
| `:cmd` | PATH commands | `:cmd git` |
|
||||
| `:sys` | System actions | `:sys shutdown` |
|
||||
| `:ssh` | SSH hosts | `:ssh server` |
|
||||
| `:clip` | Clipboard | `:clip password` |
|
||||
| `:bm` | Bookmarks | `:bm github` |
|
||||
| `:emoji` | Emoji | `:emoji heart` |
|
||||
| `:calc` | Calculator | `:calc sqrt(16)` |
|
||||
| `:web` | Web search | `:web rust docs` |
|
||||
| `:file` | Files | `:file config` |
|
||||
| `:uuctl` | systemd | `:uuctl docker` |
|
||||
| `:tag:X` | Filter by tag | `:tag:development` |
|
||||
|
||||
### Trigger Prefixes
|
||||
|
||||
| Trigger | Provider | Example |
|
||||
|---------|----------|---------|
|
||||
| `=` | Calculator | `= 5+3` |
|
||||
| `?` | Web search | `? rust programming` |
|
||||
| `/` | File search | `/ .bashrc` |
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Enter` | Launch selected item |
|
||||
| `Escape` | Close launcher / exit submenu |
|
||||
| `Up` / `Down` | Navigate results |
|
||||
| `Tab` | Cycle filter tabs |
|
||||
| `Shift+Tab` | Cycle tabs (reverse) |
|
||||
| `Ctrl+1..9` | Toggle tab by position |
|
||||
|
||||
### Plugin CLI
|
||||
|
||||
```bash
|
||||
owlry plugin list # List installed
|
||||
owlry plugin list --available # Show registry
|
||||
owlry plugin search "query" # Search registry
|
||||
owlry plugin install <name> # Install from registry
|
||||
owlry plugin install ./path # Install from local path
|
||||
owlry plugin remove <name> # Uninstall
|
||||
owlry plugin enable/disable <name> # Toggle
|
||||
owlry plugin create <name> # Create Lua plugin template
|
||||
owlry plugin create <name> -r rune # Create Rune plugin template
|
||||
owlry plugin validate ./path # Validate plugin structure
|
||||
owlry plugin run <id> <cmd> [args] # Run plugin CLI command
|
||||
owlry plugin commands <id> # List plugin commands
|
||||
owlry plugin runtimes # Show available runtimes
|
||||
```
|
||||
|
||||
## Release Workflow
|
||||
|
||||
Always use `just` for releases and AUR deployment:
|
||||
Always use `just` for releases - do NOT manually edit Cargo.toml for version bumps:
|
||||
|
||||
```bash
|
||||
# Bump version (updates Cargo.toml + Cargo.lock, commits)
|
||||
just bump 0.x.y
|
||||
# Bump a single crate
|
||||
just bump-crate owlry-core 0.5.1
|
||||
|
||||
# Push and create tag
|
||||
# Bump all crates to same version
|
||||
just bump-all 0.5.1
|
||||
|
||||
# Bump core UI only
|
||||
just bump 0.5.1
|
||||
|
||||
# Create and push release tag
|
||||
git push && just tag
|
||||
|
||||
# Update AUR package
|
||||
just aur-update
|
||||
# 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
|
||||
|
||||
# Review changes, then publish
|
||||
just aur-publish
|
||||
# Version inspection
|
||||
just show-versions # List all crate versions
|
||||
just aur-status # Show AUR package versions and git status
|
||||
```
|
||||
|
||||
Do NOT manually edit Cargo.toml for version bumps - use `just bump`.
|
||||
## AUR Packaging
|
||||
|
||||
## Available just recipes
|
||||
The `aur/` directory contains PKGBUILDs for core packages:
|
||||
|
||||
- `just build` / `just release` - Build debug/release
|
||||
- `just check` - Run cargo check + clippy
|
||||
- `just test` - Run tests
|
||||
- `just bump <version>` - Bump version
|
||||
- `just tag` - Create and push git tag
|
||||
- `just aur-update` - Update PKGBUILD checksums
|
||||
- `just aur-publish` - Commit and push to AUR
|
||||
- `just aur-test` - Test PKGBUILD locally
|
||||
| Category | Packages |
|
||||
|----------|----------|
|
||||
| Core UI | `owlry` |
|
||||
| Core Daemon | `owlry-core` |
|
||||
| Runtimes | `owlry-lua`, `owlry-rune` |
|
||||
| Meta-bundles | `owlry-meta-essentials`, `owlry-meta-widgets`, `owlry-meta-tools`, `owlry-meta-full` |
|
||||
|
||||
Plugin AUR packages are in the separate `owlry-plugins` repo at `somegit.dev/Owlibou/owlry-plugins`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Client/Daemon Split
|
||||
|
||||
Owlry uses a client/daemon architecture:
|
||||
|
||||
- **`owlry`** (client): GTK4 UI that connects to the daemon via Unix socket IPC. Handles rendering, user input, and launching applications. In dmenu mode, runs a local `ProviderManager` directly (no daemon needed).
|
||||
- **`owlry-core`** (daemon): Headless background service that loads plugins, manages providers, handles fuzzy matching, frecency scoring, and serves queries over IPC. Runs as a systemd user service.
|
||||
|
||||
### Workspace Structure
|
||||
|
||||
```
|
||||
owlry/
|
||||
├── Cargo.toml # Workspace root
|
||||
├── systemd/ # systemd user service/socket files
|
||||
│ ├── owlry-core.service
|
||||
│ └── owlry-core.socket
|
||||
├── crates/
|
||||
│ ├── owlry/ # UI client binary (GTK4 + Layer Shell)
|
||||
│ │ └── src/
|
||||
│ │ ├── main.rs # Entry point
|
||||
│ │ ├── app.rs # GTK Application setup, CSS loading
|
||||
│ │ ├── cli.rs # Clap CLI argument parsing
|
||||
│ │ ├── client.rs # CoreClient - IPC client to daemon
|
||||
│ │ ├── backend.rs # SearchBackend - abstraction over IPC/local
|
||||
│ │ ├── theme.rs # Theme loading
|
||||
│ │ ├── plugin_commands.rs # Plugin CLI subcommand handlers
|
||||
│ │ ├── providers/ # dmenu provider (local-only)
|
||||
│ │ └── ui/ # GTK widgets (MainWindow, ResultRow, submenu)
|
||||
│ ├── owlry-core/ # Daemon library + binary
|
||||
│ │ └── src/
|
||||
│ │ ├── main.rs # Daemon entry point
|
||||
│ │ ├── lib.rs # Public API (re-exports modules)
|
||||
│ │ ├── server.rs # Unix socket IPC server
|
||||
│ │ ├── ipc.rs # Request/Response message types
|
||||
│ │ ├── filter.rs # ProviderFilter - mode/prefix filtering
|
||||
│ │ ├── paths.rs # XDG path utilities, socket path
|
||||
│ │ ├── notify.rs # Desktop notifications
|
||||
│ │ ├── config/ # Config loading (config.toml)
|
||||
│ │ ├── data/ # FrecencyStore
|
||||
│ │ ├── providers/ # Application, Command, native/lua provider hosts
|
||||
│ │ └── plugins/ # Plugin loading, manifests, registry, runtimes
|
||||
│ ├── owlry-plugin-api/ # ABI-stable plugin interface
|
||||
│ ├── owlry-lua/ # Lua script runtime (cdylib)
|
||||
│ └── owlry-rune/ # Rune script runtime (cdylib)
|
||||
```
|
||||
|
||||
### IPC Protocol
|
||||
|
||||
Communication uses newline-delimited JSON over a Unix domain socket at `$XDG_RUNTIME_DIR/owlry/owlry.sock`.
|
||||
|
||||
**Request types** (`owlry_core::ipc::Request`):
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `Query` | Search with text and optional mode filters |
|
||||
| `Launch` | Record a launch event for frecency |
|
||||
| `Providers` | List available providers |
|
||||
| `Refresh` | Refresh a specific provider |
|
||||
| `Toggle` | Toggle visibility (client-side concern, daemon acks) |
|
||||
| `Submenu` | Query submenu actions for a plugin item |
|
||||
| `PluginAction` | Execute a plugin action command |
|
||||
|
||||
**Response types** (`owlry_core::ipc::Response`):
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `Results` | Search results with `Vec<ResultItem>` |
|
||||
| `Providers` | Provider list with `Vec<ProviderDesc>` |
|
||||
| `SubmenuItems` | Submenu actions for a plugin |
|
||||
| `Ack` | Success acknowledgement |
|
||||
| `Error` | Error with message |
|
||||
|
||||
### Core Data Flow
|
||||
|
||||
```
|
||||
[owlry UI] [owlry-core daemon]
|
||||
|
||||
main.rs → CliArgs → OwlryApp main.rs → Server::bind()
|
||||
↓ ↓
|
||||
SearchBackend UnixListener accept loop
|
||||
↓ ↓
|
||||
┌──────┴──────┐ handle_request()
|
||||
↓ ↓ ↓
|
||||
Daemon Local (dmenu) ┌───────────┴───────────┐
|
||||
↓ ↓ ↓
|
||||
CoreClient ──── IPC ────→ ProviderManager ProviderFilter
|
||||
↓ ↓
|
||||
[Provider impls] parse_query()
|
||||
↓
|
||||
LaunchItem[]
|
||||
↓
|
||||
FrecencyStore (boost)
|
||||
↓
|
||||
Response::Results ──── IPC ────→ UI rendering
|
||||
```
|
||||
|
||||
### Provider System
|
||||
|
||||
**Core providers** (in `owlry-core`):
|
||||
- **Application**: Desktop applications from XDG directories
|
||||
- **Command**: Shell commands from PATH
|
||||
|
||||
**dmenu provider** (in `owlry` client, local only):
|
||||
- **Dmenu**: Pipe-based input (dmenu compatibility)
|
||||
|
||||
All other providers are native plugins in the separate `owlry-plugins` repo (`somegit.dev/Owlibou/owlry-plugins`).
|
||||
|
||||
`ProviderManager` (in `owlry-core`) orchestrates providers and handles:
|
||||
- Fuzzy matching via `SkimMatcherV2`
|
||||
- Frecency score boosting
|
||||
- Native plugin loading from `/usr/lib/owlry/plugins/`
|
||||
|
||||
**Submenu System**: Plugins can return items with `SUBMENU:plugin_id:data` commands. When selected, the plugin is queried with `?SUBMENU:data` to get action items (e.g., systemd service actions).
|
||||
|
||||
### Plugin API
|
||||
|
||||
Native plugins use the ABI-stable interface in `owlry-plugin-api`:
|
||||
|
||||
```rust
|
||||
#[repr(C)]
|
||||
pub struct PluginVTable {
|
||||
pub info: extern "C" fn() -> PluginInfo,
|
||||
pub providers: extern "C" fn() -> RVec<ProviderInfo>,
|
||||
pub provider_init: extern "C" fn(id: RStr) -> ProviderHandle,
|
||||
pub provider_refresh: extern "C" fn(ProviderHandle) -> RVec<PluginItem>,
|
||||
pub provider_query: extern "C" fn(ProviderHandle, RStr) -> RVec<PluginItem>,
|
||||
pub provider_drop: extern "C" fn(ProviderHandle),
|
||||
}
|
||||
|
||||
// Each plugin exports:
|
||||
#[no_mangle]
|
||||
pub extern "C" fn owlry_plugin_vtable() -> &'static PluginVTable
|
||||
```
|
||||
|
||||
Plugins are compiled as `.so` (cdylib) and loaded by the daemon at startup.
|
||||
|
||||
**Plugin locations** (when deployed):
|
||||
- `/usr/lib/owlry/plugins/*.so` - Native plugins
|
||||
- `/usr/lib/owlry/runtimes/*.so` - Script runtimes (liblua.so, librune.so)
|
||||
- `~/.config/owlry/plugins/` - User plugins (Lua/Rune)
|
||||
|
||||
### Filter & Prefix System
|
||||
|
||||
`ProviderFilter` (`owlry-core/src/filter.rs`) handles:
|
||||
- CLI mode selection (`--mode app`)
|
||||
- Profile-based mode selection (`--profile dev`)
|
||||
- Provider toggling (Ctrl+1/2/3)
|
||||
- Prefix parsing (`:app`, `:cmd`, `:sys`, etc.)
|
||||
|
||||
Query parsing extracts prefix and forwards clean query to providers.
|
||||
|
||||
### SearchBackend
|
||||
|
||||
`SearchBackend` (`owlry/src/backend.rs`) abstracts over two modes:
|
||||
- **`Daemon`**: Wraps `CoreClient`, sends queries over IPC to `owlry-core`
|
||||
- **`Local`**: Wraps `ProviderManager` directly (used for dmenu mode only)
|
||||
|
||||
### UI Layer
|
||||
|
||||
- `MainWindow` (`src/ui/main_window.rs`): GTK4 window with Layer Shell overlay
|
||||
- `ResultRow` (`src/ui/result_row.rs`): Individual result rendering
|
||||
- `submenu` (`src/ui/submenu.rs`): Universal submenu parsing utilities (plugins provide actions)
|
||||
|
||||
### Configuration
|
||||
|
||||
`Config` (`owlry-core/src/config/mod.rs`) loads from `~/.config/owlry/config.toml`:
|
||||
- Auto-detects terminal (`$TERMINAL` -> `xdg-terminal-exec` -> common terminals)
|
||||
- Optional `use_uwsm = true` for systemd session integration (launches apps via `uwsm app --`)
|
||||
- Profiles: Define named mode sets under `[profiles.<name>]` with `modes = ["app", "cmd", ...]`
|
||||
|
||||
### Theming
|
||||
|
||||
CSS loading priority (`owlry/src/app.rs`):
|
||||
1. Base structural CSS (`resources/base.css`)
|
||||
2. Theme CSS (built-in "owl" or custom `~/.config/owlry/themes/{name}.css`)
|
||||
3. User overrides (`~/.config/owlry/style.css`)
|
||||
4. Config variable injection
|
||||
|
||||
### Systemd Integration
|
||||
|
||||
Service files in `systemd/`:
|
||||
- `owlry-core.service`: Runs daemon as `Type=simple`, restarts on failure
|
||||
- `owlry-core.socket`: Socket activation at `%t/owlry/owlry.sock`
|
||||
|
||||
Start with: `systemctl --user enable --now owlry-core.service`
|
||||
|
||||
## Plugins
|
||||
|
||||
Plugins live in a separate repository: `somegit.dev/Owlibou/owlry-plugins`
|
||||
|
||||
13 native plugin crates, all compiled as cdylib (.so):
|
||||
|
||||
| Category | Plugins | Behavior |
|
||||
|----------|---------|----------|
|
||||
| Static | bookmarks, clipboard, emoji, scripts, ssh, system, systemd | Loaded at startup, refresh() populates items |
|
||||
| Dynamic | calculator, websearch, filesearch | Queried per-keystroke via query() |
|
||||
| Widget | weather, media, pomodoro | Displayed at top of results |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Rc<RefCell<T>>** used throughout for GTK signal handlers needing mutable state
|
||||
- **Feature flag `dev-logging`**: Wraps debug!() calls in `#[cfg(feature = "dev-logging")]`
|
||||
- **Feature flag `lua`**: Enables built-in Lua runtime (off by default); enable to embed Lua in core binary
|
||||
- **dmenu mode**: Runs locally without daemon. Use `-m dmenu` with piped stdin
|
||||
- **Frecency**: Time-decayed frequency scoring stored in `~/.local/share/owlry/frecency.json`
|
||||
- **ABI stability**: Plugin interface uses `abi_stable` crate for safe Rust dynamic linking
|
||||
- **Plugin API v3**: Adds `position` (Normal/Widget) and `priority` fields to ProviderInfo
|
||||
- **ProviderType simplification**: Core uses only `Application`, `Command`, `Dmenu`, `Plugin(String)` - all plugin-specific types removed from core
|
||||
|
||||
## Dependencies (Rust 1.90+, GTK 4.12+)
|
||||
|
||||
External tool dependencies (for plugins):
|
||||
- Clipboard plugin: `cliphist`, `wl-clipboard`
|
||||
- File search plugin: `fd` or `mlocate`
|
||||
- Emoji plugin: `wl-clipboard`, `noto-fonts-emoji`
|
||||
- Systemd plugin: `systemd` (user services)
|
||||
- Bookmarks plugin: Firefox support uses `rusqlite` with bundled SQLite (no system dependency)
|
||||
|
||||
3825
Cargo.lock
generated
73
Cargo.toml
@@ -1,67 +1,34 @@
|
||||
[package]
|
||||
name = "owlry"
|
||||
version = "0.3.2"
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/owlry",
|
||||
"crates/owlry-core",
|
||||
"crates/owlry-plugin-api",
|
||||
"crates/owlry-lua",
|
||||
"crates/owlry-rune",
|
||||
]
|
||||
|
||||
# Shared workspace settings
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
description = "A lightweight, owl-themed application launcher for Wayland"
|
||||
authors = ["Your Name <you@example.com>"]
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://somegit.dev/Owlibou/owlry"
|
||||
keywords = ["launcher", "wayland", "gtk4", "linux"]
|
||||
categories = ["gui"]
|
||||
|
||||
[dependencies]
|
||||
# GTK4 for the UI
|
||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
||||
|
||||
# Layer shell support for Wayland overlay behavior
|
||||
gtk4-layer-shell = "0.4"
|
||||
|
||||
# Async runtime for non-blocking operations
|
||||
tokio = { version = "1", features = ["rt", "sync", "process", "fs"] }
|
||||
|
||||
# Fuzzy matching for search
|
||||
fuzzy-matcher = "0.3"
|
||||
|
||||
# XDG desktop entry parsing
|
||||
freedesktop-desktop-entry = "0.7"
|
||||
|
||||
# Directory utilities
|
||||
dirs = "5"
|
||||
|
||||
# Low-level syscalls for stdin detection
|
||||
libc = "0.2"
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
# Configuration
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
|
||||
# CLI argument parsing
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# Math expression evaluation for calculator
|
||||
meval = "0.2"
|
||||
|
||||
# JSON serialization for data persistence
|
||||
serde_json = "1"
|
||||
|
||||
# Date/time for frecency calculations
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Release profile (shared across all crates)
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
opt-level = "z" # Optimize for size
|
||||
opt-level = "z"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
debug = true
|
||||
|
||||
# For installing a testable build: cargo install --path crates/owlry --profile dev-install --features dev-logging
|
||||
[profile.dev-install]
|
||||
inherits = "release"
|
||||
strip = false
|
||||
debug = 1
|
||||
|
||||
539
README.md
@@ -10,32 +10,63 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
|
||||
|
||||
## Features
|
||||
|
||||
- **Provider-based architecture** - Search applications, commands, system actions, SSH hosts, clipboard history, bookmarks, emoji, and more
|
||||
- **Fuzzy search** - Fast, typo-tolerant matching across all providers
|
||||
- **Filter tabs & prefixes** - Scope searches with UI tabs or `:app`, `:cmd`, `:sys` prefixes
|
||||
- **Calculator** - Quick math with `= 5+3` or `calc sin(pi/2)`
|
||||
- **Web search** - Search the web with `? query` or `web query`
|
||||
- **File search** - Find files with `/ filename` or `find config` (requires `fd` or `locate`)
|
||||
- **Frecency ranking** - Frequently/recently used items rank higher
|
||||
- **GTK4 theming** - Respects system theme by default, with optional custom themes
|
||||
- **Wayland native** - Uses Layer Shell for proper overlay behavior
|
||||
- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory
|
||||
- **Modular plugin architecture** — Install only what you need
|
||||
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
|
||||
- **13 native plugins** — Calculator, clipboard, emoji, weather, media, and more
|
||||
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
|
||||
- **Config profiles** — Named mode presets for different workflows
|
||||
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc.
|
||||
- **Frecency ranking** — Frequently/recently used items rank higher
|
||||
- **Toggle behavior** — Bind one key to open/close the launcher
|
||||
- **GTK4 theming** — System theme by default, with 9 built-in themes
|
||||
- **Wayland native** — Uses Layer Shell for proper overlay behavior
|
||||
- **Extensible** — Create custom plugins in Lua or Rune
|
||||
|
||||
## Installation
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
# Minimal core (applications + commands only)
|
||||
yay -S owlry
|
||||
|
||||
# Using paru
|
||||
paru -S owlry
|
||||
# Add individual plugins
|
||||
yay -S owlry-plugin-calculator owlry-plugin-weather
|
||||
|
||||
# Or install bundles:
|
||||
yay -S owlry-meta-essentials # calculator, system, ssh, scripts, bookmarks
|
||||
yay -S owlry-meta-widgets # weather, media, pomodoro
|
||||
yay -S owlry-meta-tools # clipboard, emoji, websearch, filesearch, systemd
|
||||
yay -S owlry-meta-full # everything
|
||||
|
||||
# For custom Lua/Rune plugins
|
||||
yay -S owlry-lua # Lua 5.4 runtime
|
||||
yay -S owlry-rune # Rune runtime
|
||||
```
|
||||
|
||||
### Build from source
|
||||
### Available Packages
|
||||
|
||||
#### Dependencies
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `owlry` | Core: UI client (`owlry`) and daemon (`owlry-core`) |
|
||||
| `owlry-plugin-calculator` | Math expressions (`= 5+3`) |
|
||||
| `owlry-plugin-system` | Shutdown, reboot, suspend, lock |
|
||||
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
|
||||
| `owlry-plugin-clipboard` | History via cliphist |
|
||||
| `owlry-plugin-emoji` | 400+ searchable emoji |
|
||||
| `owlry-plugin-scripts` | User scripts |
|
||||
| `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
|
||||
| `owlry-plugin-websearch` | Web search (`? query`) |
|
||||
| `owlry-plugin-filesearch` | File search (`/ filename`) |
|
||||
| `owlry-plugin-systemd` | User services with actions |
|
||||
| `owlry-plugin-weather` | Weather widget |
|
||||
| `owlry-plugin-media` | MPRIS media controls |
|
||||
| `owlry-plugin-pomodoro` | Pomodoro timer widget |
|
||||
|
||||
### Build from Source
|
||||
|
||||
**Dependencies:**
|
||||
```bash
|
||||
# Arch Linux
|
||||
sudo pacman -S gtk4 gtk4-layer-shell
|
||||
@@ -47,44 +78,152 @@ sudo apt install libgtk-4-dev libgtk4-layer-shell-dev
|
||||
sudo dnf install gtk4-devel gtk4-layer-shell-devel
|
||||
```
|
||||
|
||||
#### Optional dependencies
|
||||
|
||||
```bash
|
||||
# For clipboard history
|
||||
sudo pacman -S cliphist wl-clipboard
|
||||
|
||||
# For file search
|
||||
sudo pacman -S fd # or: mlocate
|
||||
```
|
||||
|
||||
#### Build
|
||||
|
||||
Requires Rust 1.90 or later.
|
||||
|
||||
**Build (requires Rust 1.90+):**
|
||||
```bash
|
||||
git clone https://somegit.dev/Owlibou/owlry.git
|
||||
cd owlry
|
||||
cargo build --release
|
||||
|
||||
# Build core only (daemon + UI)
|
||||
cargo build --release -p owlry -p owlry-core
|
||||
|
||||
# Build specific plugin
|
||||
cargo build --release -p owlry-plugin-calculator
|
||||
|
||||
# Build everything
|
||||
cargo build --release --workspace
|
||||
```
|
||||
|
||||
The binary will be at `target/release/owlry`.
|
||||
**Install locally:**
|
||||
```bash
|
||||
just install-local
|
||||
```
|
||||
|
||||
This installs both binaries, all plugins, runtimes, and the systemd service files.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Owlry uses a client/daemon architecture. The daemon (`owlry-core`) loads providers and plugins into memory. The UI client (`owlry`) connects to the daemon over a Unix socket for instant results.
|
||||
|
||||
### Starting the Daemon
|
||||
|
||||
Choose one of three methods:
|
||||
|
||||
**1. Compositor autostart (recommended for most users)**
|
||||
|
||||
Add to your compositor config:
|
||||
|
||||
```bash
|
||||
# Hyprland (~/.config/hypr/hyprland.conf)
|
||||
exec-once = owlry-core
|
||||
|
||||
# Sway (~/.config/sway/config)
|
||||
exec owlry-core
|
||||
```
|
||||
|
||||
**2. Systemd user service**
|
||||
|
||||
```bash
|
||||
systemctl --user enable --now owlry-core.service
|
||||
```
|
||||
|
||||
**3. Socket activation (auto-start on first use)**
|
||||
|
||||
```bash
|
||||
systemctl --user enable owlry-core.socket
|
||||
```
|
||||
|
||||
The daemon starts automatically when the UI client first connects. No manual startup needed.
|
||||
|
||||
### Launching the UI
|
||||
|
||||
Bind `owlry` to a key in your compositor:
|
||||
|
||||
```bash
|
||||
# Hyprland
|
||||
bind = SUPER, Space, exec, owlry
|
||||
|
||||
# Sway
|
||||
bindsym $mod+space exec owlry
|
||||
```
|
||||
|
||||
Running `owlry` a second time while it is already open sends a toggle command — the window closes. This means a single keybind acts as open/close.
|
||||
|
||||
If the daemon is not running when the UI launches, it will attempt to start it via systemd automatically.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Launch with default settings (GTK theme, all providers)
|
||||
owlry
|
||||
|
||||
# Launch with only applications
|
||||
owlry --mode app
|
||||
|
||||
# Launch with specific providers
|
||||
owlry --providers app,cmd
|
||||
|
||||
# Show help
|
||||
owlry --help
|
||||
owlry # Launch with all providers
|
||||
owlry -m app # Applications only
|
||||
owlry -m cmd # PATH commands only
|
||||
owlry -m calc # Calculator plugin only (if installed)
|
||||
owlry --profile dev # Use a named profile from config
|
||||
owlry --help # Show all options with examples
|
||||
```
|
||||
|
||||
### Profiles
|
||||
|
||||
Profiles are named sets of modes defined in your config:
|
||||
|
||||
```toml
|
||||
[profiles.dev]
|
||||
modes = ["app", "cmd", "ssh"]
|
||||
|
||||
[profiles.media]
|
||||
modes = ["media", "emoji"]
|
||||
|
||||
[profiles.minimal]
|
||||
modes = ["app"]
|
||||
```
|
||||
|
||||
Launch with a profile:
|
||||
|
||||
```bash
|
||||
owlry --profile dev
|
||||
```
|
||||
|
||||
You can bind different profiles to different keys:
|
||||
|
||||
```bash
|
||||
# Hyprland
|
||||
bind = SUPER, Space, exec, owlry
|
||||
bind = SUPER, D, exec, owlry --profile dev
|
||||
bind = SUPER, M, exec, owlry --profile media
|
||||
```
|
||||
|
||||
### dmenu Mode
|
||||
|
||||
Owlry is dmenu-compatible. Pipe input for interactive selection — the selected item is printed to stdout (not executed), so you pipe the output to execute it.
|
||||
|
||||
dmenu mode is self-contained: it does not use the daemon and works without `owlry-core` running.
|
||||
|
||||
```bash
|
||||
# Screenshot menu (execute selected command)
|
||||
printf '%s\n' \
|
||||
"grimblast --notify copy screen" \
|
||||
"grimblast --notify copy area" \
|
||||
"grimblast --notify edit screen" \
|
||||
| owlry -m dmenu -p "Screenshot" \
|
||||
| sh
|
||||
|
||||
# Git branch checkout
|
||||
git branch | owlry -m dmenu -p "checkout" | xargs git checkout
|
||||
|
||||
# Kill a process
|
||||
ps -eo comm | sort -u | owlry -m dmenu -p "kill" | xargs pkill
|
||||
|
||||
# Select and open a project
|
||||
find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
|
||||
|
||||
# Package manager search
|
||||
pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S
|
||||
|
||||
# Open selected file
|
||||
ls ~/Documents | owlry -m dmenu | xargs xdg-open
|
||||
```
|
||||
|
||||
The `-p` / `--prompt` flag sets a custom label for the search input.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
@@ -92,216 +231,177 @@ owlry --help
|
||||
| `Enter` | Launch selected item |
|
||||
| `Escape` | Close launcher / exit submenu |
|
||||
| `Up` / `Down` | Navigate results |
|
||||
| `Tab` | Cycle filter modes |
|
||||
| `Shift+Tab` | Cycle filter modes (reverse) |
|
||||
| `Ctrl+1` | Toggle Applications filter |
|
||||
| `Ctrl+2` | Toggle Commands filter |
|
||||
| `Ctrl+3` | Toggle systemd filter |
|
||||
| `Tab` | Cycle filter tabs |
|
||||
| `Shift+Tab` | Cycle filter tabs (reverse) |
|
||||
| `Ctrl+1..9` | Toggle tab by position |
|
||||
|
||||
### Search Prefixes
|
||||
|
||||
Filter results by provider using prefixes:
|
||||
|
||||
| Prefix | Provider | Example |
|
||||
|--------|----------|---------|
|
||||
| `:app` | Applications | `:app firefox` |
|
||||
| `:cmd` | PATH commands | `:cmd git` |
|
||||
| `:sys` | System actions | `:sys shutdown` |
|
||||
| `:ssh` | SSH hosts | `:ssh server` |
|
||||
| `:clip` | Clipboard history | `:clip password` |
|
||||
| `:bm` | Browser bookmarks | `:bm github` |
|
||||
| `:emoji` | Emoji picker | `:emoji heart` |
|
||||
| `:script` | Custom scripts | `:script backup` |
|
||||
| `:file` | File search | `:file config.toml` |
|
||||
| `:calc` | Calculator | `:calc 5+3` |
|
||||
| `:clip` | Clipboard | `:clip password` |
|
||||
| `:bm` | Bookmarks | `:bm github` |
|
||||
| `:emoji` | Emoji | `:emoji heart` |
|
||||
| `:script` | Scripts | `:script backup` |
|
||||
| `:file` | Files | `:file config` |
|
||||
| `:calc` | Calculator | `:calc sqrt(16)` |
|
||||
| `:web` | Web search | `:web rust docs` |
|
||||
| `:uuctl` | systemd services | `:uuctl docker` |
|
||||
| `:uuctl` | systemd | `:uuctl docker` |
|
||||
| `:tag:X` | Filter by tag | `:tag:development` |
|
||||
|
||||
### Trigger Prefixes
|
||||
|
||||
Some providers can be triggered directly without filter mode:
|
||||
|
||||
| Trigger | Provider | Example |
|
||||
|---------|----------|---------|
|
||||
| `=` | Calculator | `= 5+3` or `=5*2` |
|
||||
| `=` | Calculator | `= 5+3` |
|
||||
| `calc ` | Calculator | `calc sqrt(16)` |
|
||||
| `?` | Web search | `? rust programming` |
|
||||
| `web ` | Web search | `web linux tips` |
|
||||
| `search ` | Web search | `search owlry` |
|
||||
| `/` | File search | `/ .bashrc` |
|
||||
| `find ` | File search | `find config` |
|
||||
|
||||
## Providers
|
||||
|
||||
### Applications
|
||||
Searches `.desktop` files from standard XDG directories.
|
||||
|
||||
### Commands
|
||||
Searches executable files in `$PATH`.
|
||||
|
||||
### System
|
||||
Quick access to system actions:
|
||||
- Shutdown, Reboot, Suspend, Hibernate
|
||||
- Lock Screen, Log Out
|
||||
- **Reboot into BIOS** - Restart directly into UEFI/BIOS setup
|
||||
|
||||
### SSH
|
||||
Parses `~/.ssh/config` and offers quick connections to configured hosts. Opens in your configured terminal.
|
||||
|
||||
### Clipboard (requires cliphist)
|
||||
Search and paste from clipboard history. Requires `cliphist` and `wl-clipboard`:
|
||||
```bash
|
||||
sudo pacman -S cliphist wl-clipboard
|
||||
```
|
||||
|
||||
### Bookmarks
|
||||
Reads bookmarks from Chromium-based browsers:
|
||||
- Chrome, Chromium, Brave, Edge, Vivaldi
|
||||
|
||||
### Emoji
|
||||
Search 300+ emojis by name or keywords. Selected emoji is copied to clipboard via `wl-copy`.
|
||||
|
||||
### Scripts
|
||||
Runs executable scripts from `~/.config/owlry/scripts/`. Create the directory and add your scripts:
|
||||
```bash
|
||||
mkdir -p ~/.config/owlry/scripts
|
||||
echo '#!/bin/bash
|
||||
# My backup script
|
||||
rsync -av ~/Documents /backup/' > ~/.config/owlry/scripts/backup
|
||||
chmod +x ~/.config/owlry/scripts/backup
|
||||
```
|
||||
|
||||
### Calculator
|
||||
Evaluate math expressions with `= expr` or `calc expr`:
|
||||
- Basic: `= 5+3`, `= 10/3`
|
||||
- Functions: `= sqrt(16)`, `= sin(pi/2)`
|
||||
- Constants: `= pi`, `= e`
|
||||
|
||||
### Web Search
|
||||
Search the web with `? query` or `web query`. Configurable search engine:
|
||||
- Google, DuckDuckGo, Bing, Brave, Ecosia, Startpage, SearXNG
|
||||
- Or custom URL with `{query}` placeholder
|
||||
|
||||
### File Search (requires fd or locate)
|
||||
Search files with `/ pattern` or `find pattern`:
|
||||
```bash
|
||||
sudo pacman -S fd # recommended, faster
|
||||
# or
|
||||
sudo pacman -S mlocate && sudo updatedb
|
||||
```
|
||||
|
||||
### systemd User Services
|
||||
Lists and controls user-level systemd services. Select a service to access actions:
|
||||
- Start / Stop / Restart / Reload
|
||||
- Kill (force stop)
|
||||
- Status (opens in terminal)
|
||||
- Journal (live logs in terminal)
|
||||
- Enable / Disable (autostart)
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration file: `~/.config/owlry/config.toml`
|
||||
Owlry follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/):
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `~/.config/owlry/config.toml` | Main configuration |
|
||||
| `~/.config/owlry/themes/*.css` | Custom themes |
|
||||
| `~/.config/owlry/style.css` | CSS overrides |
|
||||
| `~/.config/owlry/plugins/` | User plugins (Lua/Rune) |
|
||||
| `~/.local/share/owlry/scripts/` | User scripts |
|
||||
| `~/.local/share/owlry/frecency.json` | Usage history |
|
||||
|
||||
System locations:
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/usr/lib/owlry/plugins/*.so` | Installed native plugins |
|
||||
| `/usr/lib/owlry/runtimes/*.so` | Lua/Rune script runtimes |
|
||||
| `/usr/share/doc/owlry/config.example.toml` | Example configuration |
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Copy example config
|
||||
mkdir -p ~/.config/owlry
|
||||
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
|
||||
```
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```toml
|
||||
[general]
|
||||
show_icons = true
|
||||
max_results = 10
|
||||
# terminal_command = "kitty" # Auto-detected if not set
|
||||
# launch_wrapper = "uwsm app --" # Auto-detected for uwsm/hyprland
|
||||
tabs = ["app", "cmd", "uuctl"]
|
||||
# terminal_command = "kitty" # Auto-detected
|
||||
# use_uwsm = false # Enable for systemd session integration
|
||||
|
||||
[appearance]
|
||||
width = 600
|
||||
height = 400
|
||||
width = 850
|
||||
height = 650
|
||||
font_size = 14
|
||||
border_radius = 12
|
||||
# theme = "owl" # Optional: "owl" or custom theme name
|
||||
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc.
|
||||
|
||||
[plugins]
|
||||
disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
|
||||
|
||||
[providers]
|
||||
applications = true
|
||||
commands = true
|
||||
uuctl = true
|
||||
calculator = true
|
||||
websearch = true
|
||||
search_engine = "duckduckgo" # google, bing, brave, ecosia, startpage, searxng
|
||||
system = true
|
||||
ssh = true
|
||||
clipboard = true
|
||||
bookmarks = true
|
||||
emoji = true
|
||||
scripts = true
|
||||
files = true
|
||||
frecency = true
|
||||
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
|
||||
applications = true # .desktop files
|
||||
commands = true # PATH executables
|
||||
frecency = true # Boost frequently used items
|
||||
frecency_weight = 0.3 # 0.0-1.0
|
||||
|
||||
# Web search engine: google, duckduckgo, bing, startpage, brave, ecosia
|
||||
search_engine = "duckduckgo"
|
||||
|
||||
# Profiles: named sets of modes
|
||||
[profiles.dev]
|
||||
modes = ["app", "cmd", "ssh"]
|
||||
|
||||
[profiles.media]
|
||||
modes = ["media", "emoji"]
|
||||
```
|
||||
|
||||
### Default Values
|
||||
See `/usr/share/doc/owlry/config.example.toml` for all options with documentation.
|
||||
|
||||
| Setting | Default |
|
||||
|---------|---------|
|
||||
| `show_icons` | `true` |
|
||||
| `max_results` | `10` |
|
||||
| `terminal_command` | Auto-detected ($TERMINAL -> xdg-terminal-exec -> kitty/alacritty/etc) |
|
||||
| `launch_wrapper` | Auto-detected (uwsm -> hyprctl -> none) |
|
||||
| `width` | `600` |
|
||||
| `height` | `400` |
|
||||
| `font_size` | `14` |
|
||||
| `border_radius` | `12` |
|
||||
| `theme` | None (GTK default) |
|
||||
## Plugin System
|
||||
|
||||
### Launch Wrapper
|
||||
Owlry uses a modular plugin architecture. Plugins are loaded by the daemon (`owlry-core`) from:
|
||||
|
||||
When running in uwsm-managed or Hyprland sessions, owlry auto-detects and uses the appropriate launch wrapper:
|
||||
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
|
||||
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
|
||||
|
||||
| Session | Wrapper | Purpose |
|
||||
|---------|---------|---------|
|
||||
| uwsm | `uwsm app --` | Proper systemd scope and session management |
|
||||
| Hyprland | `hyprctl dispatch exec --` | Native Hyprland window management |
|
||||
| Other | None (direct `sh -c`) | Standard shell execution |
|
||||
### Disabling Plugins
|
||||
|
||||
Add plugin IDs to the disabled list in your config:
|
||||
|
||||
```toml
|
||||
[plugins]
|
||||
disabled = ["emoji", "pomodoro"]
|
||||
```
|
||||
|
||||
### Plugin Management CLI
|
||||
|
||||
```bash
|
||||
# List installed plugins
|
||||
owlry plugin list
|
||||
owlry plugin list --enabled # Only enabled
|
||||
owlry plugin list --available # Show registry plugins
|
||||
|
||||
# Search registry
|
||||
owlry plugin search "weather"
|
||||
|
||||
# Install/remove
|
||||
owlry plugin install <name> # From registry
|
||||
owlry plugin install ./my-plugin # From local path
|
||||
owlry plugin remove <name>
|
||||
|
||||
# Enable/disable
|
||||
owlry plugin enable <name>
|
||||
owlry plugin disable <name>
|
||||
|
||||
# Plugin info
|
||||
owlry plugin info <name>
|
||||
owlry plugin commands <name> # List plugin CLI commands
|
||||
|
||||
# Create new plugin
|
||||
owlry plugin create my-plugin # Lua (default)
|
||||
owlry plugin create my-plugin -r rune # Rune
|
||||
|
||||
# Run plugin command
|
||||
owlry plugin run <plugin-id> <command> [args...]
|
||||
```
|
||||
|
||||
### Creating Custom Plugins
|
||||
|
||||
See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
|
||||
- Native plugin development (Rust)
|
||||
- Lua plugin development
|
||||
- Rune plugin development
|
||||
- Available APIs
|
||||
|
||||
## Theming
|
||||
|
||||
### GTK Theme (Default)
|
||||
|
||||
By default, Owlry inherits colors from your system GTK4 theme (Adwaita, Breeze, etc.).
|
||||
|
||||
### Built-in Themes
|
||||
|
||||
Owlry includes an owl-inspired dark theme:
|
||||
|
||||
```toml
|
||||
[appearance]
|
||||
theme = "owl"
|
||||
```
|
||||
|
||||
### Included Example Themes
|
||||
|
||||
Example themes are installed to `/usr/share/owlry/themes/`:
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| `owl` | Owl-inspired dark theme with amber accents |
|
||||
| `catppuccin-mocha` | Soothing pastel theme |
|
||||
| `nord` | Arctic, north-bluish palette |
|
||||
| `rose-pine` | All natural pine, faux fur and soho vibes |
|
||||
| `dracula` | Dark theme for vampires |
|
||||
| `gruvbox-dark` | Retro groove color scheme |
|
||||
| `tokyo-night` | Lights of Tokyo at night |
|
||||
| `solarized-dark` | Precision colors for machines and people |
|
||||
| `one-dark` | Atom's iconic One Dark theme |
|
||||
|
||||
To use an example theme:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/owlry/themes
|
||||
cp /usr/share/owlry/themes/catppuccin-mocha.css ~/.config/owlry/themes/
|
||||
```
|
||||
|
||||
Then set in config:
|
||||
| `owl` | Dark theme with amber accents |
|
||||
| `catppuccin-mocha` | Soothing pastel |
|
||||
| `nord` | Arctic blue palette |
|
||||
| `rose-pine` | Natural pine vibes |
|
||||
| `dracula` | Dark vampire theme |
|
||||
| `gruvbox-dark` | Retro groove |
|
||||
| `tokyo-night` | Tokyo city lights |
|
||||
| `solarized-dark` | Precision colors |
|
||||
| `one-dark` | Atom's One Dark |
|
||||
|
||||
```toml
|
||||
[appearance]
|
||||
@@ -310,7 +410,7 @@ theme = "catppuccin-mocha"
|
||||
|
||||
### Custom Theme
|
||||
|
||||
Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
|
||||
Create `~/.config/owlry/themes/mytheme.css`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
@@ -324,7 +424,7 @@ Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Variables Reference
|
||||
### CSS Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
@@ -333,22 +433,39 @@ Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
|
||||
| `--owlry-border` | Border color |
|
||||
| `--owlry-text` | Primary text |
|
||||
| `--owlry-text-secondary` | Muted text |
|
||||
| `--owlry-accent` | Accent/highlight color |
|
||||
| `--owlry-accent` | Accent color |
|
||||
| `--owlry-accent-bright` | Bright accent |
|
||||
| `--owlry-font-size` | Base font size |
|
||||
| `--owlry-border-radius` | Border radius |
|
||||
| `--owlry-badge-*` | Provider badge colors (app, cmd, sys, ssh, clip, emoji, etc.) |
|
||||
|
||||
### Custom Stylesheet
|
||||
## Architecture
|
||||
|
||||
For full control, create `~/.config/owlry/style.css` with any GTK4 CSS.
|
||||
Owlry uses a client/daemon split:
|
||||
|
||||
```
|
||||
owlry-core (daemon) owlry (GTK4 UI client)
|
||||
├── Loads config + plugins ├── Connects to daemon via Unix socket
|
||||
├── Applications provider ├── Renders results in GTK4 window
|
||||
├── Commands provider ├── Handles keyboard input
|
||||
├── Plugin loader ├── Toggle: second launch closes window
|
||||
│ ├── /usr/lib/owlry/plugins/*.so └── dmenu mode (self-contained, no daemon)
|
||||
│ ├── /usr/lib/owlry/runtimes/
|
||||
│ └── ~/.config/owlry/plugins/
|
||||
├── Frecency tracking
|
||||
└── IPC server (Unix socket)
|
||||
│
|
||||
└── $XDG_RUNTIME_DIR/owlry/owlry.sock
|
||||
```
|
||||
|
||||
The daemon keeps providers and plugins loaded in memory, so the UI appears instantly when launched. The UI client is a thin GTK4 layer that sends queries and receives results over the socket.
|
||||
|
||||
For detailed architecture information, see [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 or later - see [LICENSE](LICENSE) for details.
|
||||
GNU General Public License v3.0 — see [LICENSE](LICENSE).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [GTK4](https://gtk.org/) - UI toolkit
|
||||
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) - Wayland Layer Shell bindings
|
||||
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) - Fuzzy search algorithm
|
||||
- [GTK4](https://gtk.org/) — UI toolkit
|
||||
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) — Wayland Layer Shell
|
||||
- [abi_stable](https://crates.io/crates/abi_stable) — ABI-stable Rust plugins
|
||||
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) — Fuzzy search
|
||||
|
||||
109
ROADMAP.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Owlry Roadmap
|
||||
|
||||
Feature ideas and future development plans for Owlry.
|
||||
|
||||
## High Value, Low Effort
|
||||
|
||||
### Plugin hot-reload
|
||||
Detect `.so` file changes in `/usr/lib/owlry/plugins/` and reload without restarting the launcher. The loader infrastructure already exists.
|
||||
|
||||
### Frecency pruning
|
||||
Add `max_entries` and `max_age_days` config options. Prune old entries on startup to prevent `frecency.json` from growing unbounded.
|
||||
|
||||
### `:recent` prefix
|
||||
Show last N launched items. Data already exists in frecency.json — just needs a provider to surface it.
|
||||
|
||||
### Clipboard images
|
||||
`cliphist` supports images. Extend the clipboard plugin to show image thumbnails in results.
|
||||
|
||||
---
|
||||
|
||||
## Medium Effort, High Value
|
||||
|
||||
### Actions on any result
|
||||
Generalize the submenu system beyond systemd. Every result type gets contextual actions:
|
||||
|
||||
| Provider | Actions |
|
||||
|----------|---------|
|
||||
| Applications | Open, Open in terminal, Show .desktop location |
|
||||
| Files | Open, Open folder, Copy path, Delete |
|
||||
| SSH | Connect, Copy hostname, Edit config |
|
||||
| Bookmarks | Open, Copy URL, Open incognito |
|
||||
| Clipboard | Paste, Delete from history |
|
||||
|
||||
This is the difference between a launcher and a command palette.
|
||||
|
||||
### Plugin settings UI
|
||||
A `:settings` provider that lists installed plugins and their configurable options. Edit values inline, writes to `config.toml`.
|
||||
|
||||
### Result action capture
|
||||
Calculator shows `= 5+3 → 8`. Allow pressing Tab or Ctrl+C to copy the result to clipboard instead of "launching" it. Useful for calculator, file paths, URLs.
|
||||
|
||||
---
|
||||
|
||||
## Bigger Bets
|
||||
|
||||
### Window switcher with live thumbnails
|
||||
A `windows` plugin using Wayland screencopy to show live thumbnails of open windows. Hyprland and Sway expose window lists via IPC. Could replace Alt+Tab.
|
||||
|
||||
### Cross-device bookmark sync
|
||||
Firefox and Chrome sync bookmarks across devices. Parse sync metadata to show "recently added on other devices" or "bookmarks from phone".
|
||||
|
||||
### Natural language commands
|
||||
Parse simple natural language into system commands:
|
||||
|
||||
```
|
||||
"shutdown in 30 minutes" → systemd-run --user --on-active=30m systemctl poweroff
|
||||
"remind me in 1 hour" → notify-send scheduled via at/systemd timer
|
||||
"volume 50%" → wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.5
|
||||
```
|
||||
|
||||
Local pattern matching, no AI/cloud required.
|
||||
|
||||
### Plugin marketplace
|
||||
A curated registry of third-party Lua/Rune plugins with one-command install:
|
||||
|
||||
```bash
|
||||
owlry plugin install github-notifications
|
||||
owlry plugin install todoist
|
||||
owlry plugin install spotify-controls
|
||||
```
|
||||
|
||||
The script runtimes make this viable without recompiling.
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt
|
||||
|
||||
### Split monorepo for user build efficiency
|
||||
Currently, a small core fix requires all 16 AUR packages to rebuild (same source tarball). Split into 3 repos:
|
||||
|
||||
| Repo | Contents | Versioning |
|
||||
|------|----------|------------|
|
||||
| `owlry` | Core binary | Independent |
|
||||
| `owlry-plugin-api` | ABI interface (crates.io) | Semver, conservative |
|
||||
| `owlry-plugins` | 13 plugins + 2 runtimes | Independent per plugin |
|
||||
|
||||
**Execution order:**
|
||||
1. Publish `owlry-plugin-api` to crates.io
|
||||
2. Update monorepo to use crates.io dependency
|
||||
3. Create `owlry-plugins` repo, move plugins + runtimes
|
||||
4. Slim current repo to core-only
|
||||
5. Update AUR PKGBUILDs with new source URLs
|
||||
|
||||
**Benefit:** Core bugfix = 1 rebuild. Plugin fix = 1 rebuild. Third-party plugins possible via crates.io.
|
||||
|
||||
### Replace meval with evalexpr
|
||||
`meval` depends on `nom v1.2.4` which will be rejected by future Rust versions. Migrate calculator plugin and Lua runtime to `evalexpr` v13+.
|
||||
|
||||
### Plugin API backwards compatibility
|
||||
When `API_VERSION` increments, provide a compatibility shim so v3 plugins work with v4 core. Prevents ecosystem fragmentation.
|
||||
|
||||
### Per-plugin configuration
|
||||
Current flat `[providers]` config doesn't scale. Design a `[plugins.weather]`, `[plugins.pomodoro]` structure that plugins can declare and the core validates.
|
||||
|
||||
---
|
||||
|
||||
## Priority
|
||||
|
||||
If we had to pick one: **Actions on any result**. It transforms every provider from "search and launch" to "search and do anything". The ROI is massive.
|
||||
@@ -1,77 +0,0 @@
|
||||
# Owlry Configuration
|
||||
# Copy to ~/.config/owlry/config.toml
|
||||
|
||||
[general]
|
||||
show_icons = true
|
||||
max_results = 10
|
||||
terminal_command = "kitty" # Auto-detected if not set
|
||||
|
||||
# Launch wrapper for app execution (auto-detected if not set)
|
||||
# Examples:
|
||||
# "uwsm app --" # For uwsm sessions
|
||||
# "hyprctl dispatch exec --" # For Hyprland
|
||||
# "" # Direct execution
|
||||
# launch_wrapper = "uwsm app --"
|
||||
|
||||
[appearance]
|
||||
width = 600
|
||||
height = 400
|
||||
font_size = 14
|
||||
border_radius = 12
|
||||
|
||||
# Theme: "owl" for built-in dark theme, or leave unset for GTK default
|
||||
# theme = "owl"
|
||||
|
||||
# Individual color overrides (CSS color values)
|
||||
# [appearance.colors]
|
||||
# background = "#1a1b26"
|
||||
# background_secondary = "#24283b"
|
||||
# border = "#414868"
|
||||
# text = "#c0caf5"
|
||||
# text_secondary = "#565f89"
|
||||
# accent = "#7aa2f7"
|
||||
# accent_bright = "#89b4fa"
|
||||
# badge_app = "#9ece6a"
|
||||
# badge_calc = "#e0af68"
|
||||
# badge_cmd = "#7aa2f7"
|
||||
# badge_dmenu = "#bb9af7"
|
||||
# badge_uuctl = "#f7768e"
|
||||
|
||||
[providers]
|
||||
applications = true
|
||||
commands = true
|
||||
uuctl = true
|
||||
|
||||
# Calculator provider (type "= 5+3" or "calc 5+3")
|
||||
calculator = true
|
||||
|
||||
# Frecency: boost frequently/recently used items in search results
|
||||
frecency = true
|
||||
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
|
||||
|
||||
# Web search provider (type "? query" or "web query")
|
||||
websearch = true
|
||||
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
|
||||
# Or custom URL with {query} placeholder, e.g. "https://search.example.com/?q={query}"
|
||||
search_engine = "duckduckgo"
|
||||
|
||||
# System commands (shutdown, reboot, lock, suspend, hibernate, logout, BIOS)
|
||||
system = true
|
||||
|
||||
# SSH connections from ~/.ssh/config
|
||||
ssh = true
|
||||
|
||||
# Clipboard history (requires cliphist)
|
||||
clipboard = true
|
||||
|
||||
# Browser bookmarks (Chrome, Chromium, Brave, Edge, Vivaldi)
|
||||
bookmarks = true
|
||||
|
||||
# Emoji picker (copies to clipboard)
|
||||
emoji = true
|
||||
|
||||
# Custom scripts from ~/.config/owlry/scripts/
|
||||
scripts = true
|
||||
|
||||
# File search (requires fd or locate, trigger with "/ pattern" or "find pattern")
|
||||
files = true
|
||||
58
crates/owlry-core/Cargo.toml
Normal file
@@ -0,0 +1,58 @@
|
||||
[package]
|
||||
name = "owlry-core"
|
||||
version = "1.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Core daemon for the Owlry application launcher"
|
||||
|
||||
[lib]
|
||||
name = "owlry_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "owlry-core"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# Provider system
|
||||
fuzzy-matcher = "0.3"
|
||||
freedesktop-desktop-entry = "0.8"
|
||||
|
||||
# Plugin loading
|
||||
libloading = "0.8"
|
||||
semver = "1"
|
||||
|
||||
# Data & config
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "5"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
# Signal handling
|
||||
ctrlc = { version = "3", features = ["termination"] }
|
||||
|
||||
# Logging & notifications
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
notify-rust = "4"
|
||||
|
||||
# Optional: embedded Lua runtime
|
||||
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true }
|
||||
meval = { version = "0.2", optional = true }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
lua = ["dep:mlua", "dep:meval", "dep:reqwest"]
|
||||
dev-logging = []
|
||||
593
crates/owlry-core/src/config/mod.rs
Normal file
@@ -0,0 +1,593 @@
|
||||
use log::{debug, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// A named profile that selects a set of provider modes.
|
||||
///
|
||||
/// Defined in config.toml as:
|
||||
/// ```toml
|
||||
/// [profiles.dev]
|
||||
/// modes = ["app", "cmd", "ssh"]
|
||||
///
|
||||
/// [profiles.media]
|
||||
/// modes = ["media", "emoji"]
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct ProfileConfig {
|
||||
pub modes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub general: GeneralConfig,
|
||||
#[serde(default)]
|
||||
pub appearance: AppearanceConfig,
|
||||
#[serde(default)]
|
||||
pub providers: ProvidersConfig,
|
||||
#[serde(default)]
|
||||
pub plugins: PluginsConfig,
|
||||
#[serde(default)]
|
||||
pub profiles: HashMap<String, ProfileConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneralConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub show_icons: bool,
|
||||
#[serde(default = "default_max_results")]
|
||||
pub max_results: usize,
|
||||
/// Terminal command (auto-detected if not specified)
|
||||
#[serde(default)]
|
||||
pub terminal_command: Option<String>,
|
||||
/// Enable uwsm (Universal Wayland Session Manager) for launching apps.
|
||||
/// When enabled, desktop files are launched via `uwsm app -- <file>`
|
||||
/// which starts apps in a proper systemd user session.
|
||||
/// When disabled (default), apps are launched via `gio launch`.
|
||||
#[serde(default)]
|
||||
pub use_uwsm: bool,
|
||||
/// Provider tabs shown in the header bar.
|
||||
/// Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
|
||||
#[serde(default = "default_tabs")]
|
||||
pub tabs: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for GeneralConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_icons: true,
|
||||
max_results: 100,
|
||||
terminal_command: None,
|
||||
use_uwsm: false,
|
||||
tabs: default_tabs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_max_results() -> usize {
|
||||
100
|
||||
}
|
||||
|
||||
fn default_tabs() -> Vec<String> {
|
||||
vec!["app".to_string(), "cmd".to_string(), "uuctl".to_string()]
|
||||
}
|
||||
|
||||
/// User-customizable theme colors
|
||||
/// All fields are optional - unset values inherit from theme or GTK defaults
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ThemeColors {
|
||||
// Core colors
|
||||
pub background: Option<String>,
|
||||
pub background_secondary: Option<String>,
|
||||
pub border: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub text_secondary: Option<String>,
|
||||
pub accent: Option<String>,
|
||||
pub accent_bright: Option<String>,
|
||||
// Provider badge colors
|
||||
pub badge_app: Option<String>,
|
||||
pub badge_bookmark: Option<String>,
|
||||
pub badge_calc: Option<String>,
|
||||
pub badge_clip: Option<String>,
|
||||
pub badge_cmd: Option<String>,
|
||||
pub badge_dmenu: Option<String>,
|
||||
pub badge_emoji: Option<String>,
|
||||
pub badge_file: Option<String>,
|
||||
pub badge_script: Option<String>,
|
||||
pub badge_ssh: Option<String>,
|
||||
pub badge_sys: Option<String>,
|
||||
pub badge_uuctl: Option<String>,
|
||||
pub badge_web: Option<String>,
|
||||
// Widget badge colors
|
||||
pub badge_media: Option<String>,
|
||||
pub badge_weather: Option<String>,
|
||||
pub badge_pomo: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppearanceConfig {
|
||||
#[serde(default = "default_width")]
|
||||
pub width: i32,
|
||||
#[serde(default = "default_height")]
|
||||
pub height: i32,
|
||||
#[serde(default = "default_font_size")]
|
||||
pub font_size: u32,
|
||||
#[serde(default = "default_border_radius")]
|
||||
pub border_radius: u32,
|
||||
/// Theme name: None = GTK default, "owl" = built-in owl theme
|
||||
#[serde(default)]
|
||||
pub theme: Option<String>,
|
||||
/// Individual color overrides
|
||||
#[serde(default)]
|
||||
pub colors: ThemeColors,
|
||||
}
|
||||
|
||||
impl Default for AppearanceConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 850,
|
||||
height: 650,
|
||||
font_size: 14,
|
||||
border_radius: 12,
|
||||
theme: None,
|
||||
colors: ThemeColors::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_width() -> i32 {
|
||||
850
|
||||
}
|
||||
fn default_height() -> i32 {
|
||||
650
|
||||
}
|
||||
fn default_font_size() -> u32 {
|
||||
14
|
||||
}
|
||||
fn default_border_radius() -> u32 {
|
||||
12
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProvidersConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub applications: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub commands: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub uuctl: bool,
|
||||
/// Enable calculator provider (= expression or calc expression)
|
||||
#[serde(default = "default_true")]
|
||||
pub calculator: bool,
|
||||
/// Enable frecency-based result ranking
|
||||
#[serde(default = "default_true")]
|
||||
pub frecency: bool,
|
||||
/// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost)
|
||||
#[serde(default = "default_frecency_weight")]
|
||||
pub frecency_weight: f64,
|
||||
/// Enable web search provider (? query or web query)
|
||||
#[serde(default = "default_true")]
|
||||
pub websearch: bool,
|
||||
/// Search engine for web search
|
||||
/// Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
|
||||
/// Or custom URL with {query} placeholder
|
||||
#[serde(default = "default_search_engine")]
|
||||
pub search_engine: String,
|
||||
/// Enable system commands (shutdown, reboot, etc.)
|
||||
#[serde(default = "default_true")]
|
||||
pub system: bool,
|
||||
/// Enable SSH connections from ~/.ssh/config
|
||||
#[serde(default = "default_true")]
|
||||
pub ssh: bool,
|
||||
/// Enable clipboard history (requires cliphist)
|
||||
#[serde(default = "default_true")]
|
||||
pub clipboard: bool,
|
||||
/// Enable browser bookmarks
|
||||
#[serde(default = "default_true")]
|
||||
pub bookmarks: bool,
|
||||
/// Enable emoji picker
|
||||
#[serde(default = "default_true")]
|
||||
pub emoji: bool,
|
||||
/// Enable custom scripts from ~/.config/owlry/scripts/
|
||||
#[serde(default = "default_true")]
|
||||
pub scripts: bool,
|
||||
/// Enable file search (requires fd or locate)
|
||||
#[serde(default = "default_true")]
|
||||
pub files: bool,
|
||||
|
||||
// ─── Widget Providers ───────────────────────────────────────────────
|
||||
/// Enable MPRIS media player widget
|
||||
#[serde(default = "default_true")]
|
||||
pub media: bool,
|
||||
|
||||
/// Enable weather widget
|
||||
#[serde(default)]
|
||||
pub weather: bool,
|
||||
|
||||
/// Weather provider: wttr.in (default), openweathermap, open-meteo
|
||||
#[serde(default = "default_weather_provider")]
|
||||
pub weather_provider: String,
|
||||
|
||||
/// API key for weather services that require it (e.g., OpenWeatherMap)
|
||||
#[serde(default)]
|
||||
pub weather_api_key: Option<String>,
|
||||
|
||||
/// Location for weather (city name or coordinates)
|
||||
#[serde(default)]
|
||||
pub weather_location: Option<String>,
|
||||
|
||||
/// Enable pomodoro timer widget
|
||||
#[serde(default)]
|
||||
pub pomodoro: bool,
|
||||
|
||||
/// Pomodoro work duration in minutes
|
||||
#[serde(default = "default_pomodoro_work")]
|
||||
pub pomodoro_work_mins: u32,
|
||||
|
||||
/// Pomodoro break duration in minutes
|
||||
#[serde(default = "default_pomodoro_break")]
|
||||
pub pomodoro_break_mins: u32,
|
||||
}
|
||||
|
||||
impl Default for ProvidersConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
applications: true,
|
||||
commands: true,
|
||||
uuctl: true,
|
||||
calculator: true,
|
||||
frecency: true,
|
||||
frecency_weight: 0.3,
|
||||
websearch: true,
|
||||
search_engine: "duckduckgo".to_string(),
|
||||
system: true,
|
||||
ssh: true,
|
||||
clipboard: true,
|
||||
bookmarks: true,
|
||||
emoji: true,
|
||||
scripts: true,
|
||||
files: true,
|
||||
media: true,
|
||||
weather: false,
|
||||
weather_provider: "wttr.in".to_string(),
|
||||
weather_api_key: None,
|
||||
weather_location: Some("Berlin".to_string()),
|
||||
pomodoro: false,
|
||||
pomodoro_work_mins: 25,
|
||||
pomodoro_break_mins: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for plugins
|
||||
///
|
||||
/// Supports per-plugin configuration via `[plugins.<name>]` sections:
|
||||
/// ```toml
|
||||
/// [plugins]
|
||||
/// enabled = true
|
||||
///
|
||||
/// [plugins.weather]
|
||||
/// location = "Berlin"
|
||||
/// units = "metric"
|
||||
///
|
||||
/// [plugins.pomodoro]
|
||||
/// work_mins = 25
|
||||
/// break_mins = 5
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginsConfig {
|
||||
/// Whether plugins are enabled globally
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// List of plugin IDs to enable (empty = all discovered plugins)
|
||||
#[serde(default)]
|
||||
pub enabled_plugins: Vec<String>,
|
||||
|
||||
/// List of plugin IDs to explicitly disable
|
||||
#[serde(default)]
|
||||
pub disabled_plugins: Vec<String>,
|
||||
|
||||
/// Sandbox settings for plugin execution
|
||||
#[serde(default)]
|
||||
pub sandbox: SandboxConfig,
|
||||
|
||||
/// Plugin registry URL (for `owlry plugin search` and registry installs)
|
||||
/// Defaults to the official owlry plugin registry if not specified.
|
||||
#[serde(default)]
|
||||
pub registry_url: Option<String>,
|
||||
|
||||
/// Per-plugin configuration tables
|
||||
/// Accessed via `[plugins.<plugin_name>]` sections in config.toml
|
||||
/// Each plugin can define its own config schema
|
||||
#[serde(flatten)]
|
||||
pub plugin_configs: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
/// Sandbox settings for plugin security
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxConfig {
|
||||
/// Allow plugins to access the filesystem (beyond their own directory)
|
||||
#[serde(default)]
|
||||
pub allow_filesystem: bool,
|
||||
|
||||
/// Allow plugins to make network requests
|
||||
#[serde(default)]
|
||||
pub allow_network: bool,
|
||||
|
||||
/// Allow plugins to run shell commands
|
||||
#[serde(default)]
|
||||
pub allow_commands: bool,
|
||||
|
||||
/// Memory limit for Lua runtime in bytes (0 = unlimited)
|
||||
#[serde(default = "default_memory_limit")]
|
||||
pub memory_limit: usize,
|
||||
}
|
||||
|
||||
impl Default for PluginsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
enabled_plugins: Vec::new(),
|
||||
disabled_plugins: Vec::new(),
|
||||
sandbox: SandboxConfig::default(),
|
||||
registry_url: None,
|
||||
plugin_configs: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginsConfig {
|
||||
/// Get configuration for a specific plugin by name
|
||||
///
|
||||
/// Returns the plugin's config table if it exists in `[plugins.<name>]`
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_config(&self, plugin_name: &str) -> Option<&toml::Value> {
|
||||
self.plugin_configs.get(plugin_name)
|
||||
}
|
||||
|
||||
/// Get a string value from a plugin's config
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_string(&self, plugin_name: &str, key: &str) -> Option<&str> {
|
||||
self.plugin_configs.get(plugin_name)?.get(key)?.as_str()
|
||||
}
|
||||
|
||||
/// Get an integer value from a plugin's config
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_int(&self, plugin_name: &str, key: &str) -> Option<i64> {
|
||||
self.plugin_configs.get(plugin_name)?.get(key)?.as_integer()
|
||||
}
|
||||
|
||||
/// Get a boolean value from a plugin's config
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_bool(&self, plugin_name: &str, key: &str) -> Option<bool> {
|
||||
self.plugin_configs.get(plugin_name)?.get(key)?.as_bool()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SandboxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_filesystem: false,
|
||||
allow_network: false,
|
||||
allow_commands: false,
|
||||
memory_limit: default_memory_limit(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_memory_limit() -> usize {
|
||||
64 * 1024 * 1024 // 64 MB
|
||||
}
|
||||
|
||||
fn default_search_engine() -> String {
|
||||
"duckduckgo".to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_frecency_weight() -> f64 {
|
||||
0.3
|
||||
}
|
||||
|
||||
fn default_weather_provider() -> String {
|
||||
"wttr.in".to_string()
|
||||
}
|
||||
|
||||
fn default_pomodoro_work() -> u32 {
|
||||
25
|
||||
}
|
||||
|
||||
fn default_pomodoro_break() -> u32 {
|
||||
5
|
||||
}
|
||||
|
||||
/// Detect the best available terminal emulator
|
||||
/// Fallback chain:
|
||||
/// 1. $TERMINAL env var (user's explicit preference)
|
||||
/// 2. xdg-terminal-exec (freedesktop standard - if available)
|
||||
/// 3. Desktop-environment native terminal (GNOME→gnome-terminal, KDE→konsole, etc.)
|
||||
/// 4. Common Wayland-native terminals (kitty, alacritty, wezterm, foot)
|
||||
/// 5. Common X11/legacy terminals
|
||||
/// 6. x-terminal-emulator (Debian alternatives)
|
||||
/// 7. xterm (ultimate fallback - the cockroach of terminals)
|
||||
fn detect_terminal() -> String {
|
||||
// 1. Check $TERMINAL env var first (user's explicit preference)
|
||||
if let Ok(term) = std::env::var("TERMINAL")
|
||||
&& !term.is_empty()
|
||||
&& command_exists(&term)
|
||||
{
|
||||
debug!("Using $TERMINAL: {}", term);
|
||||
return term;
|
||||
}
|
||||
|
||||
// 2. Try xdg-terminal-exec (freedesktop standard)
|
||||
if command_exists("xdg-terminal-exec") {
|
||||
debug!("Using xdg-terminal-exec");
|
||||
return "xdg-terminal-exec".to_string();
|
||||
}
|
||||
|
||||
// 3. Desktop-environment aware detection
|
||||
if let Some(term) = detect_de_terminal() {
|
||||
debug!("Using DE-native terminal: {}", term);
|
||||
return term;
|
||||
}
|
||||
|
||||
// 4. Common Wayland-native terminals (preferred for modern setups)
|
||||
let wayland_terminals = ["kitty", "alacritty", "wezterm", "foot"];
|
||||
for term in wayland_terminals {
|
||||
if command_exists(term) {
|
||||
debug!("Found Wayland terminal: {}", term);
|
||||
return term.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Common X11/legacy terminals
|
||||
let legacy_terminals = [
|
||||
"gnome-terminal",
|
||||
"konsole",
|
||||
"xfce4-terminal",
|
||||
"mate-terminal",
|
||||
"tilix",
|
||||
"terminator",
|
||||
];
|
||||
for term in legacy_terminals {
|
||||
if command_exists(term) {
|
||||
debug!("Found legacy terminal: {}", term);
|
||||
return term.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Try x-terminal-emulator (Debian alternatives system)
|
||||
if command_exists("x-terminal-emulator") {
|
||||
debug!("Using x-terminal-emulator");
|
||||
return "x-terminal-emulator".to_string();
|
||||
}
|
||||
|
||||
// 7. Ultimate fallback - xterm exists everywhere
|
||||
debug!("Falling back to xterm");
|
||||
"xterm".to_string()
|
||||
}
|
||||
|
||||
/// Detect desktop environment and return its native terminal
|
||||
fn detect_de_terminal() -> Option<String> {
|
||||
// Check XDG_CURRENT_DESKTOP first
|
||||
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase());
|
||||
|
||||
// Also check for Wayland compositor-specific env vars
|
||||
let is_hyprland = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok();
|
||||
let is_sway = std::env::var("SWAYSOCK").is_ok();
|
||||
|
||||
// Map desktop environments to their native/preferred terminals
|
||||
let candidates: &[&str] = if is_hyprland {
|
||||
// Hyprland: foot and kitty are most popular in the community
|
||||
&["foot", "kitty", "alacritty", "wezterm"]
|
||||
} else if is_sway {
|
||||
// Sway: foot is the recommended terminal (lightweight, Wayland-native)
|
||||
&["foot", "alacritty", "kitty", "wezterm"]
|
||||
} else if let Some(ref de) = desktop {
|
||||
match de.as_str() {
|
||||
s if s.contains("gnome") => &["gnome-terminal", "gnome-console", "kgx"],
|
||||
s if s.contains("kde") || s.contains("plasma") => &["konsole"],
|
||||
s if s.contains("xfce") => &["xfce4-terminal"],
|
||||
s if s.contains("mate") => &["mate-terminal"],
|
||||
s if s.contains("lxqt") => &["qterminal"],
|
||||
s if s.contains("lxde") => &["lxterminal"],
|
||||
s if s.contains("cinnamon") => &["gnome-terminal"],
|
||||
s if s.contains("budgie") => &["tilix", "gnome-terminal"],
|
||||
s if s.contains("pantheon") => &["io.elementary.terminal", "pantheon-terminal"],
|
||||
s if s.contains("deepin") => &["deepin-terminal"],
|
||||
s if s.contains("hyprland") => &["foot", "kitty", "alacritty", "wezterm"],
|
||||
s if s.contains("sway") => &["foot", "alacritty", "kitty", "wezterm"],
|
||||
_ => return None,
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
for term in candidates {
|
||||
if command_exists(term) {
|
||||
return Some(term.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a command exists in PATH
|
||||
fn command_exists(cmd: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default
|
||||
|
||||
impl Config {
|
||||
pub fn config_path() -> Option<PathBuf> {
|
||||
paths::config_file()
|
||||
}
|
||||
|
||||
pub fn load_or_default() -> Self {
|
||||
Self::load().unwrap_or_else(|e| {
|
||||
warn!("Failed to load config: {}, using defaults", e);
|
||||
Self::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let path = Self::config_path().ok_or("Could not determine config path")?;
|
||||
|
||||
let mut config = if !path.exists() {
|
||||
info!("Config file not found, using defaults");
|
||||
Self::default()
|
||||
} else {
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
let config: Config = toml::from_str(&content)?;
|
||||
info!("Loaded config from {:?}", path);
|
||||
config
|
||||
};
|
||||
|
||||
// Auto-detect terminal if not configured or configured terminal doesn't exist
|
||||
match &config.general.terminal_command {
|
||||
None => {
|
||||
let terminal = detect_terminal();
|
||||
info!("Detected terminal: {}", terminal);
|
||||
config.general.terminal_command = Some(terminal);
|
||||
}
|
||||
Some(term) if !command_exists(term) => {
|
||||
warn!("Configured terminal '{}' not found, auto-detecting", term);
|
||||
let terminal = detect_terminal();
|
||||
info!("Using detected terminal: {}", terminal);
|
||||
config.general.terminal_command = Some(terminal);
|
||||
}
|
||||
Some(term) => {
|
||||
debug!("Using configured terminal: {}", term);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let path = Self::config_path().ok_or("Could not determine config path")?;
|
||||
|
||||
paths::ensure_parent_dir(&path)?;
|
||||
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
std::fs::write(&path, content)?;
|
||||
info!("Saved config to {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// A single frecency entry tracking launch count and recency
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FrecencyEntry {
|
||||
@@ -56,10 +58,7 @@ impl FrecencyStore {
|
||||
|
||||
/// Get the path to the frecency data file
|
||||
fn data_path() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("owlry")
|
||||
.join("frecency.json")
|
||||
paths::frecency_file().unwrap_or_else(|| PathBuf::from("frecency.json"))
|
||||
}
|
||||
|
||||
/// Load frecency data from a file
|
||||
@@ -85,10 +84,7 @@ impl FrecencyStore {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
paths::ensure_parent_dir(&self.path)?;
|
||||
|
||||
let content = serde_json::to_string_pretty(&self.data)?;
|
||||
std::fs::write(&self.path, content)?;
|
||||
632
crates/owlry-core/src/filter.rs
Normal file
@@ -0,0 +1,632 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
|
||||
use crate::config::ProvidersConfig;
|
||||
use crate::providers::ProviderType;
|
||||
|
||||
/// Tracks which providers are enabled and handles prefix-based filtering
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderFilter {
|
||||
enabled: HashSet<ProviderType>,
|
||||
active_prefix: Option<ProviderType>,
|
||||
/// When true, `is_active`/`is_enabled` accept any provider type
|
||||
/// (unless a prefix narrows the scope). Used by `all()` so that
|
||||
/// dynamically loaded plugins are accepted without being listed.
|
||||
accept_all: bool,
|
||||
}
|
||||
|
||||
/// Result of parsing a query for prefix syntax
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedQuery {
|
||||
pub prefix: Option<ProviderType>,
|
||||
pub tag_filter: Option<String>,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
impl ProviderFilter {
|
||||
/// Create filter from CLI args and config
|
||||
pub fn new(
|
||||
cli_mode: Option<ProviderType>,
|
||||
cli_providers: Option<Vec<ProviderType>>,
|
||||
config_providers: &ProvidersConfig,
|
||||
) -> Self {
|
||||
let enabled = if let Some(mode) = cli_mode {
|
||||
// --mode overrides everything: single provider
|
||||
HashSet::from([mode])
|
||||
} else if let Some(providers) = cli_providers {
|
||||
// --providers overrides config
|
||||
providers.into_iter().collect()
|
||||
} else {
|
||||
// Use config file settings, default to apps only
|
||||
let mut set = HashSet::new();
|
||||
// Core providers
|
||||
if config_providers.applications {
|
||||
set.insert(ProviderType::Application);
|
||||
}
|
||||
if config_providers.commands {
|
||||
set.insert(ProviderType::Command);
|
||||
}
|
||||
// Plugin providers - use Plugin(type_id) for all
|
||||
if config_providers.uuctl {
|
||||
set.insert(ProviderType::Plugin("uuctl".to_string()));
|
||||
}
|
||||
if config_providers.system {
|
||||
set.insert(ProviderType::Plugin("system".to_string()));
|
||||
}
|
||||
if config_providers.ssh {
|
||||
set.insert(ProviderType::Plugin("ssh".to_string()));
|
||||
}
|
||||
if config_providers.clipboard {
|
||||
set.insert(ProviderType::Plugin("clipboard".to_string()));
|
||||
}
|
||||
if config_providers.bookmarks {
|
||||
set.insert(ProviderType::Plugin("bookmarks".to_string()));
|
||||
}
|
||||
if config_providers.emoji {
|
||||
set.insert(ProviderType::Plugin("emoji".to_string()));
|
||||
}
|
||||
if config_providers.scripts {
|
||||
set.insert(ProviderType::Plugin("scripts".to_string()));
|
||||
}
|
||||
// Dynamic providers
|
||||
if config_providers.files {
|
||||
set.insert(ProviderType::Plugin("filesearch".to_string()));
|
||||
}
|
||||
if config_providers.calculator {
|
||||
set.insert(ProviderType::Plugin("calc".to_string()));
|
||||
}
|
||||
if config_providers.websearch {
|
||||
set.insert(ProviderType::Plugin("websearch".to_string()));
|
||||
}
|
||||
// Default to apps if nothing enabled
|
||||
if set.is_empty() {
|
||||
set.insert(ProviderType::Application);
|
||||
}
|
||||
set
|
||||
};
|
||||
|
||||
let filter = Self {
|
||||
enabled,
|
||||
active_prefix: None,
|
||||
accept_all: false,
|
||||
};
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] Created with enabled providers: {:?}",
|
||||
filter.enabled
|
||||
);
|
||||
|
||||
filter
|
||||
}
|
||||
|
||||
/// Default filter: apps only
|
||||
#[allow(dead_code)]
|
||||
pub fn apps_only() -> Self {
|
||||
Self {
|
||||
enabled: HashSet::from([ProviderType::Application]),
|
||||
active_prefix: None,
|
||||
accept_all: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a provider on/off
|
||||
pub fn toggle(&mut self, provider: ProviderType) {
|
||||
if self.enabled.contains(&provider) {
|
||||
self.enabled.remove(&provider);
|
||||
// Ensure at least one provider is always enabled
|
||||
if self.enabled.is_empty() {
|
||||
self.enabled.insert(ProviderType::Application);
|
||||
}
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] Toggled OFF {:?}, enabled: {:?}",
|
||||
provider, self.enabled
|
||||
);
|
||||
} else {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
let provider_debug = format!("{:?}", provider);
|
||||
self.enabled.insert(provider);
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] Toggled ON {}, enabled: {:?}",
|
||||
provider_debug, self.enabled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable a specific provider
|
||||
pub fn enable(&mut self, provider: ProviderType) {
|
||||
self.enabled.insert(provider);
|
||||
}
|
||||
|
||||
/// Disable a specific provider (ensures at least one remains)
|
||||
pub fn disable(&mut self, provider: ProviderType) {
|
||||
self.enabled.remove(&provider);
|
||||
if self.enabled.is_empty() {
|
||||
self.enabled.insert(ProviderType::Application);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set to single provider mode
|
||||
pub fn set_single_mode(&mut self, provider: ProviderType) {
|
||||
self.enabled.clear();
|
||||
self.enabled.insert(provider);
|
||||
}
|
||||
|
||||
/// Set prefix mode (from :app, :cmd, etc.)
|
||||
pub fn set_prefix(&mut self, prefix: Option<ProviderType>) {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
if self.active_prefix != prefix {
|
||||
debug!(
|
||||
"[Filter] Prefix changed: {:?} -> {:?}",
|
||||
self.active_prefix, prefix
|
||||
);
|
||||
}
|
||||
self.active_prefix = prefix;
|
||||
}
|
||||
|
||||
/// Check if a provider should be searched
|
||||
pub fn is_active(&self, provider: ProviderType) -> bool {
|
||||
if let Some(ref prefix) = self.active_prefix {
|
||||
&provider == prefix
|
||||
} else if self.accept_all {
|
||||
true
|
||||
} else {
|
||||
self.enabled.contains(&provider)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if provider is in enabled set (ignoring prefix)
|
||||
pub fn is_enabled(&self, provider: ProviderType) -> bool {
|
||||
self.accept_all || self.enabled.contains(&provider)
|
||||
}
|
||||
|
||||
/// Get current active prefix if any
|
||||
#[allow(dead_code)]
|
||||
pub fn active_prefix(&self) -> Option<ProviderType> {
|
||||
self.active_prefix.clone()
|
||||
}
|
||||
|
||||
/// Parse query for prefix syntax
|
||||
/// Prefixes map to Plugin(type_id) for plugin providers
|
||||
pub fn parse_query(query: &str) -> ParsedQuery {
|
||||
let trimmed = query.trim_start();
|
||||
|
||||
// Check for tag filter pattern: ":tag:XXX query" or ":tag:XXX"
|
||||
if let Some(rest) = trimmed.strip_prefix(":tag:") {
|
||||
// Find the end of the tag (space or end of string)
|
||||
if let Some(space_idx) = rest.find(' ') {
|
||||
let tag = rest[..space_idx].to_lowercase();
|
||||
let query_part = rest[space_idx + 1..].to_string();
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> tag={:?}, query={:?}",
|
||||
query, tag, query_part
|
||||
);
|
||||
return ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: Some(tag),
|
||||
query: query_part,
|
||||
};
|
||||
} else {
|
||||
// Just the tag, no query yet
|
||||
let tag = rest.to_lowercase();
|
||||
return ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: Some(tag),
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Core provider prefixes
|
||||
let core_prefixes: &[(&str, ProviderType)] = &[
|
||||
(":app ", ProviderType::Application),
|
||||
(":apps ", ProviderType::Application),
|
||||
(":cmd ", ProviderType::Command),
|
||||
(":command ", ProviderType::Command),
|
||||
];
|
||||
|
||||
// Plugin provider prefixes - mapped to Plugin(type_id)
|
||||
let plugin_prefixes: &[(&str, &str)] = &[
|
||||
(":bm ", "bookmarks"),
|
||||
(":bookmark ", "bookmarks"),
|
||||
(":bookmarks ", "bookmarks"),
|
||||
(":calc ", "calc"),
|
||||
(":calculator ", "calc"),
|
||||
(":clip ", "clipboard"),
|
||||
(":clipboard ", "clipboard"),
|
||||
(":emoji ", "emoji"),
|
||||
(":emojis ", "emoji"),
|
||||
(":file ", "filesearch"),
|
||||
(":files ", "filesearch"),
|
||||
(":find ", "filesearch"),
|
||||
(":script ", "scripts"),
|
||||
(":scripts ", "scripts"),
|
||||
(":ssh ", "ssh"),
|
||||
(":sys ", "system"),
|
||||
(":system ", "system"),
|
||||
(":power ", "system"),
|
||||
(":uuctl ", "uuctl"),
|
||||
(":systemd ", "uuctl"),
|
||||
(":web ", "websearch"),
|
||||
(":search ", "websearch"),
|
||||
];
|
||||
|
||||
// Check core prefixes
|
||||
for (prefix_str, provider) in core_prefixes {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
|
||||
query, provider, rest
|
||||
);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider.clone()),
|
||||
tag_filter: None,
|
||||
query: rest.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check plugin prefixes
|
||||
for (prefix_str, type_id) in plugin_prefixes {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
|
||||
let provider = ProviderType::Plugin(type_id.to_string());
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
|
||||
query, provider, rest
|
||||
);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider),
|
||||
tag_filter: None,
|
||||
query: rest.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle partial prefixes (still typing)
|
||||
let partial_core: &[(&str, ProviderType)] = &[
|
||||
(":app", ProviderType::Application),
|
||||
(":apps", ProviderType::Application),
|
||||
(":cmd", ProviderType::Command),
|
||||
(":command", ProviderType::Command),
|
||||
];
|
||||
|
||||
let partial_plugin: &[(&str, &str)] = &[
|
||||
(":bm", "bookmarks"),
|
||||
(":bookmark", "bookmarks"),
|
||||
(":bookmarks", "bookmarks"),
|
||||
(":calc", "calc"),
|
||||
(":calculator", "calc"),
|
||||
(":clip", "clipboard"),
|
||||
(":clipboard", "clipboard"),
|
||||
(":emoji", "emoji"),
|
||||
(":emojis", "emoji"),
|
||||
(":file", "filesearch"),
|
||||
(":files", "filesearch"),
|
||||
(":find", "filesearch"),
|
||||
(":script", "scripts"),
|
||||
(":scripts", "scripts"),
|
||||
(":ssh", "ssh"),
|
||||
(":sys", "system"),
|
||||
(":system", "system"),
|
||||
(":power", "system"),
|
||||
(":uuctl", "uuctl"),
|
||||
(":systemd", "uuctl"),
|
||||
(":web", "websearch"),
|
||||
(":search", "websearch"),
|
||||
];
|
||||
|
||||
for (prefix_str, provider) in partial_core {
|
||||
if trimmed == *prefix_str {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> partial prefix {:?}",
|
||||
query, provider
|
||||
);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider.clone()),
|
||||
tag_filter: None,
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (prefix_str, type_id) in partial_plugin {
|
||||
if trimmed == *prefix_str {
|
||||
let provider = ProviderType::Plugin(type_id.to_string());
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> partial prefix {:?}",
|
||||
query, provider
|
||||
);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider),
|
||||
tag_filter: None,
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let result = ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: None,
|
||||
query: query.to_string(),
|
||||
};
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}",
|
||||
query, result.prefix, result.tag_filter, result.query
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Get enabled providers for UI display (sorted)
|
||||
pub fn enabled_providers(&self) -> Vec<ProviderType> {
|
||||
let mut providers: Vec<_> = self.enabled.iter().cloned().collect();
|
||||
providers.sort_by_key(|p| match p {
|
||||
ProviderType::Application => 0,
|
||||
ProviderType::Command => 1,
|
||||
ProviderType::Dmenu => 2,
|
||||
ProviderType::Plugin(_) => 100, // Plugin providers sort after core
|
||||
});
|
||||
providers
|
||||
}
|
||||
|
||||
/// Create a filter from a list of mode name strings.
|
||||
///
|
||||
/// Maps each string to a ProviderType: "app" -> Application, "cmd" -> Command,
|
||||
/// "dmenu" -> Dmenu, anything else -> Plugin(id). An empty list produces an
|
||||
/// all-providers filter.
|
||||
pub fn from_mode_strings(modes: &[String]) -> Self {
|
||||
if modes.is_empty() {
|
||||
return Self::all();
|
||||
}
|
||||
let enabled: HashSet<ProviderType> = modes
|
||||
.iter()
|
||||
.map(|s| Self::mode_string_to_provider_type(s))
|
||||
.collect();
|
||||
Self {
|
||||
enabled,
|
||||
active_prefix: None,
|
||||
accept_all: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a filter that accepts all providers, including any
|
||||
/// dynamically loaded plugin.
|
||||
///
|
||||
/// Sets `accept_all` so that `is_active`/`is_enabled` return true for
|
||||
/// every `ProviderType` without maintaining a static list of plugin IDs.
|
||||
/// Core types are still placed in `enabled` for UI purposes (tab display).
|
||||
///
|
||||
/// The daemon uses this as the default when no modes are specified.
|
||||
pub fn all() -> Self {
|
||||
let mut enabled = HashSet::new();
|
||||
enabled.insert(ProviderType::Application);
|
||||
enabled.insert(ProviderType::Command);
|
||||
enabled.insert(ProviderType::Dmenu);
|
||||
Self {
|
||||
enabled,
|
||||
active_prefix: None,
|
||||
accept_all: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a mode string to a ProviderType.
|
||||
///
|
||||
/// Delegates to the existing `FromStr` impl on `ProviderType` which maps
|
||||
/// "app"/"apps"/"application" -> Application, "cmd"/"command" -> Command,
|
||||
/// "dmenu" -> Dmenu, and everything else -> Plugin(id).
|
||||
pub fn mode_string_to_provider_type(mode: &str) -> ProviderType {
|
||||
mode.parse::<ProviderType>()
|
||||
.unwrap_or_else(|_| ProviderType::Plugin(mode.to_string()))
|
||||
}
|
||||
|
||||
/// Get display name for current mode
|
||||
pub fn mode_display_name(&self) -> &'static str {
|
||||
if let Some(ref prefix) = self.active_prefix {
|
||||
return match prefix {
|
||||
ProviderType::Application => "Apps",
|
||||
ProviderType::Command => "Commands",
|
||||
ProviderType::Dmenu => "dmenu",
|
||||
ProviderType::Plugin(_) => "Plugin",
|
||||
};
|
||||
}
|
||||
|
||||
let enabled: Vec<_> = self.enabled_providers();
|
||||
if enabled.len() == 1 {
|
||||
match &enabled[0] {
|
||||
ProviderType::Application => "Apps",
|
||||
ProviderType::Command => "Commands",
|
||||
ProviderType::Dmenu => "dmenu",
|
||||
ProviderType::Plugin(_) => "Plugin",
|
||||
}
|
||||
} else {
|
||||
"All"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_with_prefix() {
|
||||
let result = ProviderFilter::parse_query(":app firefox");
|
||||
assert_eq!(result.prefix, Some(ProviderType::Application));
|
||||
assert_eq!(result.query, "firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_without_prefix() {
|
||||
let result = ProviderFilter::parse_query("firefox");
|
||||
assert_eq!(result.prefix, None);
|
||||
assert_eq!(result.query, "firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_partial_prefix() {
|
||||
let result = ProviderFilter::parse_query(":cmd");
|
||||
assert_eq!(result.prefix, Some(ProviderType::Command));
|
||||
assert_eq!(result.query, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_plugin_prefix() {
|
||||
let result = ProviderFilter::parse_query(":calc 5+3");
|
||||
assert_eq!(
|
||||
result.prefix,
|
||||
Some(ProviderType::Plugin("calc".to_string()))
|
||||
);
|
||||
assert_eq!(result.query, "5+3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_ensures_one_enabled() {
|
||||
let mut filter = ProviderFilter::apps_only();
|
||||
filter.toggle(ProviderType::Application);
|
||||
// Should still have apps enabled as fallback
|
||||
assert!(filter.is_enabled(ProviderType::Application));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_mode_strings_single_core() {
|
||||
let filter = ProviderFilter::from_mode_strings(&["app".to_string()]);
|
||||
assert!(filter.is_enabled(ProviderType::Application));
|
||||
assert!(!filter.is_enabled(ProviderType::Command));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_mode_strings_multiple() {
|
||||
let filter = ProviderFilter::from_mode_strings(&[
|
||||
"app".to_string(),
|
||||
"cmd".to_string(),
|
||||
"calc".to_string(),
|
||||
]);
|
||||
assert!(filter.is_enabled(ProviderType::Application));
|
||||
assert!(filter.is_enabled(ProviderType::Command));
|
||||
assert!(filter.is_enabled(ProviderType::Plugin("calc".to_string())));
|
||||
assert!(!filter.is_enabled(ProviderType::Dmenu));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_mode_strings_empty_returns_all() {
|
||||
let filter = ProviderFilter::from_mode_strings(&[]);
|
||||
assert!(filter.is_enabled(ProviderType::Application));
|
||||
assert!(filter.is_enabled(ProviderType::Command));
|
||||
assert!(filter.is_enabled(ProviderType::Dmenu));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_mode_strings_plugin() {
|
||||
let filter = ProviderFilter::from_mode_strings(&["emoji".to_string()]);
|
||||
assert!(filter.is_enabled(ProviderType::Plugin("emoji".to_string())));
|
||||
assert!(!filter.is_enabled(ProviderType::Application));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_mode_strings_dmenu() {
|
||||
let filter = ProviderFilter::from_mode_strings(&["dmenu".to_string()]);
|
||||
assert!(filter.is_enabled(ProviderType::Dmenu));
|
||||
assert!(!filter.is_enabled(ProviderType::Application));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_includes_core_types() {
|
||||
let filter = ProviderFilter::all();
|
||||
assert!(filter.is_enabled(ProviderType::Application));
|
||||
assert!(filter.is_enabled(ProviderType::Command));
|
||||
assert!(filter.is_enabled(ProviderType::Dmenu));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_accepts_any_plugin() {
|
||||
let filter = ProviderFilter::all();
|
||||
// Known plugins
|
||||
assert!(filter.is_enabled(ProviderType::Plugin("calc".to_string())));
|
||||
assert!(filter.is_enabled(ProviderType::Plugin("clipboard".to_string())));
|
||||
// Arbitrary unknown plugins must also be accepted
|
||||
assert!(filter.is_enabled(ProviderType::Plugin("some-future-plugin".to_string())));
|
||||
assert!(filter.is_enabled(ProviderType::Plugin("custom-user-plugin".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_is_active_for_any_plugin() {
|
||||
let filter = ProviderFilter::all();
|
||||
assert!(filter.is_active(ProviderType::Application));
|
||||
assert!(filter.is_active(ProviderType::Plugin("unknown-plugin".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_with_prefix_narrows_scope() {
|
||||
let mut filter = ProviderFilter::all();
|
||||
filter.set_prefix(Some(ProviderType::Application));
|
||||
// Prefix narrows: only Application passes
|
||||
assert!(filter.is_active(ProviderType::Application));
|
||||
assert!(!filter.is_active(ProviderType::Command));
|
||||
assert!(!filter.is_active(ProviderType::Plugin("calc".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_explicit_mode_filter_rejects_unknown_plugins() {
|
||||
let filter = ProviderFilter::from_mode_strings(&["app".to_string(), "cmd".to_string()]);
|
||||
assert!(filter.is_active(ProviderType::Application));
|
||||
assert!(filter.is_active(ProviderType::Command));
|
||||
// Plugins not in the explicit list must be rejected
|
||||
assert!(!filter.is_active(ProviderType::Plugin("calc".to_string())));
|
||||
assert!(!filter.is_active(ProviderType::Plugin("unknown".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_string_to_provider_type_core() {
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("app"),
|
||||
ProviderType::Application
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("cmd"),
|
||||
ProviderType::Command
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("dmenu"),
|
||||
ProviderType::Dmenu
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_string_to_provider_type_plugin() {
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("calc"),
|
||||
ProviderType::Plugin("calc".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("websearch"),
|
||||
ProviderType::Plugin("websearch".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_string_to_provider_type_aliases() {
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("apps"),
|
||||
ProviderType::Application
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("application"),
|
||||
ProviderType::Application
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("command"),
|
||||
ProviderType::Command
|
||||
);
|
||||
}
|
||||
}
|
||||
63
crates/owlry-core/src/ipc.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Request {
|
||||
Query {
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
modes: Option<Vec<String>>,
|
||||
},
|
||||
Launch {
|
||||
item_id: String,
|
||||
provider: String,
|
||||
},
|
||||
Providers,
|
||||
Refresh {
|
||||
provider: String,
|
||||
},
|
||||
Toggle,
|
||||
Submenu {
|
||||
plugin_id: String,
|
||||
data: String,
|
||||
},
|
||||
PluginAction {
|
||||
command: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Response {
|
||||
Results { items: Vec<ResultItem> },
|
||||
Providers { list: Vec<ProviderDesc> },
|
||||
SubmenuItems { items: Vec<ResultItem> },
|
||||
Ack,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ResultItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub icon: String,
|
||||
pub provider: String,
|
||||
pub score: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub terminal: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProviderDesc {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prefix: Option<String>,
|
||||
pub icon: String,
|
||||
pub position: String,
|
||||
}
|
||||
9
crates/owlry-core/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod config;
|
||||
pub mod data;
|
||||
pub mod filter;
|
||||
pub mod ipc;
|
||||
pub mod notify;
|
||||
pub mod paths;
|
||||
pub mod plugins;
|
||||
pub mod providers;
|
||||
pub mod server;
|
||||
38
crates/owlry-core/src/main.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use log::info;
|
||||
|
||||
use owlry_core::paths;
|
||||
use owlry_core::server::Server;
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||
|
||||
let sock = paths::socket_path();
|
||||
info!("Starting owlry-core daemon...");
|
||||
|
||||
// Ensure the socket parent directory exists
|
||||
if let Err(e) = paths::ensure_parent_dir(&sock) {
|
||||
eprintln!("Failed to create socket directory: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let server = match Server::bind(&sock) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start owlry-core: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Graceful shutdown on SIGTERM/SIGINT
|
||||
let sock_cleanup = sock.clone();
|
||||
ctrlc::set_handler(move || {
|
||||
let _ = std::fs::remove_file(&sock_cleanup);
|
||||
std::process::exit(0);
|
||||
})
|
||||
.ok();
|
||||
|
||||
if let Err(e) = server.run() {
|
||||
eprintln!("Server error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
91
crates/owlry-core/src/notify.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! Desktop notification system
|
||||
//!
|
||||
//! Provides system notifications for owlry and its plugins.
|
||||
//! Uses the freedesktop notification specification via notify-rust.
|
||||
//!
|
||||
//! Note: Some convenience functions are provided for future use and
|
||||
//! are currently unused by the core (plugins use the Host API instead).
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use notify_rust::{Notification, Urgency};
|
||||
|
||||
/// Notification urgency level
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum NotifyUrgency {
|
||||
/// Low priority notification
|
||||
Low,
|
||||
/// Normal priority notification (default)
|
||||
#[default]
|
||||
Normal,
|
||||
/// Critical/urgent notification
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl From<NotifyUrgency> for Urgency {
|
||||
fn from(urgency: NotifyUrgency) -> Self {
|
||||
match urgency {
|
||||
NotifyUrgency::Low => Urgency::Low,
|
||||
NotifyUrgency::Normal => Urgency::Normal,
|
||||
NotifyUrgency::Critical => Urgency::Critical,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a simple notification
|
||||
pub fn notify(summary: &str, body: &str) {
|
||||
notify_with_options(summary, body, None, NotifyUrgency::Normal);
|
||||
}
|
||||
|
||||
/// Send a notification with an icon
|
||||
pub fn notify_with_icon(summary: &str, body: &str, icon: &str) {
|
||||
notify_with_options(summary, body, Some(icon), NotifyUrgency::Normal);
|
||||
}
|
||||
|
||||
/// Send a notification with full options
|
||||
pub fn notify_with_options(summary: &str, body: &str, icon: Option<&str>, urgency: NotifyUrgency) {
|
||||
let mut notification = Notification::new();
|
||||
notification
|
||||
.appname("Owlry")
|
||||
.summary(summary)
|
||||
.body(body)
|
||||
.urgency(urgency.into());
|
||||
|
||||
if let Some(icon_name) = icon {
|
||||
notification.icon(icon_name);
|
||||
}
|
||||
|
||||
if let Err(e) = notification.show() {
|
||||
log::warn!("Failed to show notification: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a notification with a timeout
|
||||
pub fn notify_with_timeout(summary: &str, body: &str, icon: Option<&str>, timeout_ms: i32) {
|
||||
let mut notification = Notification::new();
|
||||
notification
|
||||
.appname("Owlry")
|
||||
.summary(summary)
|
||||
.body(body)
|
||||
.timeout(timeout_ms);
|
||||
|
||||
if let Some(icon_name) = icon {
|
||||
notification.icon(icon_name);
|
||||
}
|
||||
|
||||
if let Err(e) = notification.show() {
|
||||
log::warn!("Failed to show notification: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_urgency_conversion() {
|
||||
assert_eq!(Urgency::from(NotifyUrgency::Low), Urgency::Low);
|
||||
assert_eq!(Urgency::from(NotifyUrgency::Normal), Urgency::Normal);
|
||||
assert_eq!(Urgency::from(NotifyUrgency::Critical), Urgency::Critical);
|
||||
}
|
||||
}
|
||||
217
crates/owlry-core/src/paths.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
//! Centralized path handling following XDG Base Directory Specification.
|
||||
//!
|
||||
//! XDG directories used:
|
||||
//! - `$XDG_CONFIG_HOME/owlry/` - User configuration (config.toml, themes/, style.css)
|
||||
//! - `$XDG_DATA_HOME/owlry/` - User data (scripts/, frecency.json)
|
||||
//! - `$XDG_CACHE_HOME/owlry/` - Cache files (future use)
|
||||
//!
|
||||
//! See: https://specifications.freedesktop.org/basedir-spec/latest/
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Application name used in XDG paths
|
||||
const APP_NAME: &str = "owlry";
|
||||
|
||||
// =============================================================================
|
||||
// XDG Base Directories
|
||||
// =============================================================================
|
||||
|
||||
/// Get XDG config home: `$XDG_CONFIG_HOME` or `~/.config`
|
||||
pub fn config_home() -> Option<PathBuf> {
|
||||
dirs::config_dir()
|
||||
}
|
||||
|
||||
/// Get XDG data home: `$XDG_DATA_HOME` or `~/.local/share`
|
||||
pub fn data_home() -> Option<PathBuf> {
|
||||
dirs::data_dir()
|
||||
}
|
||||
|
||||
/// Get XDG cache home: `$XDG_CACHE_HOME` or `~/.cache`
|
||||
#[allow(dead_code)]
|
||||
pub fn cache_home() -> Option<PathBuf> {
|
||||
dirs::cache_dir()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Owlry-specific directories
|
||||
// =============================================================================
|
||||
|
||||
/// Owlry config directory: `$XDG_CONFIG_HOME/owlry/`
|
||||
pub fn owlry_config_dir() -> Option<PathBuf> {
|
||||
config_home().map(|p| p.join(APP_NAME))
|
||||
}
|
||||
|
||||
/// Owlry data directory: `$XDG_DATA_HOME/owlry/`
|
||||
pub fn owlry_data_dir() -> Option<PathBuf> {
|
||||
data_home().map(|p| p.join(APP_NAME))
|
||||
}
|
||||
|
||||
/// Owlry cache directory: `$XDG_CACHE_HOME/owlry/`
|
||||
#[allow(dead_code)]
|
||||
pub fn owlry_cache_dir() -> Option<PathBuf> {
|
||||
cache_home().map(|p| p.join(APP_NAME))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config files
|
||||
// =============================================================================
|
||||
|
||||
/// Main config file: `$XDG_CONFIG_HOME/owlry/config.toml`
|
||||
pub fn config_file() -> Option<PathBuf> {
|
||||
owlry_config_dir().map(|p| p.join("config.toml"))
|
||||
}
|
||||
|
||||
/// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css`
|
||||
pub fn custom_style_file() -> Option<PathBuf> {
|
||||
owlry_config_dir().map(|p| p.join("style.css"))
|
||||
}
|
||||
|
||||
/// User themes directory: `$XDG_CONFIG_HOME/owlry/themes/`
|
||||
pub fn themes_dir() -> Option<PathBuf> {
|
||||
owlry_config_dir().map(|p| p.join("themes"))
|
||||
}
|
||||
|
||||
/// Get path for a specific theme: `$XDG_CONFIG_HOME/owlry/themes/{name}.css`
|
||||
pub fn theme_file(name: &str) -> Option<PathBuf> {
|
||||
themes_dir().map(|p| p.join(format!("{}.css", name)))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Data files
|
||||
// =============================================================================
|
||||
|
||||
/// User plugins directory: `$XDG_CONFIG_HOME/owlry/plugins/`
|
||||
///
|
||||
/// Plugins are stored in config because they contain user-installed code
|
||||
/// that the user explicitly chose to add (similar to themes).
|
||||
pub fn plugins_dir() -> Option<PathBuf> {
|
||||
owlry_config_dir().map(|p| p.join("plugins"))
|
||||
}
|
||||
|
||||
/// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json`
|
||||
pub fn frecency_file() -> Option<PathBuf> {
|
||||
owlry_data_dir().map(|p| p.join("frecency.json"))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// System directories
|
||||
// =============================================================================
|
||||
|
||||
/// System data directories for applications (XDG_DATA_DIRS)
|
||||
///
|
||||
/// Follows the XDG Base Directory Specification:
|
||||
/// - $XDG_DATA_HOME/applications (defaults to ~/.local/share/applications)
|
||||
/// - $XDG_DATA_DIRS/*/applications (defaults to /usr/local/share:/usr/share)
|
||||
/// - Additional Flatpak and Snap directories
|
||||
pub fn system_data_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
// Helper to add unique directories
|
||||
let mut add_dir = |path: PathBuf| {
|
||||
if seen.insert(path.clone()) {
|
||||
dirs.push(path);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. User data directory first (highest priority)
|
||||
if let Some(data) = data_home() {
|
||||
add_dir(data.join("applications"));
|
||||
}
|
||||
|
||||
// 2. XDG_DATA_DIRS - parse the environment variable
|
||||
// Default per spec: /usr/local/share:/usr/share
|
||||
let xdg_data_dirs = std::env::var("XDG_DATA_DIRS")
|
||||
.unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
|
||||
|
||||
for dir in xdg_data_dirs.split(':') {
|
||||
if !dir.is_empty() {
|
||||
add_dir(PathBuf::from(dir).join("applications"));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Always include standard system directories as fallback
|
||||
// Some environments set XDG_DATA_DIRS without including these
|
||||
add_dir(PathBuf::from("/usr/share/applications"));
|
||||
add_dir(PathBuf::from("/usr/local/share/applications"));
|
||||
|
||||
// 4. Flatpak directories (user and system)
|
||||
if let Some(data) = data_home() {
|
||||
add_dir(data.join("flatpak/exports/share/applications"));
|
||||
}
|
||||
add_dir(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
|
||||
|
||||
// 5. Snap directories
|
||||
add_dir(PathBuf::from("/var/lib/snapd/desktop/applications"));
|
||||
|
||||
// 6. Nix directories (common on NixOS)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
add_dir(home.join(".nix-profile/share/applications"));
|
||||
}
|
||||
add_dir(PathBuf::from("/run/current-system/sw/share/applications"));
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Runtime files
|
||||
// =============================================================================
|
||||
|
||||
/// IPC socket path: `$XDG_RUNTIME_DIR/owlry/owlry.sock`
|
||||
///
|
||||
/// Falls back to `/tmp` if `$XDG_RUNTIME_DIR` is not set.
|
||||
pub fn socket_path() -> PathBuf {
|
||||
let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/tmp"));
|
||||
runtime_dir.join(APP_NAME).join("owlry.sock")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper functions
|
||||
// =============================================================================
|
||||
|
||||
/// Ensure parent directory of a file exists
|
||||
pub fn ensure_parent_dir(path: &std::path::Path) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.exists()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_paths_are_consistent() {
|
||||
// All owlry paths should be under XDG directories
|
||||
if let (Some(config), Some(data)) = (owlry_config_dir(), owlry_data_dir()) {
|
||||
assert!(config.ends_with("owlry"));
|
||||
assert!(data.ends_with("owlry"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_file_path() {
|
||||
if let Some(path) = config_file() {
|
||||
assert!(path.ends_with("config.toml"));
|
||||
assert!(path.to_string_lossy().contains("owlry"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frecency_in_data_dir() {
|
||||
if let Some(path) = frecency_file() {
|
||||
assert!(path.ends_with("frecency.json"));
|
||||
// Should be in data dir, not config dir
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(
|
||||
path_str.contains(".local/share") || path_str.contains("XDG_DATA_HOME"),
|
||||
"frecency should be in data directory"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
330
crates/owlry-core/src/plugins/api/action.rs
Normal file
@@ -0,0 +1,330 @@
|
||||
//! Action API for Lua plugins
|
||||
//!
|
||||
//! Allows plugins to register custom actions for result items:
|
||||
//! - `owlry.action.register(config)` - Register a custom action
|
||||
|
||||
use mlua::{Function, Lua, Result as LuaResult, Table, Value};
|
||||
|
||||
/// Action registration data
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Used by UI integration
|
||||
pub struct ActionRegistration {
|
||||
/// Unique action ID
|
||||
pub id: String,
|
||||
/// Human-readable name shown in UI
|
||||
pub display_name: String,
|
||||
/// Icon name (optional)
|
||||
pub icon: Option<String>,
|
||||
/// Keyboard shortcut hint (optional, e.g., "Ctrl+C")
|
||||
pub shortcut: Option<String>,
|
||||
/// Plugin that registered this action
|
||||
pub plugin_id: String,
|
||||
}
|
||||
|
||||
/// Register action APIs
|
||||
pub fn register_action_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> {
|
||||
let action_table = lua.create_table()?;
|
||||
let plugin_id_owned = plugin_id.to_string();
|
||||
|
||||
// Initialize action storage in Lua registry
|
||||
if lua.named_registry_value::<Value>("actions")?.is_nil() {
|
||||
let actions: Table = lua.create_table()?;
|
||||
lua.set_named_registry_value("actions", actions)?;
|
||||
}
|
||||
|
||||
// owlry.action.register(config) -> string (action_id)
|
||||
// config = {
|
||||
// id = "copy-url",
|
||||
// name = "Copy URL",
|
||||
// icon = "edit-copy", -- optional
|
||||
// shortcut = "Ctrl+C", -- optional
|
||||
// filter = function(item) return item.provider == "bookmarks" end, -- optional
|
||||
// handler = function(item) ... end
|
||||
// }
|
||||
let plugin_id_for_register = plugin_id_owned.clone();
|
||||
action_table.set(
|
||||
"register",
|
||||
lua.create_function(move |lua, config: Table| {
|
||||
// Extract required fields
|
||||
let id: String = config
|
||||
.get("id")
|
||||
.map_err(|_| mlua::Error::external("action.register: 'id' is required"))?;
|
||||
|
||||
let name: String = config
|
||||
.get("name")
|
||||
.map_err(|_| mlua::Error::external("action.register: 'name' is required"))?;
|
||||
|
||||
let _handler: Function = config.get("handler").map_err(|_| {
|
||||
mlua::Error::external("action.register: 'handler' function is required")
|
||||
})?;
|
||||
|
||||
// Extract optional fields
|
||||
let icon: Option<String> = config.get("icon").ok();
|
||||
let shortcut: Option<String> = config.get("shortcut").ok();
|
||||
|
||||
// Store action in registry
|
||||
let actions: Table = lua.named_registry_value("actions")?;
|
||||
|
||||
// Create full action ID with plugin prefix
|
||||
let full_id = format!("{}:{}", plugin_id_for_register, id);
|
||||
|
||||
// Store config with full ID
|
||||
let action_entry = lua.create_table()?;
|
||||
action_entry.set("id", full_id.clone())?;
|
||||
action_entry.set("name", name.clone())?;
|
||||
action_entry.set("plugin_id", plugin_id_for_register.clone())?;
|
||||
if let Some(ref i) = icon {
|
||||
action_entry.set("icon", i.clone())?;
|
||||
}
|
||||
if let Some(ref s) = shortcut {
|
||||
action_entry.set("shortcut", s.clone())?;
|
||||
}
|
||||
// Store filter and handler functions
|
||||
if let Ok(filter) = config.get::<Function>("filter") {
|
||||
action_entry.set("filter", filter)?;
|
||||
}
|
||||
action_entry.set("handler", config.get::<Function>("handler")?)?;
|
||||
|
||||
actions.set(full_id.clone(), action_entry)?;
|
||||
|
||||
log::info!(
|
||||
"[plugin:{}] Registered action '{}' ({})",
|
||||
plugin_id_for_register,
|
||||
name,
|
||||
full_id
|
||||
);
|
||||
|
||||
Ok(full_id)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.action.unregister(id) -> boolean
|
||||
let plugin_id_for_unregister = plugin_id_owned.clone();
|
||||
action_table.set(
|
||||
"unregister",
|
||||
lua.create_function(move |lua, id: String| {
|
||||
let actions: Table = lua.named_registry_value("actions")?;
|
||||
let full_id = format!("{}:{}", plugin_id_for_unregister, id);
|
||||
|
||||
if actions.contains_key(full_id.clone())? {
|
||||
actions.set(full_id, Value::Nil)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("action", action_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all registered actions from a Lua runtime
|
||||
#[allow(dead_code)] // Will be used by UI
|
||||
pub fn get_actions(lua: &Lua) -> LuaResult<Vec<ActionRegistration>> {
|
||||
let actions: Table = match lua.named_registry_value("actions") {
|
||||
Ok(a) => a,
|
||||
Err(_) => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for pair in actions.pairs::<String, Table>() {
|
||||
let (_, entry) = pair?;
|
||||
|
||||
let id: String = entry.get("id")?;
|
||||
let display_name: String = entry.get("name")?;
|
||||
let plugin_id: String = entry.get("plugin_id")?;
|
||||
let icon: Option<String> = entry.get("icon").ok();
|
||||
let shortcut: Option<String> = entry.get("shortcut").ok();
|
||||
|
||||
result.push(ActionRegistration {
|
||||
id,
|
||||
display_name,
|
||||
icon,
|
||||
shortcut,
|
||||
plugin_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get actions that apply to a specific item
|
||||
#[allow(dead_code)] // Will be used by UI context menu
|
||||
pub fn get_actions_for_item(lua: &Lua, item: &Table) -> LuaResult<Vec<ActionRegistration>> {
|
||||
let actions: Table = match lua.named_registry_value("actions") {
|
||||
Ok(a) => a,
|
||||
Err(_) => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for pair in actions.pairs::<String, Table>() {
|
||||
let (_, entry) = pair?;
|
||||
|
||||
// Check filter if present
|
||||
if let Ok(filter) = entry.get::<Function>("filter") {
|
||||
match filter.call::<bool>(item.clone()) {
|
||||
Ok(true) => {} // Include this action
|
||||
Ok(false) => continue, // Skip this action
|
||||
Err(e) => {
|
||||
log::warn!("Action filter failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id: String = entry.get("id")?;
|
||||
let display_name: String = entry.get("name")?;
|
||||
let plugin_id: String = entry.get("plugin_id")?;
|
||||
let icon: Option<String> = entry.get("icon").ok();
|
||||
let shortcut: Option<String> = entry.get("shortcut").ok();
|
||||
|
||||
result.push(ActionRegistration {
|
||||
id,
|
||||
display_name,
|
||||
icon,
|
||||
shortcut,
|
||||
plugin_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Execute an action by ID
|
||||
#[allow(dead_code)] // Will be used by UI
|
||||
pub fn execute_action(lua: &Lua, action_id: &str, item: &Table) -> LuaResult<()> {
|
||||
let actions: Table = lua.named_registry_value("actions")?;
|
||||
let action: Table = actions.get(action_id)?;
|
||||
let handler: Function = action.get("handler")?;
|
||||
|
||||
handler.call::<()>(item.clone())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_lua(plugin_id: &str) -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_action_api(&lua, &owlry, plugin_id).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_registration() {
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
return owlry.action.register({
|
||||
id = "copy-name",
|
||||
name = "Copy Name",
|
||||
icon = "edit-copy",
|
||||
handler = function(item)
|
||||
-- copy logic here
|
||||
end
|
||||
})
|
||||
"#,
|
||||
);
|
||||
let action_id: String = chunk.call(()).unwrap();
|
||||
assert_eq!(action_id, "test-plugin:copy-name");
|
||||
|
||||
// Verify action is registered
|
||||
let actions = get_actions(&lua).unwrap();
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert_eq!(actions[0].display_name, "Copy Name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_with_filter() {
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
owlry.action.register({
|
||||
id = "bookmark-action",
|
||||
name = "Open in Browser",
|
||||
filter = function(item)
|
||||
return item.provider == "bookmarks"
|
||||
end,
|
||||
handler = function(item) end
|
||||
})
|
||||
"#,
|
||||
);
|
||||
chunk.call::<()>(()).unwrap();
|
||||
|
||||
// Create bookmark item
|
||||
let bookmark_item = lua.create_table().unwrap();
|
||||
bookmark_item.set("provider", "bookmarks").unwrap();
|
||||
bookmark_item.set("name", "Test Bookmark").unwrap();
|
||||
|
||||
let actions = get_actions_for_item(&lua, &bookmark_item).unwrap();
|
||||
assert_eq!(actions.len(), 1);
|
||||
|
||||
// Create non-bookmark item
|
||||
let app_item = lua.create_table().unwrap();
|
||||
app_item.set("provider", "applications").unwrap();
|
||||
app_item.set("name", "Test App").unwrap();
|
||||
|
||||
let actions2 = get_actions_for_item(&lua, &app_item).unwrap();
|
||||
assert_eq!(actions2.len(), 0); // Filtered out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_unregister() {
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
owlry.action.register({
|
||||
id = "temp-action",
|
||||
name = "Temporary",
|
||||
handler = function(item) end
|
||||
})
|
||||
return owlry.action.unregister("temp-action")
|
||||
"#,
|
||||
);
|
||||
let unregistered: bool = chunk.call(()).unwrap();
|
||||
assert!(unregistered);
|
||||
|
||||
let actions = get_actions(&lua).unwrap();
|
||||
assert_eq!(actions.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_action() {
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
// Register action that sets a global
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
result = nil
|
||||
owlry.action.register({
|
||||
id = "test-exec",
|
||||
name = "Test Execute",
|
||||
handler = function(item)
|
||||
result = item.name
|
||||
end
|
||||
})
|
||||
"#,
|
||||
);
|
||||
chunk.call::<()>(()).unwrap();
|
||||
|
||||
// Create test item
|
||||
let item = lua.create_table().unwrap();
|
||||
item.set("name", "TestItem").unwrap();
|
||||
|
||||
// Execute action
|
||||
execute_action(&lua, "test-plugin:test-exec", &item).unwrap();
|
||||
|
||||
// Verify handler was called
|
||||
let result: String = lua.globals().get("result").unwrap();
|
||||
assert_eq!(result, "TestItem");
|
||||
}
|
||||
}
|
||||
307
crates/owlry-core/src/plugins/api/cache.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
//! Cache API for Lua plugins
|
||||
//!
|
||||
//! Provides in-memory caching with optional TTL:
|
||||
//! - `owlry.cache.get(key)` - Get cached value
|
||||
//! - `owlry.cache.set(key, value, ttl_seconds?)` - Set cached value
|
||||
//! - `owlry.cache.delete(key)` - Delete cached value
|
||||
//! - `owlry.cache.clear()` - Clear all cached values
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Cached entry with optional expiration
|
||||
struct CacheEntry {
|
||||
value: String, // Store as JSON string for simplicity
|
||||
expires_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl CacheEntry {
|
||||
fn is_expired(&self) -> bool {
|
||||
self.expires_at.map(|e| Instant::now() > e).unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global cache storage (shared across all plugins)
|
||||
static CACHE: LazyLock<Mutex<HashMap<String, CacheEntry>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Register cache APIs
|
||||
pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let cache_table = lua.create_table()?;
|
||||
|
||||
// owlry.cache.get(key) -> value or nil
|
||||
cache_table.set(
|
||||
"get",
|
||||
lua.create_function(|lua, key: String| {
|
||||
let cache = CACHE
|
||||
.lock()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
|
||||
|
||||
if let Some(entry) = cache.get(&key) {
|
||||
if entry.is_expired() {
|
||||
drop(cache);
|
||||
// Remove expired entry
|
||||
if let Ok(mut cache) = CACHE.lock() {
|
||||
cache.remove(&key);
|
||||
}
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
|
||||
// Parse JSON back to Lua value
|
||||
let json_value: serde_json::Value =
|
||||
serde_json::from_str(&entry.value).map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to parse cached value: {}", e))
|
||||
})?;
|
||||
|
||||
json_to_lua(lua, &json_value)
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.cache.set(key, value, ttl_seconds?) -> boolean
|
||||
cache_table.set(
|
||||
"set",
|
||||
lua.create_function(|_lua, (key, value, ttl): (String, Value, Option<u64>)| {
|
||||
let json_value = lua_value_to_json(&value)?;
|
||||
let json_str = serde_json::to_string(&json_value)
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to serialize value: {}", e)))?;
|
||||
|
||||
let expires_at = ttl.map(|secs| Instant::now() + Duration::from_secs(secs));
|
||||
|
||||
let entry = CacheEntry {
|
||||
value: json_str,
|
||||
expires_at,
|
||||
};
|
||||
|
||||
let mut cache = CACHE
|
||||
.lock()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
|
||||
|
||||
cache.insert(key, entry);
|
||||
Ok(true)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.cache.delete(key) -> boolean (true if key existed)
|
||||
cache_table.set(
|
||||
"delete",
|
||||
lua.create_function(|_lua, key: String| {
|
||||
let mut cache = CACHE
|
||||
.lock()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
|
||||
|
||||
Ok(cache.remove(&key).is_some())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.cache.clear() -> number of entries removed
|
||||
cache_table.set(
|
||||
"clear",
|
||||
lua.create_function(|_lua, ()| {
|
||||
let mut cache = CACHE
|
||||
.lock()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
|
||||
|
||||
let count = cache.len();
|
||||
cache.clear();
|
||||
Ok(count)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.cache.has(key) -> boolean
|
||||
cache_table.set(
|
||||
"has",
|
||||
lua.create_function(|_lua, key: String| {
|
||||
let cache = CACHE
|
||||
.lock()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
|
||||
|
||||
if let Some(entry) = cache.get(&key) {
|
||||
Ok(!entry.is_expired())
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("cache", cache_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert Lua value to serde_json::Value
|
||||
fn lua_value_to_json(value: &Value) -> LuaResult<serde_json::Value> {
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
match value {
|
||||
Value::Nil => Ok(JsonValue::Null),
|
||||
Value::Boolean(b) => Ok(JsonValue::Bool(*b)),
|
||||
Value::Integer(i) => Ok(JsonValue::Number((*i).into())),
|
||||
Value::Number(n) => Ok(serde_json::Number::from_f64(*n)
|
||||
.map(JsonValue::Number)
|
||||
.unwrap_or(JsonValue::Null)),
|
||||
Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())),
|
||||
Value::Table(t) => lua_table_to_json(t),
|
||||
_ => Err(mlua::Error::external("Unsupported Lua type for cache")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Lua table to serde_json::Value
|
||||
fn lua_table_to_json(table: &Table) -> LuaResult<serde_json::Value> {
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
|
||||
// Check if it's an array (sequential integer keys starting from 1)
|
||||
let is_array = table
|
||||
.clone()
|
||||
.pairs::<i64, Value>()
|
||||
.enumerate()
|
||||
.all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false));
|
||||
|
||||
if is_array {
|
||||
let mut arr = Vec::new();
|
||||
for pair in table.clone().pairs::<i64, Value>() {
|
||||
let (_, v) = pair?;
|
||||
arr.push(lua_value_to_json(&v)?);
|
||||
}
|
||||
Ok(JsonValue::Array(arr))
|
||||
} else {
|
||||
let mut map = Map::new();
|
||||
for pair in table.clone().pairs::<String, Value>() {
|
||||
let (k, v) = pair?;
|
||||
map.insert(k, lua_value_to_json(&v)?);
|
||||
}
|
||||
Ok(JsonValue::Object(map))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert serde_json::Value to Lua value
|
||||
fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<Value> {
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
match value {
|
||||
JsonValue::Null => Ok(Value::Nil),
|
||||
JsonValue::Bool(b) => Ok(Value::Boolean(*b)),
|
||||
JsonValue::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Ok(Value::Integer(i))
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
Ok(Value::Number(f))
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
}
|
||||
JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)),
|
||||
JsonValue::Array(arr) => {
|
||||
let table = lua.create_table()?;
|
||||
for (i, v) in arr.iter().enumerate() {
|
||||
table.set(i + 1, json_to_lua(lua, v)?)?;
|
||||
}
|
||||
Ok(Value::Table(table))
|
||||
}
|
||||
JsonValue::Object(obj) => {
|
||||
let table = lua.create_table()?;
|
||||
for (k, v) in obj {
|
||||
table.set(k.as_str(), json_to_lua(lua, v)?)?;
|
||||
}
|
||||
Ok(Value::Table(table))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_lua() -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_cache_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
|
||||
// Clear cache between tests
|
||||
CACHE.lock().unwrap().clear();
|
||||
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_set_get() {
|
||||
let lua = setup_lua();
|
||||
|
||||
// Set a value
|
||||
let chunk = lua.load(r#"return owlry.cache.set("test_key", "test_value")"#);
|
||||
let result: bool = chunk.call(()).unwrap();
|
||||
assert!(result);
|
||||
|
||||
// Get the value back
|
||||
let chunk = lua.load(r#"return owlry.cache.get("test_key")"#);
|
||||
let value: String = chunk.call(()).unwrap();
|
||||
assert_eq!(value, "test_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_table_value() {
|
||||
let lua = setup_lua();
|
||||
|
||||
// Set a table value
|
||||
let chunk = lua.load(r#"return owlry.cache.set("table_key", {name = "test", value = 42})"#);
|
||||
let _: bool = chunk.call(()).unwrap();
|
||||
|
||||
// Get and verify
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
local t = owlry.cache.get("table_key")
|
||||
return t.name, t.value
|
||||
"#,
|
||||
);
|
||||
let (name, value): (String, i32) = chunk.call(()).unwrap();
|
||||
assert_eq!(name, "test");
|
||||
assert_eq!(value, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_delete() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
owlry.cache.set("delete_key", "value")
|
||||
local existed = owlry.cache.delete("delete_key")
|
||||
local value = owlry.cache.get("delete_key")
|
||||
return existed, value
|
||||
"#,
|
||||
);
|
||||
let (existed, value): (bool, Option<String>) = chunk.call(()).unwrap();
|
||||
assert!(existed);
|
||||
assert!(value.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_has() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
local before = owlry.cache.has("has_key")
|
||||
owlry.cache.set("has_key", "value")
|
||||
local after = owlry.cache.has("has_key")
|
||||
return before, after
|
||||
"#,
|
||||
);
|
||||
let (before, after): (bool, bool) = chunk.call(()).unwrap();
|
||||
assert!(!before);
|
||||
assert!(after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_missing_key() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"return owlry.cache.get("nonexistent_key")"#);
|
||||
let value: Value = chunk.call(()).unwrap();
|
||||
assert!(matches!(value, Value::Nil));
|
||||
}
|
||||
}
|
||||
418
crates/owlry-core/src/plugins/api/hook.rs
Normal file
@@ -0,0 +1,418 @@
|
||||
//! Hook API for Lua plugins
|
||||
//!
|
||||
//! Allows plugins to register callbacks for application events:
|
||||
//! - `owlry.hook.on(event, callback)` - Register a hook
|
||||
//! - Events: init, query, results, select, pre_launch, post_launch, shutdown
|
||||
|
||||
use mlua::{Function, Lua, Result as LuaResult, Table, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
/// Hook event types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum HookEvent {
|
||||
/// Called when plugin is initialized
|
||||
Init,
|
||||
/// Called when query changes, can modify query
|
||||
Query,
|
||||
/// Called after results are gathered, can filter/modify results
|
||||
Results,
|
||||
/// Called when an item is selected (highlighted)
|
||||
Select,
|
||||
/// Called before launching an item, can cancel launch
|
||||
PreLaunch,
|
||||
/// Called after launching an item
|
||||
PostLaunch,
|
||||
/// Called when application is shutting down
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
impl HookEvent {
|
||||
fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"init" => Some(Self::Init),
|
||||
"query" => Some(Self::Query),
|
||||
"results" => Some(Self::Results),
|
||||
"select" => Some(Self::Select),
|
||||
"pre_launch" | "prelaunch" => Some(Self::PreLaunch),
|
||||
"post_launch" | "postlaunch" => Some(Self::PostLaunch),
|
||||
"shutdown" => Some(Self::Shutdown),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Init => "init",
|
||||
Self::Query => "query",
|
||||
Self::Results => "results",
|
||||
Self::Select => "select",
|
||||
Self::PreLaunch => "pre_launch",
|
||||
Self::PostLaunch => "post_launch",
|
||||
Self::Shutdown => "shutdown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registered hook information
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Will be used for hook inspection
|
||||
pub struct HookRegistration {
|
||||
pub event: HookEvent,
|
||||
pub plugin_id: String,
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
/// Type alias for hook handlers: (plugin_id, priority)
|
||||
type HookHandlers = Vec<(String, i32)>;
|
||||
|
||||
/// Global hook registry
|
||||
/// Maps event -> list of (plugin_id, priority)
|
||||
static HOOK_REGISTRY: LazyLock<Mutex<HashMap<HookEvent, HookHandlers>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Register hook APIs
|
||||
pub fn register_hook_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> {
|
||||
let hook_table = lua.create_table()?;
|
||||
let plugin_id_owned = plugin_id.to_string();
|
||||
|
||||
// Store plugin_id in registry for later use
|
||||
lua.set_named_registry_value("plugin_id", plugin_id_owned.clone())?;
|
||||
|
||||
// Initialize hook storage in Lua registry
|
||||
if lua.named_registry_value::<Value>("hooks")?.is_nil() {
|
||||
let hooks: Table = lua.create_table()?;
|
||||
lua.set_named_registry_value("hooks", hooks)?;
|
||||
}
|
||||
|
||||
// owlry.hook.on(event, callback, priority?) -> boolean
|
||||
// Register a hook for an event
|
||||
let plugin_id_for_closure = plugin_id_owned.clone();
|
||||
hook_table.set(
|
||||
"on",
|
||||
lua.create_function(move |lua, (event_name, callback, priority): (String, Function, Option<i32>)| {
|
||||
let event = HookEvent::from_str(&event_name).ok_or_else(|| {
|
||||
mlua::Error::external(format!(
|
||||
"Unknown hook event '{}'. Valid events: init, query, results, select, pre_launch, post_launch, shutdown",
|
||||
event_name
|
||||
))
|
||||
})?;
|
||||
|
||||
let priority = priority.unwrap_or(0);
|
||||
|
||||
// Store callback in Lua registry
|
||||
let hooks: Table = lua.named_registry_value("hooks")?;
|
||||
let event_key = event.as_str();
|
||||
|
||||
let event_hooks: Table = if let Ok(t) = hooks.get::<Table>(event_key) {
|
||||
t
|
||||
} else {
|
||||
let t = lua.create_table()?;
|
||||
hooks.set(event_key, t.clone())?;
|
||||
t
|
||||
};
|
||||
|
||||
// Add callback to event hooks
|
||||
let len = event_hooks.len()? + 1;
|
||||
let hook_entry = lua.create_table()?;
|
||||
hook_entry.set("callback", callback)?;
|
||||
hook_entry.set("priority", priority)?;
|
||||
event_hooks.set(len, hook_entry)?;
|
||||
|
||||
// Register in global registry
|
||||
let mut registry = HOOK_REGISTRY.lock().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to lock hook registry: {}", e))
|
||||
})?;
|
||||
|
||||
let hooks_list = registry.entry(event).or_insert_with(Vec::new);
|
||||
hooks_list.push((plugin_id_for_closure.clone(), priority));
|
||||
// Sort by priority (higher priority first)
|
||||
hooks_list.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
log::debug!(
|
||||
"[plugin:{}] Registered hook for '{}' with priority {}",
|
||||
plugin_id_for_closure,
|
||||
event_name,
|
||||
priority
|
||||
);
|
||||
|
||||
Ok(true)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.hook.off(event) -> boolean
|
||||
// Unregister all hooks for an event from this plugin
|
||||
let plugin_id_for_off = plugin_id_owned.clone();
|
||||
hook_table.set(
|
||||
"off",
|
||||
lua.create_function(move |lua, event_name: String| {
|
||||
let event = HookEvent::from_str(&event_name).ok_or_else(|| {
|
||||
mlua::Error::external(format!("Unknown hook event '{}'", event_name))
|
||||
})?;
|
||||
|
||||
// Remove from Lua registry
|
||||
let hooks: Table = lua.named_registry_value("hooks")?;
|
||||
hooks.set(event.as_str(), Value::Nil)?;
|
||||
|
||||
// Remove from global registry
|
||||
let mut registry = HOOK_REGISTRY.lock().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to lock hook registry: {}", e))
|
||||
})?;
|
||||
|
||||
if let Some(hooks_list) = registry.get_mut(&event) {
|
||||
hooks_list.retain(|(id, _)| id != &plugin_id_for_off);
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[plugin:{}] Unregistered hooks for '{}'",
|
||||
plugin_id_for_off,
|
||||
event_name
|
||||
);
|
||||
|
||||
Ok(true)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("hook", hook_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call hooks for a specific event in a Lua runtime
|
||||
/// Returns the (possibly modified) value
|
||||
#[allow(dead_code)] // Will be used by UI integration
|
||||
pub fn call_hooks<T>(lua: &Lua, event: HookEvent, value: T) -> LuaResult<T>
|
||||
where
|
||||
T: mlua::IntoLua + mlua::FromLua,
|
||||
{
|
||||
let hooks: Table = match lua.named_registry_value("hooks") {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(value), // No hooks registered
|
||||
};
|
||||
|
||||
let event_hooks: Table = match hooks.get(event.as_str()) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(value), // No hooks for this event
|
||||
};
|
||||
|
||||
let mut current_value = value.into_lua(lua)?;
|
||||
|
||||
// Collect hooks with priorities
|
||||
let mut hook_entries: Vec<(i32, Function)> = Vec::new();
|
||||
for pair in event_hooks.pairs::<i64, Table>() {
|
||||
let (_, entry) = pair?;
|
||||
let priority: i32 = entry.get("priority").unwrap_or(0);
|
||||
let callback: Function = entry.get("callback")?;
|
||||
hook_entries.push((priority, callback));
|
||||
}
|
||||
|
||||
// Sort by priority (higher first)
|
||||
hook_entries.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
// Call each hook
|
||||
for (_, callback) in hook_entries {
|
||||
match callback.call::<Value>(current_value.clone()) {
|
||||
Ok(result) => {
|
||||
// If hook returns non-nil, use it as the new value
|
||||
if !result.is_nil() {
|
||||
current_value = result;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e);
|
||||
// Continue with other hooks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
T::from_lua(current_value, lua)
|
||||
}
|
||||
|
||||
/// Call hooks that return a boolean (for pre_launch cancellation)
|
||||
#[allow(dead_code)] // Will be used for pre_launch hooks
|
||||
pub fn call_hooks_bool(lua: &Lua, event: HookEvent, value: Value) -> LuaResult<bool> {
|
||||
let hooks: Table = match lua.named_registry_value("hooks") {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(true), // No hooks, allow
|
||||
};
|
||||
|
||||
let event_hooks: Table = match hooks.get(event.as_str()) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(true), // No hooks for this event
|
||||
};
|
||||
|
||||
// Collect and sort hooks
|
||||
let mut hook_entries: Vec<(i32, Function)> = Vec::new();
|
||||
for pair in event_hooks.pairs::<i64, Table>() {
|
||||
let (_, entry) = pair?;
|
||||
let priority: i32 = entry.get("priority").unwrap_or(0);
|
||||
let callback: Function = entry.get("callback")?;
|
||||
hook_entries.push((priority, callback));
|
||||
}
|
||||
hook_entries.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
// Call each hook - if any returns false, cancel
|
||||
for (_, callback) in hook_entries {
|
||||
match callback.call::<Value>(value.clone()) {
|
||||
Ok(result) => {
|
||||
if let Value::Boolean(false) = result {
|
||||
return Ok(false); // Cancel
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Call hooks with no return value (for notifications)
|
||||
#[allow(dead_code)] // Will be used for notification hooks
|
||||
pub fn call_hooks_void(lua: &Lua, event: HookEvent, value: Value) -> LuaResult<()> {
|
||||
let hooks: Table = match lua.named_registry_value("hooks") {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(()), // No hooks
|
||||
};
|
||||
|
||||
let event_hooks: Table = match hooks.get(event.as_str()) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(()), // No hooks for this event
|
||||
};
|
||||
|
||||
for pair in event_hooks.pairs::<i64, Table>() {
|
||||
let (_, entry) = pair?;
|
||||
let callback: Function = entry.get("callback")?;
|
||||
if let Err(e) = callback.call::<()>(value.clone()) {
|
||||
log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get list of plugins that have registered for an event
|
||||
#[allow(dead_code)]
|
||||
pub fn get_registered_plugins(event: HookEvent) -> Vec<String> {
|
||||
HOOK_REGISTRY
|
||||
.lock()
|
||||
.map(|r| {
|
||||
r.get(&event)
|
||||
.map(|v| v.iter().map(|(id, _)| id.clone()).collect())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Clear all hooks (used when reloading plugins)
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_all_hooks() {
|
||||
if let Ok(mut registry) = HOOK_REGISTRY.lock() {
|
||||
registry.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_lua(plugin_id: &str) -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_hook_api(&lua, &owlry, plugin_id).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_registration() {
|
||||
clear_all_hooks();
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
local called = false
|
||||
owlry.hook.on("init", function()
|
||||
called = true
|
||||
end)
|
||||
return true
|
||||
"#,
|
||||
);
|
||||
let result: bool = chunk.call(()).unwrap();
|
||||
assert!(result);
|
||||
|
||||
// Verify hook was registered
|
||||
let plugins = get_registered_plugins(HookEvent::Init);
|
||||
assert!(plugins.contains(&"test-plugin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_with_priority() {
|
||||
clear_all_hooks();
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
owlry.hook.on("query", function(q) return q .. "1" end, 10)
|
||||
owlry.hook.on("query", function(q) return q .. "2" end, 20)
|
||||
return true
|
||||
"#,
|
||||
);
|
||||
chunk.call::<()>(()).unwrap();
|
||||
|
||||
// Call hooks - higher priority (20) should run first
|
||||
let result: String = call_hooks(&lua, HookEvent::Query, "test".to_string()).unwrap();
|
||||
// Priority 20 adds "2" first, then priority 10 adds "1"
|
||||
assert_eq!(result, "test21");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_off() {
|
||||
clear_all_hooks();
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
owlry.hook.on("select", function() end)
|
||||
owlry.hook.off("select")
|
||||
return true
|
||||
"#,
|
||||
);
|
||||
chunk.call::<()>(()).unwrap();
|
||||
|
||||
let plugins = get_registered_plugins(HookEvent::Select);
|
||||
assert!(!plugins.contains(&"test-plugin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pre_launch_cancel() {
|
||||
clear_all_hooks();
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
owlry.hook.on("pre_launch", function(item)
|
||||
if item.name == "blocked" then
|
||||
return false -- cancel launch
|
||||
end
|
||||
return true
|
||||
end)
|
||||
"#,
|
||||
);
|
||||
chunk.call::<()>(()).unwrap();
|
||||
|
||||
// Create a test item table
|
||||
let item = lua.create_table().unwrap();
|
||||
item.set("name", "blocked").unwrap();
|
||||
|
||||
let allow = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item)).unwrap();
|
||||
assert!(!allow); // Should be blocked
|
||||
|
||||
// Test with allowed item
|
||||
let item2 = lua.create_table().unwrap();
|
||||
item2.set("name", "allowed").unwrap();
|
||||
|
||||
let allow2 = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item2)).unwrap();
|
||||
assert!(allow2); // Should be allowed
|
||||
}
|
||||
}
|
||||
350
crates/owlry-core/src/plugins/api/http.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
//! HTTP client API for Lua plugins
|
||||
//!
|
||||
//! Provides:
|
||||
//! - `owlry.http.get(url, opts)` - HTTP GET request
|
||||
//! - `owlry.http.post(url, body, opts)` - HTTP POST request
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Register HTTP client APIs
|
||||
pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let http_table = lua.create_table()?;
|
||||
|
||||
// owlry.http.get(url, opts?) -> { status, body, headers }
|
||||
http_table.set(
|
||||
"get",
|
||||
lua.create_function(|lua, (url, opts): (String, Option<Table>)| {
|
||||
log::debug!("[plugin] http.get: {}", url);
|
||||
|
||||
let timeout_secs = opts
|
||||
.as_ref()
|
||||
.and_then(|o| o.get::<u64>("timeout").ok())
|
||||
.unwrap_or(30);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to create HTTP client: {}", e))
|
||||
})?;
|
||||
|
||||
let mut request = client.get(&url);
|
||||
|
||||
// Add custom headers if provided
|
||||
if let Some(ref opts) = opts
|
||||
&& let Ok(headers) = opts.get::<Table>("headers")
|
||||
{
|
||||
for pair in headers.pairs::<String, String>() {
|
||||
let (key, value) = pair?;
|
||||
request = request.header(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let headers = extract_headers(&response);
|
||||
let body = response.text().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to read response body: {}", e))
|
||||
})?;
|
||||
|
||||
let result = lua.create_table()?;
|
||||
result.set("status", status)?;
|
||||
result.set("body", body)?;
|
||||
result.set("ok", (200..300).contains(&status))?;
|
||||
|
||||
let headers_table = lua.create_table()?;
|
||||
for (key, value) in headers {
|
||||
headers_table.set(key, value)?;
|
||||
}
|
||||
result.set("headers", headers_table)?;
|
||||
|
||||
Ok(result)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.http.post(url, body, opts?) -> { status, body, headers }
|
||||
http_table.set(
|
||||
"post",
|
||||
lua.create_function(|lua, (url, body, opts): (String, Value, Option<Table>)| {
|
||||
log::debug!("[plugin] http.post: {}", url);
|
||||
|
||||
let timeout_secs = opts
|
||||
.as_ref()
|
||||
.and_then(|o| o.get::<u64>("timeout").ok())
|
||||
.unwrap_or(30);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to create HTTP client: {}", e))
|
||||
})?;
|
||||
|
||||
let mut request = client.post(&url);
|
||||
|
||||
// Add custom headers if provided
|
||||
if let Some(ref opts) = opts
|
||||
&& let Ok(headers) = opts.get::<Table>("headers")
|
||||
{
|
||||
for pair in headers.pairs::<String, String>() {
|
||||
let (key, value) = pair?;
|
||||
request = request.header(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
// Set body based on type
|
||||
request = match body {
|
||||
Value::String(s) => request.body(s.to_str()?.to_string()),
|
||||
Value::Table(t) => {
|
||||
// Assume JSON if body is a table
|
||||
let json_str = table_to_json(&t)?;
|
||||
request
|
||||
.header("Content-Type", "application/json")
|
||||
.body(json_str)
|
||||
}
|
||||
Value::Nil => request,
|
||||
_ => return Err(mlua::Error::external("POST body must be a string or table")),
|
||||
};
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let headers = extract_headers(&response);
|
||||
let body = response.text().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to read response body: {}", e))
|
||||
})?;
|
||||
|
||||
let result = lua.create_table()?;
|
||||
result.set("status", status)?;
|
||||
result.set("body", body)?;
|
||||
result.set("ok", (200..300).contains(&status))?;
|
||||
|
||||
let headers_table = lua.create_table()?;
|
||||
for (key, value) in headers {
|
||||
headers_table.set(key, value)?;
|
||||
}
|
||||
result.set("headers", headers_table)?;
|
||||
|
||||
Ok(result)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.http.get_json(url, opts?) -> parsed JSON as table
|
||||
// Convenience function that parses JSON response
|
||||
http_table.set(
|
||||
"get_json",
|
||||
lua.create_function(|lua, (url, opts): (String, Option<Table>)| {
|
||||
log::debug!("[plugin] http.get_json: {}", url);
|
||||
|
||||
let timeout_secs = opts
|
||||
.as_ref()
|
||||
.and_then(|o| o.get::<u64>("timeout").ok())
|
||||
.unwrap_or(30);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to create HTTP client: {}", e))
|
||||
})?;
|
||||
|
||||
let mut request = client.get(&url);
|
||||
request = request.header("Accept", "application/json");
|
||||
|
||||
// Add custom headers if provided
|
||||
if let Some(ref opts) = opts
|
||||
&& let Ok(headers) = opts.get::<Table>("headers")
|
||||
{
|
||||
for pair in headers.pairs::<String, String>() {
|
||||
let (key, value) = pair?;
|
||||
request = request.header(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(mlua::Error::external(format!(
|
||||
"HTTP request failed with status {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response.text().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to read response body: {}", e))
|
||||
})?;
|
||||
|
||||
// Parse JSON and convert to Lua table
|
||||
let json_value: serde_json::Value = serde_json::from_str(&body)
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to parse JSON: {}", e)))?;
|
||||
|
||||
json_to_lua(lua, &json_value)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("http", http_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract headers from response into a HashMap
|
||||
fn extract_headers(response: &reqwest::blocking::Response) -> HashMap<String, String> {
|
||||
response
|
||||
.headers()
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
v.to_str()
|
||||
.ok()
|
||||
.map(|v| (k.as_str().to_lowercase(), v.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert a Lua table to JSON string
|
||||
fn table_to_json(table: &Table) -> LuaResult<String> {
|
||||
let value = lua_to_json(table)?;
|
||||
serde_json::to_string(&value)
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to serialize to JSON: {}", e)))
|
||||
}
|
||||
|
||||
/// Convert Lua table to serde_json::Value
|
||||
fn lua_to_json(table: &Table) -> LuaResult<serde_json::Value> {
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
|
||||
// Check if it's an array (sequential integer keys starting from 1)
|
||||
let is_array = table
|
||||
.clone()
|
||||
.pairs::<i64, Value>()
|
||||
.enumerate()
|
||||
.all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false));
|
||||
|
||||
if is_array {
|
||||
let mut arr = Vec::new();
|
||||
for pair in table.clone().pairs::<i64, Value>() {
|
||||
let (_, v) = pair?;
|
||||
arr.push(lua_value_to_json(&v)?);
|
||||
}
|
||||
Ok(JsonValue::Array(arr))
|
||||
} else {
|
||||
let mut map = Map::new();
|
||||
for pair in table.clone().pairs::<String, Value>() {
|
||||
let (k, v) = pair?;
|
||||
map.insert(k, lua_value_to_json(&v)?);
|
||||
}
|
||||
Ok(JsonValue::Object(map))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a single Lua value to JSON
|
||||
fn lua_value_to_json(value: &Value) -> LuaResult<serde_json::Value> {
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
match value {
|
||||
Value::Nil => Ok(JsonValue::Null),
|
||||
Value::Boolean(b) => Ok(JsonValue::Bool(*b)),
|
||||
Value::Integer(i) => Ok(JsonValue::Number((*i).into())),
|
||||
Value::Number(n) => Ok(serde_json::Number::from_f64(*n)
|
||||
.map(JsonValue::Number)
|
||||
.unwrap_or(JsonValue::Null)),
|
||||
Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())),
|
||||
Value::Table(t) => lua_to_json(t),
|
||||
_ => Err(mlua::Error::external("Unsupported Lua type for JSON")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert serde_json::Value to Lua value
|
||||
fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<Value> {
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
match value {
|
||||
JsonValue::Null => Ok(Value::Nil),
|
||||
JsonValue::Bool(b) => Ok(Value::Boolean(*b)),
|
||||
JsonValue::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Ok(Value::Integer(i))
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
Ok(Value::Number(f))
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
}
|
||||
JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)),
|
||||
JsonValue::Array(arr) => {
|
||||
let table = lua.create_table()?;
|
||||
for (i, v) in arr.iter().enumerate() {
|
||||
table.set(i + 1, json_to_lua(lua, v)?)?;
|
||||
}
|
||||
Ok(Value::Table(table))
|
||||
}
|
||||
JsonValue::Object(obj) => {
|
||||
let table = lua.create_table()?;
|
||||
for (k, v) in obj {
|
||||
table.set(k.as_str(), json_to_lua(lua, v)?)?;
|
||||
}
|
||||
Ok(Value::Table(table))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_lua() -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_http_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_conversion() {
|
||||
let lua = setup_lua();
|
||||
|
||||
// Test table to JSON
|
||||
let table = lua.create_table().unwrap();
|
||||
table.set("name", "test").unwrap();
|
||||
table.set("value", 42).unwrap();
|
||||
|
||||
let json = table_to_json(&table).unwrap();
|
||||
assert!(json.contains("name"));
|
||||
assert!(json.contains("test"));
|
||||
assert!(json.contains("42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_to_json() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let table = lua.create_table().unwrap();
|
||||
table.set(1, "first").unwrap();
|
||||
table.set(2, "second").unwrap();
|
||||
table.set(3, "third").unwrap();
|
||||
|
||||
let json = table_to_json(&table).unwrap();
|
||||
assert!(json.starts_with('['));
|
||||
assert!(json.contains("first"));
|
||||
}
|
||||
|
||||
// Note: Network tests are skipped in CI - they require internet access
|
||||
// Use `cargo test -- --ignored` to run them locally
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_http_get() {
|
||||
let lua = setup_lua();
|
||||
let chunk = lua.load(r#"return owlry.http.get("https://httpbin.org/get")"#);
|
||||
let result: Table = chunk.call(()).unwrap();
|
||||
|
||||
assert_eq!(result.get::<u16>("status").unwrap(), 200);
|
||||
assert!(result.get::<bool>("ok").unwrap());
|
||||
}
|
||||
}
|
||||
187
crates/owlry-core/src/plugins/api/math.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! Math calculation API for Lua plugins
|
||||
//!
|
||||
//! Provides safe math expression evaluation:
|
||||
//! - `owlry.math.calculate(expression)` - Evaluate a math expression
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table};
|
||||
|
||||
/// Register math APIs
|
||||
pub fn register_math_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let math_table = lua.create_table()?;
|
||||
|
||||
// owlry.math.calculate(expression) -> number or nil, error
|
||||
// Evaluates a mathematical expression safely
|
||||
// Returns (result, nil) on success or (nil, error_message) on failure
|
||||
math_table.set(
|
||||
"calculate",
|
||||
lua.create_function(
|
||||
|_lua, expr: String| -> LuaResult<(Option<f64>, Option<String>)> {
|
||||
match meval::eval_str(&expr) {
|
||||
Ok(result) => {
|
||||
if result.is_finite() {
|
||||
Ok((Some(result), None))
|
||||
} else {
|
||||
Ok((None, Some("Result is not a finite number".to_string())))
|
||||
}
|
||||
}
|
||||
Err(e) => Ok((None, Some(e.to_string()))),
|
||||
}
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// owlry.math.calc(expression) -> number (throws on error)
|
||||
// Convenience function that throws instead of returning error
|
||||
math_table.set(
|
||||
"calc",
|
||||
lua.create_function(|_lua, expr: String| {
|
||||
meval::eval_str(&expr)
|
||||
.map_err(|e| mlua::Error::external(format!("Math error: {}", e)))
|
||||
.and_then(|r| {
|
||||
if r.is_finite() {
|
||||
Ok(r)
|
||||
} else {
|
||||
Err(mlua::Error::external("Result is not a finite number"))
|
||||
}
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.math.is_expression(str) -> boolean
|
||||
// Check if a string looks like a math expression
|
||||
math_table.set(
|
||||
"is_expression",
|
||||
lua.create_function(|_lua, expr: String| {
|
||||
let trimmed = expr.trim();
|
||||
|
||||
// Must have at least one digit
|
||||
if !trimmed.chars().any(|c| c.is_ascii_digit()) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Should only contain valid math characters
|
||||
let valid = trimmed.chars().all(|c| {
|
||||
c.is_ascii_digit()
|
||||
|| c.is_ascii_alphabetic()
|
||||
|| matches!(c, '+' | '-' | '*' | '/' | '^' | '(' | ')' | '.' | ' ' | '%')
|
||||
});
|
||||
|
||||
Ok(valid)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.math.format(number, decimals?) -> string
|
||||
// Format a number with optional decimal places
|
||||
math_table.set(
|
||||
"format",
|
||||
lua.create_function(|_lua, (num, decimals): (f64, Option<usize>)| {
|
||||
let decimals = decimals.unwrap_or(2);
|
||||
|
||||
// Check if it's effectively an integer
|
||||
if (num - num.round()).abs() < f64::EPSILON {
|
||||
Ok(format!("{}", num as i64))
|
||||
} else {
|
||||
Ok(format!("{:.prec$}", num, prec = decimals))
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("math", math_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_lua() -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_math_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_basic() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
local result, err = owlry.math.calculate("2 + 2")
|
||||
if err then error(err) end
|
||||
return result
|
||||
"#,
|
||||
);
|
||||
let result: f64 = chunk.call(()).unwrap();
|
||||
assert!((result - 4.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_complex() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
local result, err = owlry.math.calculate("sqrt(16) + 2^3")
|
||||
if err then error(err) end
|
||||
return result
|
||||
"#,
|
||||
);
|
||||
let result: f64 = chunk.call(()).unwrap();
|
||||
assert!((result - 12.0).abs() < f64::EPSILON); // sqrt(16) = 4, 2^3 = 8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_error() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
local result, err = owlry.math.calculate("invalid expression @@")
|
||||
if result then
|
||||
return false -- should not succeed
|
||||
else
|
||||
return true -- correctly failed
|
||||
end
|
||||
"#,
|
||||
);
|
||||
let had_error: bool = chunk.call(()).unwrap();
|
||||
assert!(had_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calc_throws() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"return owlry.math.calc("3 * 4")"#);
|
||||
let result: f64 = chunk.call(()).unwrap();
|
||||
assert!((result - 12.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_expression() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"return owlry.math.is_expression("2 + 2")"#);
|
||||
let is_expr: bool = chunk.call(()).unwrap();
|
||||
assert!(is_expr);
|
||||
|
||||
let chunk = lua.load(r#"return owlry.math.is_expression("hello world")"#);
|
||||
let is_expr: bool = chunk.call(()).unwrap();
|
||||
assert!(!is_expr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"return owlry.math.format(3.14159, 2)"#);
|
||||
let formatted: String = chunk.call(()).unwrap();
|
||||
assert_eq!(formatted, "3.14");
|
||||
|
||||
let chunk = lua.load(r#"return owlry.math.format(42.0)"#);
|
||||
let formatted: String = chunk.call(()).unwrap();
|
||||
assert_eq!(formatted, "42");
|
||||
}
|
||||
}
|
||||
77
crates/owlry-core/src/plugins/api/mod.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! Lua API implementations for plugins
|
||||
//!
|
||||
//! This module provides the `owlry` global table and its submodules
|
||||
//! that plugins can use to interact with owlry.
|
||||
|
||||
pub mod action;
|
||||
mod cache;
|
||||
pub mod hook;
|
||||
mod http;
|
||||
mod math;
|
||||
mod process;
|
||||
pub mod provider;
|
||||
pub mod theme;
|
||||
mod utils;
|
||||
|
||||
use mlua::{Lua, Result as LuaResult};
|
||||
|
||||
pub use action::ActionRegistration;
|
||||
pub use hook::HookEvent;
|
||||
pub use provider::ProviderRegistration;
|
||||
pub use theme::ThemeRegistration;
|
||||
|
||||
/// Register all owlry APIs in the Lua runtime
|
||||
///
|
||||
/// This creates the `owlry` global table with all available APIs:
|
||||
/// - `owlry.log.*` - Logging functions
|
||||
/// - `owlry.path.*` - XDG path helpers
|
||||
/// - `owlry.fs.*` - Filesystem operations
|
||||
/// - `owlry.json.*` - JSON encode/decode
|
||||
/// - `owlry.provider.*` - Provider registration
|
||||
/// - `owlry.process.*` - Process execution
|
||||
/// - `owlry.env.*` - Environment variables
|
||||
/// - `owlry.http.*` - HTTP client
|
||||
/// - `owlry.cache.*` - In-memory caching
|
||||
/// - `owlry.math.*` - Math expression evaluation
|
||||
/// - `owlry.hook.*` - Event hooks
|
||||
/// - `owlry.action.*` - Custom actions
|
||||
/// - `owlry.theme.*` - Theme registration
|
||||
pub fn register_apis(lua: &Lua, plugin_dir: &std::path::Path, plugin_id: &str) -> LuaResult<()> {
|
||||
let globals = lua.globals();
|
||||
|
||||
// Create the main owlry table
|
||||
let owlry = lua.create_table()?;
|
||||
|
||||
// Register utility APIs (log, path, fs, json)
|
||||
utils::register_log_api(lua, &owlry)?;
|
||||
utils::register_path_api(lua, &owlry, plugin_dir)?;
|
||||
utils::register_fs_api(lua, &owlry, plugin_dir)?;
|
||||
utils::register_json_api(lua, &owlry)?;
|
||||
|
||||
// Register provider API
|
||||
provider::register_provider_api(lua, &owlry)?;
|
||||
|
||||
// Register extended APIs (Phase 3)
|
||||
process::register_process_api(lua, &owlry)?;
|
||||
process::register_env_api(lua, &owlry)?;
|
||||
http::register_http_api(lua, &owlry)?;
|
||||
cache::register_cache_api(lua, &owlry)?;
|
||||
math::register_math_api(lua, &owlry)?;
|
||||
|
||||
// Register Phase 4 APIs (hooks, actions, themes)
|
||||
hook::register_hook_api(lua, &owlry, plugin_id)?;
|
||||
action::register_action_api(lua, &owlry, plugin_id)?;
|
||||
theme::register_theme_api(lua, &owlry, plugin_id, plugin_dir)?;
|
||||
|
||||
// Set owlry as global
|
||||
globals.set("owlry", owlry)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get provider registrations from the Lua runtime
|
||||
///
|
||||
/// Returns all providers that were registered via `owlry.provider.register()`
|
||||
pub fn get_provider_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
|
||||
provider::get_registrations(lua)
|
||||
}
|
||||
213
crates/owlry-core/src/plugins/api/process.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! Process and environment APIs for Lua plugins
|
||||
//!
|
||||
//! Provides:
|
||||
//! - `owlry.process.run(cmd)` - Run a shell command and return output
|
||||
//! - `owlry.process.exists(cmd)` - Check if a command exists in PATH
|
||||
//! - `owlry.env.get(name)` - Get an environment variable
|
||||
//! - `owlry.env.set(name, value)` - Set an environment variable (for plugin scope)
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table};
|
||||
use std::process::Command;
|
||||
|
||||
/// Register process-related APIs
|
||||
pub fn register_process_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let process_table = lua.create_table()?;
|
||||
|
||||
// owlry.process.run(cmd) -> { stdout, stderr, exit_code, success }
|
||||
// Runs a shell command and returns the result
|
||||
process_table.set(
|
||||
"run",
|
||||
lua.create_function(|lua, cmd: String| {
|
||||
log::debug!("[plugin] process.run: {}", cmd);
|
||||
|
||||
let output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.output()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?;
|
||||
|
||||
let result = lua.create_table()?;
|
||||
result.set(
|
||||
"stdout",
|
||||
String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
)?;
|
||||
result.set(
|
||||
"stderr",
|
||||
String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
)?;
|
||||
result.set("exit_code", output.status.code().unwrap_or(-1))?;
|
||||
result.set("success", output.status.success())?;
|
||||
|
||||
Ok(result)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.process.run_lines(cmd) -> table of lines
|
||||
// Convenience function that runs a command and returns stdout split into lines
|
||||
process_table.set(
|
||||
"run_lines",
|
||||
lua.create_function(|lua, cmd: String| {
|
||||
log::debug!("[plugin] process.run_lines: {}", cmd);
|
||||
|
||||
let output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.output()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(mlua::Error::external(format!(
|
||||
"Command failed with exit code {}: {}",
|
||||
output.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
|
||||
let result = lua.create_table()?;
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
result.set(i + 1, *line)?;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.process.exists(cmd) -> boolean
|
||||
// Checks if a command exists in PATH
|
||||
process_table.set(
|
||||
"exists",
|
||||
lua.create_function(|_lua, cmd: String| {
|
||||
let exists = Command::new("which")
|
||||
.arg(&cmd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(exists)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("process", process_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register environment variable APIs
|
||||
pub fn register_env_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let env_table = lua.create_table()?;
|
||||
|
||||
// owlry.env.get(name) -> string or nil
|
||||
env_table.set(
|
||||
"get",
|
||||
lua.create_function(|_lua, name: String| Ok(std::env::var(&name).ok()))?,
|
||||
)?;
|
||||
|
||||
// owlry.env.get_or(name, default) -> string
|
||||
env_table.set(
|
||||
"get_or",
|
||||
lua.create_function(|_lua, (name, default): (String, String)| {
|
||||
Ok(std::env::var(&name).unwrap_or(default))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.env.home() -> string
|
||||
// Convenience function to get home directory
|
||||
env_table.set(
|
||||
"home",
|
||||
lua.create_function(|_lua, ()| {
|
||||
Ok(dirs::home_dir().map(|p| p.to_string_lossy().to_string()))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("env", env_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_lua() -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_process_api(&lua, &owlry).unwrap();
|
||||
register_env_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_run() {
|
||||
let lua = setup_lua();
|
||||
let chunk = lua.load(r#"return owlry.process.run("echo hello")"#);
|
||||
let result: Table = chunk.call(()).unwrap();
|
||||
|
||||
assert_eq!(result.get::<bool>("success").unwrap(), true);
|
||||
assert_eq!(result.get::<i32>("exit_code").unwrap(), 0);
|
||||
assert!(result.get::<String>("stdout").unwrap().contains("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_run_lines() {
|
||||
let lua = setup_lua();
|
||||
let chunk = lua.load(r#"return owlry.process.run_lines("echo -e 'line1\nline2\nline3'")"#);
|
||||
let result: Table = chunk.call(()).unwrap();
|
||||
|
||||
assert_eq!(result.get::<String>(1).unwrap(), "line1");
|
||||
assert_eq!(result.get::<String>(2).unwrap(), "line2");
|
||||
assert_eq!(result.get::<String>(3).unwrap(), "line3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_exists() {
|
||||
let lua = setup_lua();
|
||||
|
||||
// 'sh' should always exist
|
||||
let chunk = lua.load(r#"return owlry.process.exists("sh")"#);
|
||||
let exists: bool = chunk.call(()).unwrap();
|
||||
assert!(exists);
|
||||
|
||||
// Made-up command should not exist
|
||||
let chunk = lua
|
||||
.load(r#"return owlry.process.exists("this_command_definitely_does_not_exist_12345")"#);
|
||||
let not_exists: bool = chunk.call(()).unwrap();
|
||||
assert!(!not_exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_get() {
|
||||
let lua = setup_lua();
|
||||
|
||||
// HOME should be set on any Unix system
|
||||
let chunk = lua.load(r#"return owlry.env.get("HOME")"#);
|
||||
let home: Option<String> = chunk.call(()).unwrap();
|
||||
assert!(home.is_some());
|
||||
|
||||
// Non-existent variable should return nil
|
||||
let chunk = lua.load(r#"return owlry.env.get("THIS_VAR_DOES_NOT_EXIST_12345")"#);
|
||||
let missing: Option<String> = chunk.call(()).unwrap();
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_get_or() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua
|
||||
.load(r#"return owlry.env.get_or("THIS_VAR_DOES_NOT_EXIST_12345", "default_value")"#);
|
||||
let result: String = chunk.call(()).unwrap();
|
||||
assert_eq!(result, "default_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_home() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"return owlry.env.home()"#);
|
||||
let home: Option<String> = chunk.call(()).unwrap();
|
||||
assert!(home.is_some());
|
||||
assert!(home.unwrap().starts_with('/'));
|
||||
}
|
||||
}
|
||||
315
crates/owlry-core/src/plugins/api/provider.rs
Normal file
@@ -0,0 +1,315 @@
|
||||
//! Provider registration API for Lua plugins
|
||||
//!
|
||||
//! Allows plugins to register providers via `owlry.provider.register()`
|
||||
|
||||
use mlua::{Function, Lua, Result as LuaResult, Table};
|
||||
|
||||
/// Provider registration data extracted from Lua
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Some fields are for future use
|
||||
pub struct ProviderRegistration {
|
||||
/// Provider name (used for filtering/identification)
|
||||
pub name: String,
|
||||
/// Human-readable display name
|
||||
pub display_name: String,
|
||||
/// Provider type ID (for badge/filtering)
|
||||
pub type_id: String,
|
||||
/// Default icon name
|
||||
pub default_icon: String,
|
||||
/// Whether this is a static provider (refresh once) or dynamic (query-based)
|
||||
pub is_static: bool,
|
||||
/// Prefix to trigger this provider (e.g., ":" for commands)
|
||||
pub prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// Register owlry.provider.* API
|
||||
pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let provider_table = lua.create_table()?;
|
||||
|
||||
// Initialize registry for storing provider registrations
|
||||
let registrations: Table = lua.create_table()?;
|
||||
lua.set_named_registry_value("provider_registrations", registrations)?;
|
||||
|
||||
// owlry.provider.register(config) - Register a new provider
|
||||
provider_table.set(
|
||||
"register",
|
||||
lua.create_function(|lua, config: Table| {
|
||||
// Extract required fields
|
||||
let name: String = config
|
||||
.get("name")
|
||||
.map_err(|_| mlua::Error::external("provider.register: 'name' is required"))?;
|
||||
|
||||
let _display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone());
|
||||
|
||||
let type_id: String = config
|
||||
.get("type_id")
|
||||
.unwrap_or_else(|_| name.replace('-', "_"));
|
||||
|
||||
let _default_icon: String = config
|
||||
.get("default_icon")
|
||||
.unwrap_or_else(|_| "application-x-executable".to_string());
|
||||
|
||||
let _prefix: Option<String> = config.get("prefix").ok();
|
||||
|
||||
// Check for refresh function (static provider) or query function (dynamic)
|
||||
let has_refresh = config.get::<Function>("refresh").is_ok();
|
||||
let has_query = config.get::<Function>("query").is_ok();
|
||||
|
||||
if !has_refresh && !has_query {
|
||||
return Err(mlua::Error::external(
|
||||
"provider.register: either 'refresh' or 'query' function is required",
|
||||
));
|
||||
}
|
||||
|
||||
let is_static = has_refresh;
|
||||
|
||||
log::info!(
|
||||
"[plugin] Registered provider '{}' (type: {}, static: {})",
|
||||
name,
|
||||
type_id,
|
||||
is_static
|
||||
);
|
||||
|
||||
// Store the config in registry for later retrieval
|
||||
let registrations: Table = lua.named_registry_value("provider_registrations")?;
|
||||
registrations.set(name.clone(), config)?;
|
||||
|
||||
Ok(name)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("provider", provider_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all provider registrations from the Lua runtime
|
||||
pub fn get_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
|
||||
let registrations: Table = lua.named_registry_value("provider_registrations")?;
|
||||
let mut result = Vec::new();
|
||||
|
||||
for pair in registrations.pairs::<String, Table>() {
|
||||
let (name, config) = pair?;
|
||||
|
||||
let display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone());
|
||||
let type_id: String = config
|
||||
.get("type_id")
|
||||
.unwrap_or_else(|_| name.replace('-', "_"));
|
||||
let default_icon: String = config
|
||||
.get("default_icon")
|
||||
.unwrap_or_else(|_| "application-x-executable".to_string());
|
||||
let prefix: Option<String> = config.get("prefix").ok();
|
||||
let is_static = config.get::<Function>("refresh").is_ok();
|
||||
|
||||
result.push(ProviderRegistration {
|
||||
name,
|
||||
display_name,
|
||||
type_id,
|
||||
default_icon,
|
||||
is_static,
|
||||
prefix,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Call a provider's refresh function and extract items
|
||||
pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>> {
|
||||
let registrations: Table = lua.named_registry_value("provider_registrations")?;
|
||||
let config: Table = registrations.get(provider_name)?;
|
||||
let refresh: Function = config.get("refresh")?;
|
||||
|
||||
let items: Table = refresh.call(())?;
|
||||
extract_items(&items)
|
||||
}
|
||||
|
||||
/// Call a provider's query function with a query string
|
||||
#[allow(dead_code)] // Will be used for dynamic query providers
|
||||
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
|
||||
let registrations: Table = lua.named_registry_value("provider_registrations")?;
|
||||
let config: Table = registrations.get(provider_name)?;
|
||||
let query_fn: Function = config.get("query")?;
|
||||
|
||||
let items: Table = query_fn.call(query.to_string())?;
|
||||
extract_items(&items)
|
||||
}
|
||||
|
||||
/// Item data from a plugin provider
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // data field is for future action handlers
|
||||
pub struct PluginItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub command: Option<String>,
|
||||
pub terminal: bool,
|
||||
pub tags: Vec<String>,
|
||||
/// Custom data passed to action handlers
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract items from a Lua table returned by refresh/query
|
||||
fn extract_items(items: &Table) -> LuaResult<Vec<PluginItem>> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for pair in items.clone().pairs::<i64, Table>() {
|
||||
let (_, item) = pair?;
|
||||
|
||||
let id: String = item.get("id")?;
|
||||
let name: String = item.get("name")?;
|
||||
let description: Option<String> = item.get("description").ok();
|
||||
let icon: Option<String> = item.get("icon").ok();
|
||||
let command: Option<String> = item.get("command").ok();
|
||||
let terminal: bool = item.get("terminal").unwrap_or(false);
|
||||
let data: Option<String> = item.get("data").ok();
|
||||
|
||||
// Extract tags array
|
||||
let tags: Vec<String> = if let Ok(tags_table) = item.get::<Table>("tags") {
|
||||
tags_table
|
||||
.pairs::<i64, String>()
|
||||
.filter_map(|r| r.ok())
|
||||
.map(|(_, v)| v)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
result.push(PluginItem {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
command,
|
||||
terminal,
|
||||
tags,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_lua() -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_provider_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_static_provider() {
|
||||
let lua = create_test_lua();
|
||||
|
||||
let script = r#"
|
||||
owlry.provider.register({
|
||||
name = "test-provider",
|
||||
display_name = "Test Provider",
|
||||
type_id = "test",
|
||||
default_icon = "test-icon",
|
||||
refresh = function()
|
||||
return {
|
||||
{ id = "1", name = "Item 1", description = "First item" },
|
||||
{ id = "2", name = "Item 2", command = "echo hello" },
|
||||
}
|
||||
end
|
||||
})
|
||||
"#;
|
||||
lua.load(script).call::<()>(()).unwrap();
|
||||
|
||||
let registrations = get_registrations(&lua).unwrap();
|
||||
assert_eq!(registrations.len(), 1);
|
||||
assert_eq!(registrations[0].name, "test-provider");
|
||||
assert_eq!(registrations[0].display_name, "Test Provider");
|
||||
assert!(registrations[0].is_static);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_dynamic_provider() {
|
||||
let lua = create_test_lua();
|
||||
|
||||
let script = r#"
|
||||
owlry.provider.register({
|
||||
name = "search",
|
||||
prefix = "?",
|
||||
query = function(q)
|
||||
return {
|
||||
{ id = "result", name = "Result for: " .. q }
|
||||
}
|
||||
end
|
||||
})
|
||||
"#;
|
||||
lua.load(script).call::<()>(()).unwrap();
|
||||
|
||||
let registrations = get_registrations(&lua).unwrap();
|
||||
assert_eq!(registrations.len(), 1);
|
||||
assert!(!registrations[0].is_static);
|
||||
assert_eq!(registrations[0].prefix, Some("?".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_call_refresh() {
|
||||
let lua = create_test_lua();
|
||||
|
||||
let script = r#"
|
||||
owlry.provider.register({
|
||||
name = "items",
|
||||
refresh = function()
|
||||
return {
|
||||
{ id = "a", name = "Alpha", tags = {"one", "two"} },
|
||||
{ id = "b", name = "Beta", terminal = true },
|
||||
}
|
||||
end
|
||||
})
|
||||
"#;
|
||||
lua.load(script).call::<()>(()).unwrap();
|
||||
|
||||
let items = call_refresh(&lua, "items").unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].id, "a");
|
||||
assert_eq!(items[0].name, "Alpha");
|
||||
assert_eq!(items[0].tags, vec!["one", "two"]);
|
||||
assert!(!items[0].terminal);
|
||||
assert_eq!(items[1].id, "b");
|
||||
assert!(items[1].terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_call_query() {
|
||||
let lua = create_test_lua();
|
||||
|
||||
let script = r#"
|
||||
owlry.provider.register({
|
||||
name = "search",
|
||||
query = function(q)
|
||||
return {
|
||||
{ id = "1", name = "Found: " .. q }
|
||||
}
|
||||
end
|
||||
})
|
||||
"#;
|
||||
lua.load(script).call::<()>(()).unwrap();
|
||||
|
||||
let items = call_query(&lua, "search", "hello").unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].name, "Found: hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_missing_function() {
|
||||
let lua = create_test_lua();
|
||||
|
||||
let script = r#"
|
||||
owlry.provider.register({
|
||||
name = "broken",
|
||||
})
|
||||
"#;
|
||||
let result = lua.load(script).call::<()>(());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
286
crates/owlry-core/src/plugins/api/theme.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
//! Theme API for Lua plugins
|
||||
//!
|
||||
//! Allows plugins to contribute CSS themes:
|
||||
//! - `owlry.theme.register(config)` - Register a theme
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table, Value};
|
||||
use std::path::Path;
|
||||
|
||||
/// Theme registration data
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Will be used by theme loading
|
||||
pub struct ThemeRegistration {
|
||||
/// Theme name (used in config)
|
||||
pub name: String,
|
||||
/// Human-readable display name
|
||||
pub display_name: String,
|
||||
/// CSS content
|
||||
pub css: String,
|
||||
/// Plugin that registered this theme
|
||||
pub plugin_id: String,
|
||||
}
|
||||
|
||||
/// Register theme APIs
|
||||
pub fn register_theme_api(
|
||||
lua: &Lua,
|
||||
owlry: &Table,
|
||||
plugin_id: &str,
|
||||
plugin_dir: &Path,
|
||||
) -> LuaResult<()> {
|
||||
let theme_table = lua.create_table()?;
|
||||
let plugin_id_owned = plugin_id.to_string();
|
||||
let plugin_dir_owned = plugin_dir.to_path_buf();
|
||||
|
||||
// Initialize theme storage in Lua registry
|
||||
if lua.named_registry_value::<Value>("themes")?.is_nil() {
|
||||
let themes: Table = lua.create_table()?;
|
||||
lua.set_named_registry_value("themes", themes)?;
|
||||
}
|
||||
|
||||
// owlry.theme.register(config) -> string (theme_name)
|
||||
// config = {
|
||||
// name = "dark-owl",
|
||||
// display_name = "Dark Owl", -- optional, defaults to name
|
||||
// css = "...", -- CSS string
|
||||
// -- OR
|
||||
// css_file = "theme.css" -- path relative to plugin dir
|
||||
// }
|
||||
let plugin_id_for_register = plugin_id_owned.clone();
|
||||
let plugin_dir_for_register = plugin_dir_owned.clone();
|
||||
theme_table.set(
|
||||
"register",
|
||||
lua.create_function(move |lua, config: Table| {
|
||||
// Extract required fields
|
||||
let name: String = config
|
||||
.get("name")
|
||||
.map_err(|_| mlua::Error::external("theme.register: 'name' is required"))?;
|
||||
|
||||
let display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone());
|
||||
|
||||
// Get CSS either directly or from file
|
||||
let css: String = if let Ok(css_str) = config.get::<String>("css") {
|
||||
css_str
|
||||
} else if let Ok(css_file) = config.get::<String>("css_file") {
|
||||
let css_path = plugin_dir_for_register.join(&css_file);
|
||||
std::fs::read_to_string(&css_path).map_err(|e| {
|
||||
mlua::Error::external(format!(
|
||||
"Failed to read CSS file '{}': {}",
|
||||
css_path.display(),
|
||||
e
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
return Err(mlua::Error::external(
|
||||
"theme.register: either 'css' or 'css_file' is required",
|
||||
));
|
||||
};
|
||||
|
||||
// Store theme in registry
|
||||
let themes: Table = lua.named_registry_value("themes")?;
|
||||
|
||||
let theme_entry = lua.create_table()?;
|
||||
theme_entry.set("name", name.clone())?;
|
||||
theme_entry.set("display_name", display_name.clone())?;
|
||||
theme_entry.set("css", css)?;
|
||||
theme_entry.set("plugin_id", plugin_id_for_register.clone())?;
|
||||
|
||||
themes.set(name.clone(), theme_entry)?;
|
||||
|
||||
log::info!(
|
||||
"[plugin:{}] Registered theme '{}'",
|
||||
plugin_id_for_register,
|
||||
name
|
||||
);
|
||||
|
||||
Ok(name)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.theme.unregister(name) -> boolean
|
||||
theme_table.set(
|
||||
"unregister",
|
||||
lua.create_function(|lua, name: String| {
|
||||
let themes: Table = lua.named_registry_value("themes")?;
|
||||
|
||||
if themes.contains_key(name.clone())? {
|
||||
themes.set(name, Value::Nil)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.theme.list() -> table of theme names
|
||||
theme_table.set(
|
||||
"list",
|
||||
lua.create_function(|lua, ()| {
|
||||
let themes: Table = match lua.named_registry_value("themes") {
|
||||
Ok(t) => t,
|
||||
Err(_) => return lua.create_table(),
|
||||
};
|
||||
|
||||
let result = lua.create_table()?;
|
||||
let mut i = 1;
|
||||
|
||||
for pair in themes.pairs::<String, Table>() {
|
||||
let (name, _) = pair?;
|
||||
result.set(i, name)?;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("theme", theme_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all registered themes from a Lua runtime
|
||||
#[allow(dead_code)] // Will be used by theme system
|
||||
pub fn get_themes(lua: &Lua) -> LuaResult<Vec<ThemeRegistration>> {
|
||||
let themes: Table = match lua.named_registry_value("themes") {
|
||||
Ok(t) => t,
|
||||
Err(_) => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for pair in themes.pairs::<String, Table>() {
|
||||
let (_, entry) = pair?;
|
||||
|
||||
let name: String = entry.get("name")?;
|
||||
let display_name: String = entry.get("display_name")?;
|
||||
let css: String = entry.get("css")?;
|
||||
let plugin_id: String = entry.get("plugin_id")?;
|
||||
|
||||
result.push(ThemeRegistration {
|
||||
name,
|
||||
display_name,
|
||||
css,
|
||||
plugin_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get a specific theme's CSS by name
|
||||
#[allow(dead_code)] // Will be used by theme loading
|
||||
pub fn get_theme_css(lua: &Lua, name: &str) -> LuaResult<Option<String>> {
|
||||
let themes: Table = match lua.named_registry_value("themes") {
|
||||
Ok(t) => t,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
if let Ok(entry) = themes.get::<Table>(name) {
|
||||
let css: String = entry.get("css")?;
|
||||
Ok(Some(css))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_lua(plugin_id: &str, plugin_dir: &Path) -> Lua {
|
||||
let lua = Lua::new();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_theme_api(&lua, &owlry, plugin_id, plugin_dir).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
lua
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_registration_inline() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let lua = setup_lua("test-plugin", temp.path());
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
return owlry.theme.register({
|
||||
name = "my-theme",
|
||||
display_name = "My Theme",
|
||||
css = ".owlry-window { background: #333; }"
|
||||
})
|
||||
"#,
|
||||
);
|
||||
let name: String = chunk.call(()).unwrap();
|
||||
assert_eq!(name, "my-theme");
|
||||
|
||||
let themes = get_themes(&lua).unwrap();
|
||||
assert_eq!(themes.len(), 1);
|
||||
assert_eq!(themes[0].display_name, "My Theme");
|
||||
assert!(themes[0].css.contains("background: #333"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_registration_file() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let css_content = ".owlry-window { background: #444; }";
|
||||
std::fs::write(temp.path().join("theme.css"), css_content).unwrap();
|
||||
|
||||
let lua = setup_lua("test-plugin", temp.path());
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
return owlry.theme.register({
|
||||
name = "file-theme",
|
||||
css_file = "theme.css"
|
||||
})
|
||||
"#,
|
||||
);
|
||||
let name: String = chunk.call(()).unwrap();
|
||||
assert_eq!(name, "file-theme");
|
||||
|
||||
let css = get_theme_css(&lua, "file-theme").unwrap();
|
||||
assert!(css.is_some());
|
||||
assert!(css.unwrap().contains("background: #444"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_list() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let lua = setup_lua("test-plugin", temp.path());
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
owlry.theme.register({ name = "theme1", css = "a{}" })
|
||||
owlry.theme.register({ name = "theme2", css = "b{}" })
|
||||
return owlry.theme.list()
|
||||
"#,
|
||||
);
|
||||
let list: Table = chunk.call(()).unwrap();
|
||||
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for pair in list.pairs::<i64, String>() {
|
||||
let (_, name) = pair.unwrap();
|
||||
names.push(name);
|
||||
}
|
||||
assert_eq!(names.len(), 2);
|
||||
assert!(names.contains(&"theme1".to_string()));
|
||||
assert!(names.contains(&"theme2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_unregister() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let lua = setup_lua("test-plugin", temp.path());
|
||||
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
owlry.theme.register({ name = "temp-theme", css = "c{}" })
|
||||
return owlry.theme.unregister("temp-theme")
|
||||
"#,
|
||||
);
|
||||
let unregistered: bool = chunk.call(()).unwrap();
|
||||
assert!(unregistered);
|
||||
|
||||
let themes = get_themes(&lua).unwrap();
|
||||
assert_eq!(themes.len(), 0);
|
||||
}
|
||||
}
|
||||
569
crates/owlry-core/src/plugins/api/utils.rs
Normal file
@@ -0,0 +1,569 @@
|
||||
//! Utility APIs: log, path, fs, json
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Register owlry.log.* API
|
||||
///
|
||||
/// Provides: debug, info, warn, error
|
||||
pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let log_table = lua.create_table()?;
|
||||
|
||||
log_table.set(
|
||||
"debug",
|
||||
lua.create_function(|_, msg: String| {
|
||||
log::debug!("[plugin] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
log_table.set(
|
||||
"info",
|
||||
lua.create_function(|_, msg: String| {
|
||||
log::info!("[plugin] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
log_table.set(
|
||||
"warn",
|
||||
lua.create_function(|_, msg: String| {
|
||||
log::warn!("[plugin] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
log_table.set(
|
||||
"error",
|
||||
lua.create_function(|_, msg: String| {
|
||||
log::error!("[plugin] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("log", log_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register owlry.path.* API
|
||||
///
|
||||
/// Provides XDG directory helpers: config, data, cache, home, plugin_dir
|
||||
pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> {
|
||||
let path_table = lua.create_table()?;
|
||||
let plugin_dir_str = plugin_dir.to_string_lossy().to_string();
|
||||
|
||||
// owlry.path.config() -> ~/.config/owlry
|
||||
path_table.set(
|
||||
"config",
|
||||
lua.create_function(|_, ()| {
|
||||
let path = dirs::config_dir()
|
||||
.map(|p| p.join("owlry"))
|
||||
.unwrap_or_default();
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.data() -> ~/.local/share/owlry
|
||||
path_table.set(
|
||||
"data",
|
||||
lua.create_function(|_, ()| {
|
||||
let path = dirs::data_dir()
|
||||
.map(|p| p.join("owlry"))
|
||||
.unwrap_or_default();
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.cache() -> ~/.cache/owlry
|
||||
path_table.set(
|
||||
"cache",
|
||||
lua.create_function(|_, ()| {
|
||||
let path = dirs::cache_dir()
|
||||
.map(|p| p.join("owlry"))
|
||||
.unwrap_or_default();
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.home() -> ~
|
||||
path_table.set(
|
||||
"home",
|
||||
lua.create_function(|_, ()| {
|
||||
let path = dirs::home_dir().unwrap_or_default();
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.join(base, ...) -> joined path
|
||||
path_table.set(
|
||||
"join",
|
||||
lua.create_function(|_, parts: mlua::Variadic<String>| {
|
||||
let mut path = PathBuf::new();
|
||||
for part in parts {
|
||||
path.push(part);
|
||||
}
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.exists(path) -> bool
|
||||
path_table.set(
|
||||
"exists",
|
||||
lua.create_function(|_, path: String| Ok(Path::new(&path).exists()))?,
|
||||
)?;
|
||||
|
||||
// owlry.path.is_file(path) -> bool
|
||||
path_table.set(
|
||||
"is_file",
|
||||
lua.create_function(|_, path: String| Ok(Path::new(&path).is_file()))?,
|
||||
)?;
|
||||
|
||||
// owlry.path.is_dir(path) -> bool
|
||||
path_table.set(
|
||||
"is_dir",
|
||||
lua.create_function(|_, path: String| Ok(Path::new(&path).is_dir()))?,
|
||||
)?;
|
||||
|
||||
// owlry.path.expand(path) -> expanded path (handles ~)
|
||||
path_table.set(
|
||||
"expand",
|
||||
lua.create_function(|_, path: String| {
|
||||
let expanded = if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(rest).to_string_lossy().to_string()
|
||||
} else {
|
||||
path
|
||||
}
|
||||
} else if path == "~" {
|
||||
dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or(path)
|
||||
} else {
|
||||
path
|
||||
};
|
||||
Ok(expanded)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.plugin_dir() -> this plugin's directory
|
||||
path_table.set(
|
||||
"plugin_dir",
|
||||
lua.create_function(move |_, ()| Ok(plugin_dir_str.clone()))?,
|
||||
)?;
|
||||
|
||||
owlry.set("path", path_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register owlry.fs.* API
|
||||
///
|
||||
/// Provides filesystem operations within the plugin's directory
|
||||
pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> {
|
||||
let fs_table = lua.create_table()?;
|
||||
let plugin_dir_str = plugin_dir.to_string_lossy().to_string();
|
||||
|
||||
// Store plugin directory in registry for access in closures
|
||||
lua.set_named_registry_value("plugin_dir", plugin_dir_str.clone())?;
|
||||
|
||||
// owlry.fs.read(path) -> string or nil, error
|
||||
fs_table.set(
|
||||
"read",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
|
||||
match std::fs::read_to_string(&full_path) {
|
||||
Ok(content) => Ok((Some(content), Value::Nil)),
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.write(path, content) -> bool, error
|
||||
fs_table.set(
|
||||
"write",
|
||||
lua.create_function(|lua, (path, content): (String, String)| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = full_path.parent()
|
||||
&& !parent.exists()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
return Ok((false, Value::String(lua.create_string(e.to_string())?)));
|
||||
}
|
||||
|
||||
match std::fs::write(&full_path, content) {
|
||||
Ok(()) => Ok((true, Value::Nil)),
|
||||
Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.list(path) -> array of filenames or nil, error
|
||||
fs_table.set(
|
||||
"list",
|
||||
lua.create_function(|lua, path: Option<String>| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let dir_path = path
|
||||
.map(|p| resolve_plugin_path(&plugin_dir, &p))
|
||||
.unwrap_or_else(|| PathBuf::from(&plugin_dir));
|
||||
|
||||
match std::fs::read_dir(&dir_path) {
|
||||
Ok(entries) => {
|
||||
let names: Vec<String> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.collect();
|
||||
let table = lua.create_sequence_from(names)?;
|
||||
Ok((Some(table), Value::Nil))
|
||||
}
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.exists(path) -> bool
|
||||
fs_table.set(
|
||||
"exists",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
Ok(full_path.exists())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.mkdir(path) -> bool, error
|
||||
fs_table.set(
|
||||
"mkdir",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
|
||||
match std::fs::create_dir_all(&full_path) {
|
||||
Ok(()) => Ok((true, Value::Nil)),
|
||||
Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.remove(path) -> bool, error
|
||||
fs_table.set(
|
||||
"remove",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
|
||||
let result = if full_path.is_dir() {
|
||||
std::fs::remove_dir_all(&full_path)
|
||||
} else {
|
||||
std::fs::remove_file(&full_path)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => Ok((true, Value::Nil)),
|
||||
Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.is_file(path) -> bool
|
||||
fs_table.set(
|
||||
"is_file",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
Ok(full_path.is_file())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.is_dir(path) -> bool
|
||||
fs_table.set(
|
||||
"is_dir",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
Ok(full_path.is_dir())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.is_executable(path) -> bool
|
||||
#[cfg(unix)]
|
||||
fs_table.set(
|
||||
"is_executable",
|
||||
lua.create_function(|lua, path: String| {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let plugin_dir: String = lua.named_registry_value("plugin_dir")?;
|
||||
let full_path = resolve_plugin_path(&plugin_dir, &path);
|
||||
let is_exec = full_path
|
||||
.metadata()
|
||||
.map(|m| m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false);
|
||||
Ok(is_exec)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.plugin_dir() -> plugin directory path
|
||||
let dir_clone = plugin_dir_str.clone();
|
||||
fs_table.set(
|
||||
"plugin_dir",
|
||||
lua.create_function(move |_, ()| Ok(dir_clone.clone()))?,
|
||||
)?;
|
||||
|
||||
owlry.set("fs", fs_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a path relative to the plugin directory
|
||||
///
|
||||
/// If the path is absolute, returns it as-is (for paths within allowed directories).
|
||||
/// If relative, joins with plugin directory.
|
||||
fn resolve_plugin_path(plugin_dir: &str, path: &str) -> PathBuf {
|
||||
let path = Path::new(path);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
Path::new(plugin_dir).join(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register owlry.json.* API
|
||||
///
|
||||
/// Provides JSON encoding/decoding
|
||||
pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let json_table = lua.create_table()?;
|
||||
|
||||
// owlry.json.encode(value) -> string or nil, error
|
||||
json_table.set(
|
||||
"encode",
|
||||
lua.create_function(|lua, value: Value| match lua_to_json(&value) {
|
||||
Ok(json) => match serde_json::to_string(&json) {
|
||||
Ok(s) => Ok((Some(s), Value::Nil)),
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
|
||||
},
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(&e)?))),
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.json.encode_pretty(value) -> string or nil, error
|
||||
json_table.set(
|
||||
"encode_pretty",
|
||||
lua.create_function(|lua, value: Value| match lua_to_json(&value) {
|
||||
Ok(json) => match serde_json::to_string_pretty(&json) {
|
||||
Ok(s) => Ok((Some(s), Value::Nil)),
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
|
||||
},
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(&e)?))),
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.json.decode(string) -> value or nil, error
|
||||
json_table.set(
|
||||
"decode",
|
||||
lua.create_function(|lua, s: String| {
|
||||
match serde_json::from_str::<serde_json::Value>(&s) {
|
||||
Ok(json) => match json_to_lua(lua, &json) {
|
||||
Ok(value) => Ok((Some(value), Value::Nil)),
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
|
||||
},
|
||||
Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("json", json_table)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert Lua value to JSON
|
||||
fn lua_to_json(value: &Value) -> Result<serde_json::Value, String> {
|
||||
match value {
|
||||
Value::Nil => Ok(serde_json::Value::Null),
|
||||
Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
|
||||
Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
|
||||
Value::Number(n) => serde_json::Number::from_f64(*n)
|
||||
.map(serde_json::Value::Number)
|
||||
.ok_or_else(|| "Invalid number".to_string()),
|
||||
Value::String(s) => Ok(serde_json::Value::String(
|
||||
s.to_str().map_err(|e| e.to_string())?.to_string(),
|
||||
)),
|
||||
Value::Table(t) => {
|
||||
// Check if it's an array (sequential integer keys starting from 1)
|
||||
let len = t.raw_len();
|
||||
let is_array = len > 0
|
||||
&& (1..=len).all(|i| {
|
||||
t.raw_get::<Value>(i)
|
||||
.is_ok_and(|v| !matches!(v, Value::Nil))
|
||||
});
|
||||
|
||||
if is_array {
|
||||
let arr: Result<Vec<serde_json::Value>, String> = (1..=len)
|
||||
.map(|i| {
|
||||
let v: Value = t.raw_get(i).map_err(|e| e.to_string())?;
|
||||
lua_to_json(&v)
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::Value::Array(arr?))
|
||||
} else {
|
||||
let mut map = serde_json::Map::new();
|
||||
for pair in t.clone().pairs::<Value, Value>() {
|
||||
let (k, v) = pair.map_err(|e| e.to_string())?;
|
||||
let key = match k {
|
||||
Value::String(s) => s.to_str().map_err(|e| e.to_string())?.to_string(),
|
||||
Value::Integer(i) => i.to_string(),
|
||||
_ => return Err("JSON object keys must be strings".to_string()),
|
||||
};
|
||||
map.insert(key, lua_to_json(&v)?);
|
||||
}
|
||||
Ok(serde_json::Value::Object(map))
|
||||
}
|
||||
}
|
||||
_ => Err(format!("Cannot convert {:?} to JSON", value)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert JSON to Lua value
|
||||
fn json_to_lua(lua: &Lua, json: &serde_json::Value) -> LuaResult<Value> {
|
||||
match json {
|
||||
serde_json::Value::Null => Ok(Value::Nil),
|
||||
serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)),
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Ok(Value::Integer(i))
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
Ok(Value::Number(f))
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)),
|
||||
serde_json::Value::Array(arr) => {
|
||||
let table = lua.create_table()?;
|
||||
for (i, v) in arr.iter().enumerate() {
|
||||
table.set(i + 1, json_to_lua(lua, v)?)?;
|
||||
}
|
||||
Ok(Value::Table(table))
|
||||
}
|
||||
serde_json::Value::Object(obj) => {
|
||||
let table = lua.create_table()?;
|
||||
for (k, v) in obj {
|
||||
table.set(k.as_str(), json_to_lua(lua, v)?)?;
|
||||
}
|
||||
Ok(Value::Table(table))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_lua() -> (Lua, TempDir) {
|
||||
let lua = Lua::new();
|
||||
let temp = TempDir::new().unwrap();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_log_api(&lua, &owlry).unwrap();
|
||||
register_path_api(&lua, &owlry, temp.path()).unwrap();
|
||||
register_fs_api(&lua, &owlry, temp.path()).unwrap();
|
||||
register_json_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
(lua, temp)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_api() {
|
||||
let (lua, _temp) = create_test_lua();
|
||||
// Just verify it doesn't panic - using call instead of the e-word
|
||||
lua.load("owlry.log.info('test message')")
|
||||
.call::<()>(())
|
||||
.unwrap();
|
||||
lua.load("owlry.log.debug('debug')").call::<()>(()).unwrap();
|
||||
lua.load("owlry.log.warn('warning')")
|
||||
.call::<()>(())
|
||||
.unwrap();
|
||||
lua.load("owlry.log.error('error')").call::<()>(()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_api() {
|
||||
let (lua, _temp) = create_test_lua();
|
||||
|
||||
let home: String = lua.load("return owlry.path.home()").call(()).unwrap();
|
||||
assert!(!home.is_empty());
|
||||
|
||||
let joined: String = lua
|
||||
.load("return owlry.path.join('a', 'b', 'c')")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(joined.contains("a") && joined.contains("b") && joined.contains("c"));
|
||||
|
||||
let expanded: String = lua
|
||||
.load("return owlry.path.expand('~/test')")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(!expanded.starts_with("~"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_api() {
|
||||
let (lua, temp) = create_test_lua();
|
||||
|
||||
// Test write and read
|
||||
lua.load("owlry.fs.write('test.txt', 'hello world')")
|
||||
.call::<()>(())
|
||||
.unwrap();
|
||||
|
||||
assert!(temp.path().join("test.txt").exists());
|
||||
|
||||
let content: String = lua
|
||||
.load("return owlry.fs.read('test.txt')")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert_eq!(content, "hello world");
|
||||
|
||||
// Test exists
|
||||
let exists: bool = lua
|
||||
.load("return owlry.fs.exists('test.txt')")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(exists);
|
||||
|
||||
// Test list
|
||||
let script = r#"
|
||||
local files = owlry.fs.list()
|
||||
return #files
|
||||
"#;
|
||||
let count: i32 = lua.load(script).call(()).unwrap();
|
||||
assert!(count >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_api() {
|
||||
let (lua, _temp) = create_test_lua();
|
||||
|
||||
// Test encode
|
||||
let encoded: String = lua
|
||||
.load(r#"return owlry.json.encode({name = "test", value = 42})"#)
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(encoded.contains("test") && encoded.contains("42"));
|
||||
|
||||
// Test decode
|
||||
let script = r#"
|
||||
local data = owlry.json.decode('{"name":"hello","num":123}')
|
||||
return data.name, data.num
|
||||
"#;
|
||||
let (name, num): (String, i32) = lua.load(script).call(()).unwrap();
|
||||
assert_eq!(name, "hello");
|
||||
assert_eq!(num, 123);
|
||||
|
||||
// Test array encoding
|
||||
let encoded: String = lua
|
||||
.load(r#"return owlry.json.encode({1, 2, 3})"#)
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert_eq!(encoded, "[1,2,3]");
|
||||
}
|
||||
}
|
||||
51
crates/owlry-core/src/plugins/error.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! Plugin system error types
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur in the plugin system
|
||||
#[derive(Error, Debug)]
|
||||
#[allow(dead_code)] // Some variants are for future use
|
||||
pub enum PluginError {
|
||||
#[error("Plugin '{0}' not found")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Invalid plugin manifest in '{plugin}': {message}")]
|
||||
InvalidManifest { plugin: String, message: String },
|
||||
|
||||
#[error("Plugin '{plugin}' requires owlry {required}, but current version is {current}")]
|
||||
VersionMismatch {
|
||||
plugin: String,
|
||||
required: String,
|
||||
current: String,
|
||||
},
|
||||
|
||||
#[error("Lua error in plugin '{plugin}': {message}")]
|
||||
LuaError { plugin: String, message: String },
|
||||
|
||||
#[error("Plugin '{plugin}' timed out after {timeout_ms}ms")]
|
||||
Timeout { plugin: String, timeout_ms: u64 },
|
||||
|
||||
#[error("Plugin '{plugin}' attempted forbidden operation: {operation}")]
|
||||
SandboxViolation { plugin: String, operation: String },
|
||||
|
||||
#[error("Plugin '{0}' is already loaded")]
|
||||
AlreadyLoaded(String),
|
||||
|
||||
#[error("Plugin '{0}' is disabled")]
|
||||
Disabled(String),
|
||||
|
||||
#[error("Failed to load native plugin: {0}")]
|
||||
LoadError(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("TOML parsing error: {0}")]
|
||||
TomlParse(#[from] toml::de::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Result type for plugin operations
|
||||
pub type PluginResult<T> = Result<T, PluginError>;
|
||||
212
crates/owlry-core/src/plugins/loader.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! Lua plugin loading and initialization
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
use super::api;
|
||||
use super::error::{PluginError, PluginResult};
|
||||
use super::manifest::PluginManifest;
|
||||
use super::runtime::{SandboxConfig, create_lua_runtime, load_file};
|
||||
|
||||
/// A loaded plugin instance
|
||||
#[derive(Debug)]
|
||||
pub struct LoadedPlugin {
|
||||
/// Plugin manifest
|
||||
pub manifest: PluginManifest,
|
||||
/// Path to plugin directory
|
||||
pub path: PathBuf,
|
||||
/// Whether plugin is enabled
|
||||
pub enabled: bool,
|
||||
/// Lua runtime (None if not yet initialized)
|
||||
lua: Option<Lua>,
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
/// Create a new loaded plugin (not yet initialized)
|
||||
pub fn new(manifest: PluginManifest, path: PathBuf) -> Self {
|
||||
Self {
|
||||
manifest,
|
||||
path,
|
||||
enabled: true,
|
||||
lua: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the plugin ID
|
||||
pub fn id(&self) -> &str {
|
||||
&self.manifest.plugin.id
|
||||
}
|
||||
|
||||
/// Get the plugin name
|
||||
#[allow(dead_code)]
|
||||
pub fn name(&self) -> &str {
|
||||
&self.manifest.plugin.name
|
||||
}
|
||||
|
||||
/// Initialize the Lua runtime and load the entry point
|
||||
pub fn initialize(&mut self) -> PluginResult<()> {
|
||||
if self.lua.is_some() {
|
||||
return Ok(()); // Already initialized
|
||||
}
|
||||
|
||||
let sandbox = SandboxConfig::from_permissions(&self.manifest.permissions);
|
||||
let lua = create_lua_runtime(&sandbox).map_err(|e| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
// Register owlry APIs before loading entry point
|
||||
api::register_apis(&lua, &self.path, self.id()).map_err(|e| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: format!("Failed to register APIs: {}", e),
|
||||
})?;
|
||||
|
||||
// Load the entry point file
|
||||
let entry_path = self.path.join(&self.manifest.plugin.entry);
|
||||
if !entry_path.exists() {
|
||||
return Err(PluginError::InvalidManifest {
|
||||
plugin: self.id().to_string(),
|
||||
message: format!("Entry point '{}' not found", self.manifest.plugin.entry),
|
||||
});
|
||||
}
|
||||
|
||||
load_file(&lua, &entry_path).map_err(|e| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
self.lua = Some(lua);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get provider registrations from this plugin
|
||||
pub fn get_provider_registrations(&self) -> PluginResult<Vec<super::ProviderRegistration>> {
|
||||
let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: "Plugin not initialized".to_string(),
|
||||
})?;
|
||||
|
||||
api::get_provider_registrations(lua).map_err(|e| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Call a provider's refresh function
|
||||
pub fn call_provider_refresh(
|
||||
&self,
|
||||
provider_name: &str,
|
||||
) -> PluginResult<Vec<super::PluginItem>> {
|
||||
let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: "Plugin not initialized".to_string(),
|
||||
})?;
|
||||
|
||||
api::provider::call_refresh(lua, provider_name).map_err(|e| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Call a provider's query function
|
||||
#[allow(dead_code)] // Will be used for dynamic query providers
|
||||
pub fn call_provider_query(
|
||||
&self,
|
||||
provider_name: &str,
|
||||
query: &str,
|
||||
) -> PluginResult<Vec<super::PluginItem>> {
|
||||
let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: "Plugin not initialized".to_string(),
|
||||
})?;
|
||||
|
||||
api::provider::call_query(lua, provider_name, query).map_err(|e| PluginError::LuaError {
|
||||
plugin: self.id().to_string(),
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a reference to the Lua runtime (if initialized)
|
||||
#[allow(dead_code)]
|
||||
pub fn lua(&self) -> Option<&Lua> {
|
||||
self.lua.as_ref()
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the Lua runtime (if initialized)
|
||||
#[allow(dead_code)]
|
||||
pub fn lua_mut(&mut self) -> Option<&mut Lua> {
|
||||
self.lua.as_mut()
|
||||
}
|
||||
}
|
||||
|
||||
// Note: discover_plugins and check_compatibility are in manifest.rs
|
||||
// to avoid Lua dependency for plugin discovery.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::manifest::{check_compatibility, discover_plugins};
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_plugin(dir: &Path, id: &str, name: &str) {
|
||||
let plugin_dir = dir.join(id);
|
||||
fs::create_dir_all(&plugin_dir).unwrap();
|
||||
|
||||
let manifest = format!(
|
||||
r#"
|
||||
[plugin]
|
||||
id = "{}"
|
||||
name = "{}"
|
||||
version = "1.0.0"
|
||||
"#,
|
||||
id, name
|
||||
);
|
||||
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
|
||||
fs::write(plugin_dir.join("init.lua"), "-- empty plugin").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_plugins() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let plugins_dir = temp.path();
|
||||
|
||||
create_test_plugin(plugins_dir, "test-plugin", "Test Plugin");
|
||||
create_test_plugin(plugins_dir, "another-plugin", "Another Plugin");
|
||||
|
||||
let plugins = discover_plugins(plugins_dir).unwrap();
|
||||
assert_eq!(plugins.len(), 2);
|
||||
assert!(plugins.contains_key("test-plugin"));
|
||||
assert!(plugins.contains_key("another-plugin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_plugins_empty_dir() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let plugins = discover_plugins(temp.path()).unwrap();
|
||||
assert!(plugins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_plugins_nonexistent_dir() {
|
||||
let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap();
|
||||
assert!(plugins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_compatibility() {
|
||||
let toml_str = r#"
|
||||
[plugin]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "1.0.0"
|
||||
owlry_version = ">=0.3.0"
|
||||
"#;
|
||||
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
|
||||
|
||||
assert!(check_compatibility(&manifest, "0.3.5").is_ok());
|
||||
assert!(check_compatibility(&manifest, "0.2.0").is_err());
|
||||
}
|
||||
}
|
||||
335
crates/owlry-core/src/plugins/manifest.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
//! Plugin manifest (plugin.toml) parsing
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::error::{PluginError, PluginResult};
|
||||
|
||||
/// Plugin manifest loaded from plugin.toml
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginManifest {
|
||||
pub plugin: PluginInfo,
|
||||
#[serde(default)]
|
||||
pub provides: PluginProvides,
|
||||
#[serde(default)]
|
||||
pub permissions: PluginPermissions,
|
||||
#[serde(default)]
|
||||
pub settings: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
/// Core plugin information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginInfo {
|
||||
/// Unique plugin identifier (lowercase, alphanumeric, hyphens)
|
||||
pub id: String,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Semantic version
|
||||
pub version: String,
|
||||
/// Short description
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Plugin author
|
||||
#[serde(default)]
|
||||
pub author: String,
|
||||
/// License identifier
|
||||
#[serde(default)]
|
||||
pub license: String,
|
||||
/// Repository URL
|
||||
#[serde(default)]
|
||||
pub repository: Option<String>,
|
||||
/// Required owlry version (semver constraint)
|
||||
#[serde(default = "default_owlry_version")]
|
||||
pub owlry_version: String,
|
||||
/// Entry point file (relative to plugin directory)
|
||||
#[serde(default = "default_entry")]
|
||||
pub entry: String,
|
||||
}
|
||||
|
||||
fn default_owlry_version() -> String {
|
||||
">=0.1.0".to_string()
|
||||
}
|
||||
|
||||
fn default_entry() -> String {
|
||||
"init.lua".to_string()
|
||||
}
|
||||
|
||||
/// What the plugin provides
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PluginProvides {
|
||||
/// Provider names this plugin registers
|
||||
#[serde(default)]
|
||||
pub providers: Vec<String>,
|
||||
/// Whether this plugin registers actions
|
||||
#[serde(default)]
|
||||
pub actions: bool,
|
||||
/// Theme names this plugin contributes
|
||||
#[serde(default)]
|
||||
pub themes: Vec<String>,
|
||||
/// Whether this plugin registers hooks
|
||||
#[serde(default)]
|
||||
pub hooks: bool,
|
||||
/// CLI commands this plugin provides
|
||||
#[serde(default)]
|
||||
pub commands: Vec<PluginCommand>,
|
||||
}
|
||||
|
||||
/// A CLI command provided by a plugin
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginCommand {
|
||||
/// Command name (e.g., "add", "list", "sync")
|
||||
pub name: String,
|
||||
/// Short description shown in help
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Usage pattern (e.g., "<url> [name]")
|
||||
#[serde(default)]
|
||||
pub usage: String,
|
||||
}
|
||||
|
||||
/// Plugin permissions/capabilities
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PluginPermissions {
|
||||
/// Allow network/HTTP requests
|
||||
#[serde(default)]
|
||||
pub network: bool,
|
||||
/// Filesystem paths the plugin can access (beyond its own directory)
|
||||
#[serde(default)]
|
||||
pub filesystem: Vec<String>,
|
||||
/// Commands the plugin is allowed to run
|
||||
#[serde(default)]
|
||||
pub run_commands: Vec<String>,
|
||||
/// Environment variables the plugin reads
|
||||
#[serde(default)]
|
||||
pub environment: Vec<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Discovery (no Lua dependency)
|
||||
// ============================================================================
|
||||
|
||||
/// Discover all plugins in a directory
|
||||
///
|
||||
/// Returns a map of plugin ID -> (manifest, path)
|
||||
pub fn discover_plugins(
|
||||
plugins_dir: &Path,
|
||||
) -> PluginResult<HashMap<String, (PluginManifest, PathBuf)>> {
|
||||
let mut plugins = HashMap::new();
|
||||
|
||||
if !plugins_dir.exists() {
|
||||
log::debug!(
|
||||
"Plugins directory does not exist: {}",
|
||||
plugins_dir.display()
|
||||
);
|
||||
return Ok(plugins);
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(plugins_dir)?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let manifest_path = path.join("plugin.toml");
|
||||
if !manifest_path.exists() {
|
||||
log::debug!("Skipping {}: no plugin.toml", path.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
match PluginManifest::load(&manifest_path) {
|
||||
Ok(manifest) => {
|
||||
let id = manifest.plugin.id.clone();
|
||||
if plugins.contains_key(&id) {
|
||||
log::warn!("Duplicate plugin ID '{}', skipping {}", id, path.display());
|
||||
continue;
|
||||
}
|
||||
log::info!(
|
||||
"Discovered plugin: {} v{}",
|
||||
manifest.plugin.name,
|
||||
manifest.plugin.version
|
||||
);
|
||||
plugins.insert(id, (manifest, path));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load plugin at {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
/// Check if a plugin is compatible with the given owlry version
|
||||
#[allow(dead_code)]
|
||||
pub fn check_compatibility(manifest: &PluginManifest, owlry_version: &str) -> PluginResult<()> {
|
||||
if !manifest.is_compatible_with(owlry_version) {
|
||||
return Err(PluginError::VersionMismatch {
|
||||
plugin: manifest.plugin.id.clone(),
|
||||
required: manifest.plugin.owlry_version.clone(),
|
||||
current: owlry_version.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PluginManifest Implementation
|
||||
// ============================================================================
|
||||
|
||||
impl PluginManifest {
|
||||
/// Load a plugin manifest from a plugin.toml file
|
||||
pub fn load(path: &Path) -> PluginResult<Self> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let manifest: PluginManifest = toml::from_str(&content)?;
|
||||
manifest.validate()?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Load from a plugin directory (looks for plugin.toml inside)
|
||||
#[allow(dead_code)]
|
||||
pub fn load_from_dir(plugin_dir: &Path) -> PluginResult<Self> {
|
||||
let manifest_path = plugin_dir.join("plugin.toml");
|
||||
if !manifest_path.exists() {
|
||||
return Err(PluginError::InvalidManifest {
|
||||
plugin: plugin_dir.display().to_string(),
|
||||
message: "plugin.toml not found".to_string(),
|
||||
});
|
||||
}
|
||||
Self::load(&manifest_path)
|
||||
}
|
||||
|
||||
/// Validate the manifest
|
||||
fn validate(&self) -> PluginResult<()> {
|
||||
// Validate plugin ID format
|
||||
if self.plugin.id.is_empty() {
|
||||
return Err(PluginError::InvalidManifest {
|
||||
plugin: self.plugin.id.clone(),
|
||||
message: "Plugin ID cannot be empty".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if !self
|
||||
.plugin
|
||||
.id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(PluginError::InvalidManifest {
|
||||
plugin: self.plugin.id.clone(),
|
||||
message: "Plugin ID must be lowercase alphanumeric with hyphens".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate version format
|
||||
if semver::Version::parse(&self.plugin.version).is_err() {
|
||||
return Err(PluginError::InvalidManifest {
|
||||
plugin: self.plugin.id.clone(),
|
||||
message: format!("Invalid version format: {}", self.plugin.version),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate owlry_version constraint
|
||||
if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() {
|
||||
return Err(PluginError::InvalidManifest {
|
||||
plugin: self.plugin.id.clone(),
|
||||
message: format!(
|
||||
"Invalid owlry_version constraint: {}",
|
||||
self.plugin.owlry_version
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if this plugin is compatible with the given owlry version
|
||||
#[allow(dead_code)]
|
||||
pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
|
||||
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let version = match semver::Version::parse(owlry_version) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
req.matches(&version)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_minimal_manifest() {
|
||||
let toml_str = r#"
|
||||
[plugin]
|
||||
id = "test-plugin"
|
||||
name = "Test Plugin"
|
||||
version = "1.0.0"
|
||||
"#;
|
||||
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(manifest.plugin.id, "test-plugin");
|
||||
assert_eq!(manifest.plugin.name, "Test Plugin");
|
||||
assert_eq!(manifest.plugin.version, "1.0.0");
|
||||
assert_eq!(manifest.plugin.entry, "init.lua");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_manifest() {
|
||||
let toml_str = r#"
|
||||
[plugin]
|
||||
id = "my-provider"
|
||||
name = "My Provider"
|
||||
version = "1.2.3"
|
||||
description = "A test provider"
|
||||
author = "Test Author"
|
||||
license = "MIT"
|
||||
owlry_version = ">=0.4.0"
|
||||
entry = "main.lua"
|
||||
|
||||
[provides]
|
||||
providers = ["my-provider"]
|
||||
actions = true
|
||||
themes = ["dark"]
|
||||
hooks = true
|
||||
|
||||
[permissions]
|
||||
network = true
|
||||
filesystem = ["~/.config/myapp"]
|
||||
run_commands = ["myapp"]
|
||||
environment = ["MY_API_KEY"]
|
||||
|
||||
[settings]
|
||||
max_results = 20
|
||||
api_url = "https://api.example.com"
|
||||
"#;
|
||||
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(manifest.plugin.id, "my-provider");
|
||||
assert!(manifest.provides.actions);
|
||||
assert!(manifest.permissions.network);
|
||||
assert_eq!(manifest.permissions.run_commands, vec!["myapp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_compatibility() {
|
||||
let toml_str = r#"
|
||||
[plugin]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "1.0.0"
|
||||
owlry_version = ">=0.3.0, <1.0.0"
|
||||
"#;
|
||||
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
|
||||
assert!(manifest.is_compatible_with("0.3.5"));
|
||||
assert!(manifest.is_compatible_with("0.4.0"));
|
||||
assert!(!manifest.is_compatible_with("0.2.0"));
|
||||
assert!(!manifest.is_compatible_with("1.0.0"));
|
||||
}
|
||||
}
|
||||
353
crates/owlry-core/src/plugins/mod.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
//! Owlry Plugin System
|
||||
//!
|
||||
//! This module provides plugin support for extending owlry's functionality.
|
||||
//! Plugins can register providers, actions, themes, and hooks.
|
||||
//!
|
||||
//! # Plugin Types
|
||||
//!
|
||||
//! - **Native plugins** (.so): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/`
|
||||
//! - **Lua plugins**: Script-based plugins from `~/.config/owlry/plugins/` (requires `lua` feature)
|
||||
//!
|
||||
//! # Plugin Structure (Lua)
|
||||
//!
|
||||
//! Each Lua plugin lives in its own directory under `~/.config/owlry/plugins/`:
|
||||
//!
|
||||
//! ```text
|
||||
//! ~/.config/owlry/plugins/
|
||||
//! my-plugin/
|
||||
//! plugin.toml # Plugin manifest
|
||||
//! init.lua # Entry point
|
||||
//! lib/ # Optional modules
|
||||
//! ```
|
||||
|
||||
// Always available
|
||||
pub mod error;
|
||||
pub mod manifest;
|
||||
pub mod native_loader;
|
||||
pub mod registry;
|
||||
pub mod runtime_loader;
|
||||
|
||||
// Lua-specific modules (require mlua)
|
||||
#[cfg(feature = "lua")]
|
||||
pub mod api;
|
||||
#[cfg(feature = "lua")]
|
||||
pub mod loader;
|
||||
#[cfg(feature = "lua")]
|
||||
pub mod runtime;
|
||||
|
||||
// Re-export commonly used types
|
||||
#[cfg(feature = "lua")]
|
||||
pub use api::provider::{PluginItem, ProviderRegistration};
|
||||
#[cfg(feature = "lua")]
|
||||
#[allow(unused_imports)]
|
||||
pub use api::{ActionRegistration, HookEvent, ThemeRegistration};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use error::{PluginError, PluginResult};
|
||||
|
||||
#[cfg(feature = "lua")]
|
||||
pub use loader::LoadedPlugin;
|
||||
|
||||
// Used by plugins/commands.rs for plugin CLI commands
|
||||
#[allow(unused_imports)]
|
||||
pub use manifest::{PluginManifest, check_compatibility, discover_plugins};
|
||||
|
||||
// ============================================================================
|
||||
// Lua Plugin Manager (only available with lua feature)
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(feature = "lua")]
|
||||
mod lua_manager {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use manifest::{check_compatibility, discover_plugins};
|
||||
|
||||
/// Plugin manager coordinates loading, initialization, and lifecycle of Lua plugins
|
||||
pub struct PluginManager {
|
||||
/// Directory where plugins are stored
|
||||
plugins_dir: PathBuf,
|
||||
/// Current owlry version for compatibility checks
|
||||
owlry_version: String,
|
||||
/// Loaded plugins by ID (Rc<RefCell<>> allows sharing with LuaProviders)
|
||||
plugins: HashMap<String, Rc<RefCell<LoadedPlugin>>>,
|
||||
/// Plugin IDs that are explicitly disabled
|
||||
disabled: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginManager {
|
||||
/// Create a new plugin manager
|
||||
pub fn new(plugins_dir: PathBuf, owlry_version: &str) -> Self {
|
||||
Self {
|
||||
plugins_dir,
|
||||
owlry_version: owlry_version.to_string(),
|
||||
plugins: HashMap::new(),
|
||||
disabled: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the list of disabled plugin IDs
|
||||
pub fn set_disabled(&mut self, disabled: Vec<String>) {
|
||||
self.disabled = disabled;
|
||||
}
|
||||
|
||||
/// Discover and load all plugins from the plugins directory
|
||||
pub fn discover(&mut self) -> PluginResult<usize> {
|
||||
log::info!("Discovering plugins in {}", self.plugins_dir.display());
|
||||
|
||||
let discovered = discover_plugins(&self.plugins_dir)?;
|
||||
let mut loaded_count = 0;
|
||||
|
||||
for (id, (manifest, path)) in discovered {
|
||||
// Skip disabled plugins
|
||||
if self.disabled.contains(&id) {
|
||||
log::info!("Plugin '{}' is disabled, skipping", id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check version compatibility
|
||||
if let Err(e) = check_compatibility(&manifest, &self.owlry_version) {
|
||||
log::warn!("Plugin '{}' is not compatible: {}", id, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
let plugin = LoadedPlugin::new(manifest, path);
|
||||
self.plugins.insert(id, Rc::new(RefCell::new(plugin)));
|
||||
loaded_count += 1;
|
||||
}
|
||||
|
||||
log::info!("Discovered {} compatible plugins", loaded_count);
|
||||
Ok(loaded_count)
|
||||
}
|
||||
|
||||
/// Initialize all discovered plugins (load their Lua code)
|
||||
pub fn initialize_all(&mut self) -> Vec<PluginError> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, plugin_rc) in &self.plugins {
|
||||
let mut plugin = plugin_rc.borrow_mut();
|
||||
if !plugin.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
log::debug!("Initializing plugin: {}", id);
|
||||
if let Err(e) = plugin.initialize() {
|
||||
log::error!("Failed to initialize plugin '{}': {}", id, e);
|
||||
errors.push(e);
|
||||
plugin.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
/// Get a loaded plugin by ID (returns Rc for shared ownership)
|
||||
#[allow(dead_code)]
|
||||
pub fn get(&self, id: &str) -> Option<Rc<RefCell<LoadedPlugin>>> {
|
||||
self.plugins.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Get all loaded plugins
|
||||
#[allow(dead_code)]
|
||||
pub fn plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
|
||||
self.plugins.values().cloned()
|
||||
}
|
||||
|
||||
/// Get all enabled plugins
|
||||
pub fn enabled_plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
|
||||
self.plugins
|
||||
.values()
|
||||
.filter(|p| p.borrow().enabled)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Get the number of loaded plugins
|
||||
#[allow(dead_code)]
|
||||
pub fn plugin_count(&self) -> usize {
|
||||
self.plugins.len()
|
||||
}
|
||||
|
||||
/// Get the number of enabled plugins
|
||||
#[allow(dead_code)]
|
||||
pub fn enabled_count(&self) -> usize {
|
||||
self.plugins.values().filter(|p| p.borrow().enabled).count()
|
||||
}
|
||||
|
||||
/// Enable a plugin by ID
|
||||
#[allow(dead_code)]
|
||||
pub fn enable(&mut self, id: &str) -> PluginResult<()> {
|
||||
let plugin_rc = self
|
||||
.plugins
|
||||
.get(id)
|
||||
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
|
||||
let mut plugin = plugin_rc.borrow_mut();
|
||||
|
||||
if !plugin.enabled {
|
||||
plugin.enabled = true;
|
||||
// Initialize if not already done
|
||||
plugin.initialize()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disable a plugin by ID
|
||||
#[allow(dead_code)]
|
||||
pub fn disable(&mut self, id: &str) -> PluginResult<()> {
|
||||
let plugin_rc = self
|
||||
.plugins
|
||||
.get(id)
|
||||
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
|
||||
plugin_rc.borrow_mut().enabled = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get plugin IDs that provide a specific feature
|
||||
#[allow(dead_code)]
|
||||
pub fn providers_for(&self, provider_name: &str) -> Vec<String> {
|
||||
self.enabled_plugins()
|
||||
.filter(|p| {
|
||||
p.borrow()
|
||||
.manifest
|
||||
.provides
|
||||
.providers
|
||||
.contains(&provider_name.to_string())
|
||||
})
|
||||
.map(|p| p.borrow().id().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if any plugin provides actions
|
||||
#[allow(dead_code)]
|
||||
pub fn has_action_plugins(&self) -> bool {
|
||||
self.enabled_plugins()
|
||||
.any(|p| p.borrow().manifest.provides.actions)
|
||||
}
|
||||
|
||||
/// Check if any plugin provides hooks
|
||||
#[allow(dead_code)]
|
||||
pub fn has_hook_plugins(&self) -> bool {
|
||||
self.enabled_plugins()
|
||||
.any(|p| p.borrow().manifest.provides.hooks)
|
||||
}
|
||||
|
||||
/// Get all theme names provided by plugins
|
||||
#[allow(dead_code)]
|
||||
pub fn theme_names(&self) -> Vec<String> {
|
||||
self.enabled_plugins()
|
||||
.flat_map(|p| p.borrow().manifest.provides.themes.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Create providers from all enabled plugins
|
||||
///
|
||||
/// This must be called after `initialize_all()`. Returns a vec of Provider trait
|
||||
/// objects that can be added to the ProviderManager.
|
||||
pub fn create_providers(&self) -> Vec<Box<dyn crate::providers::Provider>> {
|
||||
use crate::providers::lua_provider::create_providers_from_plugin;
|
||||
|
||||
let mut providers = Vec::new();
|
||||
|
||||
for plugin_rc in self.enabled_plugins() {
|
||||
let plugin_providers = create_providers_from_plugin(plugin_rc);
|
||||
providers.extend(plugin_providers);
|
||||
}
|
||||
|
||||
providers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "lua")]
|
||||
pub use lua_manager::PluginManager;
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(all(test, feature = "lua"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_plugin(dir: &std::path::Path, id: &str, version: &str, owlry_req: &str) {
|
||||
let plugin_dir = dir.join(id);
|
||||
fs::create_dir_all(&plugin_dir).unwrap();
|
||||
|
||||
let manifest = format!(
|
||||
r#"
|
||||
[plugin]
|
||||
id = "{}"
|
||||
name = "Test {}"
|
||||
version = "{}"
|
||||
owlry_version = "{}"
|
||||
|
||||
[provides]
|
||||
providers = ["{}"]
|
||||
"#,
|
||||
id, id, version, owlry_req, id
|
||||
);
|
||||
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
|
||||
fs::write(plugin_dir.join("init.lua"), "-- test plugin").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_manager_discover() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0");
|
||||
create_test_plugin(temp.path(), "plugin-b", "2.0.0", ">=0.3.0");
|
||||
|
||||
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
|
||||
let count = manager.discover().unwrap();
|
||||
|
||||
assert_eq!(count, 2);
|
||||
assert!(manager.get("plugin-a").is_some());
|
||||
assert!(manager.get("plugin-b").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_manager_disabled() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0");
|
||||
create_test_plugin(temp.path(), "plugin-b", "1.0.0", ">=0.3.0");
|
||||
|
||||
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
|
||||
manager.set_disabled(vec!["plugin-b".to_string()]);
|
||||
let count = manager.discover().unwrap();
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert!(manager.get("plugin-a").is_some());
|
||||
assert!(manager.get("plugin-b").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_manager_version_compat() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
create_test_plugin(temp.path(), "old-plugin", "1.0.0", ">=0.5.0"); // Requires future version
|
||||
create_test_plugin(temp.path(), "new-plugin", "1.0.0", ">=0.3.0");
|
||||
|
||||
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
|
||||
let count = manager.discover().unwrap();
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert!(manager.get("old-plugin").is_none()); // Incompatible
|
||||
assert!(manager.get("new-plugin").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_providers_for() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
create_test_plugin(temp.path(), "my-provider", "1.0.0", ">=0.3.0");
|
||||
|
||||
let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5");
|
||||
manager.discover().unwrap();
|
||||
|
||||
let providers = manager.providers_for("my-provider");
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(providers[0], "my-provider");
|
||||
}
|
||||
}
|
||||
402
crates/owlry-core/src/plugins/native_loader.rs
Normal file
@@ -0,0 +1,402 @@
|
||||
//! Native Plugin Loader
|
||||
//!
|
||||
//! Loads pre-compiled Rust plugins (.so files) from `/usr/lib/owlry/plugins/`.
|
||||
//! These plugins use the ABI-stable interface defined in `owlry-plugin-api`.
|
||||
//!
|
||||
//! Note: This module is infrastructure for the plugin architecture. Full integration
|
||||
//! with ProviderManager is pending Phase 5 (AUR Packaging) when native plugins
|
||||
//! will actually be deployed.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Once};
|
||||
|
||||
use libloading::Library;
|
||||
use log::{debug, error, info, warn};
|
||||
use owlry_plugin_api::{
|
||||
API_VERSION, HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo,
|
||||
ProviderKind, RStr,
|
||||
};
|
||||
|
||||
use crate::notify;
|
||||
|
||||
// ============================================================================
|
||||
// Host API Implementation
|
||||
// ============================================================================
|
||||
|
||||
/// Host notification handler
|
||||
extern "C" fn host_notify(
|
||||
summary: RStr<'_>,
|
||||
body: RStr<'_>,
|
||||
icon: RStr<'_>,
|
||||
urgency: NotifyUrgency,
|
||||
) {
|
||||
let icon_str = icon.as_str();
|
||||
let icon_opt = if icon_str.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(icon_str)
|
||||
};
|
||||
|
||||
let notify_urgency = match urgency {
|
||||
NotifyUrgency::Low => notify::NotifyUrgency::Low,
|
||||
NotifyUrgency::Normal => notify::NotifyUrgency::Normal,
|
||||
NotifyUrgency::Critical => notify::NotifyUrgency::Critical,
|
||||
};
|
||||
|
||||
notify::notify_with_options(summary.as_str(), body.as_str(), icon_opt, notify_urgency);
|
||||
}
|
||||
|
||||
/// Host log info handler
|
||||
extern "C" fn host_log_info(message: RStr<'_>) {
|
||||
info!("[plugin] {}", message.as_str());
|
||||
}
|
||||
|
||||
/// Host log warning handler
|
||||
extern "C" fn host_log_warn(message: RStr<'_>) {
|
||||
warn!("[plugin] {}", message.as_str());
|
||||
}
|
||||
|
||||
/// Host log error handler
|
||||
extern "C" fn host_log_error(message: RStr<'_>) {
|
||||
error!("[plugin] {}", message.as_str());
|
||||
}
|
||||
|
||||
/// Static host API instance
|
||||
static HOST_API: HostAPI = HostAPI {
|
||||
notify: host_notify,
|
||||
log_info: host_log_info,
|
||||
log_warn: host_log_warn,
|
||||
log_error: host_log_error,
|
||||
};
|
||||
|
||||
/// Initialize the host API (called once before loading plugins)
|
||||
static HOST_API_INIT: Once = Once::new();
|
||||
|
||||
fn ensure_host_api_initialized() {
|
||||
HOST_API_INIT.call_once(|| {
|
||||
// SAFETY: We only call this once, before any plugins are loaded
|
||||
unsafe {
|
||||
owlry_plugin_api::init_host_api(&HOST_API);
|
||||
}
|
||||
debug!("Host API initialized for plugins");
|
||||
});
|
||||
}
|
||||
|
||||
use super::error::{PluginError, PluginResult};
|
||||
|
||||
/// Default directory for system-installed native plugins
|
||||
pub const SYSTEM_PLUGINS_DIR: &str = "/usr/lib/owlry/plugins";
|
||||
|
||||
/// A loaded native plugin with its library handle and vtable
|
||||
pub struct NativePlugin {
|
||||
/// Plugin metadata
|
||||
pub info: PluginInfo,
|
||||
/// List of providers this plugin offers
|
||||
pub providers: Vec<ProviderInfo>,
|
||||
/// The vtable for calling plugin functions
|
||||
vtable: &'static PluginVTable,
|
||||
/// The loaded library (must be kept alive)
|
||||
_library: Library,
|
||||
}
|
||||
|
||||
impl NativePlugin {
|
||||
/// Get the plugin ID
|
||||
pub fn id(&self) -> &str {
|
||||
self.info.id.as_str()
|
||||
}
|
||||
|
||||
/// Get the plugin name
|
||||
pub fn name(&self) -> &str {
|
||||
self.info.name.as_str()
|
||||
}
|
||||
|
||||
/// Initialize a provider by ID
|
||||
pub fn init_provider(&self, provider_id: &str) -> ProviderHandle {
|
||||
(self.vtable.provider_init)(provider_id.into())
|
||||
}
|
||||
|
||||
/// Refresh a static provider
|
||||
pub fn refresh_provider(&self, handle: ProviderHandle) -> Vec<owlry_plugin_api::PluginItem> {
|
||||
(self.vtable.provider_refresh)(handle).into_iter().collect()
|
||||
}
|
||||
|
||||
/// Query a dynamic provider
|
||||
pub fn query_provider(
|
||||
&self,
|
||||
handle: ProviderHandle,
|
||||
query: &str,
|
||||
) -> Vec<owlry_plugin_api::PluginItem> {
|
||||
(self.vtable.provider_query)(handle, query.into())
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop a provider handle
|
||||
pub fn drop_provider(&self, handle: ProviderHandle) {
|
||||
(self.vtable.provider_drop)(handle)
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: NativePlugin is safe to send between threads because:
|
||||
// - `info` and `providers` are plain data (RString, RVec from abi_stable are Send+Sync)
|
||||
// - `vtable` is a &'static reference to immutable function pointers
|
||||
// - `_library` (libloading::Library) is Send+Sync
|
||||
unsafe impl Send for NativePlugin {}
|
||||
unsafe impl Sync for NativePlugin {}
|
||||
|
||||
/// Manages native plugin discovery and loading
|
||||
pub struct NativePluginLoader {
|
||||
/// Directory to scan for plugins
|
||||
plugins_dir: PathBuf,
|
||||
/// Loaded plugins by ID (Arc for shared ownership with providers)
|
||||
plugins: HashMap<String, Arc<NativePlugin>>,
|
||||
/// Plugin IDs that are disabled
|
||||
disabled: Vec<String>,
|
||||
}
|
||||
|
||||
impl NativePluginLoader {
|
||||
/// Create a new loader with the default system plugins directory
|
||||
pub fn new() -> Self {
|
||||
Self::with_dir(PathBuf::from(SYSTEM_PLUGINS_DIR))
|
||||
}
|
||||
|
||||
/// Create a new loader with a custom plugins directory
|
||||
pub fn with_dir(plugins_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
plugins_dir,
|
||||
plugins: HashMap::new(),
|
||||
disabled: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the list of disabled plugin IDs
|
||||
pub fn set_disabled(&mut self, disabled: Vec<String>) {
|
||||
self.disabled = disabled;
|
||||
}
|
||||
|
||||
/// Check if the plugins directory exists
|
||||
pub fn plugins_dir_exists(&self) -> bool {
|
||||
self.plugins_dir.exists()
|
||||
}
|
||||
|
||||
/// Discover and load all native plugins
|
||||
pub fn discover(&mut self) -> PluginResult<usize> {
|
||||
// Initialize host API before loading any plugins
|
||||
ensure_host_api_initialized();
|
||||
|
||||
if !self.plugins_dir.exists() {
|
||||
debug!(
|
||||
"Native plugins directory does not exist: {}",
|
||||
self.plugins_dir.display()
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Discovering native plugins in {}",
|
||||
self.plugins_dir.display()
|
||||
);
|
||||
|
||||
let entries = std::fs::read_dir(&self.plugins_dir).map_err(|e| {
|
||||
PluginError::LoadError(format!(
|
||||
"Failed to read plugins directory {}: {}",
|
||||
self.plugins_dir.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut loaded_count = 0;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
// Only process .so files
|
||||
if path.extension() != Some(OsStr::new("so")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.load_plugin(&path) {
|
||||
Ok(plugin) => {
|
||||
let id = plugin.id().to_string();
|
||||
|
||||
// Check if disabled
|
||||
if self.disabled.contains(&id) {
|
||||
info!("Native plugin '{}' is disabled, skipping", id);
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Loaded native plugin '{}' v{} with {} providers",
|
||||
plugin.name(),
|
||||
plugin.info.version.as_str(),
|
||||
plugin.providers.len()
|
||||
);
|
||||
|
||||
self.plugins.insert(id, Arc::new(plugin));
|
||||
loaded_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to load plugin {:?}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Loaded {} native plugins", loaded_count);
|
||||
Ok(loaded_count)
|
||||
}
|
||||
|
||||
/// Load a single plugin from a .so file
|
||||
fn load_plugin(&self, path: &Path) -> PluginResult<NativePlugin> {
|
||||
debug!("Loading native plugin from {:?}", path);
|
||||
|
||||
// Load the library
|
||||
// SAFETY: We trust plugins in /usr/lib/owlry/plugins/ as they were
|
||||
// installed by the package manager
|
||||
let library = unsafe { Library::new(path) }.map_err(|e| {
|
||||
PluginError::LoadError(format!("Failed to load library {:?}: {}", path, e))
|
||||
})?;
|
||||
|
||||
// Get the vtable function
|
||||
let vtable: &'static PluginVTable = unsafe {
|
||||
let func: libloading::Symbol<extern "C" fn() -> &'static PluginVTable> =
|
||||
library.get(b"owlry_plugin_vtable").map_err(|e| {
|
||||
PluginError::LoadError(format!(
|
||||
"Plugin {:?} missing owlry_plugin_vtable symbol: {}",
|
||||
path, e
|
||||
))
|
||||
})?;
|
||||
func()
|
||||
};
|
||||
|
||||
// Get plugin info
|
||||
let info = (vtable.info)();
|
||||
|
||||
// Check API version compatibility
|
||||
if info.api_version != API_VERSION {
|
||||
return Err(PluginError::LoadError(format!(
|
||||
"Plugin '{}' has API version {} but owlry requires version {}",
|
||||
info.id.as_str(),
|
||||
info.api_version,
|
||||
API_VERSION
|
||||
)));
|
||||
}
|
||||
|
||||
// Get provider list
|
||||
let providers: Vec<ProviderInfo> = (vtable.providers)().into_iter().collect();
|
||||
|
||||
Ok(NativePlugin {
|
||||
info,
|
||||
providers,
|
||||
vtable,
|
||||
_library: library,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a loaded plugin by ID
|
||||
pub fn get(&self, id: &str) -> Option<Arc<NativePlugin>> {
|
||||
self.plugins.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Get all loaded plugins as Arc references
|
||||
pub fn plugins(&self) -> impl Iterator<Item = Arc<NativePlugin>> + '_ {
|
||||
self.plugins.values().cloned()
|
||||
}
|
||||
|
||||
/// Get all loaded plugins as a Vec (for passing to create_providers)
|
||||
pub fn into_plugins(self) -> Vec<Arc<NativePlugin>> {
|
||||
self.plugins.into_values().collect()
|
||||
}
|
||||
|
||||
/// Get the number of loaded plugins
|
||||
pub fn plugin_count(&self) -> usize {
|
||||
self.plugins.len()
|
||||
}
|
||||
|
||||
/// Create providers from all loaded native plugins
|
||||
///
|
||||
/// Returns a vec of (plugin_id, provider_info, handle) tuples that can be
|
||||
/// used to create NativeProvider instances.
|
||||
pub fn create_provider_handles(&self) -> Vec<(String, ProviderInfo, ProviderHandle)> {
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for plugin in self.plugins.values() {
|
||||
for provider_info in &plugin.providers {
|
||||
let handle = plugin.init_provider(provider_info.id.as_str());
|
||||
handles.push((plugin.id().to_string(), provider_info.clone(), handle));
|
||||
}
|
||||
}
|
||||
|
||||
handles
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NativePluginLoader {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Active provider instance from a native plugin
|
||||
pub struct NativeProviderInstance {
|
||||
/// Plugin ID this provider belongs to
|
||||
pub plugin_id: String,
|
||||
/// Provider metadata
|
||||
pub info: ProviderInfo,
|
||||
/// Handle to the provider state
|
||||
pub handle: ProviderHandle,
|
||||
/// Cached items for static providers
|
||||
pub cached_items: Vec<owlry_plugin_api::PluginItem>,
|
||||
}
|
||||
|
||||
impl NativeProviderInstance {
|
||||
/// Create a new provider instance
|
||||
pub fn new(plugin_id: String, info: ProviderInfo, handle: ProviderHandle) -> Self {
|
||||
Self {
|
||||
plugin_id,
|
||||
info,
|
||||
handle,
|
||||
cached_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a static provider
|
||||
pub fn is_static(&self) -> bool {
|
||||
self.info.provider_type == ProviderKind::Static
|
||||
}
|
||||
|
||||
/// Check if this is a dynamic provider
|
||||
pub fn is_dynamic(&self) -> bool {
|
||||
self.info.provider_type == ProviderKind::Dynamic
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_loader_nonexistent_dir() {
|
||||
let mut loader = NativePluginLoader::with_dir(PathBuf::from("/nonexistent/path"));
|
||||
let count = loader.discover().unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loader_empty_dir() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let mut loader = NativePluginLoader::with_dir(temp.path().to_path_buf());
|
||||
let count = loader.discover().unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_plugins() {
|
||||
let mut loader = NativePluginLoader::new();
|
||||
loader.set_disabled(vec!["test-plugin".to_string()]);
|
||||
assert!(loader.disabled.contains(&"test-plugin".to_string()));
|
||||
}
|
||||
}
|
||||
292
crates/owlry-core/src/plugins/registry.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
//! Plugin registry client for discovering and installing remote plugins
|
||||
//!
|
||||
//! The registry is a git repository containing an `index.toml` file with
|
||||
//! plugin metadata. Plugins are installed by cloning their source repositories.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// Default registry URL (can be overridden in config)
|
||||
pub const DEFAULT_REGISTRY_URL: &str =
|
||||
"https://raw.githubusercontent.com/owlry/plugin-registry/main/index.toml";
|
||||
|
||||
/// Cache duration for registry index (1 hour)
|
||||
const CACHE_DURATION: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Registry index containing all available plugins
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegistryIndex {
|
||||
/// Registry metadata
|
||||
#[serde(default)]
|
||||
pub registry: RegistryMeta,
|
||||
/// Available plugins
|
||||
#[serde(default)]
|
||||
pub plugins: Vec<RegistryPlugin>,
|
||||
}
|
||||
|
||||
/// Registry metadata
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RegistryMeta {
|
||||
/// Registry name
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// Registry description
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Registry maintainer URL
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Plugin entry in the registry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegistryPlugin {
|
||||
/// Unique plugin identifier
|
||||
pub id: String,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Latest version
|
||||
pub version: String,
|
||||
/// Short description
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Plugin author
|
||||
#[serde(default)]
|
||||
pub author: String,
|
||||
/// Git repository URL for installation
|
||||
pub repository: String,
|
||||
/// Search tags
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Minimum owlry version required
|
||||
#[serde(default)]
|
||||
pub owlry_version: String,
|
||||
/// License identifier
|
||||
#[serde(default)]
|
||||
pub license: String,
|
||||
}
|
||||
|
||||
/// Registry client for fetching and searching plugins
|
||||
pub struct RegistryClient {
|
||||
/// Registry URL (index.toml location)
|
||||
registry_url: String,
|
||||
/// Local cache directory
|
||||
cache_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RegistryClient {
|
||||
/// Create a new registry client with the given URL
|
||||
pub fn new(registry_url: &str) -> Self {
|
||||
let cache_dir = paths::owlry_cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp/owlry"))
|
||||
.join("registry");
|
||||
|
||||
Self {
|
||||
registry_url: registry_url.to_string(),
|
||||
cache_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a client with the default registry URL
|
||||
pub fn default_registry() -> Self {
|
||||
Self::new(DEFAULT_REGISTRY_URL)
|
||||
}
|
||||
|
||||
/// Get the path to the cached index file
|
||||
fn cache_path(&self) -> PathBuf {
|
||||
self.cache_dir.join("index.toml")
|
||||
}
|
||||
|
||||
/// Check if the cache is valid (exists and not expired)
|
||||
fn is_cache_valid(&self) -> bool {
|
||||
let cache_path = self.cache_path();
|
||||
if !cache_path.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(metadata) = fs::metadata(&cache_path)
|
||||
&& let Ok(modified) = metadata.modified()
|
||||
&& let Ok(elapsed) = SystemTime::now().duration_since(modified)
|
||||
{
|
||||
return elapsed < CACHE_DURATION;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Fetch the registry index (from cache or network)
|
||||
pub fn fetch_index(&self, force_refresh: bool) -> Result<RegistryIndex, String> {
|
||||
// Use cache if valid and not forcing refresh
|
||||
if !force_refresh
|
||||
&& self.is_cache_valid()
|
||||
&& let Ok(content) = fs::read_to_string(self.cache_path())
|
||||
&& let Ok(index) = toml::from_str(&content)
|
||||
{
|
||||
return Ok(index);
|
||||
}
|
||||
|
||||
// Fetch from network
|
||||
self.fetch_from_network()
|
||||
}
|
||||
|
||||
/// Fetch the index from the network and cache it
|
||||
fn fetch_from_network(&self) -> Result<RegistryIndex, String> {
|
||||
// Use curl for fetching (available on most systems)
|
||||
let output = std::process::Command::new("curl")
|
||||
.args(["-fsSL", "--max-time", "30", &self.registry_url])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run curl: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to fetch registry: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse the index
|
||||
let index: RegistryIndex = toml::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse registry index: {}", e))?;
|
||||
|
||||
// Cache the result
|
||||
if let Err(e) = self.cache_index(&content) {
|
||||
eprintln!("Warning: Failed to cache registry index: {}", e);
|
||||
}
|
||||
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Cache the index content to disk
|
||||
fn cache_index(&self, content: &str) -> Result<(), String> {
|
||||
fs::create_dir_all(&self.cache_dir)
|
||||
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
|
||||
|
||||
fs::write(self.cache_path(), content)
|
||||
.map_err(|e| format!("Failed to write cache file: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search for plugins matching a query
|
||||
pub fn search(&self, query: &str, force_refresh: bool) -> Result<Vec<RegistryPlugin>, String> {
|
||||
let index = self.fetch_index(force_refresh)?;
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let matches: Vec<_> = index
|
||||
.plugins
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
p.id.to_lowercase().contains(&query_lower)
|
||||
|| p.name.to_lowercase().contains(&query_lower)
|
||||
|| p.description.to_lowercase().contains(&query_lower)
|
||||
|| p.tags
|
||||
.iter()
|
||||
.any(|t| t.to_lowercase().contains(&query_lower))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// Find a specific plugin by ID
|
||||
pub fn find(&self, id: &str, force_refresh: bool) -> Result<Option<RegistryPlugin>, String> {
|
||||
let index = self.fetch_index(force_refresh)?;
|
||||
|
||||
Ok(index.plugins.into_iter().find(|p| p.id == id))
|
||||
}
|
||||
|
||||
/// List all available plugins
|
||||
pub fn list_all(&self, force_refresh: bool) -> Result<Vec<RegistryPlugin>, String> {
|
||||
let index = self.fetch_index(force_refresh)?;
|
||||
Ok(index.plugins)
|
||||
}
|
||||
|
||||
/// Clear the cache
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_cache(&self) -> Result<(), String> {
|
||||
let cache_path = self.cache_path();
|
||||
if cache_path.exists() {
|
||||
fs::remove_file(&cache_path).map_err(|e| format!("Failed to remove cache: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the repository URL for a plugin
|
||||
#[allow(dead_code)]
|
||||
pub fn get_install_url(&self, id: &str) -> Result<String, String> {
|
||||
match self.find(id, false)? {
|
||||
Some(plugin) => Ok(plugin.repository),
|
||||
None => Err(format!("Plugin '{}' not found in registry", id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a string looks like a URL (for distinguishing registry names from URLs)
|
||||
pub fn is_url(s: &str) -> bool {
|
||||
s.starts_with("http://")
|
||||
|| s.starts_with("https://")
|
||||
|| s.starts_with("git@")
|
||||
|| s.starts_with("git://")
|
||||
}
|
||||
|
||||
/// Check if a string looks like a local path
|
||||
pub fn is_path(s: &str) -> bool {
|
||||
s.starts_with('/')
|
||||
|| s.starts_with("./")
|
||||
|| s.starts_with("../")
|
||||
|| s.starts_with('~')
|
||||
|| Path::new(s).exists()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_registry_index() {
|
||||
let toml_str = r#"
|
||||
[registry]
|
||||
name = "Test Registry"
|
||||
description = "A test registry"
|
||||
|
||||
[[plugins]]
|
||||
id = "test-plugin"
|
||||
name = "Test Plugin"
|
||||
version = "1.0.0"
|
||||
description = "A test plugin"
|
||||
author = "Test Author"
|
||||
repository = "https://github.com/test/plugin"
|
||||
tags = ["test", "example"]
|
||||
owlry_version = ">=0.3.0"
|
||||
"#;
|
||||
|
||||
let index: RegistryIndex = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(index.registry.name, "Test Registry");
|
||||
assert_eq!(index.plugins.len(), 1);
|
||||
assert_eq!(index.plugins[0].id, "test-plugin");
|
||||
assert_eq!(index.plugins[0].tags, vec!["test", "example"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_url() {
|
||||
assert!(is_url("https://github.com/user/repo"));
|
||||
assert!(is_url("http://example.com"));
|
||||
assert!(is_url("git@github.com:user/repo.git"));
|
||||
assert!(!is_url("my-plugin"));
|
||||
assert!(!is_url("/path/to/plugin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_path() {
|
||||
assert!(is_path("/absolute/path"));
|
||||
assert!(is_path("./relative/path"));
|
||||
assert!(is_path("../parent/path"));
|
||||
assert!(is_path("~/home/path"));
|
||||
assert!(!is_path("my-plugin"));
|
||||
assert!(!is_path("https://example.com"));
|
||||
}
|
||||
}
|
||||
154
crates/owlry-core/src/plugins/runtime.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
//! Lua runtime setup and sandboxing
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, StdLib};
|
||||
|
||||
use super::manifest::PluginPermissions;
|
||||
|
||||
/// Configuration for the Lua sandbox
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Fields used for future permission enforcement
|
||||
pub struct SandboxConfig {
|
||||
/// Allow shell command running
|
||||
pub allow_commands: bool,
|
||||
/// Allow HTTP requests
|
||||
pub allow_network: bool,
|
||||
/// Allow filesystem access outside plugin directory
|
||||
pub allow_external_fs: bool,
|
||||
/// Maximum run time per call (ms)
|
||||
pub max_run_time_ms: u64,
|
||||
/// Memory limit (bytes, 0 = unlimited)
|
||||
pub max_memory: usize,
|
||||
}
|
||||
|
||||
impl Default for SandboxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_commands: false,
|
||||
allow_network: false,
|
||||
allow_external_fs: false,
|
||||
max_run_time_ms: 5000, // 5 seconds
|
||||
max_memory: 64 * 1024 * 1024, // 64 MB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxConfig {
|
||||
/// Create a sandbox config from plugin permissions
|
||||
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
|
||||
Self {
|
||||
allow_commands: !permissions.run_commands.is_empty(),
|
||||
allow_network: permissions.network,
|
||||
allow_external_fs: !permissions.filesystem.is_empty(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new sandboxed Lua runtime
|
||||
pub fn create_lua_runtime(_sandbox: &SandboxConfig) -> LuaResult<Lua> {
|
||||
// Create Lua with safe standard libraries only
|
||||
// ALL_SAFE excludes: debug, io, os (dangerous parts), package (loadlib), ffi
|
||||
// We then customize the os table to only allow safe functions
|
||||
let libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH;
|
||||
|
||||
let lua = Lua::new_with(libs, mlua::LuaOptions::default())?;
|
||||
|
||||
// Set up safe environment
|
||||
setup_safe_globals(&lua)?;
|
||||
|
||||
Ok(lua)
|
||||
}
|
||||
|
||||
/// Set up safe global environment by removing/replacing dangerous functions
|
||||
fn setup_safe_globals(lua: &Lua) -> LuaResult<()> {
|
||||
let globals = lua.globals();
|
||||
|
||||
// Remove dangerous globals
|
||||
globals.set("dofile", mlua::Value::Nil)?;
|
||||
globals.set("loadfile", mlua::Value::Nil)?;
|
||||
|
||||
// Create a restricted os table with only safe functions
|
||||
// We do NOT include: os.exit, os.remove, os.rename, os.setlocale, os.tmpname
|
||||
// and the shell-related functions
|
||||
let os_table = lua.create_table()?;
|
||||
os_table.set(
|
||||
"clock",
|
||||
lua.create_function(|_, ()| Ok(std::time::Instant::now().elapsed().as_secs_f64()))?,
|
||||
)?;
|
||||
os_table.set("date", lua.create_function(os_date)?)?;
|
||||
os_table.set(
|
||||
"difftime",
|
||||
lua.create_function(|_, (t2, t1): (f64, f64)| Ok(t2 - t1))?,
|
||||
)?;
|
||||
os_table.set("time", lua.create_function(os_time)?)?;
|
||||
globals.set("os", os_table)?;
|
||||
|
||||
// Remove print (plugins should use owlry.log instead)
|
||||
// We'll add it back via owlry.log
|
||||
globals.set("print", mlua::Value::Nil)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Safe os.date implementation
|
||||
fn os_date(_lua: &Lua, format: Option<String>) -> LuaResult<String> {
|
||||
use chrono::Local;
|
||||
let now = Local::now();
|
||||
let fmt = format.unwrap_or_else(|| "%c".to_string());
|
||||
Ok(now.format(&fmt).to_string())
|
||||
}
|
||||
|
||||
/// Safe os.time implementation
|
||||
fn os_time(_lua: &Lua, _args: ()) -> LuaResult<i64> {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
Ok(duration.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// Load and run a Lua file in the given runtime
|
||||
pub fn load_file(lua: &Lua, path: &std::path::Path) -> LuaResult<()> {
|
||||
let content = std::fs::read_to_string(path).map_err(mlua::Error::external)?;
|
||||
lua.load(&content)
|
||||
.set_name(path.file_name().and_then(|n| n.to_str()).unwrap_or("chunk"))
|
||||
.into_function()?
|
||||
.call(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_sandboxed_runtime() {
|
||||
let config = SandboxConfig::default();
|
||||
let lua = create_lua_runtime(&config).unwrap();
|
||||
|
||||
// Verify dangerous functions are removed
|
||||
let result: LuaResult<mlua::Value> = lua.globals().get("dofile");
|
||||
assert!(matches!(result, Ok(mlua::Value::Nil)));
|
||||
|
||||
// Verify safe functions work
|
||||
let result: String = lua.load("return os.date('%Y')").call(()).unwrap();
|
||||
assert!(!result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_lua_operations() {
|
||||
let config = SandboxConfig::default();
|
||||
let lua = create_lua_runtime(&config).unwrap();
|
||||
|
||||
// Test basic math
|
||||
let result: i32 = lua.load("return 2 + 2").call(()).unwrap();
|
||||
assert_eq!(result, 4);
|
||||
|
||||
// Test table operations
|
||||
let result: i32 = lua.load("local t = {1,2,3}; return #t").call(()).unwrap();
|
||||
assert_eq!(result, 3);
|
||||
|
||||
// Test string operations
|
||||
let result: String = lua.load("return string.upper('hello')").call(()).unwrap();
|
||||
assert_eq!(result, "HELLO");
|
||||
}
|
||||
}
|
||||
292
crates/owlry-core/src/plugins/runtime_loader.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
//! 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.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use libloading::{Library, Symbol};
|
||||
use owlry_plugin_api::{PluginItem, RStr, RString, RVec};
|
||||
|
||||
use super::error::{PluginError, PluginResult};
|
||||
use crate::providers::{LaunchItem, Provider, ProviderType};
|
||||
|
||||
/// System directory for runtime libraries
|
||||
pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes";
|
||||
|
||||
/// Information about a loaded runtime
|
||||
#[repr(C)]
|
||||
#[derive(Debug)]
|
||||
pub struct RuntimeInfo {
|
||||
pub name: RString,
|
||||
pub version: RString,
|
||||
}
|
||||
|
||||
/// Information about a provider from a script runtime
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScriptProviderInfo {
|
||||
pub name: RString,
|
||||
pub display_name: RString,
|
||||
pub type_id: RString,
|
||||
pub default_icon: RString,
|
||||
pub is_static: bool,
|
||||
pub prefix: owlry_plugin_api::ROption<RString>,
|
||||
}
|
||||
|
||||
// Type alias for backwards compatibility
|
||||
pub type LuaProviderInfo = ScriptProviderInfo;
|
||||
|
||||
/// Handle to runtime-managed state
|
||||
#[repr(transparent)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RuntimeHandle(pub *mut ());
|
||||
|
||||
/// VTable for script runtime functions (used by both Lua and Rune)
|
||||
#[repr(C)]
|
||||
pub struct ScriptRuntimeVTable {
|
||||
pub info: extern "C" fn() -> RuntimeInfo,
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
|
||||
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<ScriptProviderInfo>,
|
||||
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
|
||||
pub query: extern "C" fn(
|
||||
handle: RuntimeHandle,
|
||||
provider_id: RStr<'_>,
|
||||
query: RStr<'_>,
|
||||
) -> RVec<PluginItem>,
|
||||
pub drop: extern "C" fn(handle: RuntimeHandle),
|
||||
}
|
||||
|
||||
/// A loaded script runtime
|
||||
pub struct LoadedRuntime {
|
||||
/// Runtime name (for logging)
|
||||
name: &'static str,
|
||||
/// Keep library alive
|
||||
_library: Arc<Library>,
|
||||
/// Runtime vtable
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
/// Runtime handle (state)
|
||||
handle: RuntimeHandle,
|
||||
/// Provider information
|
||||
providers: Vec<ScriptProviderInfo>,
|
||||
}
|
||||
|
||||
impl LoadedRuntime {
|
||||
/// Load the Lua runtime from the system directory
|
||||
pub fn load_lua(plugins_dir: &Path) -> PluginResult<Self> {
|
||||
Self::load_from_path(
|
||||
"Lua",
|
||||
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"),
|
||||
b"owlry_lua_runtime_vtable",
|
||||
plugins_dir,
|
||||
)
|
||||
}
|
||||
|
||||
/// Load a runtime from a specific path
|
||||
fn load_from_path(
|
||||
name: &'static str,
|
||||
library_path: &Path,
|
||||
vtable_symbol: &[u8],
|
||||
plugins_dir: &Path,
|
||||
) -> PluginResult<Self> {
|
||||
if !library_path.exists() {
|
||||
return Err(PluginError::NotFound(library_path.display().to_string()));
|
||||
}
|
||||
|
||||
// SAFETY: We trust the runtime library to be correct
|
||||
let library = unsafe { Library::new(library_path) }
|
||||
.map_err(|e| PluginError::LoadError(format!("{}: {}", library_path.display(), e)))?;
|
||||
|
||||
let library = Arc::new(library);
|
||||
|
||||
// Get the vtable
|
||||
let vtable: &'static ScriptRuntimeVTable = unsafe {
|
||||
let get_vtable: Symbol<extern "C" fn() -> &'static ScriptRuntimeVTable> =
|
||||
library.get(vtable_symbol).map_err(|e| {
|
||||
PluginError::LoadError(format!(
|
||||
"{}: Missing vtable symbol: {}",
|
||||
library_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
get_vtable()
|
||||
};
|
||||
|
||||
// Initialize the runtime
|
||||
let plugins_dir_str = plugins_dir.to_string_lossy();
|
||||
let handle = (vtable.init)(RStr::from_str(&plugins_dir_str));
|
||||
|
||||
// Get provider information
|
||||
let providers_rvec = (vtable.providers)(handle);
|
||||
let providers: Vec<ScriptProviderInfo> = providers_rvec.into_iter().collect();
|
||||
|
||||
log::info!(
|
||||
"Loaded {} runtime with {} provider(s)",
|
||||
name,
|
||||
providers.len()
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
_library: library,
|
||||
vtable,
|
||||
handle,
|
||||
providers,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all providers from this runtime
|
||||
pub fn providers(&self) -> &[ScriptProviderInfo] {
|
||||
&self.providers
|
||||
}
|
||||
|
||||
/// Create Provider trait objects for all providers in this runtime
|
||||
pub fn create_providers(&self) -> Vec<Box<dyn Provider>> {
|
||||
self.providers
|
||||
.iter()
|
||||
.map(|info| {
|
||||
let provider =
|
||||
RuntimeProvider::new(self.name, self.vtable, self.handle, info.clone());
|
||||
Box::new(provider) as Box<dyn Provider>
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LoadedRuntime {
|
||||
fn drop(&mut self) {
|
||||
(self.vtable.drop)(self.handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// A provider backed by a dynamically loaded runtime
|
||||
pub struct RuntimeProvider {
|
||||
/// Runtime name (for logging)
|
||||
#[allow(dead_code)]
|
||||
runtime_name: &'static str,
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
handle: RuntimeHandle,
|
||||
info: ScriptProviderInfo,
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl RuntimeProvider {
|
||||
fn new(
|
||||
runtime_name: &'static str,
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
handle: RuntimeHandle,
|
||||
info: ScriptProviderInfo,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime_name,
|
||||
vtable,
|
||||
handle,
|
||||
info,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_item(&self, item: PluginItem) -> LaunchItem {
|
||||
LaunchItem {
|
||||
id: item.id.to_string(),
|
||||
name: item.name.to_string(),
|
||||
description: item.description.into_option().map(|s| s.to_string()),
|
||||
icon: item.icon.into_option().map(|s| s.to_string()),
|
||||
provider: ProviderType::Plugin(self.info.type_id.to_string()),
|
||||
command: item.command.to_string(),
|
||||
terminal: item.terminal,
|
||||
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for RuntimeProvider {
|
||||
fn name(&self) -> &str {
|
||||
self.info.name.as_str()
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin(self.info.type_id.to_string())
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
if !self.info.is_static {
|
||||
return;
|
||||
}
|
||||
|
||||
let name_rstr = RStr::from_str(self.info.name.as_str());
|
||||
let items_rvec = (self.vtable.refresh)(self.handle, name_rstr);
|
||||
self.items = items_rvec
|
||||
.into_iter()
|
||||
.map(|i| self.convert_item(i))
|
||||
.collect();
|
||||
|
||||
log::debug!(
|
||||
"[RuntimeProvider] '{}' refreshed with {} items",
|
||||
self.info.name,
|
||||
self.items.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
// RuntimeProvider needs to be Send for the Provider trait
|
||||
unsafe impl Send for RuntimeProvider {}
|
||||
|
||||
/// Check if the Lua runtime is available
|
||||
pub fn lua_runtime_available() -> bool {
|
||||
PathBuf::from(SYSTEM_RUNTIMES_DIR)
|
||||
.join("liblua.so")
|
||||
.exists()
|
||||
}
|
||||
|
||||
/// 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) -> PluginResult<Self> {
|
||||
Self::load_from_path(
|
||||
"Rune",
|
||||
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"),
|
||||
b"owlry_rune_runtime_vtable",
|
||||
plugins_dir,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[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,7 +1,7 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use crate::paths;
|
||||
use freedesktop_desktop_entry::{DesktopEntry, Iter};
|
||||
use log::{debug, warn};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Clean desktop file field codes from command string.
|
||||
/// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes
|
||||
@@ -66,34 +66,18 @@ fn clean_desktop_exec_field(cmd: &str) -> String {
|
||||
cleaned
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ApplicationProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl ApplicationProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn get_application_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
// User applications
|
||||
if let Some(data_home) = dirs::data_dir() {
|
||||
dirs.push(data_home.join("applications"));
|
||||
}
|
||||
|
||||
// System applications
|
||||
dirs.push(PathBuf::from("/usr/share/applications"));
|
||||
dirs.push(PathBuf::from("/usr/local/share/applications"));
|
||||
|
||||
// Flatpak applications
|
||||
if let Some(data_home) = dirs::data_dir() {
|
||||
dirs.push(data_home.join("flatpak/exports/share/applications"));
|
||||
}
|
||||
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
|
||||
|
||||
dirs
|
||||
fn get_application_dirs() -> Vec<std::path::PathBuf> {
|
||||
paths::system_data_dirs()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +99,15 @@ impl Provider for ApplicationProvider {
|
||||
// Empty locale list for default locale
|
||||
let locales: &[&str] = &[];
|
||||
|
||||
// Get current desktop environment(s) for OnlyShowIn/NotShowIn filtering
|
||||
// XDG_CURRENT_DESKTOP can be colon-separated (e.g., "ubuntu:GNOME")
|
||||
let current_desktops: Vec<String> = std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.unwrap_or_default()
|
||||
.split(':')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
for path in Iter::new(dirs.into_iter()) {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
@@ -142,6 +135,27 @@ impl Provider for ApplicationProvider {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply OnlyShowIn/NotShowIn filters only if we know the current desktop
|
||||
// If XDG_CURRENT_DESKTOP is not set, show all apps (don't filter)
|
||||
if !current_desktops.is_empty() {
|
||||
// OnlyShowIn: if set, current desktop must be in the list
|
||||
if desktop_entry.only_show_in().is_some_and(|only| {
|
||||
!current_desktops
|
||||
.iter()
|
||||
.any(|de| only.contains(&de.as_str()))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// NotShowIn: if current desktop is in the list, skip
|
||||
if desktop_entry
|
||||
.not_show_in()
|
||||
.is_some_and(|not| current_desktops.iter().any(|de| not.contains(&de.as_str())))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let name = match desktop_entry.name(locales) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
@@ -152,6 +166,17 @@ impl Provider for ApplicationProvider {
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract categories and keywords as tags (lowercase for consistency)
|
||||
let mut tags: Vec<String> = desktop_entry
|
||||
.categories()
|
||||
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Add keywords for searchability (e.g., Nautilus has Name=Files but Keywords contains "nautilus")
|
||||
if let Some(keywords) = desktop_entry.keywords(locales) {
|
||||
tags.extend(keywords.into_iter().map(|s| s.to_lowercase()));
|
||||
}
|
||||
|
||||
let item = LaunchItem {
|
||||
id: path.to_string_lossy().to_string(),
|
||||
name,
|
||||
@@ -160,6 +185,7 @@ impl Provider for ApplicationProvider {
|
||||
provider: ProviderType::Application,
|
||||
command: run_cmd,
|
||||
terminal: desktop_entry.terminal(),
|
||||
tags,
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
@@ -167,8 +193,16 @@ impl Provider for ApplicationProvider {
|
||||
|
||||
debug!("Found {} applications", self.items.len());
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"XDG_CURRENT_DESKTOP={:?}, scanned dirs count={}",
|
||||
current_desktops,
|
||||
Self::get_application_dirs().len()
|
||||
);
|
||||
|
||||
// Sort alphabetically by name
|
||||
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
self.items
|
||||
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
@@ -190,7 +224,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_multiple_placeholders() {
|
||||
assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app");
|
||||
assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other");
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field("app --flag %u --other"),
|
||||
"app --flag --other"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -220,4 +257,18 @@ mod tests {
|
||||
"bash -c 'echo %u'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_preserves_env() {
|
||||
// env VAR=value pattern should be preserved
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field("env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity %F"),
|
||||
"env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity"
|
||||
);
|
||||
// Multiple env vars
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field("env FOO=bar BAZ=qux myapp %u"),
|
||||
"env FOO=bar BAZ=qux myapp"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,14 @@ use std::collections::HashSet;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CommandProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl CommandProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn get_path_dirs() -> Vec<PathBuf> {
|
||||
@@ -87,6 +88,7 @@ impl Provider for CommandProvider {
|
||||
provider: ProviderType::Command,
|
||||
command: name,
|
||||
terminal: false,
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
@@ -96,7 +98,8 @@ impl Provider for CommandProvider {
|
||||
debug!("Found {} commands in PATH", self.items.len());
|
||||
|
||||
// Sort alphabetically
|
||||
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
self.items
|
||||
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
140
crates/owlry-core/src/providers/lua_provider.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! LuaProvider - Bridge between Lua plugins and the Provider trait
|
||||
//!
|
||||
//! This module provides a `LuaProvider` struct that implements the `Provider` trait
|
||||
//! by delegating to a Lua plugin's registered provider functions.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::plugins::{LoadedPlugin, PluginItem, ProviderRegistration};
|
||||
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
|
||||
/// A provider backed by a Lua plugin
|
||||
///
|
||||
/// This struct implements the `Provider` trait by calling into a Lua plugin's
|
||||
/// `refresh` or `query` functions.
|
||||
pub struct LuaProvider {
|
||||
/// Provider registration info
|
||||
registration: ProviderRegistration,
|
||||
/// Reference to the loaded plugin (shared with other providers from same plugin)
|
||||
plugin: Rc<RefCell<LoadedPlugin>>,
|
||||
/// Cached items from last refresh
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl LuaProvider {
|
||||
/// Create a new LuaProvider
|
||||
pub fn new(registration: ProviderRegistration, plugin: Rc<RefCell<LoadedPlugin>>) -> Self {
|
||||
Self {
|
||||
registration,
|
||||
plugin,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a PluginItem to a LaunchItem
|
||||
fn convert_item(&self, item: PluginItem) -> LaunchItem {
|
||||
LaunchItem {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
icon: item.icon,
|
||||
provider: ProviderType::Plugin(self.registration.type_id.clone()),
|
||||
command: item.command.unwrap_or_default(),
|
||||
terminal: item.terminal,
|
||||
tags: item.tags,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for LuaProvider {
|
||||
fn name(&self) -> &str {
|
||||
&self.registration.name
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin(self.registration.type_id.clone())
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
// Only refresh static providers
|
||||
if !self.registration.is_static {
|
||||
return;
|
||||
}
|
||||
|
||||
let plugin = self.plugin.borrow();
|
||||
match plugin.call_provider_refresh(&self.registration.name) {
|
||||
Ok(items) => {
|
||||
self.items = items.into_iter().map(|i| self.convert_item(i)).collect();
|
||||
log::debug!(
|
||||
"[LuaProvider] '{}' refreshed with {} items",
|
||||
self.registration.name,
|
||||
self.items.len()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[LuaProvider] Failed to refresh '{}': {}",
|
||||
self.registration.name,
|
||||
e
|
||||
);
|
||||
self.items.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
// LuaProvider needs to be Send for the Provider trait
|
||||
// Since we're using Rc<RefCell<>>, we need to be careful about thread safety
|
||||
// For now, owlry is single-threaded, so this is safe
|
||||
unsafe impl Send for LuaProvider {}
|
||||
|
||||
/// Create LuaProviders from all registered providers in a plugin
|
||||
pub fn create_providers_from_plugin(plugin: Rc<RefCell<LoadedPlugin>>) -> Vec<Box<dyn Provider>> {
|
||||
let registrations = {
|
||||
let p = plugin.borrow();
|
||||
match p.get_provider_registrations() {
|
||||
Ok(regs) => regs,
|
||||
Err(e) => {
|
||||
log::error!("[LuaProvider] Failed to get registrations: {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
registrations
|
||||
.into_iter()
|
||||
.map(|reg| {
|
||||
let provider = LuaProvider::new(reg, plugin.clone());
|
||||
Box::new(provider) as Box<dyn Provider>
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Note: Full integration tests require a complete plugin setup
|
||||
// These tests verify the basic structure
|
||||
|
||||
#[test]
|
||||
fn test_provider_type() {
|
||||
let reg = ProviderRegistration {
|
||||
name: "test".to_string(),
|
||||
display_name: "Test".to_string(),
|
||||
type_id: "test_provider".to_string(),
|
||||
default_icon: "test-icon".to_string(),
|
||||
is_static: true,
|
||||
prefix: None,
|
||||
};
|
||||
|
||||
// We can't easily create a mock LoadedPlugin, so just test the type
|
||||
assert_eq!(reg.type_id, "test_provider");
|
||||
}
|
||||
}
|
||||
957
crates/owlry-core/src/providers/mod.rs
Normal file
@@ -0,0 +1,957 @@
|
||||
// Core providers (no plugin equivalents)
|
||||
mod application;
|
||||
mod command;
|
||||
|
||||
// Native plugin bridge
|
||||
pub mod native_provider;
|
||||
|
||||
// Lua plugin bridge (optional)
|
||||
#[cfg(feature = "lua")]
|
||||
pub mod lua_provider;
|
||||
|
||||
// Re-exports for core providers
|
||||
pub use application::ApplicationProvider;
|
||||
pub use command::CommandProvider;
|
||||
|
||||
// Re-export native provider for plugin loading
|
||||
pub use native_provider::NativeProvider;
|
||||
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use log::info;
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::data::FrecencyStore;
|
||||
|
||||
/// Metadata descriptor for an available provider (used by IPC/daemon API)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderDescriptor {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub prefix: Option<String>,
|
||||
pub icon: String,
|
||||
pub position: String,
|
||||
}
|
||||
|
||||
/// Represents a single searchable/launchable item
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchItem {
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub provider: ProviderType,
|
||||
pub command: String,
|
||||
pub terminal: bool,
|
||||
/// Tags/categories for filtering (e.g., from .desktop Categories)
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Provider type identifier for filtering and badge display
|
||||
///
|
||||
/// Core types are built-in providers. All native plugins use Plugin(type_id).
|
||||
/// This keeps the core app free of plugin-specific knowledge.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ProviderType {
|
||||
/// Built-in: Desktop applications from XDG directories
|
||||
Application,
|
||||
/// Built-in: Shell commands from PATH
|
||||
Command,
|
||||
/// Built-in: Pipe-based input (dmenu compatibility)
|
||||
Dmenu,
|
||||
/// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji")
|
||||
Plugin(String),
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ProviderType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
// Core built-in providers
|
||||
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
|
||||
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
|
||||
"dmenu" => Ok(ProviderType::Dmenu),
|
||||
// Everything else is a plugin
|
||||
other => Ok(ProviderType::Plugin(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProviderType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ProviderType::Application => write!(f, "app"),
|
||||
ProviderType::Command => write!(f, "cmd"),
|
||||
ProviderType::Dmenu => write!(f, "dmenu"),
|
||||
ProviderType::Plugin(type_id) => write!(f, "{}", type_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for all search providers
|
||||
pub trait Provider: Send {
|
||||
#[allow(dead_code)]
|
||||
fn name(&self) -> &str;
|
||||
fn provider_type(&self) -> ProviderType;
|
||||
fn refresh(&mut self);
|
||||
fn items(&self) -> &[LaunchItem];
|
||||
}
|
||||
|
||||
/// Manages all providers and handles searching
|
||||
pub struct ProviderManager {
|
||||
/// Core static providers (apps, commands, dmenu)
|
||||
providers: Vec<Box<dyn Provider>>,
|
||||
/// Static native plugin providers (need query() for submenu support)
|
||||
static_native_providers: Vec<NativeProvider>,
|
||||
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
|
||||
/// These are queried per-keystroke, not cached
|
||||
dynamic_providers: Vec<NativeProvider>,
|
||||
/// Widget providers from native plugins (weather, media, pomodoro)
|
||||
/// These appear at the top of results
|
||||
widget_providers: Vec<NativeProvider>,
|
||||
/// Fuzzy matcher for search
|
||||
matcher: SkimMatcherV2,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
/// Create a new ProviderManager with core providers and native plugins.
|
||||
///
|
||||
/// Core providers (e.g., ApplicationProvider, CommandProvider, DmenuProvider) are
|
||||
/// passed in by the caller. Native plugins are categorized based on their declared
|
||||
/// ProviderKind and ProviderPosition.
|
||||
pub fn new(
|
||||
core_providers: Vec<Box<dyn Provider>>,
|
||||
native_providers: Vec<NativeProvider>,
|
||||
) -> Self {
|
||||
let mut manager = Self {
|
||||
providers: core_providers,
|
||||
static_native_providers: Vec::new(),
|
||||
dynamic_providers: Vec::new(),
|
||||
widget_providers: Vec::new(),
|
||||
matcher: SkimMatcherV2::default(),
|
||||
};
|
||||
|
||||
// Categorize native plugins based on their declared ProviderKind and ProviderPosition
|
||||
for provider in native_providers {
|
||||
let type_id = provider.type_id();
|
||||
|
||||
if provider.is_dynamic() {
|
||||
info!(
|
||||
"Registered dynamic provider: {} ({})",
|
||||
provider.name(),
|
||||
type_id
|
||||
);
|
||||
manager.dynamic_providers.push(provider);
|
||||
} else if provider.is_widget() {
|
||||
info!(
|
||||
"Registered widget provider: {} ({})",
|
||||
provider.name(),
|
||||
type_id
|
||||
);
|
||||
manager.widget_providers.push(provider);
|
||||
} else {
|
||||
info!(
|
||||
"Registered static provider: {} ({})",
|
||||
provider.name(),
|
||||
type_id
|
||||
);
|
||||
manager.static_native_providers.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial refresh
|
||||
manager.refresh_all();
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
/// Create a self-contained ProviderManager from config.
|
||||
///
|
||||
/// Loads native plugins, creates core providers (Application + Command),
|
||||
/// categorizes everything, and performs initial refresh. Used by the daemon
|
||||
/// which doesn't have the UI-driven setup path from `app.rs`.
|
||||
pub fn new_with_config(config: &Config) -> Self {
|
||||
use crate::plugins::native_loader::NativePluginLoader;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Create core providers
|
||||
let core_providers: Vec<Box<dyn Provider>> = vec![
|
||||
Box::new(ApplicationProvider::new()),
|
||||
Box::new(CommandProvider::new()),
|
||||
];
|
||||
|
||||
// Load native plugins
|
||||
let mut loader = NativePluginLoader::new();
|
||||
loader.set_disabled(config.plugins.disabled_plugins.clone());
|
||||
|
||||
let native_providers = match loader.discover() {
|
||||
Ok(count) => {
|
||||
if count == 0 {
|
||||
info!("No native plugins found");
|
||||
Vec::new()
|
||||
} else {
|
||||
info!("Discovered {} native plugin(s)", count);
|
||||
let plugins: Vec<Arc<crate::plugins::native_loader::NativePlugin>> =
|
||||
loader.into_plugins();
|
||||
let mut providers = Vec::new();
|
||||
for plugin in plugins {
|
||||
for provider_info in &plugin.providers {
|
||||
let provider =
|
||||
NativeProvider::new(Arc::clone(&plugin), provider_info.clone());
|
||||
info!(
|
||||
"Created native provider: {} ({})",
|
||||
provider.name(),
|
||||
provider.type_id()
|
||||
);
|
||||
providers.push(provider);
|
||||
}
|
||||
}
|
||||
providers
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to discover native plugins: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Self::new(core_providers, native_providers)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_dmenu_mode(&self) -> bool {
|
||||
self.providers
|
||||
.iter()
|
||||
.any(|p| p.provider_type() == ProviderType::Dmenu)
|
||||
}
|
||||
|
||||
pub fn refresh_all(&mut self) {
|
||||
// Refresh core providers (apps, commands)
|
||||
for provider in &mut self.providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Provider '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh static native providers (clipboard, emoji, ssh, etc.)
|
||||
for provider in &mut self.static_native_providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Static provider '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
|
||||
// Widget providers are refreshed separately to avoid blocking startup
|
||||
// Call refresh_widgets() after window is shown
|
||||
|
||||
// Dynamic providers don't need refresh (they query on demand)
|
||||
}
|
||||
|
||||
/// Refresh widget providers (weather, media, pomodoro)
|
||||
/// Call this separately from refresh_all() to avoid blocking startup
|
||||
/// since widgets may make network requests or spawn processes
|
||||
pub fn refresh_widgets(&mut self) {
|
||||
for provider in &mut self.widget_providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Widget '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a native provider by type ID
|
||||
/// Searches in all native provider lists (static, dynamic, widget)
|
||||
pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> {
|
||||
// Check static native providers first (clipboard, emoji, ssh, systemd, etc.)
|
||||
if let Some(p) = self
|
||||
.static_native_providers
|
||||
.iter()
|
||||
.find(|p| p.type_id() == type_id)
|
||||
{
|
||||
return Some(p);
|
||||
}
|
||||
// Check widget providers (pomodoro, weather, media)
|
||||
if let Some(p) = self
|
||||
.widget_providers
|
||||
.iter()
|
||||
.find(|p| p.type_id() == type_id)
|
||||
{
|
||||
return Some(p);
|
||||
}
|
||||
// Then dynamic providers (calc, websearch, filesearch)
|
||||
self.dynamic_providers
|
||||
.iter()
|
||||
.find(|p| p.type_id() == type_id)
|
||||
}
|
||||
|
||||
/// Execute a plugin action command
|
||||
/// Command format: PLUGIN_ID:action_data (e.g., "POMODORO:start", "SYSTEMD:unit:restart")
|
||||
/// Returns true if the command was handled by a plugin
|
||||
pub fn execute_plugin_action(&self, command: &str) -> bool {
|
||||
// Parse command format: PLUGIN_ID:action_data
|
||||
if let Some(colon_pos) = command.find(':') {
|
||||
let plugin_id = &command[..colon_pos];
|
||||
let action = command; // Pass full command to plugin
|
||||
|
||||
// Find provider by type ID (case-insensitive for convenience)
|
||||
let type_id = plugin_id.to_lowercase();
|
||||
|
||||
if let Some(provider) = self.find_native_provider(&type_id) {
|
||||
provider.execute_action(action);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Add a dynamic provider (e.g., from a Lua plugin)
|
||||
#[allow(dead_code)]
|
||||
pub fn add_provider(&mut self, provider: Box<dyn Provider>) {
|
||||
info!("Added plugin provider: {}", provider.name());
|
||||
self.providers.push(provider);
|
||||
}
|
||||
|
||||
/// Add multiple providers at once (for batch plugin loading)
|
||||
#[allow(dead_code)]
|
||||
pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) {
|
||||
for provider in providers {
|
||||
self.add_provider(provider);
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate over all static provider items (core + native static plugins)
|
||||
fn all_static_items(&self) -> impl Iterator<Item = &LaunchItem> {
|
||||
self.providers.iter().flat_map(|p| p.items().iter()).chain(
|
||||
self.static_native_providers
|
||||
.iter()
|
||||
.flat_map(|p| p.items().iter()),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
|
||||
if query.is_empty() {
|
||||
// Return recent/popular items when query is empty
|
||||
return self
|
||||
.all_static_items()
|
||||
.take(max_results)
|
||||
.map(|item| (item.clone(), 0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = self
|
||||
.all_static_items()
|
||||
.filter_map(|item| {
|
||||
// Match against name and description
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
let score = match (name_score, desc_score) {
|
||||
(Some(n), Some(d)) => Some(n.max(d)),
|
||||
(Some(n), None) => Some(n),
|
||||
(None, Some(d)) => Some(d / 2), // Lower weight for description matches
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
score.map(|s| (item.clone(), s))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by score (descending)
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
|
||||
/// Search with provider filtering
|
||||
pub fn search_filtered(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &crate::filter::ProviderFilter,
|
||||
) -> Vec<(LaunchItem, i64)> {
|
||||
// Collect items from core providers
|
||||
let core_items = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
// Collect items from static native providers
|
||||
let native_items = self
|
||||
.static_native_providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
if query.is_empty() {
|
||||
return core_items
|
||||
.chain(native_items)
|
||||
.take(max_results)
|
||||
.map(|item| (item, 0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = core_items
|
||||
.chain(native_items)
|
||||
.filter_map(|item| {
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
let score = match (name_score, desc_score) {
|
||||
(Some(n), Some(d)) => Some(n.max(d)),
|
||||
(Some(n), None) => Some(n),
|
||||
(None, Some(d)) => Some(d / 2),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
score.map(|s| (item, s))
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
|
||||
/// Search with frecency boosting, dynamic providers, and tag filtering
|
||||
pub fn search_with_frecency(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &crate::filter::ProviderFilter,
|
||||
frecency: &FrecencyStore,
|
||||
frecency_weight: f64,
|
||||
tag_filter: Option<&str>,
|
||||
) -> Vec<(LaunchItem, i64)> {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Search] query={:?}, max={}, frecency_weight={}",
|
||||
query, max_results, frecency_weight
|
||||
);
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
|
||||
|
||||
// Add widget items first (highest priority) - only when:
|
||||
// 1. No specific filter prefix is active
|
||||
// 2. Query is empty (user hasn't started searching)
|
||||
// This keeps widgets visible on launch but hides them during active search
|
||||
// Widgets are always visible regardless of filter settings (they declare position via API)
|
||||
if filter.active_prefix().is_none() && query.is_empty() {
|
||||
// Widget priority comes from plugin-declared priority field
|
||||
for provider in &self.widget_providers {
|
||||
let base_score = provider.priority() as i64;
|
||||
for (idx, item) in provider.items().iter().enumerate() {
|
||||
results.push((item.clone(), base_score - idx as i64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query dynamic providers (calculator, websearch, filesearch)
|
||||
// Only query if:
|
||||
// 1. Their specific filter is active (e.g., :file prefix or Files tab selected), OR
|
||||
// 2. No specific single-mode filter is active (showing all providers)
|
||||
if !query.is_empty() {
|
||||
for provider in &self.dynamic_providers {
|
||||
// Skip if this provider type is explicitly filtered out
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
let dynamic_results = provider.query(query);
|
||||
// Priority comes from plugin-declared priority field
|
||||
let base_score = provider.priority() as i64;
|
||||
for (idx, item) in dynamic_results.into_iter().enumerate() {
|
||||
results.push((item, base_score - idx as i64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty query (after checking special providers) - return frecency-sorted items
|
||||
if query.is_empty() {
|
||||
// Collect items from core providers
|
||||
let core_items = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
// Collect items from static native providers
|
||||
let native_items = self
|
||||
.static_native_providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
let items: Vec<(LaunchItem, i64)> = core_items
|
||||
.chain(native_items)
|
||||
.filter(|item| {
|
||||
// Apply tag filter if present
|
||||
if let Some(tag) = tag_filter {
|
||||
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.map(|item| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
|
||||
(item, boosted)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Combine widgets (already in results) with frecency items
|
||||
results.extend(items);
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Regular search with frecency boost and tag matching
|
||||
// Helper closure for scoring items
|
||||
let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> {
|
||||
// Apply tag filter if present
|
||||
if let Some(tag) = tag_filter
|
||||
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
// Also match against tags (lower weight)
|
||||
let tag_score = item
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|t| self.matcher.fuzzy_match(t, query))
|
||||
.max()
|
||||
.map(|s| s / 3); // Lower weight for tag matches
|
||||
|
||||
let base_score = match (name_score, desc_score, tag_score) {
|
||||
(Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)),
|
||||
(Some(n), Some(d), None) => Some(n.max(d)),
|
||||
(Some(n), None, Some(t)) => Some(n.max(t)),
|
||||
(Some(n), None, None) => Some(n),
|
||||
(None, Some(d), Some(t)) => Some((d / 2).max(t)),
|
||||
(None, Some(d), None) => Some(d / 2),
|
||||
(None, None, Some(t)) => Some(t),
|
||||
(None, None, None) => None,
|
||||
};
|
||||
|
||||
base_score.map(|s| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
|
||||
(item.clone(), s + frecency_boost)
|
||||
})
|
||||
};
|
||||
|
||||
// Search core providers
|
||||
for provider in &self.providers {
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
for item in provider.items() {
|
||||
if let Some(scored) = score_item(item) {
|
||||
results.push(scored);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search static native providers
|
||||
for provider in &self.static_native_providers {
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
for item in provider.items() {
|
||||
if let Some(scored) = score_item(item) {
|
||||
results.push(scored);
|
||||
}
|
||||
}
|
||||
}
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
{
|
||||
debug!("[Search] Returning {} results", results.len());
|
||||
for (i, (item, score)) in results.iter().take(5).enumerate() {
|
||||
debug!(
|
||||
"[Search] #{}: {} (score={}, provider={:?})",
|
||||
i + 1,
|
||||
item.name,
|
||||
score,
|
||||
item.provider
|
||||
);
|
||||
}
|
||||
if results.len() > 5 {
|
||||
debug!("[Search] ... and {} more", results.len() - 5);
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Get all available provider types (for UI tabs)
|
||||
#[allow(dead_code)]
|
||||
pub fn available_provider_types(&self) -> Vec<ProviderType> {
|
||||
self.providers
|
||||
.iter()
|
||||
.map(|p| p.provider_type())
|
||||
.chain(
|
||||
self.static_native_providers
|
||||
.iter()
|
||||
.map(|p| p.provider_type()),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get descriptors for all registered providers (core + native plugins).
|
||||
///
|
||||
/// Used by the IPC server to report what providers are available to clients.
|
||||
pub fn available_providers(&self) -> Vec<ProviderDescriptor> {
|
||||
let mut descs = Vec::new();
|
||||
|
||||
// Core providers
|
||||
for provider in &self.providers {
|
||||
let (id, prefix, icon) = match provider.provider_type() {
|
||||
ProviderType::Application => (
|
||||
"app".to_string(),
|
||||
Some(":app".to_string()),
|
||||
"application-x-executable".to_string(),
|
||||
),
|
||||
ProviderType::Command => (
|
||||
"cmd".to_string(),
|
||||
Some(":cmd".to_string()),
|
||||
"utilities-terminal".to_string(),
|
||||
),
|
||||
ProviderType::Dmenu => {
|
||||
("dmenu".to_string(), None, "view-list-symbolic".to_string())
|
||||
}
|
||||
ProviderType::Plugin(type_id) => (type_id, None, "application-x-addon".to_string()),
|
||||
};
|
||||
descs.push(ProviderDescriptor {
|
||||
id,
|
||||
name: provider.name().to_string(),
|
||||
prefix,
|
||||
icon,
|
||||
position: "normal".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Static native plugin providers
|
||||
for provider in &self.static_native_providers {
|
||||
descs.push(ProviderDescriptor {
|
||||
id: provider.type_id().to_string(),
|
||||
name: provider.name().to_string(),
|
||||
prefix: provider.prefix().map(String::from),
|
||||
icon: provider.icon().to_string(),
|
||||
position: provider.position_str().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Dynamic native plugin providers
|
||||
for provider in &self.dynamic_providers {
|
||||
descs.push(ProviderDescriptor {
|
||||
id: provider.type_id().to_string(),
|
||||
name: provider.name().to_string(),
|
||||
prefix: provider.prefix().map(String::from),
|
||||
icon: provider.icon().to_string(),
|
||||
position: provider.position_str().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Widget native plugin providers
|
||||
for provider in &self.widget_providers {
|
||||
descs.push(ProviderDescriptor {
|
||||
id: provider.type_id().to_string(),
|
||||
name: provider.name().to_string(),
|
||||
prefix: provider.prefix().map(String::from),
|
||||
icon: provider.icon().to_string(),
|
||||
position: provider.position_str().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
descs
|
||||
}
|
||||
|
||||
/// Refresh a specific provider by its type_id.
|
||||
///
|
||||
/// Searches core providers (by ProviderType string), static native providers,
|
||||
/// and widget providers. Dynamic providers are skipped (they query on demand).
|
||||
pub fn refresh_provider(&mut self, provider_id: &str) {
|
||||
// Check core providers
|
||||
for provider in &mut self.providers {
|
||||
let matches = match provider.provider_type() {
|
||||
ProviderType::Application => provider_id == "app",
|
||||
ProviderType::Command => provider_id == "cmd",
|
||||
ProviderType::Dmenu => provider_id == "dmenu",
|
||||
ProviderType::Plugin(ref id) => provider_id == id,
|
||||
};
|
||||
if matches {
|
||||
provider.refresh();
|
||||
info!("Refreshed core provider '{}'", provider.name());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check static native providers
|
||||
for provider in &mut self.static_native_providers {
|
||||
if provider.type_id() == provider_id {
|
||||
provider.refresh();
|
||||
info!("Refreshed static provider '{}'", provider.name());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check widget providers
|
||||
for provider in &mut self.widget_providers {
|
||||
if provider.type_id() == provider_id {
|
||||
provider.refresh();
|
||||
info!("Refreshed widget provider '{}'", provider.name());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
info!("Provider '{}' not found for refresh", provider_id);
|
||||
}
|
||||
|
||||
/// Get a widget item by type_id (e.g., "pomodoro", "weather", "media")
|
||||
/// Returns the first item from the widget provider, if any
|
||||
pub fn get_widget_item(&self, type_id: &str) -> Option<LaunchItem> {
|
||||
self.widget_providers
|
||||
.iter()
|
||||
.find(|p| p.type_id() == type_id)
|
||||
.and_then(|p| p.items().first().cloned())
|
||||
}
|
||||
|
||||
/// Get all loaded widget provider type_ids
|
||||
/// Returns an iterator over the type_ids of currently loaded widget providers
|
||||
pub fn widget_type_ids(&self) -> impl Iterator<Item = &str> {
|
||||
self.widget_providers.iter().map(|p| p.type_id())
|
||||
}
|
||||
|
||||
/// Query a plugin for submenu actions
|
||||
///
|
||||
/// This is used when a user selects a SUBMENU:plugin_id:data item.
|
||||
/// The plugin is queried with "?SUBMENU:data" and returns action items.
|
||||
///
|
||||
/// Returns (display_name, actions) where display_name is the item name
|
||||
/// and actions are the submenu items returned by the plugin.
|
||||
pub fn query_submenu_actions(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
data: &str,
|
||||
display_name: &str,
|
||||
) -> Option<(String, Vec<LaunchItem>)> {
|
||||
// Build the submenu query
|
||||
let submenu_query = format!("?SUBMENU:{}", data);
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Submenu] Querying plugin '{}' with: {}",
|
||||
plugin_id, submenu_query
|
||||
);
|
||||
|
||||
// Search in static native providers (clipboard, emoji, ssh, systemd, etc.)
|
||||
for provider in &self.static_native_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in dynamic providers
|
||||
for provider in &self.dynamic_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in widget providers
|
||||
for provider in &self.widget_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Submenu] No submenu actions found for plugin '{}'",
|
||||
plugin_id
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimal mock provider for testing ProviderManager
|
||||
struct MockProvider {
|
||||
name: String,
|
||||
provider_type: ProviderType,
|
||||
items: Vec<LaunchItem>,
|
||||
refresh_count: usize,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
fn new(name: &str, provider_type: ProviderType) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
provider_type,
|
||||
items: Vec::new(),
|
||||
refresh_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_items(mut self, items: Vec<LaunchItem>) -> Self {
|
||||
self.items = items;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for MockProvider {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
self.provider_type.clone()
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.refresh_count += 1;
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
fn make_item(id: &str, name: &str, provider: ProviderType) -> LaunchItem {
|
||||
LaunchItem {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
icon: None,
|
||||
provider,
|
||||
command: format!("run-{}", id),
|
||||
terminal: false,
|
||||
tags: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_available_providers_core_only() {
|
||||
let providers: Vec<Box<dyn Provider>> = vec![
|
||||
Box::new(MockProvider::new("Applications", ProviderType::Application)),
|
||||
Box::new(MockProvider::new("Commands", ProviderType::Command)),
|
||||
];
|
||||
let pm = ProviderManager::new(providers, Vec::new());
|
||||
let descs = pm.available_providers();
|
||||
assert_eq!(descs.len(), 2);
|
||||
assert_eq!(descs[0].id, "app");
|
||||
assert_eq!(descs[0].name, "Applications");
|
||||
assert_eq!(descs[0].prefix, Some(":app".to_string()));
|
||||
assert_eq!(descs[0].icon, "application-x-executable");
|
||||
assert_eq!(descs[0].position, "normal");
|
||||
assert_eq!(descs[1].id, "cmd");
|
||||
assert_eq!(descs[1].name, "Commands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_available_providers_dmenu() {
|
||||
let providers: Vec<Box<dyn Provider>> =
|
||||
vec![Box::new(MockProvider::new("dmenu", ProviderType::Dmenu))];
|
||||
let pm = ProviderManager::new(providers, Vec::new());
|
||||
let descs = pm.available_providers();
|
||||
assert_eq!(descs.len(), 1);
|
||||
assert_eq!(descs[0].id, "dmenu");
|
||||
assert!(descs[0].prefix.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_available_provider_types() {
|
||||
let providers: Vec<Box<dyn Provider>> = vec![
|
||||
Box::new(MockProvider::new("Applications", ProviderType::Application)),
|
||||
Box::new(MockProvider::new("Commands", ProviderType::Command)),
|
||||
];
|
||||
let pm = ProviderManager::new(providers, Vec::new());
|
||||
let types = pm.available_provider_types();
|
||||
assert_eq!(types.len(), 2);
|
||||
assert!(types.contains(&ProviderType::Application));
|
||||
assert!(types.contains(&ProviderType::Command));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_provider_core() {
|
||||
let app = MockProvider::new("Applications", ProviderType::Application);
|
||||
let cmd = MockProvider::new("Commands", ProviderType::Command);
|
||||
let providers: Vec<Box<dyn Provider>> = vec![Box::new(app), Box::new(cmd)];
|
||||
let mut pm = ProviderManager::new(providers, Vec::new());
|
||||
|
||||
// refresh_all was called during construction, now refresh individual
|
||||
pm.refresh_provider("app");
|
||||
pm.refresh_provider("cmd");
|
||||
// Just verifying it doesn't panic; can't easily inspect refresh_count
|
||||
// through Box<dyn Provider>
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_provider_unknown_does_not_panic() {
|
||||
let providers: Vec<Box<dyn Provider>> = vec![Box::new(MockProvider::new(
|
||||
"Applications",
|
||||
ProviderType::Application,
|
||||
))];
|
||||
let mut pm = ProviderManager::new(providers, Vec::new());
|
||||
pm.refresh_provider("nonexistent");
|
||||
// Should complete without panicking
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_with_core_providers() {
|
||||
let items = vec![
|
||||
make_item("firefox", "Firefox", ProviderType::Application),
|
||||
make_item("vim", "Vim", ProviderType::Application),
|
||||
];
|
||||
let provider =
|
||||
MockProvider::new("Applications", ProviderType::Application).with_items(items);
|
||||
let providers: Vec<Box<dyn Provider>> = vec![Box::new(provider)];
|
||||
let pm = ProviderManager::new(providers, Vec::new());
|
||||
|
||||
let results = pm.search("fire", 10);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0.name, "Firefox");
|
||||
}
|
||||
}
|
||||
215
crates/owlry-core/src/providers/native_provider.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
//! Native Plugin Provider Bridge
|
||||
//!
|
||||
//! This module provides a bridge between native plugins (compiled .so files)
|
||||
//! and the core Provider trait used by ProviderManager.
|
||||
//!
|
||||
//! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files
|
||||
//! and provide search providers via an ABI-stable interface.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use log::debug;
|
||||
use owlry_plugin_api::{
|
||||
PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition,
|
||||
};
|
||||
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use crate::plugins::native_loader::NativePlugin;
|
||||
|
||||
/// A provider backed by a native plugin
|
||||
///
|
||||
/// This wraps a native plugin's provider and implements the core Provider trait,
|
||||
/// allowing native plugins to be used seamlessly with the existing ProviderManager.
|
||||
pub struct NativeProvider {
|
||||
/// The native plugin (shared reference since multiple providers may use same plugin)
|
||||
plugin: Arc<NativePlugin>,
|
||||
/// Provider metadata
|
||||
info: ProviderInfo,
|
||||
/// Handle to the provider state in the plugin
|
||||
handle: ProviderHandle,
|
||||
/// Cached items (for static providers)
|
||||
items: RwLock<Vec<LaunchItem>>,
|
||||
}
|
||||
|
||||
impl NativeProvider {
|
||||
/// Create a new native provider
|
||||
pub fn new(plugin: Arc<NativePlugin>, info: ProviderInfo) -> Self {
|
||||
let handle = plugin.init_provider(info.id.as_str());
|
||||
|
||||
Self {
|
||||
plugin,
|
||||
info,
|
||||
handle,
|
||||
items: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the ProviderType for this native provider
|
||||
/// All native plugins return Plugin(type_id) - the core has no hardcoded plugin types
|
||||
fn get_provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin(self.info.type_id.to_string())
|
||||
}
|
||||
|
||||
/// Convert a plugin API item to a core LaunchItem
|
||||
fn convert_item(&self, item: ApiPluginItem) -> LaunchItem {
|
||||
LaunchItem {
|
||||
id: item.id.to_string(),
|
||||
name: item.name.to_string(),
|
||||
description: item.description.as_ref().map(|s| s.to_string()).into(),
|
||||
icon: item.icon.as_ref().map(|s| s.to_string()).into(),
|
||||
provider: self.get_provider_type(),
|
||||
command: item.command.to_string(),
|
||||
terminal: item.terminal,
|
||||
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query the provider
|
||||
///
|
||||
/// For dynamic providers, this is called per-keystroke.
|
||||
/// For static providers, returns cached items unless query is a special command
|
||||
/// (submenu queries `?SUBMENU:` or action commands `!ACTION:`).
|
||||
pub fn query(&self, query: &str) -> Vec<LaunchItem> {
|
||||
// Special queries (submenu, actions) should always be forwarded to the plugin
|
||||
let is_special_query = query.starts_with("?SUBMENU:") || query.starts_with("!");
|
||||
|
||||
if self.info.provider_type != ProviderKind::Dynamic && !is_special_query {
|
||||
return self.items.read().unwrap().clone();
|
||||
}
|
||||
|
||||
let api_items = self.plugin.query_provider(self.handle, query);
|
||||
api_items
|
||||
.into_iter()
|
||||
.map(|item| self.convert_item(item))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if this provider has a prefix that matches the query
|
||||
#[allow(dead_code)]
|
||||
pub fn matches_prefix(&self, query: &str) -> bool {
|
||||
match self.info.prefix.as_ref().into_option() {
|
||||
Some(prefix) => query.starts_with(prefix.as_str()),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the prefix for this provider (if any)
|
||||
#[allow(dead_code)]
|
||||
pub fn prefix(&self) -> Option<&str> {
|
||||
self.info.prefix.as_ref().map(|s| s.as_str()).into()
|
||||
}
|
||||
|
||||
/// Check if this is a dynamic provider
|
||||
#[allow(dead_code)]
|
||||
pub fn is_dynamic(&self) -> bool {
|
||||
self.info.provider_type == ProviderKind::Dynamic
|
||||
}
|
||||
|
||||
/// Get the provider type ID (e.g., "calc", "clipboard", "weather")
|
||||
pub fn type_id(&self) -> &str {
|
||||
self.info.type_id.as_str()
|
||||
}
|
||||
|
||||
/// Check if this is a widget provider (appears at top of results)
|
||||
pub fn is_widget(&self) -> bool {
|
||||
self.info.position == ProviderPosition::Widget
|
||||
}
|
||||
|
||||
/// Get the provider's priority for result ordering
|
||||
/// Higher values appear first in results
|
||||
pub fn priority(&self) -> i32 {
|
||||
self.info.priority
|
||||
}
|
||||
|
||||
/// Get the provider's default icon name
|
||||
pub fn icon(&self) -> &str {
|
||||
self.info.icon.as_str()
|
||||
}
|
||||
|
||||
/// Get the provider's display position as a string
|
||||
pub fn position_str(&self) -> &str {
|
||||
match self.info.position {
|
||||
ProviderPosition::Widget => "widget",
|
||||
ProviderPosition::Normal => "normal",
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an action command on the provider
|
||||
/// Uses query with "!" prefix to trigger action handling in the plugin
|
||||
pub fn execute_action(&self, action: &str) {
|
||||
let action_query = format!("!{}", action);
|
||||
self.plugin.query_provider(self.handle, &action_query);
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for NativeProvider {
|
||||
fn name(&self) -> &str {
|
||||
self.info.name.as_str()
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
self.get_provider_type()
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
// Only refresh static providers
|
||||
if self.info.provider_type != ProviderKind::Static {
|
||||
return;
|
||||
}
|
||||
|
||||
debug!("Refreshing native provider '{}'", self.info.name.as_str());
|
||||
|
||||
let api_items = self.plugin.refresh_provider(self.handle);
|
||||
let items: Vec<LaunchItem> = api_items
|
||||
.into_iter()
|
||||
.map(|item| self.convert_item(item))
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
"Native provider '{}' loaded {} items",
|
||||
self.info.name.as_str(),
|
||||
items.len()
|
||||
);
|
||||
|
||||
*self.items.write().unwrap() = items;
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
// This is tricky with RwLock - we need to return a reference but can't
|
||||
// hold the lock across the return. We use a raw pointer approach.
|
||||
//
|
||||
// SAFETY: The items Vec is only modified during refresh() which takes
|
||||
// &mut self, so no concurrent modification can occur while this
|
||||
// reference is live.
|
||||
unsafe {
|
||||
let guard = self.items.read().unwrap();
|
||||
let ptr = guard.as_ptr();
|
||||
let len = guard.len();
|
||||
std::slice::from_raw_parts(ptr, len)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeProvider {
|
||||
fn drop(&mut self) {
|
||||
// Clean up the provider handle
|
||||
self.plugin.drop_provider(self.handle);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Note: Full testing requires actual .so plugins, which we'll test
|
||||
// via integration tests. Unit tests here focus on the conversion logic.
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_conversion() {
|
||||
// Test that type_id is correctly converted to ProviderType::Plugin
|
||||
let type_id = "calculator";
|
||||
let provider_type = ProviderType::Plugin(type_id.to_string());
|
||||
|
||||
assert_eq!(format!("{}", provider_type), "calculator");
|
||||
}
|
||||
}
|
||||
258
crates/owlry-core/src/server.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
use log::{error, info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::data::FrecencyStore;
|
||||
use crate::filter::ProviderFilter;
|
||||
use crate::ipc::{ProviderDesc, Request, Response, ResultItem};
|
||||
use crate::providers::{LaunchItem, ProviderManager};
|
||||
|
||||
/// IPC server that listens on a Unix domain socket and dispatches
|
||||
/// requests to the provider system.
|
||||
pub struct Server {
|
||||
listener: UnixListener,
|
||||
socket_path: PathBuf,
|
||||
provider_manager: Arc<Mutex<ProviderManager>>,
|
||||
frecency: Arc<Mutex<FrecencyStore>>,
|
||||
config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Bind to the given socket path, loading config and creating a ProviderManager.
|
||||
///
|
||||
/// Removes a stale socket file if one already exists at the path.
|
||||
pub fn bind(socket_path: &Path) -> io::Result<Self> {
|
||||
// Remove stale socket if present
|
||||
if socket_path.exists() {
|
||||
info!("Removing stale socket at {:?}", socket_path);
|
||||
std::fs::remove_file(socket_path)?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(socket_path)?;
|
||||
info!("IPC server listening on {:?}", socket_path);
|
||||
|
||||
let config = Config::load_or_default();
|
||||
let provider_manager = ProviderManager::new_with_config(&config);
|
||||
let frecency = FrecencyStore::new();
|
||||
|
||||
Ok(Self {
|
||||
listener,
|
||||
socket_path: socket_path.to_path_buf(),
|
||||
provider_manager: Arc::new(Mutex::new(provider_manager)),
|
||||
frecency: Arc::new(Mutex::new(frecency)),
|
||||
config: Arc::new(config),
|
||||
})
|
||||
}
|
||||
|
||||
/// Accept connections in a loop, spawning a thread per client.
|
||||
pub fn run(&self) -> io::Result<()> {
|
||||
info!("Server entering accept loop");
|
||||
for stream in self.listener.incoming() {
|
||||
match stream {
|
||||
Ok(stream) => {
|
||||
let pm = Arc::clone(&self.provider_manager);
|
||||
let frecency = Arc::clone(&self.frecency);
|
||||
let config = Arc::clone(&self.config);
|
||||
thread::spawn(move || {
|
||||
if let Err(e) = Self::handle_client(stream, pm, frecency, config) {
|
||||
warn!("Client handler error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to accept connection: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept one connection and handle all its requests until EOF.
|
||||
///
|
||||
/// Intended for integration tests where spawning a full accept loop
|
||||
/// is unnecessary.
|
||||
pub fn handle_one_for_testing(&self) -> io::Result<()> {
|
||||
let (stream, _addr) = self.listener.accept()?;
|
||||
Self::handle_client(
|
||||
stream,
|
||||
Arc::clone(&self.provider_manager),
|
||||
Arc::clone(&self.frecency),
|
||||
Arc::clone(&self.config),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read newline-delimited JSON requests from a single client stream,
|
||||
/// dispatch each, and write the JSON response back.
|
||||
fn handle_client(
|
||||
stream: UnixStream,
|
||||
pm: Arc<Mutex<ProviderManager>>,
|
||||
frecency: Arc<Mutex<FrecencyStore>>,
|
||||
config: Arc<Config>,
|
||||
) -> io::Result<()> {
|
||||
let reader = BufReader::new(stream.try_clone()?);
|
||||
let mut writer = stream;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: Request = match serde_json::from_str(trimmed) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
let resp = Response::Error {
|
||||
message: format!("invalid request JSON: {}", e),
|
||||
};
|
||||
write_response(&mut writer, &resp)?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let response = Self::handle_request(&request, &pm, &frecency, &config);
|
||||
write_response(&mut writer, &response)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a single request to the appropriate subsystem and return
|
||||
/// the response.
|
||||
fn handle_request(
|
||||
request: &Request,
|
||||
pm: &Arc<Mutex<ProviderManager>>,
|
||||
frecency: &Arc<Mutex<FrecencyStore>>,
|
||||
config: &Arc<Config>,
|
||||
) -> Response {
|
||||
match request {
|
||||
Request::Query { text, modes } => {
|
||||
let filter = match modes {
|
||||
Some(m) => ProviderFilter::from_mode_strings(m),
|
||||
None => ProviderFilter::all(),
|
||||
};
|
||||
let max = config.general.max_results;
|
||||
let weight = config.providers.frecency_weight;
|
||||
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
let frecency_guard = frecency.lock().unwrap();
|
||||
let results = pm_guard.search_with_frecency(
|
||||
text,
|
||||
max,
|
||||
&filter,
|
||||
&frecency_guard,
|
||||
weight,
|
||||
None,
|
||||
);
|
||||
|
||||
Response::Results {
|
||||
items: results
|
||||
.into_iter()
|
||||
.map(|(item, score)| launch_item_to_result(item, score))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
Request::Launch {
|
||||
item_id,
|
||||
provider: _,
|
||||
} => {
|
||||
let mut frecency_guard = frecency.lock().unwrap();
|
||||
frecency_guard.record_launch(item_id);
|
||||
Response::Ack
|
||||
}
|
||||
|
||||
Request::Providers => {
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
let descs = pm_guard.available_providers();
|
||||
Response::Providers {
|
||||
list: descs.into_iter().map(descriptor_to_desc).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
Request::Refresh { provider } => {
|
||||
let mut pm_guard = pm.lock().unwrap();
|
||||
pm_guard.refresh_provider(provider);
|
||||
Response::Ack
|
||||
}
|
||||
|
||||
Request::Toggle => {
|
||||
// Toggle visibility is a client-side concern; the daemon just acks.
|
||||
Response::Ack
|
||||
}
|
||||
|
||||
Request::Submenu { plugin_id, data } => {
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) {
|
||||
Some((_name, actions)) => Response::SubmenuItems {
|
||||
items: actions
|
||||
.into_iter()
|
||||
.map(|item| launch_item_to_result(item, 0))
|
||||
.collect(),
|
||||
},
|
||||
None => Response::Error {
|
||||
message: format!("no submenu actions for plugin '{}'", plugin_id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Request::PluginAction { command } => {
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
if pm_guard.execute_plugin_action(command) {
|
||||
Response::Ack
|
||||
} else {
|
||||
Response::Error {
|
||||
message: format!("no plugin handled action '{}'", command),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort cleanup of the socket file
|
||||
if self.socket_path.exists() {
|
||||
let _ = std::fs::remove_file(&self.socket_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a response as a single JSON line terminated by newline.
|
||||
fn write_response(writer: &mut UnixStream, response: &Response) -> io::Result<()> {
|
||||
let mut json = serde_json::to_string(response)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
json.push('\n');
|
||||
writer.write_all(json.as_bytes())?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
fn launch_item_to_result(item: LaunchItem, score: i64) -> ResultItem {
|
||||
ResultItem {
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
description: item.description.unwrap_or_default(),
|
||||
icon: item.icon.unwrap_or_default(),
|
||||
provider: format!("{}", item.provider),
|
||||
score,
|
||||
command: Some(item.command),
|
||||
terminal: item.terminal,
|
||||
tags: item.tags,
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor_to_desc(desc: crate::providers::ProviderDescriptor) -> ProviderDesc {
|
||||
ProviderDesc {
|
||||
id: desc.id,
|
||||
name: desc.name,
|
||||
prefix: desc.prefix,
|
||||
icon: desc.icon,
|
||||
position: desc.position,
|
||||
}
|
||||
}
|
||||
148
crates/owlry-core/tests/ipc_test.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
|
||||
|
||||
#[test]
|
||||
fn test_query_request_roundtrip() {
|
||||
let req = Request::Query {
|
||||
text: "fire".into(),
|
||||
modes: Some(vec!["app".into(), "cmd".into()]),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_request_without_modes() {
|
||||
let req = Request::Query {
|
||||
text: "fire".into(),
|
||||
modes: None,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(!json.contains("modes"));
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_request_roundtrip() {
|
||||
let req = Request::Launch {
|
||||
item_id: "firefox.desktop".into(),
|
||||
provider: "app".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_results_response_roundtrip() {
|
||||
let resp = Response::Results {
|
||||
items: vec![ResultItem {
|
||||
id: "firefox.desktop".into(),
|
||||
title: "Firefox".into(),
|
||||
description: "Web Browser".into(),
|
||||
icon: "firefox".into(),
|
||||
provider: "app".into(),
|
||||
score: 95,
|
||||
command: Some("firefox".into()),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let parsed: Response = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(resp, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_providers_response() {
|
||||
let resp = Response::Providers {
|
||||
list: vec![ProviderDesc {
|
||||
id: "app".into(),
|
||||
name: "Applications".into(),
|
||||
prefix: Some(":app".into()),
|
||||
icon: "application-x-executable".into(),
|
||||
position: "normal".into(),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let parsed: Response = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(resp, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_response() {
|
||||
let resp = Response::Error {
|
||||
message: "plugin not found".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let parsed: Response = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(resp, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_request() {
|
||||
let req = Request::Toggle;
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submenu_request() {
|
||||
let req = Request::Submenu {
|
||||
plugin_id: "systemd".into(),
|
||||
data: "docker.service".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_request() {
|
||||
let req = Request::Refresh {
|
||||
provider: "clipboard".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_action_request() {
|
||||
let req = Request::PluginAction {
|
||||
command: "POMODORO:start".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_field_defaults_false() {
|
||||
// terminal field should default to false when missing from JSON
|
||||
let json =
|
||||
r#"{"id":"test","title":"Test","description":"","icon":"","provider":"cmd","score":0}"#;
|
||||
let item: ResultItem = serde_json::from_str(json).unwrap();
|
||||
assert!(!item.terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_field_roundtrip() {
|
||||
let item = ResultItem {
|
||||
id: "htop".into(),
|
||||
title: "htop".into(),
|
||||
description: "Process viewer".into(),
|
||||
icon: "htop".into(),
|
||||
provider: "cmd".into(),
|
||||
score: 50,
|
||||
command: Some("htop".into()),
|
||||
terminal: true,
|
||||
tags: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
assert!(json.contains("\"terminal\":true"));
|
||||
let parsed: ResultItem = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.terminal);
|
||||
}
|
||||
239
crates/owlry-core/tests/server_test.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::thread;
|
||||
|
||||
use owlry_core::ipc::{Request, Response};
|
||||
use owlry_core::server::Server;
|
||||
|
||||
/// Helper: send a JSON request line and read the JSON response line.
|
||||
fn roundtrip(stream: &mut UnixStream, request: &Request) -> Response {
|
||||
let mut line = serde_json::to_string(request).unwrap();
|
||||
line.push('\n');
|
||||
stream.write_all(line.as_bytes()).unwrap();
|
||||
stream.flush().unwrap();
|
||||
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut buf = String::new();
|
||||
reader.read_line(&mut buf).unwrap();
|
||||
serde_json::from_str(buf.trim()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_responds_to_providers_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
// Spawn the server to handle exactly one connection
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
// Connect as a client
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let resp = roundtrip(&mut stream, &Request::Providers);
|
||||
|
||||
match resp {
|
||||
Response::Providers { list } => {
|
||||
// The default ProviderManager always has at least Application and Command
|
||||
assert!(
|
||||
list.len() >= 2,
|
||||
"expected at least 2 providers, got {}",
|
||||
list.len()
|
||||
);
|
||||
let ids: Vec<&str> = list.iter().map(|p| p.id.as_str()).collect();
|
||||
assert!(ids.contains(&"app"), "missing 'app' provider");
|
||||
assert!(ids.contains(&"cmd"), "missing 'cmd' provider");
|
||||
}
|
||||
other => panic!("expected Providers response, got: {:?}", other),
|
||||
}
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_handles_launch_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let req = Request::Launch {
|
||||
item_id: "firefox.desktop".into(),
|
||||
provider: "app".into(),
|
||||
};
|
||||
let resp = roundtrip(&mut stream, &req);
|
||||
|
||||
assert_eq!(resp, Response::Ack);
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_handles_query_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let req = Request::Query {
|
||||
text: "nonexistent_query_xyz".into(),
|
||||
modes: None,
|
||||
};
|
||||
let resp = roundtrip(&mut stream, &req);
|
||||
|
||||
match resp {
|
||||
Response::Results { items } => {
|
||||
// A nonsense query should return empty or very few results
|
||||
// (no items will fuzzy-match "nonexistent_query_xyz")
|
||||
assert!(
|
||||
items.len() <= 5,
|
||||
"expected few/no results for gibberish query"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Results response, got: {:?}", other),
|
||||
}
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_handles_toggle_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let resp = roundtrip(&mut stream, &Request::Toggle);
|
||||
|
||||
assert_eq!(resp, Response::Ack);
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_handles_refresh_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let req = Request::Refresh {
|
||||
provider: "app".into(),
|
||||
};
|
||||
let resp = roundtrip(&mut stream, &req);
|
||||
|
||||
assert_eq!(resp, Response::Ack);
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_handles_submenu_for_unknown_plugin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let req = Request::Submenu {
|
||||
plugin_id: "nonexistent_plugin".into(),
|
||||
data: "some_data".into(),
|
||||
};
|
||||
let resp = roundtrip(&mut stream, &req);
|
||||
|
||||
match resp {
|
||||
Response::Error { message } => {
|
||||
assert!(
|
||||
message.contains("nonexistent_plugin"),
|
||||
"error should mention the plugin id"
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"expected Error response for unknown plugin, got: {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_handles_multiple_requests_per_connection() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
|
||||
// Send Providers request
|
||||
let resp1 = roundtrip(&mut stream, &Request::Providers);
|
||||
assert!(matches!(resp1, Response::Providers { .. }));
|
||||
|
||||
// Send Toggle request on same connection
|
||||
let resp2 = roundtrip(&mut stream, &Request::Toggle);
|
||||
assert_eq!(resp2, Response::Ack);
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_cleans_up_stale_socket() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
// Create a stale socket file
|
||||
std::os::unix::net::UnixListener::bind(&sock).unwrap();
|
||||
assert!(sock.exists());
|
||||
|
||||
// Server::bind should succeed by removing the stale socket
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let resp = roundtrip(&mut stream, &Request::Toggle);
|
||||
assert_eq!(resp, Response::Ack);
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
46
crates/owlry-lua/Cargo.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "owlry-lua"
|
||||
version = "1.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Lua runtime for owlry plugins - enables loading user-created Lua plugins"
|
||||
keywords = ["owlry", "plugin", "lua", "runtime"]
|
||||
categories = ["development-tools"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry (shared types)
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types
|
||||
abi_stable = "0.11"
|
||||
|
||||
# Lua runtime
|
||||
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"] }
|
||||
|
||||
# Plugin manifest parsing
|
||||
toml = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Version compatibility
|
||||
semver = "1"
|
||||
|
||||
# HTTP client for plugins
|
||||
reqwest = { version = "0.13", features = ["blocking", "json"] }
|
||||
|
||||
# Math expression evaluation
|
||||
meval = "0.2"
|
||||
|
||||
# Date/time for os.date
|
||||
chrono = "0.4"
|
||||
|
||||
# XDG paths
|
||||
dirs = "5.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
52
crates/owlry-lua/src/api/mod.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! Lua API implementations for plugins
|
||||
//!
|
||||
//! This module provides the `owlry` global table and its submodules
|
||||
//! that plugins can use to interact with owlry.
|
||||
|
||||
mod provider;
|
||||
mod utils;
|
||||
|
||||
use mlua::{Lua, Result as LuaResult};
|
||||
use owlry_plugin_api::PluginItem;
|
||||
|
||||
use crate::loader::ProviderRegistration;
|
||||
|
||||
/// Register all owlry APIs in the Lua runtime
|
||||
pub fn register_apis(lua: &Lua, plugin_dir: &std::path::Path, plugin_id: &str) -> LuaResult<()> {
|
||||
let globals = lua.globals();
|
||||
|
||||
// Create the main owlry table
|
||||
let owlry = lua.create_table()?;
|
||||
|
||||
// Register utility APIs (log, path, fs, json)
|
||||
utils::register_log_api(lua, &owlry)?;
|
||||
utils::register_path_api(lua, &owlry, plugin_dir)?;
|
||||
utils::register_fs_api(lua, &owlry, plugin_dir)?;
|
||||
utils::register_json_api(lua, &owlry)?;
|
||||
|
||||
// Register provider API
|
||||
provider::register_provider_api(lua, &owlry)?;
|
||||
|
||||
// Set owlry as global
|
||||
globals.set("owlry", owlry)?;
|
||||
|
||||
// Suppress unused warnings
|
||||
let _ = plugin_id;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get provider registrations from the Lua runtime
|
||||
pub fn get_provider_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
|
||||
provider::get_registrations(lua)
|
||||
}
|
||||
|
||||
/// Call a provider's refresh function
|
||||
pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>> {
|
||||
provider::call_refresh(lua, provider_name)
|
||||
}
|
||||
|
||||
/// Call a provider's query function
|
||||
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
|
||||
provider::call_query(lua, provider_name, query)
|
||||
}
|
||||
243
crates/owlry-lua/src/api/provider.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
//! Provider registration API for Lua plugins
|
||||
|
||||
use mlua::{Function, Lua, Result as LuaResult, Table, Value};
|
||||
use owlry_plugin_api::PluginItem;
|
||||
use std::cell::RefCell;
|
||||
|
||||
use crate::loader::ProviderRegistration;
|
||||
|
||||
thread_local! {
|
||||
static REGISTRATIONS: RefCell<Vec<ProviderRegistration>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Register the provider API in the owlry table
|
||||
pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let provider = lua.create_table()?;
|
||||
|
||||
// owlry.provider.register(config)
|
||||
provider.set("register", lua.create_function(register_provider)?)?;
|
||||
|
||||
owlry.set("provider", provider)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Implementation of owlry.provider.register()
|
||||
fn register_provider(_lua: &Lua, config: Table) -> LuaResult<()> {
|
||||
let name: String = config.get("name")?;
|
||||
let display_name: String = config
|
||||
.get::<Option<String>>("display_name")?
|
||||
.unwrap_or_else(|| name.clone());
|
||||
let type_id: String = config
|
||||
.get::<Option<String>>("type_id")?
|
||||
.unwrap_or_else(|| name.replace('-', "_"));
|
||||
let default_icon: String = config
|
||||
.get::<Option<String>>("default_icon")?
|
||||
.unwrap_or_else(|| "application-x-addon".to_string());
|
||||
let prefix: Option<String> = config.get("prefix")?;
|
||||
|
||||
// Check if it's a dynamic provider (has query function) or static (has refresh)
|
||||
let has_query: bool = config.contains_key("query")?;
|
||||
let has_refresh: bool = config.contains_key("refresh")?;
|
||||
|
||||
if !has_query && !has_refresh {
|
||||
return Err(mlua::Error::external(
|
||||
"Provider must have either 'refresh' or 'query' function",
|
||||
));
|
||||
}
|
||||
|
||||
let is_dynamic = has_query;
|
||||
|
||||
REGISTRATIONS.with(|regs| {
|
||||
regs.borrow_mut().push(ProviderRegistration {
|
||||
name,
|
||||
display_name,
|
||||
type_id,
|
||||
default_icon,
|
||||
prefix,
|
||||
is_dynamic,
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all registered providers
|
||||
pub fn get_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
|
||||
// Suppress unused warning
|
||||
let _ = lua;
|
||||
|
||||
REGISTRATIONS.with(|regs| Ok(regs.borrow().clone()))
|
||||
}
|
||||
|
||||
/// Call a provider's refresh function
|
||||
pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>> {
|
||||
let globals = lua.globals();
|
||||
let owlry: Table = globals.get("owlry")?;
|
||||
let provider: Table = owlry.get("provider")?;
|
||||
|
||||
// Get the registered providers table (internal)
|
||||
let registrations: Table = match provider.get::<Value>("_registrations")? {
|
||||
Value::Table(t) => t,
|
||||
_ => {
|
||||
// Try to find the config directly from the global scope
|
||||
// This happens when register was called with the config table
|
||||
return call_provider_function(lua, provider_name, "refresh", None);
|
||||
}
|
||||
};
|
||||
|
||||
let config: Table = match registrations.get(provider_name)? {
|
||||
Value::Table(t) => t,
|
||||
_ => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let refresh_fn: Function = match config.get("refresh")? {
|
||||
Value::Function(f) => f,
|
||||
_ => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let result: Value = refresh_fn.call(())?;
|
||||
parse_items_result(result)
|
||||
}
|
||||
|
||||
/// Call a provider's query function
|
||||
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
|
||||
call_provider_function(lua, provider_name, "query", Some(query))
|
||||
}
|
||||
|
||||
/// Call a provider function by name
|
||||
fn call_provider_function(
|
||||
lua: &Lua,
|
||||
provider_name: &str,
|
||||
function_name: &str,
|
||||
query: Option<&str>,
|
||||
) -> LuaResult<Vec<PluginItem>> {
|
||||
// Search through all registered providers in the Lua globals
|
||||
// This is a workaround since we store registrations thread-locally
|
||||
let globals = lua.globals();
|
||||
|
||||
// Try to find a registered provider with matching name
|
||||
// First check if there's a _providers table
|
||||
if let Ok(Value::Table(providers)) = globals.get::<Value>("_owlry_providers")
|
||||
&& let Ok(Value::Table(config)) = providers.get::<Value>(provider_name)
|
||||
&& let Ok(Value::Function(func)) = config.get::<Value>(function_name)
|
||||
{
|
||||
let result: Value = match query {
|
||||
Some(q) => func.call(q)?,
|
||||
None => func.call(())?,
|
||||
};
|
||||
return parse_items_result(result);
|
||||
}
|
||||
|
||||
// Fall back: search through globals for functions
|
||||
// This is less reliable but handles simple cases
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Parse items from Lua return value
|
||||
fn parse_items_result(result: Value) -> LuaResult<Vec<PluginItem>> {
|
||||
let mut items = Vec::new();
|
||||
|
||||
if let Value::Table(table) = result {
|
||||
for pair in table.pairs::<i32, Table>() {
|
||||
let (_, item_table) = pair?;
|
||||
if let Ok(item) = parse_item(&item_table) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Parse a single item from a Lua table
|
||||
fn parse_item(table: &Table) -> LuaResult<PluginItem> {
|
||||
let id: String = table.get("id")?;
|
||||
let name: String = table.get("name")?;
|
||||
let command: String = table.get::<Option<String>>("command")?.unwrap_or_default();
|
||||
let description: Option<String> = table.get("description")?;
|
||||
let icon: Option<String> = table.get("icon")?;
|
||||
let terminal: bool = table.get::<Option<bool>>("terminal")?.unwrap_or(false);
|
||||
let tags: Vec<String> = table
|
||||
.get::<Option<Vec<String>>>("tags")?
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut item = PluginItem::new(id, name, command);
|
||||
|
||||
if let Some(desc) = description {
|
||||
item = item.with_description(desc);
|
||||
}
|
||||
if let Some(ic) = icon {
|
||||
item = item.with_icon(&ic);
|
||||
}
|
||||
if terminal {
|
||||
item = item.with_terminal(true);
|
||||
}
|
||||
if !tags.is_empty() {
|
||||
item = item.with_keywords(tags);
|
||||
}
|
||||
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::runtime::{SandboxConfig, create_lua_runtime};
|
||||
|
||||
#[test]
|
||||
fn test_register_static_provider() {
|
||||
let config = SandboxConfig::default();
|
||||
let lua = create_lua_runtime(&config).unwrap();
|
||||
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_provider_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
|
||||
let code = r#"
|
||||
owlry.provider.register({
|
||||
name = "test-provider",
|
||||
display_name = "Test Provider",
|
||||
refresh = function()
|
||||
return {
|
||||
{ id = "1", name = "Item 1" }
|
||||
}
|
||||
end
|
||||
})
|
||||
"#;
|
||||
lua.load(code).set_name("test").call::<()>(()).unwrap();
|
||||
|
||||
let regs = get_registrations(&lua).unwrap();
|
||||
assert_eq!(regs.len(), 1);
|
||||
assert_eq!(regs[0].name, "test-provider");
|
||||
assert!(!regs[0].is_dynamic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_dynamic_provider() {
|
||||
let config = SandboxConfig::default();
|
||||
let lua = create_lua_runtime(&config).unwrap();
|
||||
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_provider_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
|
||||
let code = r#"
|
||||
owlry.provider.register({
|
||||
name = "query-provider",
|
||||
prefix = "?",
|
||||
query = function(q)
|
||||
return {
|
||||
{ id = "search", name = "Search: " .. q }
|
||||
}
|
||||
end
|
||||
})
|
||||
"#;
|
||||
lua.load(code).set_name("test").call::<()>(()).unwrap();
|
||||
|
||||
let regs = get_registrations(&lua).unwrap();
|
||||
assert_eq!(regs.len(), 1);
|
||||
assert_eq!(regs[0].name, "query-provider");
|
||||
assert!(regs[0].is_dynamic);
|
||||
assert_eq!(regs[0].prefix, Some("?".to_string()));
|
||||
}
|
||||
}
|
||||
447
crates/owlry-lua/src/api/utils.rs
Normal file
@@ -0,0 +1,447 @@
|
||||
//! Utility APIs: logging, paths, filesystem, JSON
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, Table, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// ============================================================================
|
||||
// Logging API
|
||||
// ============================================================================
|
||||
|
||||
/// Register the log API in the owlry table
|
||||
pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let log = lua.create_table()?;
|
||||
|
||||
log.set(
|
||||
"debug",
|
||||
lua.create_function(|_, msg: String| {
|
||||
eprintln!("[DEBUG] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
log.set(
|
||||
"info",
|
||||
lua.create_function(|_, msg: String| {
|
||||
eprintln!("[INFO] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
log.set(
|
||||
"warn",
|
||||
lua.create_function(|_, msg: String| {
|
||||
eprintln!("[WARN] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
log.set(
|
||||
"error",
|
||||
lua.create_function(|_, msg: String| {
|
||||
eprintln!("[ERROR] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("log", log)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path API
|
||||
// ============================================================================
|
||||
|
||||
/// Register the path API in the owlry table
|
||||
pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> {
|
||||
let path = lua.create_table()?;
|
||||
|
||||
// owlry.path.config() -> ~/.config/owlry
|
||||
path.set(
|
||||
"config",
|
||||
lua.create_function(|_, ()| {
|
||||
Ok(dirs::config_dir()
|
||||
.map(|d| d.join("owlry"))
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.data() -> ~/.local/share/owlry
|
||||
path.set(
|
||||
"data",
|
||||
lua.create_function(|_, ()| {
|
||||
Ok(dirs::data_dir()
|
||||
.map(|d| d.join("owlry"))
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.cache() -> ~/.cache/owlry
|
||||
path.set(
|
||||
"cache",
|
||||
lua.create_function(|_, ()| {
|
||||
Ok(dirs::cache_dir()
|
||||
.map(|d| d.join("owlry"))
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.home() -> ~
|
||||
path.set(
|
||||
"home",
|
||||
lua.create_function(|_, ()| {
|
||||
Ok(dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.join(...) -> joined path
|
||||
path.set(
|
||||
"join",
|
||||
lua.create_function(|_, parts: mlua::Variadic<String>| {
|
||||
let mut path = PathBuf::new();
|
||||
for part in parts {
|
||||
path.push(part);
|
||||
}
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.path.plugin_dir() -> plugin directory
|
||||
let plugin_dir_str = plugin_dir.to_string_lossy().to_string();
|
||||
path.set(
|
||||
"plugin_dir",
|
||||
lua.create_function(move |_, ()| Ok(plugin_dir_str.clone()))?,
|
||||
)?;
|
||||
|
||||
// owlry.path.expand(path) -> expanded path (~ -> home)
|
||||
path.set(
|
||||
"expand",
|
||||
lua.create_function(|_, path: String| {
|
||||
if path.starts_with("~/")
|
||||
&& let Some(home) = dirs::home_dir()
|
||||
{
|
||||
return Ok(home.join(&path[2..]).to_string_lossy().to_string());
|
||||
}
|
||||
Ok(path)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("path", path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filesystem API
|
||||
// ============================================================================
|
||||
|
||||
/// Register the fs API in the owlry table
|
||||
pub fn register_fs_api(lua: &Lua, owlry: &Table, _plugin_dir: &Path) -> LuaResult<()> {
|
||||
let fs = lua.create_table()?;
|
||||
|
||||
// owlry.fs.exists(path) -> bool
|
||||
fs.set(
|
||||
"exists",
|
||||
lua.create_function(|_, path: String| {
|
||||
let path = expand_path(&path);
|
||||
Ok(Path::new(&path).exists())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.is_dir(path) -> bool
|
||||
fs.set(
|
||||
"is_dir",
|
||||
lua.create_function(|_, path: String| {
|
||||
let path = expand_path(&path);
|
||||
Ok(Path::new(&path).is_dir())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.read(path) -> string or nil
|
||||
fs.set(
|
||||
"read",
|
||||
lua.create_function(|_, path: String| {
|
||||
let path = expand_path(&path);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => Ok(Some(content)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.read_lines(path) -> table of strings or nil
|
||||
fs.set(
|
||||
"read_lines",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let path = expand_path(&path);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
|
||||
Ok(Some(lua.create_sequence_from(lines)?))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.list_dir(path) -> table of filenames or nil
|
||||
fs.set(
|
||||
"list_dir",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let path = expand_path(&path);
|
||||
match std::fs::read_dir(&path) {
|
||||
Ok(entries) => {
|
||||
let names: Vec<String> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.collect();
|
||||
Ok(Some(lua.create_sequence_from(names)?))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.read_json(path) -> table or nil
|
||||
fs.set(
|
||||
"read_json",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let path = expand_path(&path);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Ok(value) => json_to_lua(lua, &value),
|
||||
Err(_) => Ok(Value::Nil),
|
||||
},
|
||||
Err(_) => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.fs.write(path, content) -> bool
|
||||
fs.set(
|
||||
"write",
|
||||
lua.create_function(|_, (path, content): (String, String)| {
|
||||
let path = expand_path(&path);
|
||||
// Create parent directories if needed
|
||||
if let Some(parent) = Path::new(&path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
Ok(std::fs::write(&path, content).is_ok())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("fs", fs)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JSON API
|
||||
// ============================================================================
|
||||
|
||||
/// Register the json API in the owlry table
|
||||
pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
let json = lua.create_table()?;
|
||||
|
||||
// owlry.json.encode(value) -> string
|
||||
json.set(
|
||||
"encode",
|
||||
lua.create_function(|lua, value: Value| {
|
||||
let json_value = lua_to_json(lua, &value)?;
|
||||
Ok(serde_json::to_string(&json_value).unwrap_or_else(|_| "null".to_string()))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// owlry.json.decode(string) -> value or nil
|
||||
json.set(
|
||||
"decode",
|
||||
lua.create_function(|lua, s: String| {
|
||||
match serde_json::from_str::<serde_json::Value>(&s) {
|
||||
Ok(value) => json_to_lua(lua, &value),
|
||||
Err(_) => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("json", json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Expand ~ in paths
|
||||
fn expand_path(path: &str) -> String {
|
||||
if path.starts_with("~/")
|
||||
&& let Some(home) = dirs::home_dir()
|
||||
{
|
||||
return home.join(&path[2..]).to_string_lossy().to_string();
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
/// Convert JSON value to Lua value
|
||||
fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<Value> {
|
||||
match value {
|
||||
serde_json::Value::Null => Ok(Value::Nil),
|
||||
serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)),
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Ok(Value::Integer(i))
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
Ok(Value::Number(f))
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)),
|
||||
serde_json::Value::Array(arr) => {
|
||||
let table = lua.create_table()?;
|
||||
for (i, v) in arr.iter().enumerate() {
|
||||
table.set(i + 1, json_to_lua(lua, v)?)?;
|
||||
}
|
||||
Ok(Value::Table(table))
|
||||
}
|
||||
serde_json::Value::Object(obj) => {
|
||||
let table = lua.create_table()?;
|
||||
for (k, v) in obj {
|
||||
table.set(k.as_str(), json_to_lua(lua, v)?)?;
|
||||
}
|
||||
Ok(Value::Table(table))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Lua value to JSON value
|
||||
fn lua_to_json(_lua: &Lua, value: &Value) -> LuaResult<serde_json::Value> {
|
||||
match value {
|
||||
Value::Nil => Ok(serde_json::Value::Null),
|
||||
Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
|
||||
Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
|
||||
Value::Number(n) => Ok(serde_json::json!(*n)),
|
||||
Value::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_string())),
|
||||
Value::Table(t) => {
|
||||
// Check if it's an array (sequential integer keys starting from 1)
|
||||
let mut is_array = true;
|
||||
let mut max_key = 0i64;
|
||||
for pair in t.clone().pairs::<Value, Value>() {
|
||||
let (k, _) = pair?;
|
||||
match k {
|
||||
Value::Integer(i) if i > 0 => {
|
||||
max_key = max_key.max(i);
|
||||
}
|
||||
_ => {
|
||||
is_array = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_array && max_key > 0 {
|
||||
let mut arr = Vec::new();
|
||||
for i in 1..=max_key {
|
||||
let v: Value = t.get(i)?;
|
||||
arr.push(lua_to_json(_lua, &v)?);
|
||||
}
|
||||
Ok(serde_json::Value::Array(arr))
|
||||
} else {
|
||||
let mut obj = serde_json::Map::new();
|
||||
for pair in t.clone().pairs::<String, Value>() {
|
||||
let (k, v) = pair?;
|
||||
obj.insert(k, lua_to_json(_lua, &v)?);
|
||||
}
|
||||
Ok(serde_json::Value::Object(obj))
|
||||
}
|
||||
}
|
||||
_ => Ok(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::runtime::{SandboxConfig, create_lua_runtime};
|
||||
|
||||
#[test]
|
||||
fn test_log_api() {
|
||||
let config = SandboxConfig::default();
|
||||
let lua = create_lua_runtime(&config).unwrap();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_log_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
|
||||
// Just verify it doesn't panic
|
||||
lua.load("owlry.log.info('test message')")
|
||||
.set_name("test")
|
||||
.call::<()>(())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_api() {
|
||||
let config = SandboxConfig::default();
|
||||
let lua = create_lua_runtime(&config).unwrap();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_path_api(&lua, &owlry, Path::new("/tmp/test-plugin")).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
|
||||
let home: String = lua
|
||||
.load("return owlry.path.home()")
|
||||
.set_name("test")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(!home.is_empty());
|
||||
|
||||
let plugin_dir: String = lua
|
||||
.load("return owlry.path.plugin_dir()")
|
||||
.set_name("test")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert_eq!(plugin_dir, "/tmp/test-plugin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_api() {
|
||||
let config = SandboxConfig::default();
|
||||
let lua = create_lua_runtime(&config).unwrap();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_fs_api(&lua, &owlry, Path::new("/tmp")).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
|
||||
let exists: bool = lua
|
||||
.load("return owlry.fs.exists('/tmp')")
|
||||
.set_name("test")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(exists);
|
||||
|
||||
let is_dir: bool = lua
|
||||
.load("return owlry.fs.is_dir('/tmp')")
|
||||
.set_name("test")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(is_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_api() {
|
||||
let config = SandboxConfig::default();
|
||||
let lua = create_lua_runtime(&config).unwrap();
|
||||
let owlry = lua.create_table().unwrap();
|
||||
register_json_api(&lua, &owlry).unwrap();
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
|
||||
let code = r#"
|
||||
local t = { name = "test", value = 42 }
|
||||
local json = owlry.json.encode(t)
|
||||
local decoded = owlry.json.decode(json)
|
||||
return decoded.name, decoded.value
|
||||
"#;
|
||||
let (name, value): (String, i32) = lua.load(code).set_name("test").call(()).unwrap();
|
||||
assert_eq!(name, "test");
|
||||
assert_eq!(value, 42);
|
||||
}
|
||||
}
|
||||
366
crates/owlry-lua/src/lib.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
//! 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, ProviderKind};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use loader::LoadedPlugin;
|
||||
|
||||
// Runtime metadata
|
||||
const RUNTIME_ID: &str = "lua";
|
||||
const RUNTIME_NAME: &str = "Lua Runtime";
|
||||
const RUNTIME_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const RUNTIME_DESCRIPTION: &str = "Lua 5.4 runtime for user plugins";
|
||||
|
||||
/// API version for compatibility checking
|
||||
pub const LUA_RUNTIME_API_VERSION: u32 = 1;
|
||||
|
||||
/// 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<'_>) -> 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 id: RString,
|
||||
pub name: RString,
|
||||
pub version: RString,
|
||||
pub description: RString,
|
||||
pub api_version: u32,
|
||||
}
|
||||
|
||||
/// Opaque handle to the runtime state
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RuntimeHandle {
|
||||
pub ptr: *mut (),
|
||||
}
|
||||
|
||||
unsafe impl Send for RuntimeHandle {}
|
||||
unsafe impl Sync for RuntimeHandle {}
|
||||
|
||||
impl RuntimeHandle {
|
||||
/// Create a null handle (reserved for error cases)
|
||||
#[allow(dead_code)]
|
||||
fn null() -> Self {
|
||||
Self {
|
||||
ptr: std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_box<T>(state: Box<T>) -> Self {
|
||||
Self {
|
||||
ptr: Box::into_raw(state) as *mut (),
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn drop_as<T>(&self) {
|
||||
if !self.ptr.is_null() {
|
||||
unsafe { drop(Box::from_raw(self.ptr as *mut T)) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider info from a Lua plugin
|
||||
#[repr(C)]
|
||||
pub struct LuaProviderInfo {
|
||||
/// Full provider ID: "plugin_id:provider_name"
|
||||
pub id: RString,
|
||||
/// Plugin ID this provider belongs to
|
||||
pub plugin_id: RString,
|
||||
/// Provider name within the plugin
|
||||
pub provider_name: RString,
|
||||
/// Display name
|
||||
pub display_name: RString,
|
||||
/// Optional prefix trigger
|
||||
pub prefix: ROption<RString>,
|
||||
/// Icon name
|
||||
pub icon: RString,
|
||||
/// Provider type (static/dynamic)
|
||||
pub provider_type: ProviderKind,
|
||||
/// Type ID for filtering
|
||||
pub type_id: 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);
|
||||
let provider_type = if reg.is_dynamic {
|
||||
ProviderKind::Dynamic
|
||||
} else {
|
||||
ProviderKind::Static
|
||||
};
|
||||
|
||||
providers.push(LuaProviderInfo {
|
||||
id: RString::from(full_id),
|
||||
plugin_id: RString::from(plugin_id.as_str()),
|
||||
provider_name: RString::from(reg.name.as_str()),
|
||||
display_name: RString::from(reg.display_name.as_str()),
|
||||
prefix: reg.prefix.map(RString::from).into(),
|
||||
icon: RString::from(reg.default_icon.as_str()),
|
||||
provider_type,
|
||||
type_id: RString::from(reg.type_id.as_str()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
id: RString::from(RUNTIME_ID),
|
||||
name: RString::from(RUNTIME_NAME),
|
||||
version: RString::from(RUNTIME_VERSION),
|
||||
description: RString::from(RUNTIME_DESCRIPTION),
|
||||
api_version: LUA_RUNTIME_API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle {
|
||||
let plugins_dir = PathBuf::from(plugins_dir.as_str());
|
||||
let mut state = Box::new(LuaRuntimeState::new(plugins_dir));
|
||||
|
||||
// TODO: Get owlry version from core somehow
|
||||
// For now, use a reasonable default
|
||||
state.discover_and_load("0.3.0");
|
||||
|
||||
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.id.as_str(), "lua");
|
||||
assert_eq!(info.api_version, LUA_RUNTIME_API_VERSION);
|
||||
}
|
||||
|
||||
#[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>() };
|
||||
}
|
||||
}
|
||||
232
crates/owlry-lua/src/loader.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
//! Plugin discovery and loading
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use mlua::Lua;
|
||||
use owlry_plugin_api::PluginItem;
|
||||
|
||||
use crate::api;
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::runtime::{SandboxConfig, create_lua_runtime, load_file};
|
||||
|
||||
/// Provider registration info from Lua
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRegistration {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub type_id: String,
|
||||
pub default_icon: String,
|
||||
pub prefix: Option<String>,
|
||||
pub is_dynamic: bool,
|
||||
}
|
||||
|
||||
/// A loaded plugin instance
|
||||
pub struct LoadedPlugin {
|
||||
/// Plugin manifest
|
||||
pub manifest: PluginManifest,
|
||||
/// Path to plugin directory
|
||||
pub path: PathBuf,
|
||||
/// Whether plugin is enabled
|
||||
pub enabled: bool,
|
||||
/// Lua runtime (None if not yet initialized)
|
||||
lua: Option<Lua>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LoadedPlugin {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("LoadedPlugin")
|
||||
.field("manifest", &self.manifest)
|
||||
.field("path", &self.path)
|
||||
.field("enabled", &self.enabled)
|
||||
.field("lua", &self.lua.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
/// Create a new loaded plugin (not yet initialized)
|
||||
pub fn new(manifest: PluginManifest, path: PathBuf) -> Self {
|
||||
Self {
|
||||
manifest,
|
||||
path,
|
||||
enabled: true,
|
||||
lua: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the plugin ID
|
||||
pub fn id(&self) -> &str {
|
||||
&self.manifest.plugin.id
|
||||
}
|
||||
|
||||
/// Initialize the Lua runtime and load the entry point
|
||||
pub fn initialize(&mut self) -> Result<(), String> {
|
||||
if self.lua.is_some() {
|
||||
return Ok(()); // Already initialized
|
||||
}
|
||||
|
||||
let sandbox = SandboxConfig::from_permissions(&self.manifest.permissions);
|
||||
let lua = create_lua_runtime(&sandbox)
|
||||
.map_err(|e| format!("Failed to create Lua runtime: {}", e))?;
|
||||
|
||||
// Register owlry APIs before loading entry point
|
||||
api::register_apis(&lua, &self.path, self.id())
|
||||
.map_err(|e| format!("Failed to register APIs: {}", e))?;
|
||||
|
||||
// Load the entry point file
|
||||
let entry_path = self.path.join(&self.manifest.plugin.entry);
|
||||
if !entry_path.exists() {
|
||||
return Err(format!(
|
||||
"Entry point '{}' not found",
|
||||
self.manifest.plugin.entry
|
||||
));
|
||||
}
|
||||
|
||||
load_file(&lua, &entry_path).map_err(|e| format!("Failed to load entry point: {}", e))?;
|
||||
|
||||
self.lua = Some(lua);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get provider registrations from this plugin
|
||||
pub fn get_provider_registrations(&self) -> Result<Vec<ProviderRegistration>, String> {
|
||||
let lua = self
|
||||
.lua
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Plugin not initialized".to_string())?;
|
||||
|
||||
api::get_provider_registrations(lua)
|
||||
.map_err(|e| format!("Failed to get registrations: {}", e))
|
||||
}
|
||||
|
||||
/// Call a provider's refresh function
|
||||
pub fn call_provider_refresh(&self, provider_name: &str) -> Result<Vec<PluginItem>, String> {
|
||||
let lua = self
|
||||
.lua
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Plugin not initialized".to_string())?;
|
||||
|
||||
api::call_refresh(lua, provider_name).map_err(|e| format!("Refresh failed: {}", e))
|
||||
}
|
||||
|
||||
/// Call a provider's query function
|
||||
pub fn call_provider_query(
|
||||
&self,
|
||||
provider_name: &str,
|
||||
query: &str,
|
||||
) -> Result<Vec<PluginItem>, String> {
|
||||
let lua = self
|
||||
.lua
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Plugin not initialized".to_string())?;
|
||||
|
||||
api::call_query(lua, provider_name, query).map_err(|e| format!("Query failed: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover plugins in a directory
|
||||
pub fn discover_plugins(
|
||||
plugins_dir: &Path,
|
||||
) -> Result<HashMap<String, (PluginManifest, PathBuf)>, String> {
|
||||
let mut plugins = HashMap::new();
|
||||
|
||||
if !plugins_dir.exists() {
|
||||
return Ok(plugins);
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(plugins_dir)
|
||||
.map_err(|e| format!("Failed to read plugins directory: {}", e))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let manifest_path = path.join("plugin.toml");
|
||||
if !manifest_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match PluginManifest::load(&manifest_path) {
|
||||
Ok(manifest) => {
|
||||
let id = manifest.plugin.id.clone();
|
||||
if plugins.contains_key(&id) {
|
||||
eprintln!(
|
||||
"owlry-lua: Duplicate plugin ID '{}', skipping {}",
|
||||
id,
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
plugins.insert(id, (manifest, path));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"owlry-lua: Failed to load plugin at {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_plugin(dir: &Path, id: &str) {
|
||||
let plugin_dir = dir.join(id);
|
||||
fs::create_dir_all(&plugin_dir).unwrap();
|
||||
|
||||
let manifest = format!(
|
||||
r#"
|
||||
[plugin]
|
||||
id = "{}"
|
||||
name = "Test {}"
|
||||
version = "1.0.0"
|
||||
"#,
|
||||
id, id
|
||||
);
|
||||
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
|
||||
fs::write(plugin_dir.join("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");
|
||||
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());
|
||||
}
|
||||
}
|
||||
181
crates/owlry-lua/src/manifest.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
//! Plugin manifest (plugin.toml) parsing
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Plugin manifest loaded from plugin.toml
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginManifest {
|
||||
pub plugin: PluginInfo,
|
||||
#[serde(default)]
|
||||
pub provides: PluginProvides,
|
||||
#[serde(default)]
|
||||
pub permissions: PluginPermissions,
|
||||
#[serde(default)]
|
||||
pub settings: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
/// Core plugin information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginInfo {
|
||||
/// Unique plugin identifier (lowercase, alphanumeric, hyphens)
|
||||
pub id: String,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Semantic version
|
||||
pub version: String,
|
||||
/// Short description
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Plugin author
|
||||
#[serde(default)]
|
||||
pub author: String,
|
||||
/// License identifier
|
||||
#[serde(default)]
|
||||
pub license: String,
|
||||
/// Repository URL
|
||||
#[serde(default)]
|
||||
pub repository: Option<String>,
|
||||
/// Required owlry version (semver constraint)
|
||||
#[serde(default = "default_owlry_version")]
|
||||
pub owlry_version: String,
|
||||
/// Entry point file (relative to plugin directory)
|
||||
#[serde(default = "default_entry")]
|
||||
pub entry: String,
|
||||
}
|
||||
|
||||
fn default_owlry_version() -> String {
|
||||
">=0.1.0".to_string()
|
||||
}
|
||||
|
||||
fn default_entry() -> String {
|
||||
"init.lua".to_string()
|
||||
}
|
||||
|
||||
/// What the plugin provides
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PluginProvides {
|
||||
/// Provider names this plugin registers
|
||||
#[serde(default)]
|
||||
pub providers: Vec<String>,
|
||||
/// Whether this plugin registers actions
|
||||
#[serde(default)]
|
||||
pub actions: bool,
|
||||
/// Theme names this plugin contributes
|
||||
#[serde(default)]
|
||||
pub themes: Vec<String>,
|
||||
/// Whether this plugin registers hooks
|
||||
#[serde(default)]
|
||||
pub hooks: bool,
|
||||
}
|
||||
|
||||
/// Plugin permissions/capabilities
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PluginPermissions {
|
||||
/// Allow network/HTTP requests
|
||||
#[serde(default)]
|
||||
pub network: bool,
|
||||
/// Filesystem paths the plugin can access (beyond its own directory)
|
||||
#[serde(default)]
|
||||
pub filesystem: Vec<String>,
|
||||
/// Commands the plugin is allowed to run
|
||||
#[serde(default)]
|
||||
pub run_commands: Vec<String>,
|
||||
/// Environment variables the plugin reads
|
||||
#[serde(default)]
|
||||
pub environment: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginManifest {
|
||||
/// Load a plugin manifest from a plugin.toml file
|
||||
pub fn load(path: &Path) -> Result<Self, String> {
|
||||
let content =
|
||||
std::fs::read_to_string(path).map_err(|e| format!("Failed to read manifest: {}", e))?;
|
||||
let manifest: PluginManifest =
|
||||
toml::from_str(&content).map_err(|e| format!("Failed to parse manifest: {}", e))?;
|
||||
manifest.validate()?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Validate the manifest
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
// Validate plugin ID format
|
||||
if self.plugin.id.is_empty() {
|
||||
return Err("Plugin ID cannot be empty".to_string());
|
||||
}
|
||||
|
||||
if !self
|
||||
.plugin
|
||||
.id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err("Plugin ID must be lowercase alphanumeric with hyphens".to_string());
|
||||
}
|
||||
|
||||
// Validate version format
|
||||
if semver::Version::parse(&self.plugin.version).is_err() {
|
||||
return Err(format!("Invalid version format: {}", self.plugin.version));
|
||||
}
|
||||
|
||||
// Validate owlry_version constraint
|
||||
if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() {
|
||||
return Err(format!(
|
||||
"Invalid owlry_version constraint: {}",
|
||||
self.plugin.owlry_version
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if this plugin is compatible with the given owlry version
|
||||
pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
|
||||
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let version = match semver::Version::parse(owlry_version) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
req.matches(&version)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_minimal_manifest() {
|
||||
let toml_str = r#"
|
||||
[plugin]
|
||||
id = "test-plugin"
|
||||
name = "Test Plugin"
|
||||
version = "1.0.0"
|
||||
"#;
|
||||
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(manifest.plugin.id, "test-plugin");
|
||||
assert_eq!(manifest.plugin.name, "Test Plugin");
|
||||
assert_eq!(manifest.plugin.version, "1.0.0");
|
||||
assert_eq!(manifest.plugin.entry, "init.lua");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_compatibility() {
|
||||
let toml_str = r#"
|
||||
[plugin]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "1.0.0"
|
||||
owlry_version = ">=0.3.0, <1.0.0"
|
||||
"#;
|
||||
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
|
||||
assert!(manifest.is_compatible_with("0.3.5"));
|
||||
assert!(manifest.is_compatible_with("0.4.0"));
|
||||
assert!(!manifest.is_compatible_with("0.2.0"));
|
||||
assert!(!manifest.is_compatible_with("1.0.0"));
|
||||
}
|
||||
}
|
||||
152
crates/owlry-lua/src/runtime.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
//! Lua runtime setup and sandboxing
|
||||
|
||||
use mlua::{Lua, Result as LuaResult, StdLib};
|
||||
|
||||
use crate::manifest::PluginPermissions;
|
||||
|
||||
/// Configuration for the Lua sandbox
|
||||
///
|
||||
/// Note: Some fields are reserved for future sandbox enforcement.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct SandboxConfig {
|
||||
/// Allow shell command running (reserved for future enforcement)
|
||||
pub allow_commands: bool,
|
||||
/// Allow HTTP requests (reserved for future enforcement)
|
||||
pub allow_network: bool,
|
||||
/// Allow filesystem access outside plugin directory (reserved for future enforcement)
|
||||
pub allow_external_fs: bool,
|
||||
/// Maximum run time per call (ms) (reserved for future enforcement)
|
||||
pub max_run_time_ms: u64,
|
||||
/// Memory limit (bytes, 0 = unlimited) (reserved for future enforcement)
|
||||
pub max_memory: usize,
|
||||
}
|
||||
|
||||
impl Default for SandboxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_commands: false,
|
||||
allow_network: false,
|
||||
allow_external_fs: false,
|
||||
max_run_time_ms: 5000, // 5 seconds
|
||||
max_memory: 64 * 1024 * 1024, // 64 MB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxConfig {
|
||||
/// Create a sandbox config from plugin permissions
|
||||
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
|
||||
Self {
|
||||
allow_commands: !permissions.run_commands.is_empty(),
|
||||
allow_network: permissions.network,
|
||||
allow_external_fs: !permissions.filesystem.is_empty(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new sandboxed Lua runtime
|
||||
pub fn create_lua_runtime(_sandbox: &SandboxConfig) -> LuaResult<Lua> {
|
||||
// Create Lua with safe standard libraries only
|
||||
// We exclude: debug, io, os (dangerous parts), package (loadlib), ffi
|
||||
let libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH;
|
||||
|
||||
let lua = Lua::new_with(libs, mlua::LuaOptions::default())?;
|
||||
|
||||
// Set up safe environment
|
||||
setup_safe_globals(&lua)?;
|
||||
|
||||
Ok(lua)
|
||||
}
|
||||
|
||||
/// Set up safe global environment by removing/replacing dangerous functions
|
||||
fn setup_safe_globals(lua: &Lua) -> LuaResult<()> {
|
||||
let globals = lua.globals();
|
||||
|
||||
// Remove dangerous globals
|
||||
globals.set("dofile", mlua::Value::Nil)?;
|
||||
globals.set("loadfile", mlua::Value::Nil)?;
|
||||
|
||||
// Create a restricted os table with only safe functions
|
||||
let os_table = lua.create_table()?;
|
||||
os_table.set(
|
||||
"clock",
|
||||
lua.create_function(|_, ()| Ok(std::time::Instant::now().elapsed().as_secs_f64()))?,
|
||||
)?;
|
||||
os_table.set("date", lua.create_function(os_date)?)?;
|
||||
os_table.set(
|
||||
"difftime",
|
||||
lua.create_function(|_, (t2, t1): (f64, f64)| Ok(t2 - t1))?,
|
||||
)?;
|
||||
os_table.set("time", lua.create_function(os_time)?)?;
|
||||
globals.set("os", os_table)?;
|
||||
|
||||
// Remove print (plugins should use owlry.log instead)
|
||||
globals.set("print", mlua::Value::Nil)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Safe os.date implementation
|
||||
fn os_date(_lua: &Lua, format: Option<String>) -> LuaResult<String> {
|
||||
use chrono::Local;
|
||||
let now = Local::now();
|
||||
let fmt = format.unwrap_or_else(|| "%c".to_string());
|
||||
Ok(now.format(&fmt).to_string())
|
||||
}
|
||||
|
||||
/// Safe os.time implementation
|
||||
fn os_time(_lua: &Lua, _args: ()) -> LuaResult<i64> {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
Ok(duration.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// Load and run a Lua file in the given runtime
|
||||
pub fn load_file(lua: &Lua, path: &std::path::Path) -> LuaResult<()> {
|
||||
let content = std::fs::read_to_string(path).map_err(mlua::Error::external)?;
|
||||
lua.load(&content)
|
||||
.set_name(path.file_name().and_then(|n| n.to_str()).unwrap_or("chunk"))
|
||||
.into_function()?
|
||||
.call(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_sandboxed_runtime() {
|
||||
let config = SandboxConfig::default();
|
||||
let lua = create_lua_runtime(&config).unwrap();
|
||||
|
||||
// Verify dangerous functions are removed
|
||||
let result: LuaResult<mlua::Value> = lua.globals().get("dofile");
|
||||
assert!(matches!(result, Ok(mlua::Value::Nil)));
|
||||
|
||||
// Verify safe functions work
|
||||
let result: String = lua.load("return os.date('%Y')").call(()).unwrap();
|
||||
assert!(!result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_lua_operations() {
|
||||
let config = SandboxConfig::default();
|
||||
let lua = create_lua_runtime(&config).unwrap();
|
||||
|
||||
// Test basic math
|
||||
let result: i32 = lua.load("return 2 + 2").call(()).unwrap();
|
||||
assert_eq!(result, 4);
|
||||
|
||||
// Test table operations
|
||||
let result: i32 = lua.load("local t = {1,2,3}; return #t").call(()).unwrap();
|
||||
assert_eq!(result, 3);
|
||||
|
||||
// Test string operations
|
||||
let result: String = lua.load("return string.upper('hello')").call(()).unwrap();
|
||||
assert_eq!(result, "HELLO");
|
||||
}
|
||||
}
|
||||
17
crates/owlry-plugin-api/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "owlry-plugin-api"
|
||||
version = "1.0.0"
|
||||
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"] }
|
||||
452
crates/owlry-plugin-api/src/lib.rs
Normal file
@@ -0,0 +1,452 @@
|
||||
//! # Owlry Plugin API
|
||||
//!
|
||||
//! This crate provides the ABI-stable interface for owlry native plugins.
|
||||
//! Plugins are compiled as dynamic libraries (.so) and loaded at runtime.
|
||||
//!
|
||||
//! ## Creating a Plugin
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use owlry_plugin_api::*;
|
||||
//!
|
||||
//! // Define your plugin's vtable
|
||||
//! static VTABLE: PluginVTable = PluginVTable {
|
||||
//! info: plugin_info,
|
||||
//! providers: plugin_providers,
|
||||
//! provider_init: my_provider_init,
|
||||
//! provider_refresh: my_provider_refresh,
|
||||
//! provider_query: my_provider_query,
|
||||
//! provider_drop: my_provider_drop,
|
||||
//! };
|
||||
//!
|
||||
//! // Export the vtable
|
||||
//! #[no_mangle]
|
||||
//! pub extern "C" fn owlry_plugin_vtable() -> &'static PluginVTable {
|
||||
//! &VTABLE
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use abi_stable::StableAbi;
|
||||
|
||||
// Re-export abi_stable types for use by consumers (runtime loader, plugins)
|
||||
pub use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
|
||||
/// Current plugin API version - plugins must match this
|
||||
/// v2: Added ProviderPosition for widget support
|
||||
/// v3: Added priority field for plugin-declared result ordering
|
||||
pub const API_VERSION: u32 = 3;
|
||||
|
||||
/// Plugin metadata returned by the info function
|
||||
#[repr(C)]
|
||||
#[derive(StableAbi, Clone, Debug)]
|
||||
pub struct PluginInfo {
|
||||
/// Unique plugin identifier (e.g., "calculator", "weather")
|
||||
pub id: RString,
|
||||
/// Human-readable plugin name
|
||||
pub name: RString,
|
||||
/// Plugin version string
|
||||
pub version: RString,
|
||||
/// Short description of what the plugin provides
|
||||
pub description: RString,
|
||||
/// Plugin API version (must match API_VERSION)
|
||||
pub api_version: u32,
|
||||
}
|
||||
|
||||
/// Information about a provider offered by a plugin
|
||||
#[repr(C)]
|
||||
#[derive(StableAbi, Clone, Debug)]
|
||||
pub struct ProviderInfo {
|
||||
/// Unique provider identifier within the plugin
|
||||
pub id: RString,
|
||||
/// Human-readable provider name
|
||||
pub name: RString,
|
||||
/// Optional prefix that activates this provider (e.g., "=" for calculator)
|
||||
pub prefix: ROption<RString>,
|
||||
/// Default icon name for results from this provider
|
||||
pub icon: RString,
|
||||
/// Provider type (static or dynamic)
|
||||
pub provider_type: ProviderKind,
|
||||
/// Short type identifier for UI badges (e.g., "calc", "web")
|
||||
pub type_id: RString,
|
||||
/// Display position (Normal or Widget)
|
||||
pub position: ProviderPosition,
|
||||
/// Priority for result ordering (higher values appear first)
|
||||
/// Suggested ranges:
|
||||
/// - Widgets: 10000-12000
|
||||
/// - Dynamic providers: 7000-10000
|
||||
/// - Static providers: 0-5000 (use 0 for frecency-based ordering)
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
/// Provider behavior type
|
||||
#[repr(C)]
|
||||
#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProviderKind {
|
||||
/// Static providers load items once at startup via refresh()
|
||||
Static,
|
||||
/// Dynamic providers evaluate queries in real-time via query()
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// Provider display position
|
||||
///
|
||||
/// Controls where in the result list this provider's items appear.
|
||||
#[repr(C)]
|
||||
#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum ProviderPosition {
|
||||
/// Standard position in results (sorted by score/frecency)
|
||||
#[default]
|
||||
Normal,
|
||||
/// Widget position - appears at top of results when query is empty
|
||||
/// Widgets are always visible regardless of filter settings
|
||||
Widget,
|
||||
}
|
||||
|
||||
/// A single searchable/launchable item returned by providers
|
||||
#[repr(C)]
|
||||
#[derive(StableAbi, Clone, Debug)]
|
||||
pub struct PluginItem {
|
||||
/// Unique item identifier
|
||||
pub id: RString,
|
||||
/// Display name
|
||||
pub name: RString,
|
||||
/// Optional description shown below the name
|
||||
pub description: ROption<RString>,
|
||||
/// Optional icon name or path
|
||||
pub icon: ROption<RString>,
|
||||
/// Command to execute when selected
|
||||
pub command: RString,
|
||||
/// Whether to run in a terminal
|
||||
pub terminal: bool,
|
||||
/// Search keywords/tags for filtering
|
||||
pub keywords: RVec<RString>,
|
||||
/// Score boost for frecency (higher = more prominent)
|
||||
pub score_boost: i32,
|
||||
}
|
||||
|
||||
impl PluginItem {
|
||||
/// Create a new plugin item with required fields
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, command: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: RString::from(id.into()),
|
||||
name: RString::from(name.into()),
|
||||
description: ROption::RNone,
|
||||
icon: ROption::RNone,
|
||||
command: RString::from(command.into()),
|
||||
terminal: false,
|
||||
keywords: RVec::new(),
|
||||
score_boost: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the description
|
||||
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
|
||||
self.description = ROption::RSome(RString::from(desc.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the icon
|
||||
pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
|
||||
self.icon = ROption::RSome(RString::from(icon.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set terminal mode
|
||||
pub fn with_terminal(mut self, terminal: bool) -> Self {
|
||||
self.terminal = terminal;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add keywords
|
||||
pub fn with_keywords(mut self, keywords: Vec<String>) -> Self {
|
||||
self.keywords = keywords.into_iter().map(RString::from).collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set score boost
|
||||
pub fn with_score_boost(mut self, boost: i32) -> Self {
|
||||
self.score_boost = boost;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin function table - defines the interface between owlry and plugins
|
||||
///
|
||||
/// Every native plugin must export a function `owlry_plugin_vtable` that returns
|
||||
/// a static reference to this structure.
|
||||
#[repr(C)]
|
||||
#[derive(StableAbi)]
|
||||
pub struct PluginVTable {
|
||||
/// Return plugin metadata
|
||||
pub info: extern "C" fn() -> PluginInfo,
|
||||
|
||||
/// Return list of providers this plugin offers
|
||||
pub providers: extern "C" fn() -> RVec<ProviderInfo>,
|
||||
|
||||
/// Initialize a provider by ID, returns an opaque handle
|
||||
/// The handle is passed to refresh/query/drop functions
|
||||
pub provider_init: extern "C" fn(provider_id: RStr<'_>) -> ProviderHandle,
|
||||
|
||||
/// Refresh a static provider's items
|
||||
/// Called once at startup and when user requests refresh
|
||||
pub provider_refresh: extern "C" fn(handle: ProviderHandle) -> RVec<PluginItem>,
|
||||
|
||||
/// Query a dynamic provider
|
||||
/// Called on each keystroke for dynamic providers
|
||||
pub provider_query: extern "C" fn(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem>,
|
||||
|
||||
/// Clean up a provider handle
|
||||
pub provider_drop: extern "C" fn(handle: ProviderHandle),
|
||||
}
|
||||
|
||||
/// Opaque handle to a provider instance
|
||||
/// Plugins can use this to store state between calls
|
||||
#[repr(C)]
|
||||
#[derive(StableAbi, Clone, Copy, Debug)]
|
||||
pub struct ProviderHandle {
|
||||
/// Opaque pointer to provider state
|
||||
pub ptr: *mut (),
|
||||
}
|
||||
|
||||
impl ProviderHandle {
|
||||
/// Create a null handle
|
||||
pub fn null() -> Self {
|
||||
Self {
|
||||
ptr: std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a handle from a boxed value
|
||||
/// The caller is responsible for calling drop to free the memory
|
||||
pub fn from_box<T>(value: Box<T>) -> Self {
|
||||
Self {
|
||||
ptr: Box::into_raw(value) as *mut (),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert handle back to a reference (unsafe)
|
||||
///
|
||||
/// # Safety
|
||||
/// The handle must have been created from a Box<T> of the same type
|
||||
pub unsafe fn as_ref<T>(&self) -> Option<&T> {
|
||||
// SAFETY: Caller guarantees the pointer was created from Box<T>
|
||||
unsafe { (self.ptr as *const T).as_ref() }
|
||||
}
|
||||
|
||||
/// Convert handle back to a mutable reference (unsafe)
|
||||
///
|
||||
/// # Safety
|
||||
/// The handle must have been created from a Box<T> of the same type
|
||||
pub unsafe fn as_mut<T>(&mut self) -> Option<&mut T> {
|
||||
// SAFETY: Caller guarantees the pointer was created from Box<T>
|
||||
unsafe { (self.ptr as *mut T).as_mut() }
|
||||
}
|
||||
|
||||
/// Drop the handle and free its memory (unsafe)
|
||||
///
|
||||
/// # Safety
|
||||
/// The handle must have been created from a Box<T> of the same type
|
||||
/// and must not be used after this call
|
||||
pub unsafe fn drop_as<T>(self) {
|
||||
if !self.ptr.is_null() {
|
||||
// SAFETY: Caller guarantees the pointer was created from Box<T>
|
||||
unsafe { drop(Box::from_raw(self.ptr as *mut T)) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderHandle contains a raw pointer but we manage it carefully
|
||||
unsafe impl Send for ProviderHandle {}
|
||||
unsafe impl Sync for ProviderHandle {}
|
||||
|
||||
// ============================================================================
|
||||
// Host API - Functions the host provides to plugins
|
||||
// ============================================================================
|
||||
|
||||
/// Notification urgency level
|
||||
#[repr(C)]
|
||||
#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum NotifyUrgency {
|
||||
/// Low priority notification
|
||||
Low = 0,
|
||||
/// Normal priority notification (default)
|
||||
#[default]
|
||||
Normal = 1,
|
||||
/// Critical/urgent notification
|
||||
Critical = 2,
|
||||
}
|
||||
|
||||
/// Host API function table
|
||||
///
|
||||
/// This structure contains functions that the host (owlry) provides to plugins.
|
||||
/// Plugins can call these functions to interact with the system.
|
||||
#[repr(C)]
|
||||
#[derive(StableAbi, Clone, Copy)]
|
||||
pub struct HostAPI {
|
||||
/// Send a notification to the user
|
||||
/// Parameters: summary, body, icon (optional, empty string for none), urgency
|
||||
pub notify:
|
||||
extern "C" fn(summary: RStr<'_>, body: RStr<'_>, icon: RStr<'_>, urgency: NotifyUrgency),
|
||||
|
||||
/// Log a message at info level
|
||||
pub log_info: extern "C" fn(message: RStr<'_>),
|
||||
|
||||
/// Log a message at warning level
|
||||
pub log_warn: extern "C" fn(message: RStr<'_>),
|
||||
|
||||
/// Log a message at error level
|
||||
pub log_error: extern "C" fn(message: RStr<'_>),
|
||||
}
|
||||
|
||||
// Global host API pointer - set by the host when loading plugins
|
||||
static mut HOST_API: Option<&'static HostAPI> = None;
|
||||
|
||||
/// 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) {
|
||||
// SAFETY: Caller guarantees this is called once before any plugins use the API
|
||||
unsafe {
|
||||
HOST_API = Some(api);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the host API
|
||||
///
|
||||
/// Returns None if the host hasn't initialized the API yet
|
||||
pub fn host_api() -> Option<&'static HostAPI> {
|
||||
// SAFETY: We only read the pointer, and it's set once at startup
|
||||
unsafe { HOST_API }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Convenience functions for plugins
|
||||
// ============================================================================
|
||||
|
||||
/// Send a notification (convenience wrapper)
|
||||
pub fn notify(summary: &str, body: &str) {
|
||||
if let Some(api) = host_api() {
|
||||
(api.notify)(
|
||||
RStr::from_str(summary),
|
||||
RStr::from_str(body),
|
||||
RStr::from_str(""),
|
||||
NotifyUrgency::Normal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a notification with an icon (convenience wrapper)
|
||||
pub fn notify_with_icon(summary: &str, body: &str, icon: &str) {
|
||||
if let Some(api) = host_api() {
|
||||
(api.notify)(
|
||||
RStr::from_str(summary),
|
||||
RStr::from_str(body),
|
||||
RStr::from_str(icon),
|
||||
NotifyUrgency::Normal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a notification with full options (convenience wrapper)
|
||||
pub fn notify_with_urgency(summary: &str, body: &str, icon: &str, urgency: NotifyUrgency) {
|
||||
if let Some(api) = host_api() {
|
||||
(api.notify)(
|
||||
RStr::from_str(summary),
|
||||
RStr::from_str(body),
|
||||
RStr::from_str(icon),
|
||||
urgency,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log an info message (convenience wrapper)
|
||||
pub fn log_info(message: &str) {
|
||||
if let Some(api) = host_api() {
|
||||
(api.log_info)(RStr::from_str(message));
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a warning message (convenience wrapper)
|
||||
pub fn log_warn(message: &str) {
|
||||
if let Some(api) = host_api() {
|
||||
(api.log_warn)(RStr::from_str(message));
|
||||
}
|
||||
}
|
||||
|
||||
/// Log an error message (convenience wrapper)
|
||||
pub fn log_error(message: &str) {
|
||||
if let Some(api) = host_api() {
|
||||
(api.log_error)(RStr::from_str(message));
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper macro for defining plugin vtables
|
||||
///
|
||||
/// Usage:
|
||||
/// ```ignore
|
||||
/// owlry_plugin! {
|
||||
/// info: my_plugin_info,
|
||||
/// providers: my_providers,
|
||||
/// init: my_init,
|
||||
/// refresh: my_refresh,
|
||||
/// query: my_query,
|
||||
/// drop: my_drop,
|
||||
/// }
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! owlry_plugin {
|
||||
(
|
||||
info: $info:expr,
|
||||
providers: $providers:expr,
|
||||
init: $init:expr,
|
||||
refresh: $refresh:expr,
|
||||
query: $query:expr,
|
||||
drop: $drop:expr $(,)?
|
||||
) => {
|
||||
static OWLRY_PLUGIN_VTABLE: $crate::PluginVTable = $crate::PluginVTable {
|
||||
info: $info,
|
||||
providers: $providers,
|
||||
provider_init: $init,
|
||||
provider_refresh: $refresh,
|
||||
provider_query: $query,
|
||||
provider_drop: $drop,
|
||||
};
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn owlry_plugin_vtable() -> &'static $crate::PluginVTable {
|
||||
&OWLRY_PLUGIN_VTABLE
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_plugin_item_builder() {
|
||||
let item = PluginItem::new("test-id", "Test Item", "echo hello")
|
||||
.with_description("A test item")
|
||||
.with_icon("test-icon")
|
||||
.with_terminal(true)
|
||||
.with_keywords(vec!["test".to_string(), "example".to_string()])
|
||||
.with_score_boost(100);
|
||||
|
||||
assert_eq!(item.id.as_str(), "test-id");
|
||||
assert_eq!(item.name.as_str(), "Test Item");
|
||||
assert_eq!(item.command.as_str(), "echo hello");
|
||||
assert!(item.terminal);
|
||||
assert_eq!(item.score_boost, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_handle() {
|
||||
let value = Box::new(42i32);
|
||||
let handle = ProviderHandle::from_box(value);
|
||||
|
||||
unsafe {
|
||||
assert_eq!(*handle.as_ref::<i32>().unwrap(), 42);
|
||||
handle.drop_as::<i32>();
|
||||
}
|
||||
}
|
||||
}
|
||||
44
crates/owlry-rune/Cargo.toml
Normal file
@@ -0,0 +1,44 @@
|
||||
[package]
|
||||
name = "owlry-rune"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
description = "Rune scripting runtime for owlry plugins"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# Shared plugin API
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# Rune scripting language
|
||||
rune = "0.14"
|
||||
rune-modules = { version = "0.14", features = ["full"] }
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# HTTP client for network API
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Configuration parsing
|
||||
toml = "0.8"
|
||||
|
||||
# Semantic versioning
|
||||
semver = "1"
|
||||
|
||||
# Date/time
|
||||
chrono = "0.4"
|
||||
|
||||
# Directory paths
|
||||
dirs = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
130
crates/owlry-rune/src/api.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
//! Owlry API bindings for Rune plugins
|
||||
//!
|
||||
//! This module provides the `owlry` module that Rune plugins can use.
|
||||
|
||||
use rune::{ContextError, Module};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use owlry_plugin_api::{PluginItem, RString};
|
||||
|
||||
/// Provider registration info
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRegistration {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub type_id: String,
|
||||
pub default_icon: String,
|
||||
pub is_static: bool,
|
||||
pub prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// An item returned by a provider
|
||||
///
|
||||
/// Used for converting Rune plugin items to FFI format.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
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 {
|
||||
/// Convert to PluginItem for FFI
|
||||
#[allow(dead_code)]
|
||||
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 logging functions using builder pattern
|
||||
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());
|
||||
}
|
||||
}
|
||||
263
crates/owlry-rune/src/lib.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
//! Owlry Rune Runtime
|
||||
//!
|
||||
//! This crate provides a Rune scripting runtime for owlry user plugins.
|
||||
//! It is loaded dynamically by the core when installed.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The runtime exports a C-compatible vtable that the core uses to:
|
||||
//! 1. Initialize the runtime with a plugins directory
|
||||
//! 2. Get a list of providers from loaded plugins
|
||||
//! 3. Refresh/query providers
|
||||
//! 4. Clean up resources
|
||||
//!
|
||||
//! # Plugin Structure
|
||||
//!
|
||||
//! Rune plugins live in `~/.config/owlry/plugins/<plugin-name>/`:
|
||||
//! ```text
|
||||
//! my-plugin/
|
||||
//! plugin.toml # Manifest
|
||||
//! init.rn # Entry point (Rune script)
|
||||
//! ```
|
||||
|
||||
mod api;
|
||||
mod loader;
|
||||
mod manifest;
|
||||
mod runtime;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use owlry_plugin_api::{PluginItem, ROption, RStr, RString, RVec};
|
||||
|
||||
pub use loader::LoadedPlugin;
|
||||
pub use manifest::PluginManifest;
|
||||
|
||||
// ============================================================================
|
||||
// Runtime VTable (C-compatible interface)
|
||||
// ============================================================================
|
||||
|
||||
/// Information about this runtime
|
||||
#[repr(C)]
|
||||
pub struct RuntimeInfo {
|
||||
pub name: RString,
|
||||
pub version: RString,
|
||||
}
|
||||
|
||||
/// Information about a provider from a plugin
|
||||
#[repr(C)]
|
||||
#[derive(Clone)]
|
||||
pub struct RuneProviderInfo {
|
||||
pub name: RString,
|
||||
pub display_name: RString,
|
||||
pub type_id: RString,
|
||||
pub default_icon: RString,
|
||||
pub is_static: bool,
|
||||
pub prefix: ROption<RString>,
|
||||
}
|
||||
|
||||
/// Opaque handle to runtime state
|
||||
#[repr(transparent)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RuntimeHandle(pub *mut ());
|
||||
|
||||
/// Runtime state managed by the handle
|
||||
struct RuntimeState {
|
||||
plugins: HashMap<String, LoadedPlugin>,
|
||||
providers: Vec<RuneProviderInfo>,
|
||||
}
|
||||
|
||||
/// VTable for the Rune runtime
|
||||
#[repr(C)]
|
||||
pub struct RuneRuntimeVTable {
|
||||
pub info: extern "C" fn() -> RuntimeInfo,
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> 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<'_>) -> RuntimeHandle {
|
||||
let _ = env_logger::try_init();
|
||||
|
||||
let plugins_dir = PathBuf::from(plugins_dir.as_str());
|
||||
log::info!(
|
||||
"Initializing Rune runtime with plugins from: {}",
|
||||
plugins_dir.display()
|
||||
);
|
||||
|
||||
let mut state = RuntimeState {
|
||||
plugins: HashMap::new(),
|
||||
providers: Vec::new(),
|
||||
};
|
||||
|
||||
// Discover and load Rune plugins
|
||||
match loader::discover_rune_plugins(&plugins_dir) {
|
||||
Ok(plugins) => {
|
||||
for (id, plugin) in plugins {
|
||||
// Collect provider info before storing plugin
|
||||
for reg in plugin.provider_registrations() {
|
||||
state.providers.push(RuneProviderInfo {
|
||||
name: RString::from(reg.name.as_str()),
|
||||
display_name: RString::from(reg.display_name.as_str()),
|
||||
type_id: RString::from(reg.type_id.as_str()),
|
||||
default_icon: RString::from(reg.default_icon.as_str()),
|
||||
is_static: reg.is_static,
|
||||
prefix: reg
|
||||
.prefix
|
||||
.as_ref()
|
||||
.map(|p| RString::from(p.as_str()))
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
state.plugins.insert(id, plugin);
|
||||
}
|
||||
log::info!(
|
||||
"Loaded {} Rune plugin(s) with {} provider(s)",
|
||||
state.plugins.len(),
|
||||
state.providers.len()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to discover Rune plugins: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Box and leak the state, returning an opaque handle
|
||||
let boxed = Box::new(Mutex::new(state));
|
||||
RuntimeHandle(Box::into_raw(boxed) as *mut ())
|
||||
}
|
||||
|
||||
extern "C" fn runtime_providers(handle: RuntimeHandle) -> RVec<RuneProviderInfo> {
|
||||
let state = unsafe { &*(handle.0 as *const Mutex<RuntimeState>) };
|
||||
let guard = state.lock().unwrap();
|
||||
guard.providers.clone().into_iter().collect()
|
||||
}
|
||||
|
||||
extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem> {
|
||||
let state = unsafe { &*(handle.0 as *const Mutex<RuntimeState>) };
|
||||
let mut guard = state.lock().unwrap();
|
||||
|
||||
let provider_name = provider_id.as_str();
|
||||
|
||||
// Find the plugin that provides this provider
|
||||
for plugin in guard.plugins.values_mut() {
|
||||
if plugin.provides_provider(provider_name) {
|
||||
match plugin.refresh_provider(provider_name) {
|
||||
Ok(items) => return items.into_iter().collect(),
|
||||
Err(e) => {
|
||||
log::error!("Failed to refresh provider '{}': {}", provider_name, e);
|
||||
return RVec::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!("Provider '{}' not found", provider_name);
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn runtime_query(
|
||||
handle: RuntimeHandle,
|
||||
provider_id: RStr<'_>,
|
||||
query: RStr<'_>,
|
||||
) -> RVec<PluginItem> {
|
||||
let state = unsafe { &*(handle.0 as *const Mutex<RuntimeState>) };
|
||||
let mut guard = state.lock().unwrap();
|
||||
|
||||
let provider_name = provider_id.as_str();
|
||||
let query_str = query.as_str();
|
||||
|
||||
// Find the plugin that provides this provider
|
||||
for plugin in guard.plugins.values_mut() {
|
||||
if plugin.provides_provider(provider_name) {
|
||||
match plugin.query_provider(provider_name, query_str) {
|
||||
Ok(items) => return items.into_iter().collect(),
|
||||
Err(e) => {
|
||||
log::error!("Failed to query provider '{}': {}", provider_name, e);
|
||||
return RVec::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!("Provider '{}' not found", provider_name);
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn runtime_drop(handle: RuntimeHandle) {
|
||||
if !handle.0.is_null() {
|
||||
// SAFETY: We created this box in runtime_init
|
||||
unsafe {
|
||||
let _ = Box::from_raw(handle.0 as *mut Mutex<RuntimeState>);
|
||||
}
|
||||
log::info!("Rune runtime cleaned up");
|
||||
}
|
||||
}
|
||||
|
||||
/// Static vtable instance
|
||||
static RUNE_RUNTIME_VTABLE: RuneRuntimeVTable = RuneRuntimeVTable {
|
||||
info: runtime_info,
|
||||
init: runtime_init,
|
||||
providers: runtime_providers,
|
||||
refresh: runtime_refresh,
|
||||
query: runtime_query,
|
||||
drop: runtime_drop,
|
||||
};
|
||||
|
||||
/// Entry point - returns the runtime vtable
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn owlry_rune_runtime_vtable() -> &'static RuneRuntimeVTable {
|
||||
&RUNE_RUNTIME_VTABLE
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_runtime_info() {
|
||||
let info = runtime_info();
|
||||
assert_eq!(info.name.as_str(), "rune");
|
||||
assert!(!info.version.as_str().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_lifecycle() {
|
||||
// Create a temp directory for plugins
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let plugins_dir = temp.path().to_string_lossy();
|
||||
|
||||
// Initialize runtime
|
||||
let handle = runtime_init(RStr::from_str(&plugins_dir));
|
||||
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);
|
||||
}
|
||||
}
|
||||
181
crates/owlry-rune/src/loader.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
//! 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
|
||||
let registrations = api::get_registrations();
|
||||
|
||||
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 (stub for now)
|
||||
pub fn refresh_provider(&mut self, _name: &str) -> Result<Vec<PluginItem>, String> {
|
||||
// TODO: Implement provider refresh by calling Rune function
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Query a dynamic provider (stub for now)
|
||||
pub fn query_provider(&mut self, _name: &str, _query: &str) -> Result<Vec<PluginItem>, String> {
|
||||
// TODO: Implement provider query by calling Rune function
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover Rune plugins in a directory
|
||||
pub fn discover_rune_plugins(plugins_dir: &Path) -> Result<HashMap<String, LoadedPlugin>, String> {
|
||||
let mut plugins = HashMap::new();
|
||||
|
||||
if !plugins_dir.exists() {
|
||||
log::debug!(
|
||||
"Plugins directory does not exist: {}",
|
||||
plugins_dir.display()
|
||||
);
|
||||
return Ok(plugins);
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(plugins_dir)
|
||||
.map_err(|e| format!("Failed to read plugins directory: {}", e))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let manifest_path = path.join("plugin.toml");
|
||||
if !manifest_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
let manifest = match PluginManifest::load(&manifest_path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to load manifest at {}: {}",
|
||||
manifest_path.display(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if this is a Rune plugin (entry ends with .rn)
|
||||
if !manifest.plugin.entry.ends_with(".rn") {
|
||||
log::debug!("Skipping non-Rune plugin: {}", manifest.plugin.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load the plugin
|
||||
match LoadedPlugin::new(manifest.clone(), path.clone()) {
|
||||
Ok(plugin) => {
|
||||
let id = manifest.plugin.id.clone();
|
||||
plugins.insert(id, plugin);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load plugin '{}': {}", manifest.plugin.id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_discover_empty_dir() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let plugins = discover_rune_plugins(temp.path()).unwrap();
|
||||
assert!(plugins.is_empty());
|
||||
}
|
||||
}
|
||||
160
crates/owlry-rune/src/manifest.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
//! 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,
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
pub entry: String,
|
||||
}
|
||||
|
||||
fn default_owlry_version() -> String {
|
||||
">=0.1.0".to_string()
|
||||
}
|
||||
|
||||
fn default_entry() -> String {
|
||||
"init.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, "init.rn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_entry_point() {
|
||||
let toml_str = r#"
|
||||
[plugin]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "1.0.0"
|
||||
entry = "main.lua"
|
||||
"#;
|
||||
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
|
||||
assert!(manifest.validate().is_err()); // .lua not allowed for Rune
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_compatibility() {
|
||||
let toml_str = r#"
|
||||
[plugin]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "1.0.0"
|
||||
owlry_version = ">=0.3.0"
|
||||
"#;
|
||||
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
|
||||
assert!(manifest.is_compatible_with("0.3.5"));
|
||||
assert!(!manifest.is_compatible_with("0.2.0"));
|
||||
}
|
||||
}
|
||||
157
crates/owlry-rune/src/runtime.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
//! Rune VM runtime creation and sandboxing
|
||||
|
||||
use rune::{Context, Diagnostics, Source, Sources, Unit, Vm};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::manifest::PluginPermissions;
|
||||
|
||||
/// Configuration for the Rune sandbox
|
||||
///
|
||||
/// Some fields are reserved for future sandbox enforcement.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
#[derive(Default)]
|
||||
pub struct SandboxConfig {
|
||||
/// Allow network/HTTP operations
|
||||
pub network: bool,
|
||||
/// Allow filesystem operations
|
||||
pub filesystem: bool,
|
||||
/// Allowed filesystem paths (reserved for future sandbox enforcement)
|
||||
pub allowed_paths: Vec<String>,
|
||||
/// Allow running external commands (reserved for future sandbox enforcement)
|
||||
pub run_commands: bool,
|
||||
/// Allowed commands (reserved for future sandbox enforcement)
|
||||
pub allowed_commands: Vec<String>,
|
||||
}
|
||||
|
||||
impl SandboxConfig {
|
||||
/// Create sandbox config from plugin permissions
|
||||
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
|
||||
Self {
|
||||
network: permissions.network,
|
||||
filesystem: !permissions.filesystem.is_empty(),
|
||||
allowed_paths: permissions.filesystem.clone(),
|
||||
run_commands: !permissions.run_commands.is_empty(),
|
||||
allowed_commands: permissions.run_commands.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Rune context with owlry API modules
|
||||
pub fn create_context(sandbox: &SandboxConfig) -> Result<Context, rune::ContextError> {
|
||||
let mut context = Context::with_default_modules()?;
|
||||
|
||||
// Add standard modules based on permissions
|
||||
if sandbox.network {
|
||||
log::debug!("Network access enabled for Rune plugin");
|
||||
}
|
||||
|
||||
if sandbox.filesystem {
|
||||
log::debug!("Filesystem access enabled for Rune plugin");
|
||||
}
|
||||
|
||||
// Add owlry API module
|
||||
context.install(crate::api::module()?)?;
|
||||
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
/// Compile Rune source code into a Unit
|
||||
pub fn compile_source(context: &Context, source_path: &Path) -> Result<Arc<Unit>, CompileError> {
|
||||
let source_content =
|
||||
std::fs::read_to_string(source_path).map_err(|e| CompileError::Io(e.to_string()))?;
|
||||
|
||||
let source_name = source_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("init.rn");
|
||||
|
||||
let mut sources = Sources::new();
|
||||
sources
|
||||
.insert(
|
||||
Source::new(source_name, &source_content)
|
||||
.map_err(|e| CompileError::Compile(e.to_string()))?,
|
||||
)
|
||||
.map_err(|e| CompileError::Compile(format!("Failed to insert source: {}", e)))?;
|
||||
|
||||
let mut diagnostics = Diagnostics::new();
|
||||
|
||||
let result = rune::prepare(&mut sources)
|
||||
.with_context(context)
|
||||
.with_diagnostics(&mut diagnostics)
|
||||
.build();
|
||||
|
||||
match result {
|
||||
Ok(unit) => Ok(Arc::new(unit)),
|
||||
Err(e) => {
|
||||
// Collect error messages
|
||||
let mut error_msg = format!("Compilation failed: {}", e);
|
||||
for diagnostic in diagnostics.diagnostics() {
|
||||
error_msg.push_str(&format!("\n {:?}", diagnostic));
|
||||
}
|
||||
Err(CompileError::Compile(error_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new Rune VM from compiled unit
|
||||
pub fn create_vm(context: &Context, unit: Arc<Unit>) -> Result<Vm, CompileError> {
|
||||
let runtime = Arc::new(
|
||||
context
|
||||
.runtime()
|
||||
.map_err(|e| CompileError::Compile(format!("Failed to get runtime: {}", e)))?,
|
||||
);
|
||||
Ok(Vm::new(runtime, unit))
|
||||
}
|
||||
|
||||
/// Error type for compilation
|
||||
#[derive(Debug)]
|
||||
pub enum CompileError {
|
||||
Io(String),
|
||||
Compile(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CompileError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CompileError::Io(e) => write!(f, "IO error: {}", e),
|
||||
CompileError::Compile(e) => write!(f, "Compile error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_config_default() {
|
||||
let config = SandboxConfig::default();
|
||||
assert!(!config.network);
|
||||
assert!(!config.filesystem);
|
||||
assert!(!config.run_commands);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_from_permissions() {
|
||||
let permissions = PluginPermissions {
|
||||
network: true,
|
||||
filesystem: vec!["~/.config".to_string()],
|
||||
run_commands: vec!["notify-send".to_string()],
|
||||
};
|
||||
let config = SandboxConfig::from_permissions(&permissions);
|
||||
assert!(config.network);
|
||||
assert!(config.filesystem);
|
||||
assert!(config.run_commands);
|
||||
assert_eq!(config.allowed_paths, vec!["~/.config"]);
|
||||
assert_eq!(config.allowed_commands, vec!["notify-send"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_context() {
|
||||
let config = SandboxConfig::default();
|
||||
let context = create_context(&config);
|
||||
assert!(context.is_ok());
|
||||
}
|
||||
}
|
||||
58
crates/owlry/Cargo.toml
Normal file
@@ -0,0 +1,58 @@
|
||||
[package]
|
||||
name = "owlry"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
description = "A lightweight, owl-themed application launcher for Wayland"
|
||||
authors = ["Your Name <you@example.com>"]
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://somegit.dev/Owlibou/owlry"
|
||||
keywords = ["launcher", "wayland", "gtk4", "linux"]
|
||||
categories = ["gui"]
|
||||
|
||||
[dependencies]
|
||||
# Core backend library
|
||||
owlry-core = { path = "../owlry-core" }
|
||||
|
||||
# GTK4 for the UI
|
||||
gtk4 = { version = "0.10", features = ["v4_12"] }
|
||||
|
||||
# Layer shell support for Wayland overlay behavior
|
||||
gtk4-layer-shell = "0.7"
|
||||
|
||||
# Low-level syscalls for stdin detection (dmenu mode)
|
||||
libc = "0.2"
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# Configuration (needed for config types used in app.rs/theme.rs)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
|
||||
# CLI argument parsing
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# JSON serialization (needed by plugin commands in CLI)
|
||||
serde_json = "1"
|
||||
|
||||
# Date/time (needed by plugin commands in CLI)
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Directory utilities (needed by plugin commands)
|
||||
dirs = "5"
|
||||
|
||||
# Semantic versioning (needed by plugin commands)
|
||||
semver = "1"
|
||||
|
||||
[build-dependencies]
|
||||
# GResource compilation for bundled icons
|
||||
glib-build-tools = "0.20"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable verbose debug logging (for development/testing builds)
|
||||
dev-logging = ["owlry-core/dev-logging"]
|
||||
# Enable built-in Lua runtime (disable to use external owlry-lua package)
|
||||
lua = ["owlry-core/lua"]
|
||||
12
crates/owlry/build.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
fn main() {
|
||||
// Compile GResource bundle for icons
|
||||
glib_build_tools::compile_resources(
|
||||
&["src/resources/icons"],
|
||||
"src/resources/icons.gresource.xml",
|
||||
"icons.gresource",
|
||||
);
|
||||
|
||||
// Rerun if icon files change
|
||||
println!("cargo:rerun-if-changed=src/resources/icons.gresource.xml");
|
||||
println!("cargo:rerun-if-changed=src/resources/icons/");
|
||||
}
|
||||
281
crates/owlry/src/app.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
use crate::backend::SearchBackend;
|
||||
use crate::cli::CliArgs;
|
||||
use crate::client::CoreClient;
|
||||
use crate::providers::DmenuProvider;
|
||||
use crate::theme;
|
||||
use crate::ui::MainWindow;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Application, CssProvider, gio};
|
||||
use gtk4_layer_shell::{Edge, Layer, LayerShell};
|
||||
use log::{debug, info, warn};
|
||||
use owlry_core::config::Config;
|
||||
use owlry_core::data::FrecencyStore;
|
||||
use owlry_core::filter::ProviderFilter;
|
||||
use owlry_core::paths;
|
||||
use owlry_core::providers::{Provider, ProviderManager, ProviderType};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
const APP_ID: &str = "org.owlry.launcher";
|
||||
|
||||
pub struct OwlryApp {
|
||||
app: Application,
|
||||
}
|
||||
|
||||
impl OwlryApp {
|
||||
pub fn new(args: CliArgs) -> Self {
|
||||
let app = Application::builder()
|
||||
.application_id(APP_ID)
|
||||
.flags(gio::ApplicationFlags::FLAGS_NONE)
|
||||
.build();
|
||||
|
||||
app.connect_activate(move |app| Self::on_activate(app, &args));
|
||||
|
||||
Self { app }
|
||||
}
|
||||
|
||||
pub fn run(&self) -> i32 {
|
||||
// Use empty args since clap already parsed our CLI arguments.
|
||||
// This prevents GTK from trying to parse --mode, --profile, etc.
|
||||
self.app.run_with_args(&[] as &[&str]).into()
|
||||
}
|
||||
|
||||
fn on_activate(app: &Application, args: &CliArgs) {
|
||||
debug!("Activating Owlry");
|
||||
|
||||
// Register bundled icon resources
|
||||
gio::resources_register_include!("icons.gresource")
|
||||
.expect("Failed to register icon resources");
|
||||
|
||||
let config = Rc::new(RefCell::new(Config::load_or_default()));
|
||||
|
||||
// Build backend based on mode
|
||||
let dmenu_mode = DmenuProvider::has_stdin_data();
|
||||
|
||||
let backend = if dmenu_mode {
|
||||
// dmenu mode: local ProviderManager, no daemon
|
||||
let mut dmenu = DmenuProvider::new();
|
||||
dmenu.enable();
|
||||
let core_providers: Vec<Box<dyn Provider>> = vec![Box::new(dmenu)];
|
||||
let provider_manager = ProviderManager::new(core_providers, Vec::new());
|
||||
let frecency = FrecencyStore::load_or_default();
|
||||
|
||||
SearchBackend::Local {
|
||||
providers: Box::new(provider_manager),
|
||||
frecency,
|
||||
}
|
||||
} else {
|
||||
// Normal mode: connect to daemon via IPC
|
||||
match CoreClient::connect_or_start() {
|
||||
Ok(client) => {
|
||||
info!("Connected to owlry-core daemon");
|
||||
SearchBackend::Daemon(client)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to connect to daemon ({}), falling back to local providers",
|
||||
e
|
||||
);
|
||||
Self::create_local_backend(&config.borrow())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let backend = Rc::new(RefCell::new(backend));
|
||||
|
||||
// Create filter from CLI args, profile, and config
|
||||
let resolved_modes = resolve_modes(args, &config.borrow());
|
||||
let filter = if let Some(modes) = resolved_modes {
|
||||
// CLI --mode or --profile specified explicit modes
|
||||
let provider_types: Vec<ProviderType> = modes
|
||||
.iter()
|
||||
.map(|s| ProviderFilter::mode_string_to_provider_type(s))
|
||||
.collect();
|
||||
if provider_types.len() == 1 {
|
||||
ProviderFilter::new(
|
||||
Some(provider_types[0].clone()),
|
||||
None,
|
||||
&config.borrow().providers,
|
||||
)
|
||||
} else {
|
||||
ProviderFilter::new(None, Some(provider_types), &config.borrow().providers)
|
||||
}
|
||||
} else {
|
||||
ProviderFilter::new(None, None, &config.borrow().providers)
|
||||
};
|
||||
let filter = Rc::new(RefCell::new(filter));
|
||||
|
||||
let window = MainWindow::new(
|
||||
app,
|
||||
config.clone(),
|
||||
backend.clone(),
|
||||
filter.clone(),
|
||||
args.prompt.clone(),
|
||||
);
|
||||
|
||||
// Set up layer shell for Wayland overlay behavior
|
||||
window.init_layer_shell();
|
||||
window.set_layer(Layer::Overlay);
|
||||
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
|
||||
|
||||
// Anchor to all edges for centered overlay effect
|
||||
// We'll use margins to control the actual size
|
||||
window.set_anchor(Edge::Top, true);
|
||||
window.set_anchor(Edge::Bottom, false);
|
||||
window.set_anchor(Edge::Left, false);
|
||||
window.set_anchor(Edge::Right, false);
|
||||
|
||||
// Position from top
|
||||
window.set_margin(Edge::Top, 200);
|
||||
|
||||
// Set up icon theme fallbacks
|
||||
Self::setup_icon_theme();
|
||||
|
||||
// Load CSS styling with config for theming
|
||||
Self::load_css(&config.borrow());
|
||||
|
||||
window.present();
|
||||
}
|
||||
|
||||
/// Create a local backend as fallback when daemon is unavailable.
|
||||
/// Loads native plugins and creates providers locally.
|
||||
fn create_local_backend(config: &Config) -> SearchBackend {
|
||||
use owlry_core::plugins::native_loader::NativePluginLoader;
|
||||
use owlry_core::providers::native_provider::NativeProvider;
|
||||
use owlry_core::providers::{ApplicationProvider, CommandProvider};
|
||||
use std::sync::Arc;
|
||||
|
||||
// Load native plugins
|
||||
let mut loader = NativePluginLoader::new();
|
||||
loader.set_disabled(config.plugins.disabled_plugins.clone());
|
||||
|
||||
let native_providers: Vec<NativeProvider> = match loader.discover() {
|
||||
Ok(count) if count > 0 => {
|
||||
info!("Discovered {} native plugin(s) for local fallback", count);
|
||||
let plugins: Vec<Arc<owlry_core::plugins::native_loader::NativePlugin>> =
|
||||
loader.into_plugins();
|
||||
let mut providers = Vec::new();
|
||||
for plugin in plugins {
|
||||
for provider_info in &plugin.providers {
|
||||
let provider =
|
||||
NativeProvider::new(Arc::clone(&plugin), provider_info.clone());
|
||||
providers.push(provider);
|
||||
}
|
||||
}
|
||||
providers
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
let core_providers: Vec<Box<dyn Provider>> = vec![
|
||||
Box::new(ApplicationProvider::new()),
|
||||
Box::new(CommandProvider::new()),
|
||||
];
|
||||
|
||||
let provider_manager = ProviderManager::new(core_providers, native_providers);
|
||||
let frecency = FrecencyStore::load_or_default();
|
||||
|
||||
SearchBackend::Local {
|
||||
providers: Box::new(provider_manager),
|
||||
frecency,
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_icon_theme() {
|
||||
// Ensure we have icon fallbacks for weather/media icons
|
||||
// These may not exist in all icon themes
|
||||
if let Some(display) = gtk4::gdk::Display::default() {
|
||||
let icon_theme = gtk4::IconTheme::for_display(&display);
|
||||
|
||||
// Add Adwaita as fallback search path (has weather and media icons)
|
||||
icon_theme.add_search_path("/usr/share/icons/Adwaita");
|
||||
icon_theme.add_search_path("/usr/share/icons/breeze");
|
||||
|
||||
debug!("Icon theme search paths configured with Adwaita/breeze fallbacks");
|
||||
}
|
||||
}
|
||||
|
||||
fn load_css(config: &Config) {
|
||||
let display = gtk4::gdk::Display::default().expect("Could not get default display");
|
||||
|
||||
// 1. Load base structural CSS (always applied)
|
||||
let base_provider = CssProvider::new();
|
||||
base_provider.load_from_string(include_str!("resources/base.css"));
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&base_provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
debug!("Loaded base structural CSS");
|
||||
|
||||
// 2. Load theme if specified
|
||||
if let Some(ref theme_name) = config.appearance.theme {
|
||||
let theme_provider = CssProvider::new();
|
||||
match theme_name.as_str() {
|
||||
"owl" => {
|
||||
theme_provider.load_from_string(include_str!("resources/owl-theme.css"));
|
||||
debug!("Loaded built-in owl theme");
|
||||
}
|
||||
_ => {
|
||||
// Check for custom theme in $XDG_CONFIG_HOME/owlry/themes/{name}.css
|
||||
if let Some(theme_path) = paths::theme_file(theme_name) {
|
||||
if theme_path.exists() {
|
||||
theme_provider.load_from_path(&theme_path);
|
||||
debug!("Loaded custom theme from {:?}", theme_path);
|
||||
} else {
|
||||
debug!("Theme '{}' not found at {:?}", theme_name, theme_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&theme_provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Load user's custom stylesheet if exists
|
||||
if let Some(custom_path) = paths::custom_style_file()
|
||||
&& custom_path.exists()
|
||||
{
|
||||
let custom_provider = CssProvider::new();
|
||||
custom_provider.load_from_path(&custom_path);
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&custom_provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_USER,
|
||||
);
|
||||
debug!("Loaded custom CSS from {:?}", custom_path);
|
||||
}
|
||||
|
||||
// 4. Inject config variables (highest priority for overrides)
|
||||
let vars_css = theme::generate_variables_css(&config.appearance);
|
||||
let vars_provider = CssProvider::new();
|
||||
vars_provider.load_from_string(&vars_css);
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&vars_provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_USER + 1,
|
||||
);
|
||||
debug!("Injected config CSS variables");
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve which modes to enable based on CLI args and config profiles.
|
||||
///
|
||||
/// Priority: `--mode` > `--profile` > default (all providers from config).
|
||||
/// Returns `None` when no explicit mode selection was made.
|
||||
fn resolve_modes(args: &CliArgs, config: &Config) -> Option<Vec<String>> {
|
||||
if let Some(ref mode) = args.mode {
|
||||
return Some(vec![mode.to_string()]);
|
||||
}
|
||||
if let Some(ref profile_name) = args.profile {
|
||||
if let Some(profile) = config.profiles.get(profile_name) {
|
||||
return Some(profile.modes.clone());
|
||||
}
|
||||
eprintln!("Unknown profile: {}", profile_name);
|
||||
std::process::exit(1);
|
||||
}
|
||||
None
|
||||
}
|
||||
266
crates/owlry/src/backend.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
//! Abstraction over search backends for the UI.
|
||||
//!
|
||||
//! In normal mode, the UI talks to the owlry-core daemon via IPC.
|
||||
//! In dmenu mode, the UI uses a local ProviderManager directly (no daemon).
|
||||
|
||||
use crate::client::CoreClient;
|
||||
use log::warn;
|
||||
use owlry_core::config::Config;
|
||||
use owlry_core::data::FrecencyStore;
|
||||
use owlry_core::filter::ProviderFilter;
|
||||
use owlry_core::ipc::ResultItem;
|
||||
use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType};
|
||||
|
||||
/// Backend for search operations. Wraps either an IPC client (daemon mode)
|
||||
/// or a local ProviderManager (dmenu mode).
|
||||
pub enum SearchBackend {
|
||||
/// IPC client connected to owlry-core daemon
|
||||
Daemon(CoreClient),
|
||||
/// Direct local provider manager (dmenu mode only)
|
||||
Local {
|
||||
providers: Box<ProviderManager>,
|
||||
frecency: FrecencyStore,
|
||||
},
|
||||
}
|
||||
|
||||
impl SearchBackend {
|
||||
/// Search for items matching the query.
|
||||
///
|
||||
/// In daemon mode, sends query over IPC. The modes list is derived from
|
||||
/// the ProviderFilter's enabled set.
|
||||
///
|
||||
/// In local mode, delegates to ProviderManager directly.
|
||||
pub fn search(
|
||||
&mut self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &ProviderFilter,
|
||||
config: &Config,
|
||||
) -> Vec<LaunchItem> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
let modes: Vec<String> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
|
||||
let modes_param = if modes.is_empty() { None } else { Some(modes) };
|
||||
|
||||
match client.query(query, modes_param) {
|
||||
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchBackend::Local {
|
||||
providers,
|
||||
frecency,
|
||||
} => {
|
||||
let frecency_weight = config.providers.frecency_weight;
|
||||
let use_frecency = config.providers.frecency;
|
||||
|
||||
if use_frecency {
|
||||
providers
|
||||
.search_with_frecency(
|
||||
query,
|
||||
max_results,
|
||||
filter,
|
||||
frecency,
|
||||
frecency_weight,
|
||||
None,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
} else {
|
||||
providers
|
||||
.search_filtered(query, max_results, filter)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search with tag filter support.
|
||||
pub fn search_with_tag(
|
||||
&mut self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &ProviderFilter,
|
||||
config: &Config,
|
||||
tag_filter: Option<&str>,
|
||||
) -> Vec<LaunchItem> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
// Daemon doesn't support tag filtering in IPC yet — pass query as-is.
|
||||
// If there's a tag filter, prepend it so the daemon can handle it.
|
||||
let effective_query = if let Some(tag) = tag_filter {
|
||||
format!(":tag:{} {}", tag, query)
|
||||
} else {
|
||||
query.to_string()
|
||||
};
|
||||
|
||||
let modes: Vec<String> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
|
||||
let modes_param = if modes.is_empty() { None } else { Some(modes) };
|
||||
|
||||
match client.query(&effective_query, modes_param) {
|
||||
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchBackend::Local {
|
||||
providers,
|
||||
frecency,
|
||||
} => {
|
||||
let frecency_weight = config.providers.frecency_weight;
|
||||
let use_frecency = config.providers.frecency;
|
||||
|
||||
if use_frecency {
|
||||
providers
|
||||
.search_with_frecency(
|
||||
query,
|
||||
max_results,
|
||||
filter,
|
||||
frecency,
|
||||
frecency_weight,
|
||||
tag_filter,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
} else {
|
||||
providers
|
||||
.search_filtered(query, max_results, filter)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a plugin action command. Returns true if handled.
|
||||
pub fn execute_plugin_action(&mut self, command: &str) -> bool {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => match client.plugin_action(command) {
|
||||
Ok(handled) => handled,
|
||||
Err(e) => {
|
||||
warn!("IPC plugin_action failed: {}", e);
|
||||
false
|
||||
}
|
||||
},
|
||||
SearchBackend::Local { providers, .. } => providers.execute_plugin_action(command),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query submenu actions for a plugin item.
|
||||
/// Returns (display_name, actions) if available.
|
||||
pub fn query_submenu_actions(
|
||||
&mut self,
|
||||
plugin_id: &str,
|
||||
data: &str,
|
||||
display_name: &str,
|
||||
) -> Option<(String, Vec<LaunchItem>)> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => match client.submenu(plugin_id, data) {
|
||||
Ok(items) if !items.is_empty() => {
|
||||
let actions: Vec<LaunchItem> =
|
||||
items.into_iter().map(result_to_launch_item).collect();
|
||||
Some((display_name.to_string(), actions))
|
||||
}
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
warn!("IPC submenu query failed: {}", e);
|
||||
None
|
||||
}
|
||||
},
|
||||
SearchBackend::Local { providers, .. } => {
|
||||
providers.query_submenu_actions(plugin_id, data, display_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a launch event for frecency tracking.
|
||||
pub fn record_launch(&mut self, item_id: &str, provider: &str) {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
if let Err(e) = client.launch(item_id, provider) {
|
||||
warn!("IPC launch notification failed: {}", e);
|
||||
}
|
||||
}
|
||||
SearchBackend::Local { frecency, .. } => {
|
||||
frecency.record_launch(item_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this backend is in dmenu mode.
|
||||
pub fn is_dmenu_mode(&self) -> bool {
|
||||
match self {
|
||||
SearchBackend::Daemon(_) => false,
|
||||
SearchBackend::Local { providers, .. } => providers.is_dmenu_mode(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh widget providers. No-op for daemon mode (daemon handles refresh).
|
||||
pub fn refresh_widgets(&mut self) {
|
||||
if let SearchBackend::Local { providers, .. } = self {
|
||||
providers.refresh_widgets();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get available provider type IDs from the daemon, or from local manager.
|
||||
#[allow(dead_code)]
|
||||
pub fn available_provider_ids(&mut self) -> Vec<String> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => match client.providers() {
|
||||
Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC providers query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
SearchBackend::Local { providers, .. } => providers
|
||||
.available_providers()
|
||||
.into_iter()
|
||||
.map(|d| d.id)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an IPC ResultItem to the internal LaunchItem type.
|
||||
fn result_to_launch_item(item: ResultItem) -> LaunchItem {
|
||||
let provider: ProviderType = item.provider.parse().unwrap_or(ProviderType::Application);
|
||||
LaunchItem {
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
description: if item.description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(item.description)
|
||||
},
|
||||
icon: if item.icon.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(item.icon)
|
||||
},
|
||||
provider,
|
||||
command: item.command.unwrap_or_default(),
|
||||
terminal: item.terminal,
|
||||
tags: item.tags,
|
||||
}
|
||||
}
|
||||
263
crates/owlry/src/cli.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
//! Command-line interface for owlry launcher
|
||||
//!
|
||||
//! Provides both the launcher interface and plugin management commands.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use owlry_core::providers::ProviderType;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(
|
||||
name = "owlry",
|
||||
about = "An owl-themed application launcher for Wayland",
|
||||
long_about = "An owl-themed application launcher for Wayland, built with GTK4 and Layer Shell.\n\n\
|
||||
Owlry provides fuzzy search across applications, commands, and plugins.\n\
|
||||
Native plugins add features like calculator, clipboard, emoji, weather, and more.",
|
||||
version,
|
||||
after_help = "\
|
||||
EXAMPLES:
|
||||
owlry Launch with all providers
|
||||
owlry -m app Applications only
|
||||
owlry -m cmd PATH commands only
|
||||
owlry -m dmenu dmenu-compatible mode (reads from stdin)
|
||||
owlry --profile dev Use a named profile from config
|
||||
owlry -m calc Calculator plugin only (if installed)
|
||||
|
||||
DMENU MODE:
|
||||
Pipe input to owlry for interactive selection:
|
||||
|
||||
echo -e \"Option A\\nOption B\" | owlry -m dmenu
|
||||
ls | owlry -m dmenu -p \"checkout:\"
|
||||
git branch | owlry -m dmenu --prompt \"checkout:\"
|
||||
|
||||
PROFILES:
|
||||
Define profiles in ~/.config/owlry/config.toml:
|
||||
|
||||
[profiles.dev]
|
||||
modes = [\"app\", \"cmd\", \"ssh\"]
|
||||
|
||||
Then launch with: owlry --profile dev
|
||||
|
||||
SEARCH PREFIXES:
|
||||
:app firefox Search applications
|
||||
:cmd git Search PATH commands
|
||||
= 5+3 Calculator (requires plugin)
|
||||
? rust docs Web search (requires plugin)
|
||||
/ .bashrc File search (requires plugin)
|
||||
|
||||
For configuration, see ~/.config/owlry/config.toml
|
||||
For plugin management, see: owlry plugin --help"
|
||||
)]
|
||||
pub struct CliArgs {
|
||||
/// Start in single-provider mode
|
||||
///
|
||||
/// Core modes: app, cmd, dmenu
|
||||
/// Plugin modes: calc, clip, emoji, ssh, sys, bm, file, web, uuctl, weather, media, pomodoro
|
||||
#[arg(long, short = 'm', value_parser = parse_provider, value_name = "MODE")]
|
||||
pub mode: Option<ProviderType>,
|
||||
|
||||
/// Use a named profile from config (defines which modes to enable)
|
||||
///
|
||||
/// Profiles are defined in config.toml under [profiles.<name>].
|
||||
/// Example: --profile dev (loads modes from [profiles.dev])
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub profile: Option<String>,
|
||||
|
||||
/// Custom prompt text for the search input
|
||||
///
|
||||
/// Useful in dmenu mode to indicate what the user is selecting.
|
||||
/// Example: -p "Select file:" or --prompt "Select file:"
|
||||
#[arg(long, short = 'p', value_name = "TEXT")]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Subcommand to run (if any)
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum Command {
|
||||
/// Manage plugins
|
||||
#[command(subcommand)]
|
||||
Plugin(PluginCommand),
|
||||
}
|
||||
|
||||
/// Plugin runtime type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
|
||||
pub enum PluginRuntime {
|
||||
/// Lua runtime (requires owlry-lua package)
|
||||
Lua,
|
||||
/// Rune runtime (requires owlry-rune package)
|
||||
Rune,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PluginRuntime {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PluginRuntime::Lua => write!(f, "lua"),
|
||||
PluginRuntime::Rune => write!(f, "rune"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum PluginCommand {
|
||||
/// List installed plugins
|
||||
List {
|
||||
/// Show only enabled plugins
|
||||
#[arg(long)]
|
||||
enabled: bool,
|
||||
|
||||
/// Show only disabled plugins
|
||||
#[arg(long)]
|
||||
disabled: bool,
|
||||
|
||||
/// Filter by runtime type (lua or rune)
|
||||
#[arg(long, short = 'r', value_enum)]
|
||||
runtime: Option<PluginRuntime>,
|
||||
|
||||
/// Show available plugins from registry instead of installed
|
||||
#[arg(long)]
|
||||
available: bool,
|
||||
|
||||
/// Force refresh of registry cache
|
||||
#[arg(long)]
|
||||
refresh: bool,
|
||||
|
||||
/// Output in JSON format
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Search for plugins in the registry
|
||||
Search {
|
||||
/// Search query (matches name, description, tags)
|
||||
query: String,
|
||||
|
||||
/// Force refresh of registry cache
|
||||
#[arg(long)]
|
||||
refresh: bool,
|
||||
|
||||
/// Output in JSON format
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Show detailed information about a plugin
|
||||
Info {
|
||||
/// Plugin ID
|
||||
name: String,
|
||||
|
||||
/// Show info from registry instead of installed plugin
|
||||
#[arg(long)]
|
||||
registry: bool,
|
||||
|
||||
/// Output in JSON format
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Install a plugin from registry, path, or URL
|
||||
Install {
|
||||
/// Plugin source (registry name, local path, or git URL)
|
||||
source: String,
|
||||
|
||||
/// Force reinstall even if already installed
|
||||
#[arg(long, short = 'f')]
|
||||
force: bool,
|
||||
},
|
||||
|
||||
/// Remove an installed plugin
|
||||
Remove {
|
||||
/// Plugin ID to remove
|
||||
name: String,
|
||||
|
||||
/// Don't ask for confirmation
|
||||
#[arg(long, short = 'y')]
|
||||
yes: bool,
|
||||
},
|
||||
|
||||
/// Update installed plugins
|
||||
Update {
|
||||
/// Specific plugin to update (all if not specified)
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Enable a disabled plugin
|
||||
Enable {
|
||||
/// Plugin ID to enable
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Disable an installed plugin
|
||||
Disable {
|
||||
/// Plugin ID to disable
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Create a new plugin from template
|
||||
Create {
|
||||
/// Plugin ID (directory name)
|
||||
name: String,
|
||||
|
||||
/// Runtime type to use (default: lua)
|
||||
#[arg(long, short = 'r', value_enum, default_value = "lua")]
|
||||
runtime: PluginRuntime,
|
||||
|
||||
/// Target directory (default: current directory)
|
||||
#[arg(long, short = 'd')]
|
||||
dir: Option<String>,
|
||||
|
||||
/// Plugin display name
|
||||
#[arg(long)]
|
||||
display_name: Option<String>,
|
||||
|
||||
/// Plugin description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
|
||||
/// Validate a plugin's structure and manifest
|
||||
Validate {
|
||||
/// Path to plugin directory (default: current directory)
|
||||
path: Option<String>,
|
||||
},
|
||||
|
||||
/// Show available script runtimes
|
||||
Runtimes,
|
||||
|
||||
/// Run a plugin command
|
||||
///
|
||||
/// Plugins can provide CLI commands that are invoked via:
|
||||
/// owlry plugin run <plugin-id> <command> [args...]
|
||||
///
|
||||
/// Example:
|
||||
/// owlry plugin run bookmark add https://example.com "My Bookmark"
|
||||
Run {
|
||||
/// Plugin ID
|
||||
plugin_id: String,
|
||||
|
||||
/// Command to run
|
||||
command: String,
|
||||
|
||||
/// Arguments to pass to the command
|
||||
#[arg(trailing_var_arg = true)]
|
||||
args: Vec<String>,
|
||||
},
|
||||
|
||||
/// List commands provided by a plugin
|
||||
Commands {
|
||||
/// Plugin ID (optional - lists all if not specified)
|
||||
plugin_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn parse_provider(s: &str) -> Result<ProviderType, String> {
|
||||
s.parse()
|
||||
}
|
||||
|
||||
impl CliArgs {
|
||||
pub fn parse_args() -> Self {
|
||||
Self::parse()
|
||||
}
|
||||
}
|
||||
367
crates/owlry/src/client.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
|
||||
|
||||
/// IPC client that connects to the owlry-core daemon Unix socket
|
||||
/// and provides typed methods for all IPC operations.
|
||||
pub struct CoreClient {
|
||||
stream: UnixStream,
|
||||
reader: BufReader<UnixStream>,
|
||||
}
|
||||
|
||||
impl CoreClient {
|
||||
/// Connect to a running daemon at the given socket path.
|
||||
///
|
||||
/// Sets a 5-second read timeout so the client doesn't hang indefinitely
|
||||
/// if the daemon stops responding.
|
||||
pub fn connect(socket_path: &Path) -> io::Result<Self> {
|
||||
let stream = UnixStream::connect(socket_path)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
let reader = BufReader::new(stream.try_clone()?);
|
||||
Ok(Self { stream, reader })
|
||||
}
|
||||
|
||||
/// Try connecting to the daemon. If the socket isn't available, attempt
|
||||
/// to start the daemon via systemd and retry with exponential backoff.
|
||||
///
|
||||
/// Backoff schedule: 100ms, 200ms, 400ms.
|
||||
pub fn connect_or_start() -> io::Result<Self> {
|
||||
let path = Self::socket_path();
|
||||
|
||||
// First attempt: just try connecting.
|
||||
if let Ok(client) = Self::connect(&path) {
|
||||
return Ok(client);
|
||||
}
|
||||
|
||||
// Socket not available — try to start the daemon.
|
||||
let status = std::process::Command::new("systemctl")
|
||||
.args(["--user", "start", "owlry-core"])
|
||||
.status()
|
||||
.map_err(|e| {
|
||||
io::Error::other(format!("failed to start owlry-core via systemd: {e}"))
|
||||
})?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"systemctl --user start owlry-core exited with status {}",
|
||||
status
|
||||
)));
|
||||
}
|
||||
|
||||
// Retry with exponential backoff.
|
||||
let delays = [100, 200, 400];
|
||||
for (i, ms) in delays.iter().enumerate() {
|
||||
std::thread::sleep(Duration::from_millis(*ms));
|
||||
match Self::connect(&path) {
|
||||
Ok(client) => return Ok(client),
|
||||
Err(e) if i == delays.len() - 1 => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::ConnectionRefused,
|
||||
format!("daemon started but socket not available after retries: {e}"),
|
||||
));
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// Default socket path: `$XDG_RUNTIME_DIR/owlry/owlry.sock`.
|
||||
///
|
||||
/// Delegates to `owlry_core::paths::socket_path()` to keep a single
|
||||
/// source of truth.
|
||||
pub fn socket_path() -> PathBuf {
|
||||
owlry_core::paths::socket_path()
|
||||
}
|
||||
|
||||
/// Send a search query and return matching results.
|
||||
pub fn query(&mut self, text: &str, modes: Option<Vec<String>>) -> io::Result<Vec<ResultItem>> {
|
||||
self.send(&Request::Query {
|
||||
text: text.to_string(),
|
||||
modes,
|
||||
})?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::Results { items } => Ok(items),
|
||||
Response::Error { message } => Err(io::Error::other(message)),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to Query: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a launch event for frecency tracking.
|
||||
pub fn launch(&mut self, item_id: &str, provider: &str) -> io::Result<()> {
|
||||
self.send(&Request::Launch {
|
||||
item_id: item_id.to_string(),
|
||||
provider: provider.to_string(),
|
||||
})?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::Ack => Ok(()),
|
||||
Response::Error { message } => Err(io::Error::other(message)),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to Launch: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all available providers from the daemon.
|
||||
pub fn providers(&mut self) -> io::Result<Vec<ProviderDesc>> {
|
||||
self.send(&Request::Providers)?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::Providers { list } => Ok(list),
|
||||
Response::Error { message } => Err(io::Error::other(message)),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to Providers: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle the launcher window visibility.
|
||||
pub fn toggle(&mut self) -> io::Result<()> {
|
||||
self.send(&Request::Toggle)?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::Ack => Ok(()),
|
||||
Response::Error { message } => Err(io::Error::other(message)),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to Toggle: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a plugin action command (e.g., "POMODORO:start").
|
||||
/// Returns Ok(true) if the plugin handled the action, Ok(false) if not.
|
||||
pub fn plugin_action(&mut self, command: &str) -> io::Result<bool> {
|
||||
self.send(&Request::PluginAction {
|
||||
command: command.to_string(),
|
||||
})?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::Ack => Ok(true),
|
||||
Response::Error { .. } => Ok(false),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to PluginAction: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query a plugin's submenu actions.
|
||||
pub fn submenu(&mut self, plugin_id: &str, data: &str) -> io::Result<Vec<ResultItem>> {
|
||||
self.send(&Request::Submenu {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
data: data.to_string(),
|
||||
})?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::SubmenuItems { items } => Ok(items),
|
||||
Response::Error { message } => Err(io::Error::other(message)),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to Submenu: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal helpers
|
||||
// =========================================================================
|
||||
|
||||
fn send(&mut self, request: &Request) -> io::Result<()> {
|
||||
let json = serde_json::to_string(request)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
writeln!(self.stream, "{json}")?;
|
||||
self.stream.flush()
|
||||
}
|
||||
|
||||
fn receive(&mut self) -> io::Result<Response> {
|
||||
let mut line = String::new();
|
||||
self.reader.read_line(&mut line)?;
|
||||
if line.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"daemon closed the connection",
|
||||
));
|
||||
}
|
||||
serde_json::from_str(line.trim()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::thread;
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Spawn a mock server that accepts one connection, reads one request,
|
||||
/// and replies with the given canned response. Each call gets a unique
|
||||
/// socket path to avoid collisions when tests run in parallel.
|
||||
fn mock_server(response: Response) -> PathBuf {
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("owlry-test-{}-{}", std::process::id(), n));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let sock = dir.join("test.sock");
|
||||
let _ = std::fs::remove_file(&sock);
|
||||
|
||||
let listener = UnixListener::bind(&sock).expect("bind mock socket");
|
||||
let sock_clone = sock.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().expect("accept");
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut writer = stream;
|
||||
|
||||
// Read one request line (we don't care about contents).
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).expect("read request");
|
||||
|
||||
// Send canned response.
|
||||
let mut json = serde_json::to_string(&response).unwrap();
|
||||
json.push('\n');
|
||||
writer.write_all(json.as_bytes()).unwrap();
|
||||
writer.flush().unwrap();
|
||||
|
||||
// Clean up socket after test.
|
||||
let _ = std::fs::remove_file(&sock_clone);
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
});
|
||||
|
||||
sock
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_and_query_returns_results() {
|
||||
let canned = Response::Results {
|
||||
items: vec![ResultItem {
|
||||
id: "firefox".into(),
|
||||
title: "Firefox".into(),
|
||||
description: "Web Browser".into(),
|
||||
icon: "firefox".into(),
|
||||
provider: "app".into(),
|
||||
score: 100,
|
||||
command: Some("firefox".into()),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let sock = mock_server(canned);
|
||||
// Give the listener thread a moment to start.
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut client = CoreClient::connect(&sock).expect("connect");
|
||||
let results = client.query("fire", None).expect("query");
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].id, "firefox");
|
||||
assert_eq!(results[0].title, "Firefox");
|
||||
assert_eq!(results[0].score, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_returns_ack() {
|
||||
let sock = mock_server(Response::Ack);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut client = CoreClient::connect(&sock).expect("connect");
|
||||
client.toggle().expect("toggle should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_returns_ack() {
|
||||
let sock = mock_server(Response::Ack);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut client = CoreClient::connect(&sock).expect("connect");
|
||||
client
|
||||
.launch("firefox", "app")
|
||||
.expect("launch should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn providers_returns_list() {
|
||||
let canned = Response::Providers {
|
||||
list: vec![ProviderDesc {
|
||||
id: "app".into(),
|
||||
name: "Applications".into(),
|
||||
prefix: Some(":app".into()),
|
||||
icon: "application-x-executable".into(),
|
||||
position: "normal".into(),
|
||||
}],
|
||||
};
|
||||
|
||||
let sock = mock_server(canned);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut client = CoreClient::connect(&sock).expect("connect");
|
||||
let providers = client.providers().expect("providers");
|
||||
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(providers[0].id, "app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submenu_returns_items() {
|
||||
let canned = Response::SubmenuItems {
|
||||
items: vec![ResultItem {
|
||||
id: "start".into(),
|
||||
title: "Start Service".into(),
|
||||
description: String::new(),
|
||||
icon: "media-playback-start".into(),
|
||||
provider: "systemd".into(),
|
||||
score: 0,
|
||||
command: Some("systemctl --user start foo".into()),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let sock = mock_server(canned);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut client = CoreClient::connect(&sock).expect("connect");
|
||||
let items = client.submenu("systemd", "foo.service").expect("submenu");
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, "start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_response_is_propagated() {
|
||||
let canned = Response::Error {
|
||||
message: "something went wrong".into(),
|
||||
};
|
||||
|
||||
let sock = mock_server(canned);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut client = CoreClient::connect(&sock).expect("connect");
|
||||
let err = client.query("test", None).unwrap_err();
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("something went wrong"),
|
||||
"error message should contain the server error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_path_delegates_to_core() {
|
||||
let path = CoreClient::socket_path();
|
||||
assert!(path.ends_with("owlry/owlry.sock"));
|
||||
}
|
||||
}
|
||||
123
crates/owlry/src/main.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
mod app;
|
||||
mod backend;
|
||||
mod cli;
|
||||
pub mod client;
|
||||
mod plugin_commands;
|
||||
mod providers;
|
||||
mod theme;
|
||||
mod ui;
|
||||
|
||||
use app::OwlryApp;
|
||||
use cli::{CliArgs, Command};
|
||||
use log::{info, warn};
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
|
||||
/// Try to acquire an exclusive lock on the UI lock file.
|
||||
///
|
||||
/// Returns `Some(File)` if the lock was acquired (no other instance running),
|
||||
/// or `None` if another instance already holds the lock.
|
||||
/// The returned `File` must be kept alive for the duration of the process.
|
||||
fn try_acquire_lock() -> Option<std::fs::File> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let lock_path = owlry_core::paths::socket_path()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("owlry-ui.lock");
|
||||
|
||||
// Ensure the parent directory exists
|
||||
if let Some(parent) = lock_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&lock_path)
|
||||
.ok()
|
||||
.and_then(|f| {
|
||||
let fd = f.as_raw_fd();
|
||||
let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if ret == 0 { Some(f) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = CliArgs::parse_args();
|
||||
|
||||
// Handle subcommands before initializing the full app
|
||||
if let Some(command) = &args.command {
|
||||
// CLI commands don't need full logging
|
||||
match command {
|
||||
Command::Plugin(plugin_cmd) => {
|
||||
if let Err(e) = plugin_commands::execute(plugin_cmd.clone()) {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No subcommand - launch the app
|
||||
let default_level = if cfg!(feature = "dev-logging") {
|
||||
"debug"
|
||||
} else {
|
||||
"info"
|
||||
};
|
||||
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level))
|
||||
.format_timestamp_millis()
|
||||
.init();
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
{
|
||||
debug!("┌─────────────────────────────────────────┐");
|
||||
debug!("│ DEV-LOGGING: Verbose output enabled │");
|
||||
debug!("└─────────────────────────────────────────┘");
|
||||
debug!("CLI args: {:?}", args);
|
||||
}
|
||||
|
||||
// Toggle behavior: if another instance is already running, tell the daemon
|
||||
// to toggle visibility and exit immediately.
|
||||
let _lock_guard = match try_acquire_lock() {
|
||||
Some(file) => file,
|
||||
None => {
|
||||
// Another instance holds the lock — send toggle to daemon and exit
|
||||
info!("Another owlry instance detected, sending toggle");
|
||||
let socket_path = client::CoreClient::socket_path();
|
||||
if let Ok(mut client) = client::CoreClient::connect(&socket_path) {
|
||||
if let Err(e) = client.toggle() {
|
||||
eprintln!("Failed to toggle existing instance: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else {
|
||||
eprintln!("Another instance is running but daemon is unreachable");
|
||||
std::process::exit(1);
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
info!("Starting Owlry launcher");
|
||||
|
||||
// Diagnostic: log critical environment variables
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "<not set>".to_string());
|
||||
let path = std::env::var("PATH").unwrap_or_else(|_| "<not set>".to_string());
|
||||
let xdg_data = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| "<not set>".to_string());
|
||||
info!("HOME={}", home);
|
||||
info!("PATH={}", path);
|
||||
info!("XDG_DATA_HOME={}", xdg_data);
|
||||
|
||||
if home == "<not set>" || path == "<not set>" {
|
||||
warn!("Critical environment variables missing! Items may not load correctly.");
|
||||
}
|
||||
|
||||
let app = OwlryApp::new(args);
|
||||
std::process::exit(app.run());
|
||||
}
|
||||
1266
crates/owlry/src/plugin_commands.rs
Normal file
@@ -1,5 +1,5 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use log::debug;
|
||||
use owlry_core::providers::{LaunchItem, Provider, ProviderType};
|
||||
use std::io::{self, BufRead};
|
||||
|
||||
/// Provider for dmenu-style input from stdin
|
||||
@@ -101,6 +101,7 @@ impl Provider for DmenuProvider {
|
||||
provider: ProviderType::Dmenu,
|
||||
command: line.to_string(),
|
||||
terminal: false,
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
2
crates/owlry/src/providers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod dmenu;
|
||||
pub use dmenu::DmenuProvider;
|
||||
@@ -14,7 +14,7 @@
|
||||
background-color: var(--owlry-bg, @theme_bg_color);
|
||||
border-radius: var(--owlry-border-radius, 12px);
|
||||
border: 1px solid var(--owlry-border, @borders);
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Search entry */
|
||||
@@ -43,8 +43,8 @@
|
||||
.owlry-result-row {
|
||||
background-color: transparent;
|
||||
border-radius: calc(var(--owlry-border-radius, 12px) - 4px);
|
||||
margin: 2px 0;
|
||||
padding: 8px 12px;
|
||||
margin: 1px 0;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.owlry-result-row:hover {
|
||||
@@ -67,6 +67,18 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Symbolic icons - inherit text color */
|
||||
.owlry-symbolic-icon {
|
||||
-gtk-icon-style: symbolic;
|
||||
}
|
||||
|
||||
/* Emoji icon - displayed as large text */
|
||||
.owlry-emoji-icon {
|
||||
font-size: 24px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* Result name */
|
||||
.owlry-result-name {
|
||||
font-size: var(--owlry-font-size, 14px);
|
||||
@@ -81,7 +93,7 @@
|
||||
/* Result description */
|
||||
.owlry-result-description {
|
||||
font-size: calc(var(--owlry-font-size, 14px) - 2px);
|
||||
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.7));
|
||||
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.85));
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@@ -166,6 +178,22 @@
|
||||
color: var(--owlry-badge-web, @teal_3);
|
||||
}
|
||||
|
||||
/* Widget provider badges */
|
||||
.owlry-badge-media {
|
||||
background-color: alpha(var(--owlry-badge-media, #ec4899), 0.2);
|
||||
color: var(--owlry-badge-media, #ec4899);
|
||||
}
|
||||
|
||||
.owlry-badge-weather {
|
||||
background-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.2);
|
||||
color: var(--owlry-badge-weather, #06b6d4);
|
||||
}
|
||||
|
||||
.owlry-badge-pomo {
|
||||
background-color: alpha(var(--owlry-badge-pomo, #f97316), 0.2);
|
||||
color: var(--owlry-badge-pomo, #f97316);
|
||||
}
|
||||
|
||||
/* Header bar */
|
||||
.owlry-header {
|
||||
margin-bottom: 4px;
|
||||
@@ -283,6 +311,25 @@
|
||||
border-color: alpha(var(--owlry-badge-web, @teal_3), 0.4);
|
||||
}
|
||||
|
||||
/* Widget filter buttons */
|
||||
.owlry-filter-media:checked {
|
||||
background-color: alpha(var(--owlry-badge-media, #ec4899), 0.2);
|
||||
color: var(--owlry-badge-media, #ec4899);
|
||||
border-color: alpha(var(--owlry-badge-media, #ec4899), 0.4);
|
||||
}
|
||||
|
||||
.owlry-filter-weather:checked {
|
||||
background-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.2);
|
||||
color: var(--owlry-badge-weather, #06b6d4);
|
||||
border-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.4);
|
||||
}
|
||||
|
||||
.owlry-filter-pomodoro:checked {
|
||||
background-color: alpha(var(--owlry-badge-pomo, #f97316), 0.2);
|
||||
color: var(--owlry-badge-pomo, #f97316);
|
||||
border-color: alpha(var(--owlry-badge-pomo, #f97316), 0.4);
|
||||
}
|
||||
|
||||
/* Hints bar at bottom */
|
||||
.owlry-hints {
|
||||
padding-top: 8px;
|
||||
@@ -291,7 +338,7 @@
|
||||
|
||||
.owlry-hints-label {
|
||||
font-size: calc(var(--owlry-font-size, 14px) - 4px);
|
||||
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.7));
|
||||
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.75));
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -315,6 +362,22 @@ scrollbar slider:active {
|
||||
background-color: var(--owlry-accent, @theme_selected_bg_color);
|
||||
}
|
||||
|
||||
/* Tag badges */
|
||||
.owlry-tag-badge {
|
||||
font-size: calc(var(--owlry-font-size, 14px) - 4px);
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: alpha(var(--owlry-border, @borders), 0.5);
|
||||
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.9));
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.owlry-result-row:selected .owlry-tag-badge {
|
||||
background-color: alpha(var(--owlry-accent-bright, @theme_selected_fg_color), 0.25);
|
||||
color: var(--owlry-accent-bright, @theme_selected_fg_color);
|
||||
}
|
||||
|
||||
/* Text selection */
|
||||
selection {
|
||||
background-color: alpha(var(--owlry-accent, @theme_selected_bg_color), 0.3);
|
||||
21
crates/owlry/src/resources/icons.gresource.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gresources>
|
||||
<gresource prefix="/org/owlry/launcher/icons">
|
||||
<!-- Weather icons (Erik Flowers Weather Icons - OFL license) -->
|
||||
<file>weather/wi-day-sunny.svg</file>
|
||||
<file>weather/wi-day-cloudy.svg</file>
|
||||
<file>weather/wi-cloudy.svg</file>
|
||||
<file>weather/wi-fog.svg</file>
|
||||
<file>weather/wi-rain.svg</file>
|
||||
<file>weather/wi-snow.svg</file>
|
||||
<file>weather/wi-thunderstorm.svg</file>
|
||||
<file>weather/wi-thermometer.svg</file>
|
||||
<file>weather/wi-night-clear.svg</file>
|
||||
|
||||
<!-- Media player icons -->
|
||||
<file>media/music-note.svg</file>
|
||||
|
||||
<!-- Pomodoro icons -->
|
||||
<file>pomodoro/tomato.svg</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
3
crates/owlry/src/resources/icons/media/music-note.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#e0e0e0">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 183 B |
12
crates/owlry/src/resources/icons/pomodoro/tomato.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<ellipse cx="50" cy="58" rx="38" ry="35" fill="#e53935"/>
|
||||
<ellipse cx="50" cy="58" rx="38" ry="35" fill="url(#tomato-gradient)"/>
|
||||
<path d="M50 25 C45 15, 55 15, 50 25" fill="#4caf50"/>
|
||||
<path d="M42 28 Q50 20 58 28" stroke="#2e7d32" stroke-width="3" fill="none"/>
|
||||
<defs>
|
||||
<radialGradient id="tomato-gradient" cx="30%" cy="30%">
|
||||
<stop offset="0%" stop-color="#ff5722" stop-opacity="0.3"/>
|
||||
<stop offset="100%" stop-color="#c62828" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 574 B |
18
crates/owlry/src/resources/icons/weather/wi-cloudy.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M3.89,17.6c0-0.99,0.31-1.88,0.93-2.65s1.41-1.27,2.38-1.49c0.26-1.17,0.85-2.14,1.78-2.88c0.93-0.75,2-1.12,3.22-1.12
|
||||
c1.18,0,2.24,0.36,3.16,1.09c0.93,0.73,1.53,1.66,1.8,2.8h0.27c1.18,0,2.18,0.41,3.01,1.24s1.25,1.83,1.25,3
|
||||
c0,1.18-0.42,2.18-1.25,3.01s-1.83,1.25-3.01,1.25H8.16c-0.58,0-1.13-0.11-1.65-0.34S5.52,21,5.14,20.62
|
||||
c-0.38-0.38-0.68-0.84-0.91-1.36S3.89,18.17,3.89,17.6z M5.34,17.6c0,0.76,0.28,1.42,0.82,1.96s1.21,0.82,1.99,0.82h9.28
|
||||
c0.77,0,1.44-0.27,1.99-0.82c0.55-0.55,0.83-1.2,0.83-1.96c0-0.76-0.27-1.42-0.83-1.96c-0.55-0.54-1.21-0.82-1.99-0.82h-1.39
|
||||
c-0.1,0-0.15-0.05-0.15-0.15l-0.07-0.49c-0.1-0.94-0.5-1.73-1.19-2.35s-1.51-0.93-2.45-0.93c-0.94,0-1.76,0.31-2.46,0.94
|
||||
c-0.7,0.62-1.09,1.41-1.18,2.34l-0.07,0.42c0,0.1-0.05,0.15-0.16,0.15l-0.45,0.07c-0.72,0.06-1.32,0.36-1.81,0.89
|
||||
C5.59,16.24,5.34,16.87,5.34,17.6z M14.19,8.88c-0.1,0.09-0.08,0.16,0.07,0.21c0.43,0.19,0.79,0.37,1.08,0.55
|
||||
c0.11,0.03,0.19,0.02,0.22-0.03c0.61-0.57,1.31-0.86,2.12-0.86c0.81,0,1.5,0.27,2.1,0.81c0.59,0.54,0.92,1.21,0.99,2l0.09,0.64h1.42
|
||||
c0.65,0,1.21,0.23,1.68,0.7c0.47,0.47,0.7,1.02,0.7,1.66c0,0.6-0.21,1.12-0.62,1.57s-0.92,0.7-1.53,0.77c-0.1,0-0.15,0.05-0.15,0.16
|
||||
v1.13c0,0.11,0.05,0.16,0.15,0.16c1.01-0.06,1.86-0.46,2.55-1.19s1.04-1.6,1.04-2.6c0-1.06-0.37-1.96-1.12-2.7
|
||||
c-0.75-0.75-1.65-1.12-2.7-1.12h-0.15c-0.26-1-0.81-1.82-1.65-2.47c-0.83-0.65-1.77-0.97-2.8-0.97C16.28,7.29,15.11,7.82,14.19,8.88
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
26
crates/owlry/src/resources/icons/weather/wi-day-cloudy.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M1.56,16.9c0,0.9,0.22,1.73,0.66,2.49s1.04,1.36,1.8,1.8c0.76,0.44,1.58,0.66,2.47,0.66h10.83c0.89,0,1.72-0.22,2.48-0.66
|
||||
c0.76-0.44,1.37-1.04,1.81-1.8c0.44-0.76,0.67-1.59,0.67-2.49c0-0.66-0.14-1.33-0.42-2C22.62,13.98,23,12.87,23,11.6
|
||||
c0-0.71-0.14-1.39-0.41-2.04c-0.27-0.65-0.65-1.2-1.12-1.67C21,7.42,20.45,7.04,19.8,6.77c-0.65-0.28-1.33-0.41-2.04-0.41
|
||||
c-1.48,0-2.77,0.58-3.88,1.74c-0.77-0.44-1.67-0.66-2.7-0.66c-1.41,0-2.65,0.44-3.73,1.31c-1.08,0.87-1.78,1.99-2.08,3.35
|
||||
c-1.12,0.26-2.03,0.83-2.74,1.73S1.56,15.75,1.56,16.9z M3.27,16.9c0-0.84,0.28-1.56,0.84-2.17c0.56-0.61,1.26-0.96,2.1-1.06
|
||||
l0.5-0.03c0.12,0,0.19-0.06,0.19-0.18l0.07-0.54c0.14-1.08,0.61-1.99,1.41-2.71c0.8-0.73,1.74-1.09,2.81-1.09
|
||||
c1.1,0,2.06,0.37,2.87,1.1c0.82,0.73,1.27,1.63,1.37,2.71l0.07,0.58c0.02,0.11,0.09,0.17,0.21,0.17h1.61c0.88,0,1.64,0.32,2.28,0.96
|
||||
c0.64,0.64,0.96,1.39,0.96,2.27c0,0.91-0.32,1.68-0.95,2.32c-0.63,0.64-1.4,0.96-2.28,0.96H6.49c-0.88,0-1.63-0.32-2.27-0.97
|
||||
C3.59,18.57,3.27,17.8,3.27,16.9z M9.97,4.63c0,0.24,0.08,0.45,0.24,0.63l0.66,0.64c0.25,0.19,0.46,0.27,0.64,0.25
|
||||
c0.21,0,0.39-0.09,0.55-0.26s0.24-0.38,0.24-0.62c0-0.24-0.09-0.44-0.26-0.59l-0.59-0.66c-0.18-0.16-0.38-0.24-0.61-0.24
|
||||
c-0.24,0-0.45,0.08-0.62,0.25C10.05,4.19,9.97,4.39,9.97,4.63z M15.31,9.06c0.69-0.67,1.51-1,2.45-1c0.99,0,1.83,0.34,2.52,1.03
|
||||
c0.69,0.69,1.04,1.52,1.04,2.51c0,0.62-0.17,1.24-0.51,1.84C19.84,12.48,18.68,12,17.32,12H17C16.75,10.91,16.19,9.93,15.31,9.06z
|
||||
M16.94,3.78c0,0.26,0.08,0.46,0.23,0.62s0.35,0.23,0.59,0.23c0.26,0,0.46-0.08,0.62-0.23c0.16-0.16,0.23-0.36,0.23-0.62V1.73
|
||||
c0-0.24-0.08-0.43-0.24-0.59s-0.36-0.23-0.61-0.23c-0.24,0-0.43,0.08-0.59,0.23s-0.23,0.35-0.23,0.59V3.78z M22.46,6.07
|
||||
c0,0.26,0.07,0.46,0.22,0.62c0.21,0.16,0.42,0.24,0.62,0.24c0.18,0,0.38-0.08,0.59-0.24l1.43-1.43c0.16-0.18,0.24-0.39,0.24-0.64
|
||||
c0-0.24-0.08-0.44-0.24-0.6c-0.16-0.16-0.36-0.24-0.59-0.24c-0.24,0-0.43,0.08-0.58,0.24l-1.47,1.43
|
||||
C22.53,5.64,22.46,5.84,22.46,6.07z M23.25,17.91c0,0.24,0.08,0.45,0.25,0.63l0.65,0.63c0.15,0.16,0.34,0.24,0.58,0.24
|
||||
s0.44-0.08,0.6-0.25c0.16-0.17,0.24-0.37,0.24-0.62c0-0.22-0.08-0.42-0.24-0.58l-0.65-0.65c-0.16-0.16-0.35-0.24-0.57-0.24
|
||||
c-0.24,0-0.44,0.08-0.6,0.24C23.34,17.47,23.25,17.67,23.25,17.91z M24.72,11.6c0,0.23,0.09,0.42,0.26,0.58
|
||||
c0.16,0.16,0.37,0.24,0.61,0.24h2.04c0.23,0,0.42-0.08,0.58-0.23s0.23-0.35,0.23-0.59c0-0.24-0.08-0.44-0.23-0.6
|
||||
s-0.35-0.25-0.58-0.25h-2.04c-0.24,0-0.44,0.08-0.61,0.25C24.8,11.17,24.72,11.37,24.72,11.6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
27
crates/owlry/src/resources/icons/weather/wi-day-sunny.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M4.37,14.62c0-0.24,0.08-0.45,0.25-0.62c0.17-0.16,0.38-0.24,0.6-0.24h2.04c0.23,0,0.42,0.08,0.58,0.25
|
||||
c0.15,0.17,0.23,0.37,0.23,0.61S8,15.06,7.85,15.23c-0.15,0.17-0.35,0.25-0.58,0.25H5.23c-0.23,0-0.43-0.08-0.6-0.25
|
||||
C4.46,15.06,4.37,14.86,4.37,14.62z M7.23,21.55c0-0.23,0.08-0.43,0.23-0.61l1.47-1.43c0.15-0.16,0.35-0.23,0.59-0.23
|
||||
c0.24,0,0.44,0.08,0.6,0.23s0.24,0.34,0.24,0.57c0,0.24-0.08,0.46-0.24,0.64L8.7,22.14c-0.41,0.32-0.82,0.32-1.23,0
|
||||
C7.31,21.98,7.23,21.78,7.23,21.55z M7.23,7.71c0-0.23,0.08-0.43,0.23-0.61C7.66,6.93,7.87,6.85,8.1,6.85
|
||||
c0.22,0,0.42,0.08,0.59,0.24l1.43,1.47c0.16,0.15,0.24,0.35,0.24,0.59c0,0.24-0.08,0.44-0.24,0.6s-0.36,0.24-0.6,0.24
|
||||
c-0.24,0-0.44-0.08-0.59-0.24L7.47,8.32C7.31,8.16,7.23,7.95,7.23,7.71z M9.78,14.62c0-0.93,0.23-1.8,0.7-2.6s1.1-1.44,1.91-1.91
|
||||
s1.67-0.7,2.6-0.7c0.7,0,1.37,0.14,2.02,0.42c0.64,0.28,1.2,0.65,1.66,1.12c0.47,0.47,0.84,1.02,1.11,1.66
|
||||
c0.27,0.64,0.41,1.32,0.41,2.02c0,0.94-0.23,1.81-0.7,2.61c-0.47,0.8-1.1,1.43-1.9,1.9c-0.8,0.47-1.67,0.7-2.61,0.7
|
||||
s-1.81-0.23-2.61-0.7c-0.8-0.47-1.43-1.1-1.9-1.9C10.02,16.43,9.78,15.56,9.78,14.62z M11.48,14.62c0,0.98,0.34,1.81,1.03,2.5
|
||||
c0.68,0.69,1.51,1.04,2.49,1.04s1.81-0.35,2.5-1.04s1.04-1.52,1.04-2.5c0-0.96-0.35-1.78-1.04-2.47c-0.69-0.68-1.52-1.02-2.5-1.02
|
||||
c-0.97,0-1.8,0.34-2.48,1.02C11.82,12.84,11.48,13.66,11.48,14.62z M14.14,22.4c0-0.24,0.08-0.44,0.25-0.6s0.37-0.24,0.6-0.24
|
||||
c0.24,0,0.45,0.08,0.61,0.24s0.24,0.36,0.24,0.6v1.99c0,0.24-0.08,0.45-0.25,0.62c-0.17,0.17-0.37,0.25-0.6,0.25
|
||||
s-0.44-0.08-0.6-0.25c-0.17-0.17-0.25-0.38-0.25-0.62V22.4z M14.14,6.9V4.86c0-0.23,0.08-0.43,0.25-0.6C14.56,4.09,14.76,4,15,4
|
||||
s0.43,0.08,0.6,0.25c0.17,0.17,0.25,0.37,0.25,0.6V6.9c0,0.23-0.08,0.42-0.25,0.58S15.23,7.71,15,7.71s-0.44-0.08-0.6-0.23
|
||||
S14.14,7.13,14.14,6.9z M19.66,20.08c0-0.23,0.08-0.42,0.23-0.56c0.15-0.16,0.34-0.23,0.56-0.23c0.24,0,0.44,0.08,0.6,0.23
|
||||
l1.46,1.43c0.16,0.17,0.24,0.38,0.24,0.61c0,0.23-0.08,0.43-0.24,0.59c-0.4,0.31-0.8,0.31-1.2,0l-1.42-1.42
|
||||
C19.74,20.55,19.66,20.34,19.66,20.08z M19.66,9.16c0-0.25,0.08-0.45,0.23-0.59l1.42-1.47c0.17-0.16,0.37-0.24,0.59-0.24
|
||||
c0.24,0,0.44,0.08,0.6,0.25c0.17,0.17,0.25,0.37,0.25,0.6c0,0.25-0.08,0.46-0.24,0.62l-1.46,1.43c-0.18,0.16-0.38,0.24-0.6,0.24
|
||||
c-0.23,0-0.41-0.08-0.56-0.24S19.66,9.4,19.66,9.16z M21.92,14.62c0-0.24,0.08-0.44,0.24-0.62c0.16-0.16,0.35-0.24,0.57-0.24h2.02
|
||||
c0.23,0,0.43,0.09,0.6,0.26c0.17,0.17,0.26,0.37,0.26,0.6s-0.09,0.43-0.26,0.6c-0.17,0.17-0.37,0.25-0.6,0.25h-2.02
|
||||
c-0.23,0-0.43-0.08-0.58-0.25S21.92,14.86,21.92,14.62z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
18
crates/owlry/src/resources/icons/weather/wi-fog.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M2.62,21.05c0-0.24,0.08-0.45,0.25-0.61c0.17-0.16,0.38-0.24,0.63-0.24h18.67c0.25,0,0.45,0.08,0.61,0.24
|
||||
c0.16,0.16,0.24,0.36,0.24,0.61c0,0.23-0.08,0.43-0.25,0.58c-0.17,0.16-0.37,0.23-0.6,0.23H3.5c-0.25,0-0.46-0.08-0.63-0.23
|
||||
C2.7,21.47,2.62,21.28,2.62,21.05z M5.24,17.91c0-0.24,0.09-0.44,0.26-0.6c0.15-0.15,0.35-0.23,0.59-0.23h18.67
|
||||
c0.23,0,0.42,0.08,0.58,0.24c0.16,0.16,0.23,0.35,0.23,0.59c0,0.24-0.08,0.44-0.23,0.6c-0.16,0.17-0.35,0.25-0.58,0.25H6.09
|
||||
c-0.24,0-0.44-0.08-0.6-0.25C5.32,18.34,5.24,18.14,5.24,17.91z M5.37,15.52c0,0.09,0.05,0.13,0.15,0.13h1.43
|
||||
c0.06,0,0.13-0.05,0.2-0.16c0.24-0.52,0.59-0.94,1.06-1.27c0.47-0.33,0.99-0.52,1.55-0.56l0.55-0.07c0.11,0,0.17-0.06,0.17-0.18
|
||||
l0.07-0.5c0.11-1.08,0.56-1.98,1.37-2.7c0.81-0.72,1.76-1.08,2.85-1.08c1.08,0,2.02,0.36,2.83,1.07c0.8,0.71,1.26,1.61,1.37,2.68
|
||||
l0.08,0.57c0,0.11,0.07,0.17,0.2,0.17h1.59c0.64,0,1.23,0.17,1.76,0.52s0.92,0.8,1.18,1.37c0.07,0.11,0.14,0.16,0.21,0.16h1.43
|
||||
c0.12,0,0.17-0.07,0.14-0.23c-0.29-1.02-0.88-1.86-1.74-2.51c-0.87-0.65-1.86-0.97-2.97-0.97h-0.32c-0.33-1.33-1.03-2.42-2.1-3.27
|
||||
s-2.28-1.27-3.65-1.27c-1.4,0-2.64,0.44-3.73,1.32s-1.78,2-2.09,3.36c-0.85,0.2-1.6,0.6-2.24,1.21c-0.64,0.61-1.09,1.33-1.34,2.18
|
||||
v-0.04C5.37,15.45,5.37,15.48,5.37,15.52z M6.98,24.11c0-0.24,0.09-0.43,0.26-0.59c0.15-0.15,0.35-0.23,0.6-0.23h18.68
|
||||
c0.24,0,0.44,0.08,0.6,0.23c0.17,0.16,0.25,0.35,0.25,0.58c0,0.24-0.08,0.44-0.25,0.61c-0.17,0.17-0.37,0.25-0.6,0.25H7.84
|
||||
c-0.23,0-0.43-0.09-0.6-0.26C7.07,24.55,6.98,24.34,6.98,24.11z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
13
crates/owlry/src/resources/icons/weather/wi-night-clear.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M7.91,14.48c0-0.96,0.19-1.87,0.56-2.75s0.88-1.63,1.51-2.26c0.63-0.63,1.39-1.14,2.27-1.52c0.88-0.38,1.8-0.57,2.75-0.57
|
||||
h1.14c0.16,0.04,0.23,0.14,0.23,0.28l0.05,0.88c0.04,1.27,0.49,2.35,1.37,3.24c0.88,0.89,1.94,1.37,3.19,1.42l0.82,0.07
|
||||
c0.16,0,0.24,0.08,0.24,0.23v0.98c0.01,1.28-0.3,2.47-0.93,3.56c-0.63,1.09-1.48,1.95-2.57,2.59c-1.08,0.63-2.27,0.95-3.55,0.95
|
||||
c-0.97,0-1.9-0.19-2.78-0.56s-1.63-0.88-2.26-1.51c-0.63-0.63-1.13-1.39-1.5-2.26C8.1,16.37,7.91,15.45,7.91,14.48z M9.74,14.48
|
||||
c0,0.76,0.15,1.48,0.45,2.16c0.3,0.67,0.7,1.24,1.19,1.7c0.49,0.46,1.05,0.82,1.69,1.08c0.63,0.27,1.28,0.4,1.94,0.4
|
||||
c0.58,0,1.17-0.11,1.76-0.34c0.59-0.23,1.14-0.55,1.65-0.96c0.51-0.41,0.94-0.93,1.31-1.57c0.37-0.64,0.6-1.33,0.71-2.09
|
||||
c-1.63-0.34-2.94-1.04-3.92-2.1s-1.55-2.3-1.7-3.74C13.86,9.08,13,9.37,12.21,9.9c-0.78,0.53-1.39,1.2-1.82,2.02
|
||||
C9.96,12.74,9.74,13.59,9.74,14.48z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
23
crates/owlry/src/resources/icons/weather/wi-rain.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M4.64,16.91c0-1.15,0.36-2.17,1.08-3.07c0.72-0.9,1.63-1.47,2.73-1.73c0.31-1.36,1.02-2.48,2.11-3.36s2.34-1.31,3.75-1.31
|
||||
c1.38,0,2.6,0.43,3.68,1.28c1.08,0.85,1.78,1.95,2.1,3.29h0.32c0.89,0,1.72,0.22,2.48,0.65s1.37,1.03,1.81,1.78
|
||||
c0.44,0.75,0.67,1.58,0.67,2.47c0,0.88-0.21,1.69-0.63,2.44c-0.42,0.75-1,1.35-1.73,1.8c-0.73,0.45-1.53,0.69-2.4,0.71
|
||||
c-0.13,0-0.2-0.06-0.2-0.17v-1.33c0-0.12,0.07-0.18,0.2-0.18c0.85-0.04,1.58-0.38,2.18-1.02s0.9-1.39,0.9-2.26s-0.33-1.62-0.98-2.26
|
||||
s-1.42-0.96-2.31-0.96h-1.61c-0.12,0-0.18-0.06-0.18-0.17l-0.08-0.58c-0.11-1.08-0.58-1.99-1.39-2.71
|
||||
c-0.82-0.73-1.76-1.09-2.85-1.09c-1.09,0-2.05,0.36-2.85,1.09c-0.81,0.73-1.26,1.63-1.36,2.71l-0.07,0.53c0,0.12-0.07,0.19-0.2,0.19
|
||||
l-0.53,0.03c-0.83,0.1-1.53,0.46-2.1,1.07s-0.85,1.33-0.85,2.16c0,0.87,0.3,1.62,0.9,2.26s1.33,0.98,2.18,1.02
|
||||
c0.11,0,0.17,0.06,0.17,0.18v1.33c0,0.11-0.06,0.17-0.17,0.17c-1.34-0.06-2.47-0.57-3.4-1.53S4.64,18.24,4.64,16.91z M9.99,23.6
|
||||
c0-0.04,0.01-0.11,0.04-0.2l1.63-5.77c0.06-0.19,0.17-0.34,0.32-0.44c0.15-0.1,0.31-0.15,0.46-0.15c0.07,0,0.15,0.01,0.24,0.03
|
||||
c0.24,0.04,0.42,0.17,0.54,0.37c0.12,0.2,0.15,0.42,0.08,0.67l-1.63,5.73c-0.12,0.43-0.4,0.64-0.82,0.64
|
||||
c-0.04,0-0.07-0.01-0.11-0.02c-0.06-0.02-0.09-0.03-0.1-0.03c-0.22-0.06-0.38-0.17-0.49-0.33C10.04,23.93,9.99,23.77,9.99,23.6z
|
||||
M12.61,26.41l2.44-8.77c0.04-0.19,0.14-0.34,0.3-0.44c0.16-0.1,0.32-0.15,0.49-0.15c0.09,0,0.18,0.01,0.27,0.03
|
||||
c0.22,0.06,0.38,0.19,0.49,0.39c0.11,0.2,0.13,0.41,0.07,0.64l-2.43,8.78c-0.04,0.17-0.13,0.31-0.29,0.43
|
||||
c-0.16,0.12-0.32,0.18-0.51,0.18c-0.09,0-0.18-0.02-0.25-0.05c-0.2-0.05-0.37-0.18-0.52-0.39C12.56,26.88,12.54,26.67,12.61,26.41z
|
||||
M16.74,23.62c0-0.04,0.01-0.11,0.04-0.23l1.63-5.77c0.06-0.19,0.16-0.34,0.3-0.44c0.15-0.1,0.3-0.15,0.46-0.15
|
||||
c0.08,0,0.17,0.01,0.26,0.03c0.21,0.06,0.36,0.16,0.46,0.31c0.1,0.15,0.15,0.31,0.15,0.47c0,0.03-0.01,0.08-0.02,0.14
|
||||
s-0.02,0.1-0.02,0.12l-1.63,5.73c-0.04,0.19-0.13,0.35-0.28,0.46s-0.32,0.17-0.51,0.17l-0.24-0.05c-0.2-0.06-0.35-0.16-0.46-0.32
|
||||
C16.79,23.94,16.74,23.78,16.74,23.62z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
27
crates/owlry/src/resources/icons/weather/wi-snow.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M4.64,16.95c0-1.16,0.35-2.18,1.06-3.08s1.62-1.48,2.74-1.76c0.31-1.36,1.01-2.48,2.1-3.36s2.34-1.31,3.75-1.31
|
||||
c1.38,0,2.6,0.43,3.68,1.28c1.08,0.85,1.78,1.95,2.1,3.29h0.32c0.89,0,1.72,0.22,2.48,0.66c0.76,0.44,1.37,1.04,1.81,1.8
|
||||
c0.44,0.76,0.67,1.59,0.67,2.48c0,1.32-0.46,2.47-1.39,3.42c-0.92,0.96-2.05,1.46-3.38,1.5c-0.13,0-0.2-0.06-0.2-0.17v-1.33
|
||||
c0-0.12,0.07-0.18,0.2-0.18c0.85-0.04,1.58-0.38,2.18-1.02s0.9-1.38,0.9-2.23c0-0.89-0.32-1.65-0.97-2.3s-1.42-0.97-2.32-0.97h-1.61
|
||||
c-0.12,0-0.18-0.06-0.18-0.17l-0.08-0.58c-0.11-1.08-0.58-1.99-1.39-2.72c-0.82-0.73-1.76-1.1-2.85-1.1c-1.1,0-2.05,0.37-2.86,1.11
|
||||
c-0.81,0.74-1.27,1.65-1.37,2.75l-0.06,0.5c0,0.12-0.07,0.19-0.2,0.19l-0.53,0.07c-0.83,0.07-1.53,0.41-2.1,1.04
|
||||
s-0.85,1.35-0.85,2.19c0,0.85,0.3,1.59,0.9,2.23s1.33,0.97,2.18,1.02c0.11,0,0.17,0.06,0.17,0.18v1.33c0,0.11-0.06,0.17-0.17,0.17
|
||||
c-1.34-0.04-2.47-0.54-3.4-1.5C5.1,19.42,4.64,18.27,4.64,16.95z M11,21.02c0-0.22,0.08-0.42,0.24-0.58
|
||||
c0.16-0.16,0.35-0.24,0.59-0.24c0.23,0,0.43,0.08,0.59,0.24c0.16,0.16,0.24,0.36,0.24,0.58c0,0.24-0.08,0.44-0.24,0.6
|
||||
c-0.16,0.17-0.35,0.25-0.59,0.25c-0.23,0-0.43-0.08-0.59-0.25C11.08,21.46,11,21.26,11,21.02z M11,24.65c0-0.24,0.08-0.44,0.24-0.6
|
||||
c0.16-0.15,0.35-0.23,0.58-0.23c0.23,0,0.43,0.08,0.59,0.23c0.16,0.16,0.24,0.35,0.24,0.59c0,0.24-0.08,0.43-0.24,0.59
|
||||
c-0.16,0.16-0.35,0.23-0.59,0.23c-0.23,0-0.43-0.08-0.59-0.23C11.08,25.08,11,24.88,11,24.65z M14.19,22.95
|
||||
c0-0.23,0.08-0.44,0.25-0.62c0.16-0.16,0.35-0.24,0.57-0.24c0.23,0,0.43,0.09,0.6,0.26c0.17,0.17,0.26,0.37,0.26,0.6
|
||||
c0,0.23-0.08,0.43-0.25,0.6c-0.17,0.17-0.37,0.25-0.61,0.25c-0.23,0-0.42-0.08-0.58-0.25S14.19,23.18,14.19,22.95z M14.19,19.33
|
||||
c0-0.23,0.08-0.43,0.25-0.6c0.18-0.16,0.37-0.24,0.57-0.24c0.24,0,0.44,0.08,0.61,0.25c0.17,0.17,0.25,0.36,0.25,0.6
|
||||
c0,0.23-0.08,0.43-0.25,0.59c-0.17,0.16-0.37,0.24-0.61,0.24c-0.23,0-0.42-0.08-0.58-0.24C14.27,19.76,14.19,19.56,14.19,19.33z
|
||||
M14.19,26.61c0-0.23,0.08-0.43,0.25-0.61c0.16-0.16,0.35-0.24,0.57-0.24c0.24,0,0.44,0.08,0.61,0.25c0.17,0.17,0.25,0.37,0.25,0.6
|
||||
s-0.08,0.43-0.25,0.59c-0.17,0.16-0.37,0.24-0.61,0.24c-0.23,0-0.42-0.08-0.58-0.24C14.27,27.03,14.19,26.84,14.19,26.61z
|
||||
M17.41,21.02c0-0.22,0.08-0.41,0.25-0.58c0.17-0.17,0.37-0.25,0.6-0.25c0.23,0,0.43,0.08,0.59,0.24c0.16,0.16,0.24,0.36,0.24,0.58
|
||||
c0,0.24-0.08,0.44-0.24,0.6c-0.16,0.17-0.35,0.25-0.59,0.25c-0.24,0-0.44-0.08-0.6-0.25C17.5,21.45,17.41,21.25,17.41,21.02z
|
||||
M17.41,24.65c0-0.22,0.08-0.42,0.25-0.6c0.16-0.15,0.36-0.23,0.6-0.23c0.24,0,0.43,0.08,0.59,0.23s0.23,0.35,0.23,0.59
|
||||
c0,0.24-0.08,0.43-0.23,0.59c-0.16,0.16-0.35,0.23-0.59,0.23c-0.24,0-0.44-0.08-0.6-0.24C17.5,25.07,17.41,24.88,17.41,24.65z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
15
crates/owlry/src/resources/icons/weather/wi-thermometer.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M9.91,19.56c0-0.85,0.2-1.64,0.59-2.38s0.94-1.35,1.65-1.84V5.42c0-0.8,0.27-1.48,0.82-2.03S14.2,2.55,15,2.55
|
||||
c0.81,0,1.49,0.28,2.04,0.83c0.55,0.56,0.83,1.23,0.83,2.03v9.92c0.71,0.49,1.25,1.11,1.64,1.84s0.58,1.53,0.58,2.38
|
||||
c0,0.92-0.23,1.78-0.68,2.56s-1.07,1.4-1.85,1.85s-1.63,0.68-2.56,0.68c-0.92,0-1.77-0.23-2.55-0.68s-1.4-1.07-1.86-1.85
|
||||
S9.91,20.48,9.91,19.56z M11.67,19.56c0,0.93,0.33,1.73,0.98,2.39c0.65,0.66,1.44,0.99,2.36,0.99c0.93,0,1.73-0.33,2.4-1
|
||||
s1.01-1.46,1.01-2.37c0-0.62-0.16-1.2-0.48-1.73c-0.32-0.53-0.76-0.94-1.32-1.23l-0.28-0.14c-0.1-0.04-0.15-0.14-0.15-0.29V5.42
|
||||
c0-0.32-0.11-0.59-0.34-0.81C15.62,4.4,15.34,4.29,15,4.29c-0.32,0-0.6,0.11-0.83,0.32c-0.23,0.21-0.34,0.48-0.34,0.81v10.74
|
||||
c0,0.15-0.05,0.25-0.14,0.29l-0.27,0.14c-0.55,0.29-0.98,0.7-1.29,1.23C11.82,18.35,11.67,18.92,11.67,19.56z M12.45,19.56
|
||||
c0,0.71,0.24,1.32,0.73,1.82s1.07,0.75,1.76,0.75s1.28-0.25,1.79-0.75c0.51-0.5,0.76-1.11,0.76-1.81c0-0.63-0.22-1.19-0.65-1.67
|
||||
c-0.43-0.48-0.96-0.77-1.58-0.85V9.69c0-0.06-0.03-0.13-0.1-0.19c-0.07-0.07-0.14-0.1-0.22-0.1c-0.09,0-0.16,0.03-0.21,0.08
|
||||
c-0.05,0.06-0.08,0.12-0.08,0.21v7.34c-0.61,0.09-1.13,0.37-1.56,0.85C12.66,18.37,12.45,18.92,12.45,19.56z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
21
crates/owlry/src/resources/icons/weather/wi-thunderstorm.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M4.63,16.91c0,1.11,0.33,2.1,0.99,2.97s1.52,1.47,2.58,1.79l-0.66,1.68c-0.03,0.14,0.02,0.22,0.14,0.22h2.13l-0.98,4.3h0.28
|
||||
l3.92-5.75c0.04-0.04,0.04-0.09,0.01-0.14c-0.03-0.05-0.08-0.07-0.15-0.07h-2.18l2.48-4.64c0.07-0.14,0.02-0.22-0.14-0.22h-2.94
|
||||
c-0.09,0-0.17,0.05-0.23,0.15l-1.07,2.87c-0.71-0.18-1.3-0.57-1.77-1.16c-0.47-0.59-0.7-1.26-0.7-2.01c0-0.83,0.28-1.55,0.85-2.17
|
||||
c0.57-0.61,1.27-0.97,2.1-1.07l0.53-0.07c0.13,0,0.2-0.06,0.2-0.18l0.07-0.51c0.11-1.08,0.56-1.99,1.37-2.72
|
||||
c0.81-0.73,1.76-1.1,2.85-1.1c1.09,0,2.04,0.37,2.85,1.1c0.82,0.73,1.28,1.64,1.4,2.72l0.07,0.58c0,0.11,0.06,0.17,0.18,0.17h1.6
|
||||
c0.91,0,1.68,0.32,2.32,0.95c0.64,0.63,0.97,1.4,0.97,2.28c0,0.85-0.3,1.59-0.89,2.21c-0.59,0.62-1.33,0.97-2.2,1.04
|
||||
c-0.13,0-0.2,0.06-0.2,0.18v1.37c0,0.11,0.07,0.17,0.2,0.17c1.33-0.04,2.46-0.55,3.39-1.51s1.39-2.11,1.39-3.45
|
||||
c0-0.9-0.22-1.73-0.67-2.49c-0.44-0.76-1.05-1.36-1.81-1.8c-0.77-0.44-1.6-0.66-2.5-0.66H20.1c-0.33-1.33-1.04-2.42-2.11-3.26
|
||||
s-2.3-1.27-3.68-1.27c-1.41,0-2.67,0.44-3.76,1.31s-1.79,1.99-2.1,3.36c-1.11,0.26-2.02,0.83-2.74,1.73S4.63,15.76,4.63,16.91z
|
||||
M12.77,26.62c0,0.39,0.19,0.65,0.58,0.77c0.01,0,0.05,0,0.11,0.01c0.06,0.01,0.11,0.01,0.14,0.01c0.17,0,0.33-0.05,0.49-0.15
|
||||
c0.16-0.1,0.27-0.26,0.32-0.48l2.25-8.69c0.06-0.24,0.04-0.45-0.07-0.65c-0.11-0.19-0.27-0.32-0.5-0.39
|
||||
c-0.17-0.02-0.26-0.03-0.26-0.03c-0.16,0-0.32,0.05-0.47,0.15c-0.15,0.1-0.26,0.25-0.31,0.45l-2.26,8.72
|
||||
C12.78,26.44,12.77,26.53,12.77,26.62z M16.93,23.56c0,0.13,0.03,0.26,0.1,0.38c0.14,0.22,0.31,0.37,0.51,0.44
|
||||
c0.11,0.03,0.21,0.05,0.3,0.05s0.2-0.02,0.32-0.08c0.21-0.09,0.35-0.28,0.42-0.57l1.44-5.67c0.03-0.14,0.05-0.23,0.05-0.27
|
||||
c0-0.15-0.05-0.3-0.16-0.45s-0.26-0.26-0.46-0.32c-0.17-0.02-0.26-0.03-0.26-0.03c-0.17,0-0.33,0.05-0.47,0.15
|
||||
c-0.14,0.1-0.24,0.25-0.3,0.45l-1.46,5.7c0,0.02,0,0.05-0.01,0.11C16.93,23.5,16.93,23.53,16.93,23.56z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -1,4 +1,4 @@
|
||||
use crate::config::AppearanceConfig;
|
||||
use owlry_core::config::AppearanceConfig;
|
||||
|
||||
/// Generate CSS with :root variables from config settings
|
||||
pub fn generate_variables_css(config: &AppearanceConfig) -> String {
|
||||
@@ -6,7 +6,10 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
|
||||
|
||||
// Always inject layout config values
|
||||
css.push_str(&format!(" --owlry-font-size: {}px;\n", config.font_size));
|
||||
css.push_str(&format!(" --owlry-border-radius: {}px;\n", config.border_radius));
|
||||
css.push_str(&format!(
|
||||
" --owlry-border-radius: {}px;\n",
|
||||
config.border_radius
|
||||
));
|
||||
|
||||
// Only inject colors if user specified them
|
||||
if let Some(ref bg) = config.colors.background {
|
||||
@@ -22,7 +25,10 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
|
||||
css.push_str(&format!(" --owlry-text: {};\n", text));
|
||||
}
|
||||
if let Some(ref text_secondary) = config.colors.text_secondary {
|
||||
css.push_str(&format!(" --owlry-text-secondary: {};\n", text_secondary));
|
||||
css.push_str(&format!(
|
||||
" --owlry-text-secondary: {};\n",
|
||||
text_secondary
|
||||
));
|
||||
}
|
||||
if let Some(ref accent) = config.colors.accent {
|
||||
css.push_str(&format!(" --owlry-accent: {};\n", accent));
|
||||
@@ -36,7 +42,10 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
|
||||
css.push_str(&format!(" --owlry-badge-app: {};\n", badge_app));
|
||||
}
|
||||
if let Some(ref badge_bookmark) = config.colors.badge_bookmark {
|
||||
css.push_str(&format!(" --owlry-badge-bookmark: {};\n", badge_bookmark));
|
||||
css.push_str(&format!(
|
||||
" --owlry-badge-bookmark: {};\n",
|
||||
badge_bookmark
|
||||
));
|
||||
}
|
||||
if let Some(ref badge_calc) = config.colors.badge_calc {
|
||||
css.push_str(&format!(" --owlry-badge-calc: {};\n", badge_calc));
|
||||
@@ -72,6 +81,17 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
|
||||
css.push_str(&format!(" --owlry-badge-web: {};\n", badge_web));
|
||||
}
|
||||
|
||||
// Widget badge colors
|
||||
if let Some(ref badge_media) = config.colors.badge_media {
|
||||
css.push_str(&format!(" --owlry-badge-media: {};\n", badge_media));
|
||||
}
|
||||
if let Some(ref badge_weather) = config.colors.badge_weather {
|
||||
css.push_str(&format!(" --owlry-badge-weather: {};\n", badge_weather));
|
||||
}
|
||||
if let Some(ref badge_pomo) = config.colors.badge_pomo {
|
||||
css.push_str(&format!(" --owlry-badge-pomo: {};\n", badge_pomo));
|
||||
}
|
||||
|
||||
css.push_str("}\n");
|
||||
css
|
||||
}
|
||||
1450
crates/owlry/src/ui/main_window.rs
Normal file
@@ -1,5 +1,6 @@
|
||||
mod main_window;
|
||||
mod result_row;
|
||||
pub mod submenu;
|
||||
|
||||
pub use main_window::MainWindow;
|
||||
pub use result_row::ResultRow;
|
||||
165
crates/owlry/src/ui/result_row.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget};
|
||||
use owlry_core::providers::LaunchItem;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct ResultRow {
|
||||
row: ListBoxRow,
|
||||
}
|
||||
|
||||
/// Check if a string looks like an emoji (starts with a non-ASCII character
|
||||
/// and is very short - typically 1-4 chars for complex emojis with ZWJ)
|
||||
fn is_emoji_icon(s: &str) -> bool {
|
||||
if s.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Emojis are non-ASCII and typically very short (1-8 chars for complex ZWJ sequences)
|
||||
let first_char = s.chars().next().unwrap();
|
||||
!first_char.is_ascii() && s.chars().count() <= 8
|
||||
}
|
||||
|
||||
impl ResultRow {
|
||||
#[allow(clippy::new_ret_no_self)]
|
||||
pub fn new(item: &LaunchItem) -> ListBoxRow {
|
||||
let row = ListBoxRow::builder()
|
||||
.selectable(true)
|
||||
.activatable(true)
|
||||
.build();
|
||||
|
||||
row.add_css_class("owlry-result-row");
|
||||
|
||||
let hbox = GtkBox::builder()
|
||||
.orientation(Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
// Icon - handle GResource paths, file paths, icon names, emojis, and fallbacks
|
||||
let icon_widget: Widget = if let Some(icon_path) = &item.icon {
|
||||
if is_emoji_icon(icon_path) {
|
||||
// Emoji character - display as text label
|
||||
let emoji_label = Label::builder()
|
||||
.label(icon_path)
|
||||
.width_request(32)
|
||||
.height_request(32)
|
||||
.valign(gtk4::Align::Center)
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
emoji_label.add_css_class("owlry-result-icon");
|
||||
emoji_label.add_css_class("owlry-emoji-icon");
|
||||
emoji_label.upcast()
|
||||
} else if icon_path.starts_with("/org/owlry/launcher/icons/") {
|
||||
// GResource path - load from bundled resources
|
||||
let img = Image::from_resource(icon_path);
|
||||
img.set_pixel_size(32);
|
||||
img.add_css_class("owlry-result-icon");
|
||||
// SVG icons from resources should be treated as symbolic for color inheritance
|
||||
if icon_path.ends_with(".svg") {
|
||||
img.add_css_class("owlry-symbolic-icon");
|
||||
}
|
||||
img.upcast()
|
||||
} else if icon_path.starts_with('/') {
|
||||
// Absolute file path
|
||||
let img = Image::from_file(icon_path);
|
||||
img.set_pixel_size(32);
|
||||
img.add_css_class("owlry-result-icon");
|
||||
img.upcast()
|
||||
} else {
|
||||
// Icon theme name
|
||||
let img = Image::from_icon_name(icon_path);
|
||||
img.set_pixel_size(32);
|
||||
img.add_css_class("owlry-result-icon");
|
||||
// Add symbolic class for icons ending with "-symbolic"
|
||||
if icon_path.ends_with("-symbolic") {
|
||||
img.add_css_class("owlry-symbolic-icon");
|
||||
}
|
||||
img.upcast()
|
||||
}
|
||||
} else {
|
||||
// Default icon based on provider type (only core types, plugins should provide icons)
|
||||
let default_icon = match &item.provider {
|
||||
owlry_core::providers::ProviderType::Application => {
|
||||
"application-x-executable-symbolic"
|
||||
}
|
||||
owlry_core::providers::ProviderType::Command => "utilities-terminal-symbolic",
|
||||
owlry_core::providers::ProviderType::Dmenu => "view-list-symbolic",
|
||||
// Plugins should provide their own icon; fallback to generic addon icon
|
||||
owlry_core::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic",
|
||||
};
|
||||
let img = Image::from_icon_name(default_icon);
|
||||
img.set_pixel_size(32);
|
||||
img.add_css_class("owlry-result-icon");
|
||||
img.add_css_class("owlry-symbolic-icon");
|
||||
img.upcast()
|
||||
};
|
||||
|
||||
// Text container
|
||||
let text_box = GtkBox::builder()
|
||||
.orientation(Orientation::Vertical)
|
||||
.hexpand(true)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
|
||||
// Name label
|
||||
let name_label = Label::builder()
|
||||
.label(&item.name)
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
|
||||
name_label.add_css_class("owlry-result-name");
|
||||
|
||||
// Description label
|
||||
if let Some(desc) = &item.description {
|
||||
let desc_label = Label::builder()
|
||||
.label(desc)
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
|
||||
desc_label.add_css_class("owlry-result-description");
|
||||
text_box.append(&name_label);
|
||||
text_box.append(&desc_label);
|
||||
} else {
|
||||
text_box.append(&name_label);
|
||||
}
|
||||
|
||||
// Tag badges (show first 3 tags)
|
||||
if !item.tags.is_empty() {
|
||||
let tags_box = GtkBox::builder()
|
||||
.orientation(Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
|
||||
for tag in item.tags.iter().take(3) {
|
||||
let tag_label = Label::builder().label(tag).build();
|
||||
tag_label.add_css_class("owlry-tag-badge");
|
||||
tags_box.append(&tag_label);
|
||||
}
|
||||
|
||||
text_box.append(&tags_box);
|
||||
}
|
||||
|
||||
// Provider badge
|
||||
let badge = Label::builder()
|
||||
.label(item.provider.to_string())
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
|
||||
badge.add_css_class("owlry-result-badge");
|
||||
badge.add_css_class(&format!("owlry-badge-{}", item.provider));
|
||||
|
||||
hbox.append(&icon_widget);
|
||||
hbox.append(&text_box);
|
||||
hbox.append(&badge);
|
||||
|
||||
row.set_child(Some(&hbox));
|
||||
|
||||
row
|
||||
}
|
||||
}
|
||||
112
crates/owlry/src/ui/submenu.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
//! Universal Submenu Support for Plugins
|
||||
//!
|
||||
//! Provides parsing utilities for submenu commands. Plugins handle their own
|
||||
//! submenu action generation through the query interface.
|
||||
//!
|
||||
//! ## Command Format
|
||||
//!
|
||||
//! Plugins should use this command format for submenu items:
|
||||
//! ```text
|
||||
//! SUBMENU:<plugin_id>:<data>
|
||||
//! ```
|
||||
//!
|
||||
//! For example:
|
||||
//! - `SUBMENU:systemd:nginx.service:true` (systemd service with active state)
|
||||
//! - `SUBMENU:docker:container_id:running` (docker container with state)
|
||||
//!
|
||||
//! ## How It Works
|
||||
//!
|
||||
//! 1. Plugin returns items with `SUBMENU:...` commands
|
||||
//! 2. When user selects item, main_window detects it's a submenu item
|
||||
//! 3. main_window queries the plugin via `?SUBMENU:<data>` format
|
||||
//! 4. Plugin returns submenu action items
|
||||
//! 5. main_window displays the submenu
|
||||
//!
|
||||
//! ## Plugin Implementation
|
||||
//!
|
||||
//! Plugins should handle submenu queries in their `provider_query` function:
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
//! let query_str = query.as_str();
|
||||
//!
|
||||
//! // Handle submenu action requests
|
||||
//! if let Some(data) = query_str.strip_prefix("?SUBMENU:") {
|
||||
//! return generate_submenu_actions(data);
|
||||
//! }
|
||||
//!
|
||||
//! // Handle action execution
|
||||
//! if let Some(action) = query_str.strip_prefix("!") {
|
||||
//! execute_action(action);
|
||||
//! return RVec::new();
|
||||
//! }
|
||||
//!
|
||||
//! // Normal search query
|
||||
//! search(query_str)
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use owlry_core::providers::LaunchItem;
|
||||
|
||||
/// Parse a submenu command and extract plugin_id and data
|
||||
/// Returns (plugin_id, data) if command matches SUBMENU: format
|
||||
pub fn parse_submenu_command(command: &str) -> Option<(&str, &str)> {
|
||||
let rest = command.strip_prefix("SUBMENU:")?;
|
||||
let colon_pos = rest.find(':')?;
|
||||
let plugin_id = &rest[..colon_pos];
|
||||
let data = &rest[colon_pos + 1..];
|
||||
Some((plugin_id, data))
|
||||
}
|
||||
|
||||
/// Check if an item should open a submenu
|
||||
pub fn is_submenu_item(item: &LaunchItem) -> bool {
|
||||
item.command.starts_with("SUBMENU:")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use owlry_core::providers::ProviderType;
|
||||
|
||||
#[test]
|
||||
fn test_parse_submenu_command() {
|
||||
assert_eq!(
|
||||
parse_submenu_command("SUBMENU:systemd:nginx.service:true"),
|
||||
Some(("systemd", "nginx.service:true"))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_submenu_command("SUBMENU:docker:abc123:running"),
|
||||
Some(("docker", "abc123:running"))
|
||||
);
|
||||
assert_eq!(parse_submenu_command("not-a-submenu"), None);
|
||||
assert_eq!(parse_submenu_command("SUBMENU:"), None);
|
||||
assert_eq!(parse_submenu_command("SUBMENU:nocolon"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_submenu_item() {
|
||||
let submenu_item = LaunchItem {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
description: None,
|
||||
icon: None,
|
||||
provider: ProviderType::Plugin("test".to_string()),
|
||||
command: "SUBMENU:plugin:data".to_string(),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
};
|
||||
assert!(is_submenu_item(&submenu_item));
|
||||
|
||||
let normal_item = LaunchItem {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
description: None,
|
||||
icon: None,
|
||||
provider: ProviderType::Plugin("test".to_string()),
|
||||
command: "some-command".to_string(),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
};
|
||||
assert!(!is_submenu_item(&normal_item));
|
||||
}
|
||||
}
|
||||
194
data/config.example.toml
Normal file
@@ -0,0 +1,194 @@
|
||||
# Owlry Configuration
|
||||
# Copy to: ~/.config/owlry/config.toml
|
||||
#
|
||||
# File Locations (XDG Base Directory compliant):
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ Config: ~/.config/owlry/config.toml Main configuration │
|
||||
# │ Themes: ~/.config/owlry/themes/*.css Custom theme files │
|
||||
# │ Style: ~/.config/owlry/style.css CSS overrides │
|
||||
# │ Plugins: ~/.config/owlry/plugins/ User Lua/Rune plugins │
|
||||
# │ Scripts: ~/.local/share/owlry/scripts/ Executable scripts │
|
||||
# │ Data: ~/.local/share/owlry/frecency.json Usage history │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
#
|
||||
# System Plugin Locations:
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ Native: /usr/lib/owlry/plugins/*.so Installed plugins │
|
||||
# │ Runtimes: /usr/lib/owlry/runtimes/*.so Lua/Rune runtimes │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# DMENU MODE
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Dmenu mode provides interactive selection from piped input.
|
||||
# The selected item is printed to stdout (not executed), so pipe
|
||||
# the output to execute it:
|
||||
#
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ # Screenshot menu │
|
||||
# │ printf '%s\n' \ │
|
||||
# │ "grimblast --notify copy screen" \ │
|
||||
# │ "grimblast --notify copy area" \ │
|
||||
# │ | owlry -m dmenu -p "Screenshot" \ │
|
||||
# │ | sh │
|
||||
# │ │
|
||||
# │ # Git branch checkout │
|
||||
# │ git branch | owlry -m dmenu -p "checkout" | xargs git checkout │
|
||||
# │ │
|
||||
# │ # Package search │
|
||||
# │ pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# GENERAL
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[general]
|
||||
show_icons = true
|
||||
max_results = 100
|
||||
|
||||
# Terminal emulator for SSH, scripts, etc.
|
||||
# Auto-detection order: $TERMINAL → xdg-terminal-exec → DE-native → Wayland → X11 → xterm
|
||||
# Uncomment to override:
|
||||
# terminal_command = "kitty"
|
||||
|
||||
# Enable uwsm (Universal Wayland Session Manager) for launching apps.
|
||||
# When enabled, apps are launched via "uwsm app --" which starts them
|
||||
# in a proper systemd user session for better process management.
|
||||
# Requires: uwsm to be installed
|
||||
# use_uwsm = true
|
||||
|
||||
# Header tabs - providers shown as toggle buttons (Ctrl+1, Ctrl+2, etc.)
|
||||
# Values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
|
||||
tabs = ["app", "cmd", "uuctl"]
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# APPEARANCE
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[appearance]
|
||||
width = 850
|
||||
height = 650
|
||||
font_size = 14
|
||||
border_radius = 12
|
||||
|
||||
# Theme name - loads ~/.config/owlry/themes/{name}.css
|
||||
# Built-in: owl
|
||||
# Or leave unset/empty for GTK default
|
||||
# theme = "owl"
|
||||
|
||||
# Color overrides (applied on top of theme)
|
||||
# [appearance.colors]
|
||||
# background = "#1a1b26"
|
||||
# background_secondary = "#24283b"
|
||||
# border = "#414868"
|
||||
# text = "#c0caf5"
|
||||
# text_secondary = "#565f89"
|
||||
# accent = "#7aa2f7"
|
||||
# accent_bright = "#89b4fa"
|
||||
#
|
||||
# Provider badge colors (optional)
|
||||
# badge_app = "#7aa2f7"
|
||||
# badge_cmd = "#9ece6a"
|
||||
# badge_bookmark = "#e0af68"
|
||||
# badge_calc = "#bb9af7"
|
||||
# badge_clip = "#7dcfff"
|
||||
# badge_dmenu = "#c0caf5"
|
||||
# badge_emoji = "#f7768e"
|
||||
# badge_file = "#73daca"
|
||||
# badge_script = "#ff9e64"
|
||||
# badge_ssh = "#2ac3de"
|
||||
# badge_sys = "#f7768e"
|
||||
# badge_uuctl = "#9ece6a"
|
||||
# badge_web = "#7aa2f7"
|
||||
# badge_media = "#bb9af7"
|
||||
# badge_weather = "#7dcfff"
|
||||
# badge_pomo = "#f7768e"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# PLUGINS
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# All installed plugins are loaded by default. Use 'disabled_plugins' to blacklist.
|
||||
# Plugin IDs: calculator, system, ssh, clipboard, emoji, scripts, bookmarks,
|
||||
# websearch, filesearch, systemd, weather, media, pomodoro
|
||||
|
||||
[plugins]
|
||||
enabled = true # Master switch for all plugins
|
||||
|
||||
# Plugins to disable (by ID)
|
||||
disabled_plugins = []
|
||||
|
||||
# Examples:
|
||||
# disabled_plugins = ["emoji", "pomodoro"] # Disable specific plugins
|
||||
# disabled_plugins = ["weather", "media"] # Disable widget plugins
|
||||
|
||||
# Custom plugin registry URL (defaults to official registry)
|
||||
# registry_url = "https://my-registry.example.com/plugins.json"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Sandbox settings (for Lua/Rune script plugins)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# [plugins.sandbox]
|
||||
# allow_filesystem = false # Allow file system access beyond plugin dir
|
||||
# allow_network = false # Allow network requests
|
||||
# allow_commands = false # Allow shell command execution
|
||||
# memory_limit = 67108864 # Memory limit in bytes (64 MB default)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# PROVIDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Enable/disable providers and configure their settings.
|
||||
# Core providers (applications, commands) are built into the binary.
|
||||
# Plugin providers require their .so to be installed.
|
||||
|
||||
[providers]
|
||||
# Core providers (always available)
|
||||
applications = true # .desktop applications from XDG dirs
|
||||
commands = true # Executables from $PATH
|
||||
|
||||
# Frecency - boost frequently/recently used items
|
||||
# Data stored in: ~/.local/share/owlry/frecency.json
|
||||
frecency = true
|
||||
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Plugin provider toggles (require corresponding plugin installed)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
uuctl = true # systemd user units
|
||||
system = true # System commands (shutdown, reboot, etc.)
|
||||
ssh = true # SSH hosts from ~/.ssh/config
|
||||
clipboard = true # Clipboard history (requires cliphist)
|
||||
bookmarks = true # Browser bookmarks
|
||||
emoji = true # Emoji picker
|
||||
scripts = true # Custom scripts from ~/.local/share/owlry/scripts/
|
||||
files = true # File search (requires fd or mlocate)
|
||||
calculator = true # Calculator (= expression)
|
||||
websearch = true # Web search (? query)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Widget providers (displayed at top of results)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
media = true # MPRIS media player controls
|
||||
weather = false # Weather widget (disabled by default)
|
||||
pomodoro = false # Pomodoro timer (disabled by default)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Provider settings
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Web search engine
|
||||
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
|
||||
# Or custom URL: "https://search.example.com/?q={query}"
|
||||
search_engine = "duckduckgo"
|
||||
|
||||
# Weather settings (when weather = true)
|
||||
# weather_provider = "wttr.in" # Options: wttr.in, openweathermap, open-meteo
|
||||
# weather_location = "Berlin" # City name or coordinates
|
||||
# weather_api_key = "" # Required for openweathermap
|
||||
|
||||
# Pomodoro settings (when pomodoro = true)
|
||||
# pomodoro_work_mins = 25 # Work session duration
|
||||
# pomodoro_break_mins = 5 # Break duration
|
||||
24
data/scripts/example.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# Example Owlry Script
|
||||
# Copy to: ~/.local/share/owlry/scripts/
|
||||
#
|
||||
# Scripts in the scripts directory appear in Owlry search results.
|
||||
# They are executed when selected.
|
||||
#
|
||||
# Naming convention:
|
||||
# The filename (without extension) becomes the display name.
|
||||
# Example: "system-update.sh" shows as "Script: system-update"
|
||||
#
|
||||
# Tips:
|
||||
# - Make scripts executable: chmod +x script.sh
|
||||
# - Use descriptive names for easy searching
|
||||
# - Scripts can launch GUI apps, run terminal commands, etc.
|
||||
|
||||
# Example: Show a notification
|
||||
notify-send "Owlry" "Hello from example script!"
|
||||
|
||||
# Example: Open a URL
|
||||
# xdg-open "https://example.com"
|
||||
|
||||
# Example: Run a terminal command (set terminal: true in owlry if needed)
|
||||
# echo "Script executed at $(date)"
|
||||
73
data/style.example.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Owlry Custom Style Overrides
|
||||
* Copy to: ~/.config/owlry/style.css
|
||||
*
|
||||
* This file is loaded AFTER themes, allowing you to override
|
||||
* specific styles without creating a full theme.
|
||||
*
|
||||
* Available CSS classes:
|
||||
* .owlry-window - Main window container
|
||||
* .owlry-main - Main content area
|
||||
* .owlry-header - Header with mode and tabs
|
||||
* .owlry-search - Search input field
|
||||
* .owlry-results - Results list container
|
||||
* .owlry-result-row - Individual result row
|
||||
* .owlry-result-name - Result item name
|
||||
* .owlry-result-description - Result description text
|
||||
* .owlry-result-icon - Result icon
|
||||
* .owlry-tag-badge - Tag badges on results
|
||||
* .owlry-badge-* - Provider badges (app, cmd, uuctl, etc.)
|
||||
* .owlry-filter-button - Tab filter buttons
|
||||
* .owlry-filter-* - Provider-specific filter buttons
|
||||
* .owlry-mode-indicator - Current mode label
|
||||
* .owlry-hints - Bottom hints bar
|
||||
*
|
||||
* CSS Variables (set in themes or override here):
|
||||
* --owlry-bg - Main background color
|
||||
* --owlry-bg-secondary - Secondary background
|
||||
* --owlry-border - Border color
|
||||
* --owlry-text - Primary text color
|
||||
* --owlry-text-secondary - Secondary text color
|
||||
* --owlry-accent - Accent/highlight color
|
||||
* --owlry-accent-bright - Bright accent color
|
||||
* --owlry-font-size - Base font size (default: 14px)
|
||||
* --owlry-border-radius - Border radius (default: 12px)
|
||||
*/
|
||||
|
||||
/* Example: Make the window slightly larger */
|
||||
/*
|
||||
.owlry-main {
|
||||
padding: 20px;
|
||||
}
|
||||
*/
|
||||
|
||||
/* Example: Custom search field styling */
|
||||
/*
|
||||
.owlry-search {
|
||||
font-size: 18px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
*/
|
||||
|
||||
/* Example: Highlight selected row differently */
|
||||
/*
|
||||
.owlry-result-row:selected {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-left: 4px solid var(--owlry-accent);
|
||||
}
|
||||
*/
|
||||
|
||||
/* Example: Hide tag badges */
|
||||
/*
|
||||
.owlry-tag-badge {
|
||||
display: none;
|
||||
}
|
||||
*/
|
||||
|
||||
/* Example: Custom scrollbar */
|
||||
/*
|
||||
scrollbar slider {
|
||||
background-color: rgba(128, 128, 128, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
*/
|
||||
344
data/themes/apex-neon.css
Normal file
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
* Owlry - Apex Neon Theme
|
||||
* "State over Decoration."
|
||||
*
|
||||
* A high-contrast dark theme built for focus and clinical clarity.
|
||||
* Color exists to signal STATE, not to decorate space.
|
||||
*
|
||||
* Author: S0wlz (Owlibou)
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────
|
||||
* APEX DNA - Semantic Color Roles:
|
||||
*
|
||||
* RED is the Predator: Active intent, cursor, current location, critical errors
|
||||
* CYAN is Informational: Technical data, links, neutral highlights
|
||||
* PURPLE is Sacred: Root access, special modes, exceptional states
|
||||
* GREEN is Success: Completion, OK states, positive feedback
|
||||
* YELLOW is Warning: Caution, load states, attention needed
|
||||
*
|
||||
* Rule: If a UI element is not important, it does not glow.
|
||||
* ─────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Core Palette:
|
||||
* - Void Black: #050505 (absolute background)
|
||||
* - Dark Surface: #141414 (inputs, inactive elements)
|
||||
* - Light Surface: #262626 (separators, borders)
|
||||
* - Stark White: #ededed (primary text)
|
||||
* - Muted: #737373 (secondary text)
|
||||
* - Razor Red: #ff0044 (THE accent - focus, cursor, selection)
|
||||
* - Electric Cyan: #00eaff (info, links, technical)
|
||||
* - Sacred Purple: #9d00ff (special, root, elevated)
|
||||
* - Neon Green: #00ff99 (success, OK)
|
||||
* - Warning Yellow: #ffb700 (warning, caution)
|
||||
*
|
||||
* Bright Escalations:
|
||||
* - Alert Red: #ff8899 (distinguishable from cursor)
|
||||
* - Active Cyan: #5af3ff (active info)
|
||||
* - Active Green: #2bffb2 (active success)
|
||||
* - Urgent Yellow: #ffd24d (urgent warning)
|
||||
* - Elevated Purple:#c84dff (elevated special)
|
||||
*
|
||||
* Usage: Set theme = "apex-neon" in config.toml
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Core surfaces */
|
||||
--owlry-bg: #050505;
|
||||
--owlry-bg-secondary: #141414;
|
||||
--owlry-border: #262626;
|
||||
--owlry-text: #ededed;
|
||||
--owlry-text-secondary: #737373;
|
||||
|
||||
/* The Predator - primary accent */
|
||||
--owlry-accent: #ff0044;
|
||||
--owlry-accent-bright: #ff8899;
|
||||
|
||||
/* Provider badges - mapped to Apex semantics */
|
||||
--owlry-badge-app: #00eaff; /* Cyan: apps are informational */
|
||||
--owlry-badge-bookmark: #ffb700; /* Yellow: bookmarks need attention */
|
||||
--owlry-badge-calc: #ffd24d; /* Bright Yellow: calculator results */
|
||||
--owlry-badge-clip: #9d00ff; /* Purple: clipboard is special */
|
||||
--owlry-badge-cmd: #9d00ff; /* Purple: commands are elevated */
|
||||
--owlry-badge-dmenu: #00ff99; /* Green: dmenu is success/pipe */
|
||||
--owlry-badge-emoji: #c84dff; /* Bright Purple: emoji is special */
|
||||
--owlry-badge-file: #5af3ff; /* Bright Cyan: file search is active info */
|
||||
--owlry-badge-script: #2bffb2; /* Bright Green: scripts execute successfully */
|
||||
--owlry-badge-ssh: #00eaff; /* Cyan: SSH is technical/info */
|
||||
--owlry-badge-sys: #ff0044; /* Red: system actions are critical */
|
||||
--owlry-badge-uuctl: #ffb700; /* Yellow: uuctl requires attention */
|
||||
--owlry-badge-web: #00eaff; /* Cyan: web is informational */
|
||||
|
||||
/* Widget badges */
|
||||
--owlry-badge-media: #c84dff; /* Bright Purple: media is special */
|
||||
--owlry-badge-weather: #5af3ff; /* Bright Cyan: weather is active info */
|
||||
--owlry-badge-pomo: #ff8899; /* Alert Red: pomodoro demands attention */
|
||||
}
|
||||
|
||||
.owlry-main {
|
||||
background-color: rgba(5, 5, 5, 0.98);
|
||||
border: 1px solid rgba(38, 38, 38, 0.8);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8),
|
||||
0 0 0 1px rgba(255, 0, 68, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
background-color: rgba(20, 20, 20, 0.9);
|
||||
border: 2px solid rgba(38, 38, 38, 0.8);
|
||||
color: var(--owlry-text);
|
||||
caret-color: var(--owlry-accent);
|
||||
}
|
||||
|
||||
.owlry-search:focus {
|
||||
border-color: var(--owlry-accent);
|
||||
box-shadow: 0 0 0 2px rgba(255, 0, 68, 0.3);
|
||||
}
|
||||
|
||||
.owlry-result-row:hover {
|
||||
background-color: rgba(20, 20, 20, 0.8);
|
||||
}
|
||||
|
||||
.owlry-result-row:selected {
|
||||
background-color: rgba(255, 0, 68, 0.15);
|
||||
border-left: 3px solid var(--owlry-accent);
|
||||
}
|
||||
|
||||
.owlry-result-row:selected .owlry-result-name {
|
||||
color: var(--owlry-accent-bright);
|
||||
}
|
||||
|
||||
.owlry-result-row:selected .owlry-result-icon {
|
||||
color: var(--owlry-accent);
|
||||
}
|
||||
|
||||
/* Provider badges - styled per Apex semantics */
|
||||
.owlry-badge-app {
|
||||
background-color: rgba(0, 234, 255, 0.15);
|
||||
color: var(--owlry-badge-app);
|
||||
}
|
||||
|
||||
.owlry-badge-bookmark {
|
||||
background-color: rgba(255, 183, 0, 0.15);
|
||||
color: var(--owlry-badge-bookmark);
|
||||
}
|
||||
|
||||
.owlry-badge-calc {
|
||||
background-color: rgba(255, 210, 77, 0.15);
|
||||
color: var(--owlry-badge-calc);
|
||||
}
|
||||
|
||||
.owlry-badge-clip {
|
||||
background-color: rgba(157, 0, 255, 0.15);
|
||||
color: var(--owlry-badge-clip);
|
||||
}
|
||||
|
||||
.owlry-badge-cmd {
|
||||
background-color: rgba(157, 0, 255, 0.15);
|
||||
color: var(--owlry-badge-cmd);
|
||||
}
|
||||
|
||||
.owlry-badge-dmenu {
|
||||
background-color: rgba(0, 255, 153, 0.15);
|
||||
color: var(--owlry-badge-dmenu);
|
||||
}
|
||||
|
||||
.owlry-badge-emoji {
|
||||
background-color: rgba(200, 77, 255, 0.15);
|
||||
color: var(--owlry-badge-emoji);
|
||||
}
|
||||
|
||||
.owlry-badge-file {
|
||||
background-color: rgba(90, 243, 255, 0.15);
|
||||
color: var(--owlry-badge-file);
|
||||
}
|
||||
|
||||
.owlry-badge-script {
|
||||
background-color: rgba(43, 255, 178, 0.15);
|
||||
color: var(--owlry-badge-script);
|
||||
}
|
||||
|
||||
.owlry-badge-ssh {
|
||||
background-color: rgba(0, 234, 255, 0.15);
|
||||
color: var(--owlry-badge-ssh);
|
||||
}
|
||||
|
||||
.owlry-badge-sys {
|
||||
background-color: rgba(255, 0, 68, 0.15);
|
||||
color: var(--owlry-badge-sys);
|
||||
}
|
||||
|
||||
.owlry-badge-uuctl {
|
||||
background-color: rgba(255, 183, 0, 0.15);
|
||||
color: var(--owlry-badge-uuctl);
|
||||
}
|
||||
|
||||
.owlry-badge-web {
|
||||
background-color: rgba(0, 234, 255, 0.15);
|
||||
color: var(--owlry-badge-web);
|
||||
}
|
||||
|
||||
/* Widget badges */
|
||||
.owlry-badge-media {
|
||||
background-color: rgba(200, 77, 255, 0.15);
|
||||
color: var(--owlry-badge-media);
|
||||
}
|
||||
|
||||
.owlry-badge-weather {
|
||||
background-color: rgba(90, 243, 255, 0.15);
|
||||
color: var(--owlry-badge-weather);
|
||||
}
|
||||
|
||||
.owlry-badge-pomo {
|
||||
background-color: rgba(255, 136, 153, 0.15);
|
||||
color: var(--owlry-badge-pomo);
|
||||
}
|
||||
|
||||
/* Filter button - default uses The Predator */
|
||||
.owlry-filter-button:checked {
|
||||
background-color: rgba(255, 0, 68, 0.2);
|
||||
color: var(--owlry-accent);
|
||||
border-color: rgba(255, 0, 68, 0.5);
|
||||
}
|
||||
|
||||
/* Provider-specific filter buttons - follow Apex semantics */
|
||||
.owlry-filter-app:checked {
|
||||
background-color: rgba(0, 234, 255, 0.15);
|
||||
color: var(--owlry-badge-app);
|
||||
border-color: rgba(0, 234, 255, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-bookmark:checked {
|
||||
background-color: rgba(255, 183, 0, 0.15);
|
||||
color: var(--owlry-badge-bookmark);
|
||||
border-color: rgba(255, 183, 0, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-calc:checked {
|
||||
background-color: rgba(255, 210, 77, 0.15);
|
||||
color: var(--owlry-badge-calc);
|
||||
border-color: rgba(255, 210, 77, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-clip:checked {
|
||||
background-color: rgba(157, 0, 255, 0.15);
|
||||
color: var(--owlry-badge-clip);
|
||||
border-color: rgba(157, 0, 255, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-cmd:checked {
|
||||
background-color: rgba(157, 0, 255, 0.15);
|
||||
color: var(--owlry-badge-cmd);
|
||||
border-color: rgba(157, 0, 255, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-dmenu:checked {
|
||||
background-color: rgba(0, 255, 153, 0.15);
|
||||
color: var(--owlry-badge-dmenu);
|
||||
border-color: rgba(0, 255, 153, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-emoji:checked {
|
||||
background-color: rgba(200, 77, 255, 0.15);
|
||||
color: var(--owlry-badge-emoji);
|
||||
border-color: rgba(200, 77, 255, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-file:checked {
|
||||
background-color: rgba(90, 243, 255, 0.15);
|
||||
color: var(--owlry-badge-file);
|
||||
border-color: rgba(90, 243, 255, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-script:checked {
|
||||
background-color: rgba(43, 255, 178, 0.15);
|
||||
color: var(--owlry-badge-script);
|
||||
border-color: rgba(43, 255, 178, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-ssh:checked {
|
||||
background-color: rgba(0, 234, 255, 0.15);
|
||||
color: var(--owlry-badge-ssh);
|
||||
border-color: rgba(0, 234, 255, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-sys:checked {
|
||||
background-color: rgba(255, 0, 68, 0.15);
|
||||
color: var(--owlry-badge-sys);
|
||||
border-color: rgba(255, 0, 68, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-uuctl:checked {
|
||||
background-color: rgba(255, 183, 0, 0.15);
|
||||
color: var(--owlry-badge-uuctl);
|
||||
border-color: rgba(255, 183, 0, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-web:checked {
|
||||
background-color: rgba(0, 234, 255, 0.15);
|
||||
color: var(--owlry-badge-web);
|
||||
border-color: rgba(0, 234, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Widget filter buttons */
|
||||
.owlry-filter-media:checked {
|
||||
background-color: rgba(200, 77, 255, 0.15);
|
||||
color: var(--owlry-badge-media);
|
||||
border-color: rgba(200, 77, 255, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-weather:checked {
|
||||
background-color: rgba(90, 243, 255, 0.15);
|
||||
color: var(--owlry-badge-weather);
|
||||
border-color: rgba(90, 243, 255, 0.5);
|
||||
}
|
||||
|
||||
.owlry-filter-pomodoro:checked {
|
||||
background-color: rgba(255, 136, 153, 0.15);
|
||||
color: var(--owlry-badge-pomo);
|
||||
border-color: rgba(255, 136, 153, 0.5);
|
||||
}
|
||||
|
||||
/* Scrollbar - subtle in Void, The Predator on active */
|
||||
scrollbar slider {
|
||||
background-color: rgba(38, 38, 38, 0.8);
|
||||
}
|
||||
|
||||
scrollbar slider:hover {
|
||||
background-color: rgba(64, 64, 64, 0.9);
|
||||
}
|
||||
|
||||
scrollbar slider:active {
|
||||
background-color: var(--owlry-accent);
|
||||
}
|
||||
|
||||
/* Text selection - Apex Hard Rule: black text on red (target locked) */
|
||||
selection {
|
||||
background-color: var(--owlry-accent);
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
/* Mode indicator - The Predator marks current mode */
|
||||
.owlry-mode-indicator {
|
||||
background-color: rgba(255, 0, 68, 0.2);
|
||||
color: var(--owlry-accent);
|
||||
border: 1px solid rgba(255, 0, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Hints bar */
|
||||
.owlry-hints {
|
||||
border-top: 1px solid rgba(38, 38, 38, 0.8);
|
||||
}
|
||||
|
||||
.owlry-hints-label {
|
||||
color: var(--owlry-text-secondary);
|
||||
}
|
||||
|
||||
/* Tag badges in results */
|
||||
.owlry-tag-badge {
|
||||
background-color: rgba(38, 38, 38, 0.6);
|
||||
color: var(--owlry-text-secondary);
|
||||
}
|
||||
|
||||
.owlry-result-row:selected .owlry-tag-badge {
|
||||
background-color: rgba(255, 136, 153, 0.25);
|
||||
color: var(--owlry-accent-bright);
|
||||
}
|
||||