Compare commits
89 Commits
v0.3.7
...
owlry-core
| 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 |
13
.gitignore
vendored
@@ -1,2 +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/
|
||||
|
||||
411
CLAUDE.md
Normal file
@@ -0,0 +1,411 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
```bash
|
||||
just build # Debug build (all workspace members)
|
||||
just build-ui # UI binary only
|
||||
just build-daemon # Core daemon only
|
||||
just release # Release build (LTO, stripped)
|
||||
just release-daemon # Release build for daemon only
|
||||
just check # cargo check + clippy
|
||||
just test # Run tests
|
||||
just fmt # Format code
|
||||
just run [ARGS] # Run UI with optional args (e.g., just run --mode app)
|
||||
just run-daemon # Run core daemon
|
||||
just install-local # Install core + daemon + runtimes + systemd units
|
||||
|
||||
# Dev build with verbose logging
|
||||
cargo run -p owlry --features dev-logging
|
||||
|
||||
# Build core without embedded Lua (smaller binary, uses external owlry-lua)
|
||||
cargo build -p owlry --release --no-default-features
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Invocation
|
||||
|
||||
The UI client connects to the `owlry-core` daemon via Unix socket IPC. Start the daemon first:
|
||||
|
||||
```bash
|
||||
# Start daemon (systemd recommended)
|
||||
systemctl --user enable --now owlry-core.service
|
||||
|
||||
# Or run directly
|
||||
owlry-core
|
||||
|
||||
# Then launch UI
|
||||
owlry # Launch with all providers
|
||||
owlry -m app # Applications only
|
||||
owlry -m cmd # PATH commands only
|
||||
owlry --profile dev # Use a named config profile
|
||||
owlry -m calc # Calculator plugin only (if installed)
|
||||
```
|
||||
|
||||
### dmenu Mode
|
||||
|
||||
dmenu mode runs locally without the daemon. Use `-m dmenu` with piped input for interactive selection. The selected item is printed to stdout (not executed), so pipe the output to execute it:
|
||||
|
||||
```bash
|
||||
# Screenshot menu (execute selected command)
|
||||
printf '%s\n' \
|
||||
"grimblast --notify copy screen" \
|
||||
"grimblast --notify copy area" \
|
||||
"grimblast --notify edit screen" \
|
||||
| owlry -m dmenu -p "Screenshot" \
|
||||
| sh
|
||||
|
||||
# Git branch checkout
|
||||
git branch | owlry -m dmenu -p "checkout" | xargs git checkout
|
||||
|
||||
# Kill a process
|
||||
ps -eo comm | sort -u | owlry -m dmenu -p "kill" | xargs pkill
|
||||
|
||||
# Select and open a project
|
||||
find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
|
||||
```
|
||||
|
||||
### CLI Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-m`, `--mode MODE` | Start in single-provider mode (app, cmd, dmenu, calc, etc.) |
|
||||
| `--profile NAME` | Use a named profile from config (defines which modes to enable) |
|
||||
| `-p`, `--prompt TEXT` | Custom prompt text for the search input (dmenu mode) |
|
||||
|
||||
### Available Modes
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `app` | Desktop applications |
|
||||
| `cmd` | PATH commands |
|
||||
| `dmenu` | Pipe-based selection (requires stdin, runs locally) |
|
||||
| `calc` | Calculator (plugin) |
|
||||
| `clip` | Clipboard history (plugin) |
|
||||
| `emoji` | Emoji picker (plugin) |
|
||||
| `ssh` | SSH hosts (plugin) |
|
||||
| `sys` | System actions (plugin) |
|
||||
| `bm` | Bookmarks (plugin) |
|
||||
| `file` | File search (plugin) |
|
||||
| `web` | Web search (plugin) |
|
||||
| `uuctl` | systemd user units (plugin) |
|
||||
|
||||
### Search Prefixes
|
||||
|
||||
Type these in the search box to filter by provider:
|
||||
|
||||
| Prefix | Provider | Example |
|
||||
|--------|----------|---------|
|
||||
| `:app` | Applications | `:app firefox` |
|
||||
| `:cmd` | PATH commands | `:cmd git` |
|
||||
| `:sys` | System actions | `:sys shutdown` |
|
||||
| `:ssh` | SSH hosts | `:ssh server` |
|
||||
| `:clip` | Clipboard | `:clip password` |
|
||||
| `:bm` | Bookmarks | `:bm github` |
|
||||
| `:emoji` | Emoji | `:emoji heart` |
|
||||
| `:calc` | Calculator | `:calc sqrt(16)` |
|
||||
| `:web` | Web search | `:web rust docs` |
|
||||
| `:file` | Files | `:file config` |
|
||||
| `:uuctl` | systemd | `:uuctl docker` |
|
||||
| `:tag:X` | Filter by tag | `:tag:development` |
|
||||
|
||||
### Trigger Prefixes
|
||||
|
||||
| Trigger | Provider | Example |
|
||||
|---------|----------|---------|
|
||||
| `=` | Calculator | `= 5+3` |
|
||||
| `?` | Web search | `? rust programming` |
|
||||
| `/` | File search | `/ .bashrc` |
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Enter` | Launch selected item |
|
||||
| `Escape` | Close launcher / exit submenu |
|
||||
| `Up` / `Down` | Navigate results |
|
||||
| `Tab` | Cycle filter tabs |
|
||||
| `Shift+Tab` | Cycle tabs (reverse) |
|
||||
| `Ctrl+1..9` | Toggle tab by position |
|
||||
|
||||
### Plugin CLI
|
||||
|
||||
```bash
|
||||
owlry plugin list # List installed
|
||||
owlry plugin list --available # Show registry
|
||||
owlry plugin search "query" # Search registry
|
||||
owlry plugin install <name> # Install from registry
|
||||
owlry plugin install ./path # Install from local path
|
||||
owlry plugin remove <name> # Uninstall
|
||||
owlry plugin enable/disable <name> # Toggle
|
||||
owlry plugin create <name> # Create Lua plugin template
|
||||
owlry plugin create <name> -r rune # Create Rune plugin template
|
||||
owlry plugin validate ./path # Validate plugin structure
|
||||
owlry plugin run <id> <cmd> [args] # Run plugin CLI command
|
||||
owlry plugin commands <id> # List plugin commands
|
||||
owlry plugin runtimes # Show available runtimes
|
||||
```
|
||||
|
||||
## Release Workflow
|
||||
|
||||
Always use `just` for releases - do NOT manually edit Cargo.toml for version bumps:
|
||||
|
||||
```bash
|
||||
# Bump a single crate
|
||||
just bump-crate owlry-core 0.5.1
|
||||
|
||||
# Bump all crates to same version
|
||||
just bump-all 0.5.1
|
||||
|
||||
# Bump core UI only
|
||||
just bump 0.5.1
|
||||
|
||||
# Create and push release tag
|
||||
git push && just tag
|
||||
|
||||
# AUR package management
|
||||
just aur-update # Update core UI PKGBUILD
|
||||
just aur-update-pkg NAME # Update specific package (owlry-core, owlry-lua, etc.)
|
||||
just aur-update-all # Update all AUR packages
|
||||
just aur-publish # Publish core UI to AUR
|
||||
just aur-publish-all # Publish all AUR packages
|
||||
|
||||
# Version inspection
|
||||
just show-versions # List all crate versions
|
||||
just aur-status # Show AUR package versions and git status
|
||||
```
|
||||
|
||||
## AUR Packaging
|
||||
|
||||
The `aur/` directory contains PKGBUILDs for core packages:
|
||||
|
||||
| Category | Packages |
|
||||
|----------|----------|
|
||||
| Core UI | `owlry` |
|
||||
| Core Daemon | `owlry-core` |
|
||||
| Runtimes | `owlry-lua`, `owlry-rune` |
|
||||
| Meta-bundles | `owlry-meta-essentials`, `owlry-meta-widgets`, `owlry-meta-tools`, `owlry-meta-full` |
|
||||
|
||||
Plugin AUR packages are in the separate `owlry-plugins` repo at `somegit.dev/Owlibou/owlry-plugins`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Client/Daemon Split
|
||||
|
||||
Owlry uses a client/daemon architecture:
|
||||
|
||||
- **`owlry`** (client): GTK4 UI that connects to the daemon via Unix socket IPC. Handles rendering, user input, and launching applications. In dmenu mode, runs a local `ProviderManager` directly (no daemon needed).
|
||||
- **`owlry-core`** (daemon): Headless background service that loads plugins, manages providers, handles fuzzy matching, frecency scoring, and serves queries over IPC. Runs as a systemd user service.
|
||||
|
||||
### Workspace Structure
|
||||
|
||||
```
|
||||
owlry/
|
||||
├── Cargo.toml # Workspace root
|
||||
├── systemd/ # systemd user service/socket files
|
||||
│ ├── owlry-core.service
|
||||
│ └── owlry-core.socket
|
||||
├── crates/
|
||||
│ ├── owlry/ # UI client binary (GTK4 + Layer Shell)
|
||||
│ │ └── src/
|
||||
│ │ ├── main.rs # Entry point
|
||||
│ │ ├── app.rs # GTK Application setup, CSS loading
|
||||
│ │ ├── cli.rs # Clap CLI argument parsing
|
||||
│ │ ├── client.rs # CoreClient - IPC client to daemon
|
||||
│ │ ├── backend.rs # SearchBackend - abstraction over IPC/local
|
||||
│ │ ├── theme.rs # Theme loading
|
||||
│ │ ├── plugin_commands.rs # Plugin CLI subcommand handlers
|
||||
│ │ ├── providers/ # dmenu provider (local-only)
|
||||
│ │ └── ui/ # GTK widgets (MainWindow, ResultRow, submenu)
|
||||
│ ├── owlry-core/ # Daemon library + binary
|
||||
│ │ └── src/
|
||||
│ │ ├── main.rs # Daemon entry point
|
||||
│ │ ├── lib.rs # Public API (re-exports modules)
|
||||
│ │ ├── server.rs # Unix socket IPC server
|
||||
│ │ ├── ipc.rs # Request/Response message types
|
||||
│ │ ├── filter.rs # ProviderFilter - mode/prefix filtering
|
||||
│ │ ├── paths.rs # XDG path utilities, socket path
|
||||
│ │ ├── notify.rs # Desktop notifications
|
||||
│ │ ├── config/ # Config loading (config.toml)
|
||||
│ │ ├── data/ # FrecencyStore
|
||||
│ │ ├── providers/ # Application, Command, native/lua provider hosts
|
||||
│ │ └── plugins/ # Plugin loading, manifests, registry, runtimes
|
||||
│ ├── owlry-plugin-api/ # ABI-stable plugin interface
|
||||
│ ├── owlry-lua/ # Lua script runtime (cdylib)
|
||||
│ └── owlry-rune/ # Rune script runtime (cdylib)
|
||||
```
|
||||
|
||||
### IPC Protocol
|
||||
|
||||
Communication uses newline-delimited JSON over a Unix domain socket at `$XDG_RUNTIME_DIR/owlry/owlry.sock`.
|
||||
|
||||
**Request types** (`owlry_core::ipc::Request`):
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `Query` | Search with text and optional mode filters |
|
||||
| `Launch` | Record a launch event for frecency |
|
||||
| `Providers` | List available providers |
|
||||
| `Refresh` | Refresh a specific provider |
|
||||
| `Toggle` | Toggle visibility (client-side concern, daemon acks) |
|
||||
| `Submenu` | Query submenu actions for a plugin item |
|
||||
| `PluginAction` | Execute a plugin action command |
|
||||
|
||||
**Response types** (`owlry_core::ipc::Response`):
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `Results` | Search results with `Vec<ResultItem>` |
|
||||
| `Providers` | Provider list with `Vec<ProviderDesc>` |
|
||||
| `SubmenuItems` | Submenu actions for a plugin |
|
||||
| `Ack` | Success acknowledgement |
|
||||
| `Error` | Error with message |
|
||||
|
||||
### Core Data Flow
|
||||
|
||||
```
|
||||
[owlry UI] [owlry-core daemon]
|
||||
|
||||
main.rs → CliArgs → OwlryApp main.rs → Server::bind()
|
||||
↓ ↓
|
||||
SearchBackend UnixListener accept loop
|
||||
↓ ↓
|
||||
┌──────┴──────┐ handle_request()
|
||||
↓ ↓ ↓
|
||||
Daemon Local (dmenu) ┌───────────┴───────────┐
|
||||
↓ ↓ ↓
|
||||
CoreClient ──── IPC ────→ ProviderManager ProviderFilter
|
||||
↓ ↓
|
||||
[Provider impls] parse_query()
|
||||
↓
|
||||
LaunchItem[]
|
||||
↓
|
||||
FrecencyStore (boost)
|
||||
↓
|
||||
Response::Results ──── IPC ────→ UI rendering
|
||||
```
|
||||
|
||||
### Provider System
|
||||
|
||||
**Core providers** (in `owlry-core`):
|
||||
- **Application**: Desktop applications from XDG directories
|
||||
- **Command**: Shell commands from PATH
|
||||
|
||||
**dmenu provider** (in `owlry` client, local only):
|
||||
- **Dmenu**: Pipe-based input (dmenu compatibility)
|
||||
|
||||
All other providers are native plugins in the separate `owlry-plugins` repo (`somegit.dev/Owlibou/owlry-plugins`).
|
||||
|
||||
`ProviderManager` (in `owlry-core`) orchestrates providers and handles:
|
||||
- Fuzzy matching via `SkimMatcherV2`
|
||||
- Frecency score boosting
|
||||
- Native plugin loading from `/usr/lib/owlry/plugins/`
|
||||
|
||||
**Submenu System**: Plugins can return items with `SUBMENU:plugin_id:data` commands. When selected, the plugin is queried with `?SUBMENU:data` to get action items (e.g., systemd service actions).
|
||||
|
||||
### Plugin API
|
||||
|
||||
Native plugins use the ABI-stable interface in `owlry-plugin-api`:
|
||||
|
||||
```rust
|
||||
#[repr(C)]
|
||||
pub struct PluginVTable {
|
||||
pub info: extern "C" fn() -> PluginInfo,
|
||||
pub providers: extern "C" fn() -> RVec<ProviderInfo>,
|
||||
pub provider_init: extern "C" fn(id: RStr) -> ProviderHandle,
|
||||
pub provider_refresh: extern "C" fn(ProviderHandle) -> RVec<PluginItem>,
|
||||
pub provider_query: extern "C" fn(ProviderHandle, RStr) -> RVec<PluginItem>,
|
||||
pub provider_drop: extern "C" fn(ProviderHandle),
|
||||
}
|
||||
|
||||
// Each plugin exports:
|
||||
#[no_mangle]
|
||||
pub extern "C" fn owlry_plugin_vtable() -> &'static PluginVTable
|
||||
```
|
||||
|
||||
Plugins are compiled as `.so` (cdylib) and loaded by the daemon at startup.
|
||||
|
||||
**Plugin locations** (when deployed):
|
||||
- `/usr/lib/owlry/plugins/*.so` - Native plugins
|
||||
- `/usr/lib/owlry/runtimes/*.so` - Script runtimes (liblua.so, librune.so)
|
||||
- `~/.config/owlry/plugins/` - User plugins (Lua/Rune)
|
||||
|
||||
### Filter & Prefix System
|
||||
|
||||
`ProviderFilter` (`owlry-core/src/filter.rs`) handles:
|
||||
- CLI mode selection (`--mode app`)
|
||||
- Profile-based mode selection (`--profile dev`)
|
||||
- Provider toggling (Ctrl+1/2/3)
|
||||
- Prefix parsing (`:app`, `:cmd`, `:sys`, etc.)
|
||||
|
||||
Query parsing extracts prefix and forwards clean query to providers.
|
||||
|
||||
### SearchBackend
|
||||
|
||||
`SearchBackend` (`owlry/src/backend.rs`) abstracts over two modes:
|
||||
- **`Daemon`**: Wraps `CoreClient`, sends queries over IPC to `owlry-core`
|
||||
- **`Local`**: Wraps `ProviderManager` directly (used for dmenu mode only)
|
||||
|
||||
### UI Layer
|
||||
|
||||
- `MainWindow` (`src/ui/main_window.rs`): GTK4 window with Layer Shell overlay
|
||||
- `ResultRow` (`src/ui/result_row.rs`): Individual result rendering
|
||||
- `submenu` (`src/ui/submenu.rs`): Universal submenu parsing utilities (plugins provide actions)
|
||||
|
||||
### Configuration
|
||||
|
||||
`Config` (`owlry-core/src/config/mod.rs`) loads from `~/.config/owlry/config.toml`:
|
||||
- Auto-detects terminal (`$TERMINAL` -> `xdg-terminal-exec` -> common terminals)
|
||||
- Optional `use_uwsm = true` for systemd session integration (launches apps via `uwsm app --`)
|
||||
- Profiles: Define named mode sets under `[profiles.<name>]` with `modes = ["app", "cmd", ...]`
|
||||
|
||||
### Theming
|
||||
|
||||
CSS loading priority (`owlry/src/app.rs`):
|
||||
1. Base structural CSS (`resources/base.css`)
|
||||
2. Theme CSS (built-in "owl" or custom `~/.config/owlry/themes/{name}.css`)
|
||||
3. User overrides (`~/.config/owlry/style.css`)
|
||||
4. Config variable injection
|
||||
|
||||
### Systemd Integration
|
||||
|
||||
Service files in `systemd/`:
|
||||
- `owlry-core.service`: Runs daemon as `Type=simple`, restarts on failure
|
||||
- `owlry-core.socket`: Socket activation at `%t/owlry/owlry.sock`
|
||||
|
||||
Start with: `systemctl --user enable --now owlry-core.service`
|
||||
|
||||
## Plugins
|
||||
|
||||
Plugins live in a separate repository: `somegit.dev/Owlibou/owlry-plugins`
|
||||
|
||||
13 native plugin crates, all compiled as cdylib (.so):
|
||||
|
||||
| Category | Plugins | Behavior |
|
||||
|----------|---------|----------|
|
||||
| Static | bookmarks, clipboard, emoji, scripts, ssh, system, systemd | Loaded at startup, refresh() populates items |
|
||||
| Dynamic | calculator, websearch, filesearch | Queried per-keystroke via query() |
|
||||
| Widget | weather, media, pomodoro | Displayed at top of results |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Rc<RefCell<T>>** used throughout for GTK signal handlers needing mutable state
|
||||
- **Feature flag `dev-logging`**: Wraps debug!() calls in `#[cfg(feature = "dev-logging")]`
|
||||
- **Feature flag `lua`**: Enables built-in Lua runtime (off by default); enable to embed Lua in core binary
|
||||
- **dmenu mode**: Runs locally without daemon. Use `-m dmenu` with piped stdin
|
||||
- **Frecency**: Time-decayed frequency scoring stored in `~/.local/share/owlry/frecency.json`
|
||||
- **ABI stability**: Plugin interface uses `abi_stable` crate for safe Rust dynamic linking
|
||||
- **Plugin API v3**: Adds `position` (Normal/Widget) and `priority` fields to ProviderInfo
|
||||
- **ProviderType simplification**: Core uses only `Application`, `Command`, `Dmenu`, `Plugin(String)` - all plugin-specific types removed from core
|
||||
|
||||
## Dependencies (Rust 1.90+, GTK 4.12+)
|
||||
|
||||
External tool dependencies (for plugins):
|
||||
- Clipboard plugin: `cliphist`, `wl-clipboard`
|
||||
- File search plugin: `fd` or `mlocate`
|
||||
- Emoji plugin: `wl-clipboard`, `noto-fonts-emoji`
|
||||
- Systemd plugin: `systemd` (user services)
|
||||
- Bookmarks plugin: Firefox support uses `rusqlite` with bundled SQLite (no system dependency)
|
||||
3825
Cargo.lock
generated
76
Cargo.toml
@@ -1,78 +1,34 @@
|
||||
[package]
|
||||
name = "owlry"
|
||||
version = "0.3.7"
|
||||
[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"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable verbose debug logging (for development/testing builds)
|
||||
dev-logging = []
|
||||
|
||||
# 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 . --profile dev-install --features dev-logging
|
||||
# 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 # Basic debug info for stack traces
|
||||
debug = 1
|
||||
|
||||
404
README.md
@@ -10,27 +10,60 @@ 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
|
||||
- **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
|
||||
- **Configurable tabs** — Customize header tabs and keyboard shortcuts
|
||||
- **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.
|
||||
- **Calculator** — Quick math with `= 5+3` or `calc sin(pi/2)`
|
||||
- **Web search** — Search the web with `? query`
|
||||
- **File search** — Find files with `/ filename` (requires `fd` or `locate`)
|
||||
- **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
|
||||
# Minimal core (applications + commands only)
|
||||
yay -S owlry
|
||||
# or
|
||||
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
|
||||
```
|
||||
|
||||
### Available Packages
|
||||
|
||||
| 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:**
|
||||
@@ -45,40 +78,159 @@ sudo apt install libgtk-4-dev libgtk4-layer-shell-dev
|
||||
sudo dnf install gtk4-devel gtk4-layer-shell-devel
|
||||
```
|
||||
|
||||
**Optional dependencies:**
|
||||
```bash
|
||||
# Clipboard history
|
||||
sudo pacman -S cliphist wl-clipboard
|
||||
|
||||
# File search (choose one)
|
||||
sudo pacman -S fd # recommended
|
||||
sudo pacman -S mlocate # alternative
|
||||
```
|
||||
|
||||
**Build (requires Rust 1.90+):**
|
||||
```bash
|
||||
git clone https://somegit.dev/Owlibou/owlry.git
|
||||
cd owlry
|
||||
cargo build --release
|
||||
# Binary: target/release/owlry
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
**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
|
||||
owlry # Launch with defaults
|
||||
owlry --mode app # Applications only
|
||||
owlry --providers app,cmd # Specific providers
|
||||
owlry --help # Show all options
|
||||
owlry # Launch with all providers
|
||||
owlry -m app # Applications only
|
||||
owlry -m cmd # PATH commands only
|
||||
owlry -m calc # Calculator plugin only (if installed)
|
||||
owlry --profile dev # Use a named profile from config
|
||||
owlry --help # Show all options with examples
|
||||
```
|
||||
|
||||
### Profiles
|
||||
|
||||
Profiles are named sets of modes defined in your config:
|
||||
|
||||
```toml
|
||||
[profiles.dev]
|
||||
modes = ["app", "cmd", "ssh"]
|
||||
|
||||
[profiles.media]
|
||||
modes = ["media", "emoji"]
|
||||
|
||||
[profiles.minimal]
|
||||
modes = ["app"]
|
||||
```
|
||||
|
||||
Launch with a profile:
|
||||
|
||||
```bash
|
||||
owlry --profile dev
|
||||
```
|
||||
|
||||
You can bind different profiles to different keys:
|
||||
|
||||
```bash
|
||||
# Hyprland
|
||||
bind = SUPER, Space, exec, owlry
|
||||
bind = SUPER, D, exec, owlry --profile dev
|
||||
bind = SUPER, M, exec, owlry --profile media
|
||||
```
|
||||
|
||||
### dmenu Mode
|
||||
|
||||
Owlry is dmenu-compatible. Pipe input for interactive selection — the selected item is printed to stdout (not executed), so you pipe the output to execute it.
|
||||
|
||||
dmenu mode is self-contained: it does not use the daemon and works without `owlry-core` running.
|
||||
|
||||
```bash
|
||||
# Screenshot menu (execute selected command)
|
||||
printf '%s\n' \
|
||||
"grimblast --notify copy screen" \
|
||||
"grimblast --notify copy area" \
|
||||
"grimblast --notify edit screen" \
|
||||
| owlry -m dmenu -p "Screenshot" \
|
||||
| sh
|
||||
|
||||
# Git branch checkout
|
||||
git branch | owlry -m dmenu -p "checkout" | xargs git checkout
|
||||
|
||||
# Kill a process
|
||||
ps -eo comm | sort -u | owlry -m dmenu -p "kill" | xargs pkill
|
||||
|
||||
# Select and open a project
|
||||
find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
|
||||
|
||||
# Package manager search
|
||||
pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S
|
||||
|
||||
# Open selected file
|
||||
ls ~/Documents | owlry -m dmenu | xargs xdg-open
|
||||
```
|
||||
|
||||
The `-p` / `--prompt` flag sets a custom label for the search input.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Enter` | Launch selected item |
|
||||
| `Escape` | Close launcher / exit submenu |
|
||||
| `↑` / `↓` | Navigate results |
|
||||
| `Up` / `Down` | Navigate results |
|
||||
| `Tab` | Cycle filter tabs |
|
||||
| `Shift+Tab` | Cycle filter tabs (reverse) |
|
||||
| `Ctrl+1..9` | Toggle tab by position |
|
||||
@@ -112,7 +264,7 @@ owlry --help # Show all options
|
||||
| `/` | File search | `/ .bashrc` |
|
||||
| `find ` | File search | `find config` |
|
||||
|
||||
## File Locations
|
||||
## Configuration
|
||||
|
||||
Owlry follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/):
|
||||
|
||||
@@ -121,23 +273,23 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
|
||||
| `~/.config/owlry/config.toml` | Main configuration |
|
||||
| `~/.config/owlry/themes/*.css` | Custom themes |
|
||||
| `~/.config/owlry/style.css` | CSS overrides |
|
||||
| `~/.config/owlry/plugins/` | User plugins (Lua/Rune) |
|
||||
| `~/.local/share/owlry/scripts/` | User scripts |
|
||||
| `~/.local/share/owlry/frecency.json` | Usage history |
|
||||
|
||||
## Configuration
|
||||
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
|
||||
|
||||
Copy the example files:
|
||||
```bash
|
||||
# Config
|
||||
# Copy example config
|
||||
mkdir -p ~/.config/owlry
|
||||
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
|
||||
|
||||
# Optional: CSS overrides
|
||||
cp /usr/share/doc/owlry/style.example.css ~/.config/owlry/style.css
|
||||
|
||||
# Optional: Example script
|
||||
mkdir -p ~/.local/share/owlry/scripts
|
||||
cp /usr/share/doc/owlry/scripts/example.sh ~/.local/share/owlry/scripts/
|
||||
```
|
||||
|
||||
### Example Configuration
|
||||
@@ -146,94 +298,95 @@ cp /usr/share/doc/owlry/scripts/example.sh ~/.local/share/owlry/scripts/
|
||||
[general]
|
||||
show_icons = true
|
||||
max_results = 10
|
||||
tabs = ["app", "cmd", "uuctl"] # Header tabs (Ctrl+1, Ctrl+2, etc.)
|
||||
# terminal_command = "kitty" # Auto-detected
|
||||
# launch_wrapper = "uwsm app --" # Auto-detected
|
||||
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" # 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
|
||||
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"
|
||||
system = true
|
||||
ssh = true
|
||||
clipboard = true
|
||||
bookmarks = true
|
||||
emoji = true
|
||||
scripts = true
|
||||
files = true
|
||||
frecency = true
|
||||
frecency_weight = 0.3
|
||||
|
||||
# Profiles: named sets of modes
|
||||
[profiles.dev]
|
||||
modes = ["app", "cmd", "ssh"]
|
||||
|
||||
[profiles.media]
|
||||
modes = ["media", "emoji"]
|
||||
```
|
||||
|
||||
### Tab Configuration
|
||||
See `/usr/share/doc/owlry/config.example.toml` for all options with documentation.
|
||||
|
||||
Customize which providers appear as header tabs:
|
||||
## Plugin System
|
||||
|
||||
Owlry uses a modular plugin architecture. Plugins are loaded by the daemon (`owlry-core`) from:
|
||||
|
||||
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
|
||||
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
|
||||
|
||||
### Disabling Plugins
|
||||
|
||||
Add plugin IDs to the disabled list in your config:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
# Available: app, cmd, uuctl, bookmark, calc, clip, dmenu,
|
||||
# emoji, file, script, ssh, sys, web
|
||||
tabs = ["app", "cmd", "ssh", "sys"]
|
||||
[plugins]
|
||||
disabled = ["emoji", "pomodoro"]
|
||||
```
|
||||
|
||||
Keyboard shortcuts `Ctrl+1` through `Ctrl+9` map to tab positions.
|
||||
|
||||
## Providers
|
||||
|
||||
| Provider | Description | Trigger |
|
||||
|----------|-------------|---------|
|
||||
| **Applications** | `.desktop` files from XDG directories | `:app` |
|
||||
| **Commands** | Executables in `$PATH` | `:cmd` |
|
||||
| **System** | Shutdown, reboot, suspend, lock, BIOS | `:sys` |
|
||||
| **SSH** | Hosts from `~/.ssh/config` | `:ssh` |
|
||||
| **Clipboard** | History via cliphist | `:clip` |
|
||||
| **Bookmarks** | Chrome, Brave, Edge, Vivaldi | `:bm` |
|
||||
| **Emoji** | 300+ searchable emoji | `:emoji` |
|
||||
| **Scripts** | User scripts | `:script` |
|
||||
| **Calculator** | Math expressions | `=` or `:calc` |
|
||||
| **Web Search** | Configurable engine | `?` or `:web` |
|
||||
| **Files** | fd/locate search | `/` or `:file` |
|
||||
| **systemd** | User services with actions | `:uuctl` |
|
||||
|
||||
### Tags
|
||||
|
||||
Items are tagged for better search:
|
||||
- **Applications**: Categories from `.desktop` files (development, utility, etc.)
|
||||
- **System**: `power`, `system`
|
||||
- **SSH**: `ssh`
|
||||
- **Scripts**: `script`
|
||||
- **systemd**: `systemd`, `service`
|
||||
|
||||
Filter by tag with `:tag:tagname`:
|
||||
```
|
||||
:tag:development # Show development apps
|
||||
:tag:utility vim # Search utilities for "vim"
|
||||
```
|
||||
|
||||
### Scripts
|
||||
|
||||
Create executable scripts in `~/.local/share/owlry/scripts/`:
|
||||
### Plugin Management CLI
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.local/share/owlry/scripts
|
||||
cat > ~/.local/share/owlry/scripts/backup.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
rsync -av ~/Documents /backup/
|
||||
notify-send "Backup complete"
|
||||
EOF
|
||||
chmod +x ~/.local/share/owlry/scripts/backup.sh
|
||||
# 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
|
||||
|
||||
### Built-in Themes
|
||||
@@ -271,23 +424,6 @@ Create `~/.config/owlry/themes/mytheme.css`:
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Overrides
|
||||
|
||||
For tweaks without a full theme, create `~/.config/owlry/style.css`:
|
||||
|
||||
```css
|
||||
/* Larger search input */
|
||||
.owlry-search {
|
||||
font-size: 18px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Hide tag badges */
|
||||
.owlry-tag-badge {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Variables
|
||||
|
||||
| Variable | Description |
|
||||
@@ -299,8 +435,29 @@ For tweaks without a full theme, create `~/.config/owlry/style.css`:
|
||||
| `--owlry-text-secondary` | Muted text |
|
||||
| `--owlry-accent` | Accent color |
|
||||
| `--owlry-accent-bright` | Bright accent |
|
||||
| `--owlry-font-size` | Base font size |
|
||||
| `--owlry-border-radius` | Corner radius |
|
||||
|
||||
## Architecture
|
||||
|
||||
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
|
||||
|
||||
@@ -310,4 +467,5 @@ GNU General Public License v3.0 — see [LICENSE](LICENSE).
|
||||
|
||||
- [GTK4](https://gtk.org/) — UI toolkit
|
||||
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) — Wayland Layer Shell
|
||||
- [abi_stable](https://crates.io/crates/abi_stable) — ABI-stable Rust plugins
|
||||
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) — Fuzzy search
|
||||
|
||||
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.
|
||||
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(())
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,6 @@ pub fn cache_home() -> Option<PathBuf> {
|
||||
dirs::cache_dir()
|
||||
}
|
||||
|
||||
/// Get user home directory
|
||||
pub fn home() -> Option<PathBuf> {
|
||||
dirs::home_dir()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Owlry-specific directories
|
||||
// =============================================================================
|
||||
@@ -85,9 +80,12 @@ pub fn theme_file(name: &str) -> Option<PathBuf> {
|
||||
// Data files
|
||||
// =============================================================================
|
||||
|
||||
/// User scripts directory: `$XDG_DATA_HOME/owlry/scripts/`
|
||||
pub fn scripts_dir() -> Option<PathBuf> {
|
||||
owlry_data_dir().map(|p| p.join("scripts"))
|
||||
/// 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`
|
||||
@@ -100,80 +98,85 @@ pub fn frecency_file() -> Option<PathBuf> {
|
||||
// =============================================================================
|
||||
|
||||
/// System data directories for applications (XDG_DATA_DIRS)
|
||||
///
|
||||
/// Follows the XDG Base Directory Specification:
|
||||
/// - $XDG_DATA_HOME/applications (defaults to ~/.local/share/applications)
|
||||
/// - $XDG_DATA_DIRS/*/applications (defaults to /usr/local/share:/usr/share)
|
||||
/// - Additional Flatpak and Snap directories
|
||||
pub fn system_data_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
// User data directory first
|
||||
// Helper to add unique directories
|
||||
let mut add_dir = |path: PathBuf| {
|
||||
if seen.insert(path.clone()) {
|
||||
dirs.push(path);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. User data directory first (highest priority)
|
||||
if let Some(data) = data_home() {
|
||||
dirs.push(data.join("applications"));
|
||||
add_dir(data.join("applications"));
|
||||
}
|
||||
|
||||
// System directories
|
||||
dirs.push(PathBuf::from("/usr/share/applications"));
|
||||
dirs.push(PathBuf::from("/usr/local/share/applications"));
|
||||
// 2. XDG_DATA_DIRS - parse the environment variable
|
||||
// Default per spec: /usr/local/share:/usr/share
|
||||
let xdg_data_dirs = std::env::var("XDG_DATA_DIRS")
|
||||
.unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
|
||||
|
||||
// Flatpak directories
|
||||
if let Some(data) = data_home() {
|
||||
dirs.push(data.join("flatpak/exports/share/applications"));
|
||||
for dir in xdg_data_dirs.split(':') {
|
||||
if !dir.is_empty() {
|
||||
add_dir(PathBuf::from(dir).join("applications"));
|
||||
}
|
||||
}
|
||||
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
|
||||
|
||||
// 3. Always include standard system directories as fallback
|
||||
// Some environments set XDG_DATA_DIRS without including these
|
||||
add_dir(PathBuf::from("/usr/share/applications"));
|
||||
add_dir(PathBuf::from("/usr/local/share/applications"));
|
||||
|
||||
// 4. Flatpak directories (user and system)
|
||||
if let Some(data) = data_home() {
|
||||
add_dir(data.join("flatpak/exports/share/applications"));
|
||||
}
|
||||
add_dir(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
|
||||
|
||||
// 5. Snap directories
|
||||
add_dir(PathBuf::from("/var/lib/snapd/desktop/applications"));
|
||||
|
||||
// 6. Nix directories (common on NixOS)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
add_dir(home.join(".nix-profile/share/applications"));
|
||||
}
|
||||
add_dir(PathBuf::from("/run/current-system/sw/share/applications"));
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// External application paths
|
||||
// Runtime files
|
||||
// =============================================================================
|
||||
|
||||
/// SSH config file: `~/.ssh/config`
|
||||
pub fn ssh_config() -> Option<PathBuf> {
|
||||
home().map(|p| p.join(".ssh").join("config"))
|
||||
}
|
||||
|
||||
/// Firefox profile directory: `~/.mozilla/firefox/`
|
||||
pub fn firefox_dir() -> Option<PathBuf> {
|
||||
home().map(|p| p.join(".mozilla").join("firefox"))
|
||||
}
|
||||
|
||||
/// Chromium-based browser bookmark paths (using XDG config where browsers support it)
|
||||
pub fn chromium_bookmark_paths() -> Vec<PathBuf> {
|
||||
let config = match config_home() {
|
||||
Some(c) => c,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
vec![
|
||||
// Google Chrome
|
||||
config.join("google-chrome/Default/Bookmarks"),
|
||||
// Chromium
|
||||
config.join("chromium/Default/Bookmarks"),
|
||||
// Brave
|
||||
config.join("BraveSoftware/Brave-Browser/Default/Bookmarks"),
|
||||
// Microsoft Edge
|
||||
config.join("microsoft-edge/Default/Bookmarks"),
|
||||
// Vivaldi
|
||||
config.join("vivaldi/Default/Bookmarks"),
|
||||
]
|
||||
/// 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 a directory exists, creating it if necessary
|
||||
pub fn ensure_dir(path: &PathBuf) -> std::io::Result<()> {
|
||||
if !path.exists() {
|
||||
std::fs::create_dir_all(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure parent directory of a file exists
|
||||
pub fn ensure_parent_dir(path: &PathBuf) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
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(())
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -66,13 +66,14 @@ fn clean_desktop_exec_field(cmd: &str) -> String {
|
||||
cleaned
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ApplicationProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl ApplicationProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn get_application_dirs() -> Vec<std::path::PathBuf> {
|
||||
@@ -98,6 +99,15 @@ impl Provider for ApplicationProvider {
|
||||
// Empty locale list for default locale
|
||||
let locales: &[&str] = &[];
|
||||
|
||||
// Get current desktop environment(s) for OnlyShowIn/NotShowIn filtering
|
||||
// XDG_CURRENT_DESKTOP can be colon-separated (e.g., "ubuntu:GNOME")
|
||||
let current_desktops: Vec<String> = std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.unwrap_or_default()
|
||||
.split(':')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
for path in Iter::new(dirs.into_iter()) {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
@@ -125,6 +135,27 @@ impl Provider for ApplicationProvider {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply OnlyShowIn/NotShowIn filters only if we know the current desktop
|
||||
// If XDG_CURRENT_DESKTOP is not set, show all apps (don't filter)
|
||||
if !current_desktops.is_empty() {
|
||||
// OnlyShowIn: if set, current desktop must be in the list
|
||||
if desktop_entry.only_show_in().is_some_and(|only| {
|
||||
!current_desktops
|
||||
.iter()
|
||||
.any(|de| only.contains(&de.as_str()))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// NotShowIn: if current desktop is in the list, skip
|
||||
if desktop_entry
|
||||
.not_show_in()
|
||||
.is_some_and(|not| current_desktops.iter().any(|de| not.contains(&de.as_str())))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let name = match desktop_entry.name(locales) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
@@ -135,12 +166,17 @@ impl Provider for ApplicationProvider {
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract categories as tags (lowercase for consistency)
|
||||
let tags: Vec<String> = desktop_entry
|
||||
// Extract categories and keywords as tags (lowercase for consistency)
|
||||
let mut tags: Vec<String> = desktop_entry
|
||||
.categories()
|
||||
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Add keywords for searchability (e.g., Nautilus has Name=Files but Keywords contains "nautilus")
|
||||
if let Some(keywords) = desktop_entry.keywords(locales) {
|
||||
tags.extend(keywords.into_iter().map(|s| s.to_lowercase()));
|
||||
}
|
||||
|
||||
let item = LaunchItem {
|
||||
id: path.to_string_lossy().to_string(),
|
||||
name,
|
||||
@@ -157,8 +193,16 @@ impl Provider for ApplicationProvider {
|
||||
|
||||
debug!("Found {} applications", self.items.len());
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"XDG_CURRENT_DESKTOP={:?}, scanned dirs count={}",
|
||||
current_desktops,
|
||||
Self::get_application_dirs().len()
|
||||
);
|
||||
|
||||
// Sort alphabetically by name
|
||||
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
self.items
|
||||
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
@@ -180,7 +224,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_multiple_placeholders() {
|
||||
assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app");
|
||||
assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other");
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field("app --flag %u --other"),
|
||||
"app --flag --other"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -210,4 +257,18 @@ mod tests {
|
||||
"bash -c 'echo %u'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_preserves_env() {
|
||||
// env VAR=value pattern should be preserved
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field("env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity %F"),
|
||||
"env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity"
|
||||
);
|
||||
// Multiple env vars
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field("env FOO=bar BAZ=qux myapp %u"),
|
||||
"env FOO=bar BAZ=qux myapp"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,14 @@ use std::collections::HashSet;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CommandProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl CommandProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn get_path_dirs() -> Vec<PathBuf> {
|
||||
@@ -97,7 +98,8 @@ impl Provider for CommandProvider {
|
||||
debug!("Found {} commands in PATH", self.items.len());
|
||||
|
||||
// Sort alphabetically
|
||||
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
self.items
|
||||
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -321,13 +368,13 @@ scrollbar slider:active {
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: alpha(var(--owlry-border, @borders), 0.3);
|
||||
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.6));
|
||||
margin-top: 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.2);
|
||||
background-color: alpha(var(--owlry-accent-bright, @theme_selected_fg_color), 0.25);
|
||||
color: var(--owlry-accent-bright, @theme_selected_fg_color);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,41 @@
|
||||
#
|
||||
# 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 │
|
||||
# │ Scripts: ~/.local/share/owlry/scripts/ Executable scripts │
|
||||
# │ Data: ~/.local/share/owlry/frecency.json Usage history │
|
||||
# │ 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 │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -16,14 +46,18 @@
|
||||
|
||||
[general]
|
||||
show_icons = true
|
||||
max_results = 10
|
||||
max_results = 100
|
||||
|
||||
# Terminal emulator (auto-detected if not set)
|
||||
terminal_command = "kitty"
|
||||
# Terminal emulator for SSH, scripts, etc.
|
||||
# Auto-detection order: $TERMINAL → xdg-terminal-exec → DE-native → Wayland → X11 → xterm
|
||||
# Uncomment to override:
|
||||
# terminal_command = "kitty"
|
||||
|
||||
# Launch wrapper for app execution (auto-detected for uwsm/Hyprland)
|
||||
# Examples: "uwsm app --", "hyprctl dispatch exec --", ""
|
||||
# launch_wrapper = "uwsm app --"
|
||||
# Enable uwsm (Universal Wayland Session Manager) for launching apps.
|
||||
# When enabled, apps are launched via "uwsm app --" which starts them
|
||||
# in a proper systemd user session for better process management.
|
||||
# Requires: uwsm to be installed
|
||||
# use_uwsm = true
|
||||
|
||||
# Header tabs - providers shown as toggle buttons (Ctrl+1, Ctrl+2, etc.)
|
||||
# Values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
|
||||
@@ -34,15 +68,14 @@ tabs = ["app", "cmd", "uuctl"]
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[appearance]
|
||||
width = 600
|
||||
height = 400
|
||||
width = 850
|
||||
height = 650
|
||||
font_size = 14
|
||||
border_radius = 12
|
||||
|
||||
# Theme name - loads ~/.config/owlry/themes/{name}.css
|
||||
# Built-in: owl, catppuccin-mocha, dracula, gruvbox-dark, nord,
|
||||
# one-dark, rose-pine, solarized-dark, tokyo-night
|
||||
# Or leave unset for GTK default
|
||||
# Built-in: owl
|
||||
# Or leave unset/empty for GTK default
|
||||
# theme = "owl"
|
||||
|
||||
# Color overrides (applied on top of theme)
|
||||
@@ -54,62 +87,108 @@ border_radius = 12
|
||||
# text_secondary = "#565f89"
|
||||
# accent = "#7aa2f7"
|
||||
# accent_bright = "#89b4fa"
|
||||
# badge_app = "#9ece6a"
|
||||
# badge_calc = "#e0af68"
|
||||
# badge_cmd = "#7aa2f7"
|
||||
# badge_dmenu = "#bb9af7"
|
||||
# badge_uuctl = "#f7768e"
|
||||
#
|
||||
# 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 (appear in main search)
|
||||
applications = true # .desktop applications
|
||||
# Core providers (always available)
|
||||
applications = true # .desktop applications from XDG dirs
|
||||
commands = true # Executables from $PATH
|
||||
uuctl = true # systemd --user units
|
||||
|
||||
# Frecency - boost frequently/recently used items
|
||||
# Data: ~/.local/share/owlry/frecency.json
|
||||
# Data stored in: ~/.local/share/owlry/frecency.json
|
||||
frecency = true
|
||||
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Trigger Providers (activated by prefix)
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 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)
|
||||
|
||||
# Calculator: "= 5+3" or "calc 5+3" or ":calc"
|
||||
calculator = true
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 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)
|
||||
|
||||
# Web search: "? query" or "web query" or ":web"
|
||||
websearch = true
|
||||
search_engine = "duckduckgo"
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Provider settings
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Web search engine
|
||||
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
|
||||
# Custom: "https://search.example.com/?q={query}"
|
||||
# Or custom URL: "https://search.example.com/?q={query}"
|
||||
search_engine = "duckduckgo"
|
||||
|
||||
# File search: "/ pattern" or "find pattern" or ":file"
|
||||
# Requires: fd or locate
|
||||
files = true
|
||||
# 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
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Prefix Providers (use :prefix to search)
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# System: :sys or :power - shutdown, reboot, lock, suspend, hibernate, logout
|
||||
system = true
|
||||
|
||||
# SSH: :ssh - connections from ~/.ssh/config
|
||||
ssh = true
|
||||
|
||||
# Clipboard: :clip - history (requires cliphist)
|
||||
clipboard = true
|
||||
|
||||
# Bookmarks: :bm - browser bookmarks (Chrome, Chromium, Brave, Edge, Vivaldi)
|
||||
bookmarks = true
|
||||
|
||||
# Emoji: :emoji - picker (copies to clipboard)
|
||||
emoji = true
|
||||
|
||||
# Scripts: :script - executables from ~/.local/share/owlry/scripts/
|
||||
scripts = true
|
||||
# Pomodoro settings (when pomodoro = true)
|
||||
# pomodoro_work_mins = 25 # Work session duration
|
||||
# pomodoro_break_mins = 5 # Break duration
|
||||
|
||||
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);
|
||||
}
|
||||
2363
docs/superpowers/plans/2026-03-26-architecture-split.md
Normal file
458
docs/superpowers/specs/2026-03-26-architecture-split-design.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# Owlry Architecture Split — Design Spec
|
||||
|
||||
**Date:** 2026-03-26
|
||||
**Status:** Draft
|
||||
**Goal:** Split owlry into two repos with a client/daemon architecture, enabling independent release cadence and clean architectural separation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Currently owlry is a monorepo with 18 crates that all share version `0.4.10`. Changing any single package requires bumping all versions and rebuilding every AUR package from the same source. The architecture also couples the GTK UI directly to plugin loading and provider management, meaning the UI blocks during initialization.
|
||||
|
||||
This redesign:
|
||||
- Splits into two repositories (core + plugins)
|
||||
- Extracts backend logic into `owlry-core` daemon
|
||||
- Makes `owlry` a thin GTK4 client communicating over Unix socket IPC
|
||||
- Enables independent versioning and release cadence per package
|
||||
- Makes third-party plugin development straightforward
|
||||
|
||||
---
|
||||
|
||||
## 2. Repository Layout
|
||||
|
||||
### Repo 1 — `owlry/` (core, changes infrequently)
|
||||
|
||||
```
|
||||
owlry/
|
||||
├── Cargo.toml # Workspace: owlry, owlry-core, owlry-plugin-api
|
||||
├── crates/
|
||||
│ ├── owlry/ # GTK4 frontend binary (thin UI client)
|
||||
│ │ └── src/
|
||||
│ │ ├── main.rs # Entry point, CLI parsing
|
||||
│ │ ├── app.rs # GTK Application setup, CSS/theme loading
|
||||
│ │ ├── cli.rs # Argument parsing (--mode, --profile, --dmenu)
|
||||
│ │ ├── client.rs # IPC client (connects to daemon socket)
|
||||
│ │ ├── ui/
|
||||
│ │ │ ├── mod.rs
|
||||
│ │ │ ├── main_window.rs # GTK window, Layer Shell overlay
|
||||
│ │ │ ├── result_row.rs # Result rendering
|
||||
│ │ │ └── submenu.rs # Submenu rendering
|
||||
│ │ ├── theme.rs # CSS loading priority
|
||||
│ │ └── providers/
|
||||
│ │ └── dmenu.rs # Self-contained dmenu mode (bypasses daemon)
|
||||
│ ├── owlry-core/ # Daemon binary
|
||||
│ │ └── src/
|
||||
│ │ ├── main.rs # Daemon entry point
|
||||
│ │ ├── server.rs # Unix socket IPC server
|
||||
│ │ ├── config/
|
||||
│ │ │ └── mod.rs # Config loading (~/.config/owlry/config.toml)
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── mod.rs
|
||||
│ │ │ └── frecency.rs # FrecencyStore
|
||||
│ │ ├── filter.rs # ProviderFilter, prefix/query parsing
|
||||
│ │ ├── providers/
|
||||
│ │ │ ├── mod.rs # ProviderManager
|
||||
│ │ │ ├── application.rs # Desktop application provider
|
||||
│ │ │ ├── command.rs # PATH command provider
|
||||
│ │ │ ├── native_provider.rs # Bridge: native plugin → provider
|
||||
│ │ │ └── lua_provider.rs # Bridge: lua/rune runtime → provider
|
||||
│ │ ├── plugins/
|
||||
│ │ │ ├── mod.rs
|
||||
│ │ │ ├── native_loader.rs # Loads .so plugins
|
||||
│ │ │ ├── runtime_loader.rs # Loads lua/rune runtimes
|
||||
│ │ │ ├── loader.rs
|
||||
│ │ │ ├── manifest.rs
|
||||
│ │ │ ├── registry.rs
|
||||
│ │ │ ├── commands.rs
|
||||
│ │ │ ├── error.rs
|
||||
│ │ │ ├── runtime.rs
|
||||
│ │ │ └── api/ # Lua/Rune scripting API surface
|
||||
│ │ │ ├── mod.rs
|
||||
│ │ │ ├── provider.rs
|
||||
│ │ │ ├── process.rs
|
||||
│ │ │ ├── cache.rs
|
||||
│ │ │ ├── math.rs
|
||||
│ │ │ ├── action.rs
|
||||
│ │ │ ├── theme.rs
|
||||
│ │ │ ├── http.rs
|
||||
│ │ │ ├── hook.rs
|
||||
│ │ │ └── utils.rs
|
||||
│ │ ├── notify.rs # Notification dispatch
|
||||
│ │ └── paths.rs # XDG path helpers
|
||||
│ └── owlry-plugin-api/ # ABI-stable plugin interface (published crate)
|
||||
│ └── src/
|
||||
│ └── lib.rs # PluginVTable, PluginInfo, ProviderInfo, etc.
|
||||
├── aur/
|
||||
│ ├── owlry/ # UI package
|
||||
│ ├── owlry-core/ # Daemon package (new)
|
||||
│ ├── owlry-meta-essentials/
|
||||
│ ├── owlry-meta-full/
|
||||
│ ├── owlry-meta-tools/
|
||||
│ └── owlry-meta-widgets/
|
||||
├── resources/ # CSS, base themes, icons
|
||||
├── systemd/
|
||||
│ ├── owlry-core.service # Systemd user service
|
||||
│ └── owlry-core.socket # Socket activation (optional)
|
||||
├── justfile
|
||||
├── README.md
|
||||
└── docs/
|
||||
```
|
||||
|
||||
### Repo 2 — `owlry-plugins/` (plugins, releases independently)
|
||||
|
||||
```
|
||||
owlry-plugins/
|
||||
├── Cargo.toml # Workspace: all plugins + runtimes
|
||||
├── crates/
|
||||
│ ├── owlry-plugin-calculator/
|
||||
│ ├── owlry-plugin-clipboard/
|
||||
│ ├── owlry-plugin-emoji/
|
||||
│ ├── owlry-plugin-bookmarks/
|
||||
│ ├── owlry-plugin-ssh/
|
||||
│ ├── owlry-plugin-scripts/
|
||||
│ ├── owlry-plugin-system/
|
||||
│ ├── owlry-plugin-websearch/
|
||||
│ ├── owlry-plugin-filesearch/
|
||||
│ ├── owlry-plugin-weather/
|
||||
│ ├── owlry-plugin-media/
|
||||
│ ├── owlry-plugin-pomodoro/
|
||||
│ ├── owlry-plugin-systemd/
|
||||
│ ├── owlry-lua/ # Lua runtime (cdylib)
|
||||
│ └── owlry-rune/ # Rune runtime (cdylib)
|
||||
├── aur/
|
||||
│ ├── owlry-plugin-*/ # Individual plugin PKGBUILDs
|
||||
│ ├── owlry-lua/
|
||||
│ └── owlry-rune/
|
||||
├── justfile
|
||||
├── README.md
|
||||
└── docs/
|
||||
├── PLUGIN_DEVELOPMENT.md
|
||||
└── PLUGINS.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Daemon Architecture (`owlry-core`)
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- Load and manage all native plugins from:
|
||||
- `/usr/lib/owlry/plugins/*.so` (system-installed)
|
||||
- `~/.local/lib/owlry/plugins/*.so` (user-installed native)
|
||||
- Load script runtimes from:
|
||||
- `/usr/lib/owlry/runtimes/*.so` (system)
|
||||
- `~/.config/owlry/plugins/` (user lua/rune scripts)
|
||||
- Maintain `ProviderManager` with all providers initialized
|
||||
- Own `FrecencyStore` (persists across UI open/close cycles)
|
||||
- Load and watch config from `~/.config/owlry/config.toml`
|
||||
- Listen on Unix socket at `$XDG_RUNTIME_DIR/owlry/owlry.sock`
|
||||
|
||||
### Systemd Integration
|
||||
|
||||
```ini
|
||||
# owlry-core.service
|
||||
[Unit]
|
||||
Description=Owlry application launcher daemon
|
||||
Documentation=https://somegit.dev/Owlibou/owlry
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/owlry-core
|
||||
Restart=on-failure
|
||||
Environment=RUST_LOG=warn
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
```ini
|
||||
# owlry-core.socket (optional socket activation)
|
||||
[Unit]
|
||||
Description=Owlry launcher socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=%t/owlry/owlry.sock
|
||||
DirectoryMode=0700
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
```
|
||||
|
||||
Users can either:
|
||||
- `exec-once owlry-core` in compositor config
|
||||
- `systemctl --user enable --now owlry-core.service`
|
||||
- Rely on socket activation (daemon starts on first UI connection)
|
||||
|
||||
---
|
||||
|
||||
## 4. IPC Protocol
|
||||
|
||||
JSON messages over Unix domain socket, newline-delimited (`\n`). Each message is a single JSON object on one line.
|
||||
|
||||
### Client → Server
|
||||
|
||||
```json
|
||||
{"type": "query", "text": "fire", "modes": ["app", "cmd"]}
|
||||
```
|
||||
Query providers. `modes` is optional — omit to query all. Server streams back results.
|
||||
|
||||
```json
|
||||
{"type": "launch", "item_id": "firefox.desktop", "provider": "app"}
|
||||
```
|
||||
Notify daemon that an item was launched (updates frecency).
|
||||
|
||||
```json
|
||||
{"type": "providers"}
|
||||
```
|
||||
List all available providers (for UI tab rendering).
|
||||
|
||||
```json
|
||||
{"type": "refresh", "provider": "clipboard"}
|
||||
```
|
||||
Force a specific provider to refresh its data.
|
||||
|
||||
```json
|
||||
{"type": "toggle"}
|
||||
```
|
||||
Used for toggle behavior — if UI is already open, close it.
|
||||
|
||||
```json
|
||||
{"type": "submenu", "plugin_id": "systemd", "data": "docker.service"}
|
||||
```
|
||||
Request submenu items for a plugin.
|
||||
|
||||
### Server → Client
|
||||
|
||||
```json
|
||||
{"type": "results", "items": [{"id": "firefox.desktop", "title": "Firefox", "description": "Web Browser", "icon": "firefox", "provider": "app", "score": 95}]}
|
||||
```
|
||||
|
||||
```json
|
||||
{"type": "providers", "list": [{"id": "app", "name": "Applications", "prefix": ":app", "icon": "application-x-executable", "position": "normal"}]}
|
||||
```
|
||||
|
||||
```json
|
||||
{"type": "submenu_items", "items": [...]}
|
||||
```
|
||||
|
||||
```json
|
||||
{"type": "error", "message": "..."}
|
||||
```
|
||||
|
||||
```json
|
||||
{"type": "ack"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. UI Client (`owlry`)
|
||||
|
||||
### CLI Interface
|
||||
|
||||
```
|
||||
owlry # Open with all providers
|
||||
owlry -m app,cmd,calc # Open with specific modes
|
||||
owlry --profile default # Open with named profile from config
|
||||
owlry -m dmenu -p "Pick:" # dmenu mode (bypasses daemon, reads stdin)
|
||||
```
|
||||
|
||||
**Removed flags:**
|
||||
- `-p` / `--providers` — removed, was confusing overlap with `-m`
|
||||
|
||||
Note: `-p` is repurposed as the dmenu prompt flag (short for `--prompt`), matching dmenu/rofi convention.
|
||||
|
||||
### Profiles (in `config.toml`)
|
||||
|
||||
```toml
|
||||
[profiles.default]
|
||||
modes = ["app", "cmd", "calc"]
|
||||
|
||||
[profiles.utils]
|
||||
modes = ["clip", "emoji", "ssh", "sys"]
|
||||
|
||||
[profiles.dev]
|
||||
modes = ["app", "cmd", "ssh", "file"]
|
||||
```
|
||||
|
||||
Usage: `owlry --profile utils`
|
||||
|
||||
### Toggle Behavior
|
||||
|
||||
When `owlry` is invoked while already open:
|
||||
1. Client connects to daemon socket
|
||||
2. Sends `{"type": "toggle"}`
|
||||
3. If an instance is already showing, it closes
|
||||
4. If no instance is showing, opens normally
|
||||
|
||||
Implementation: the daemon tracks whether a UI client is currently connected and visible.
|
||||
|
||||
### Auto-Start Fallback
|
||||
|
||||
If the UI tries to connect to the daemon socket and it doesn't exist:
|
||||
1. Attempt to start via systemd: `systemctl --user start owlry-core`
|
||||
2. If systemd activation fails, fork `owlry-core` directly
|
||||
3. Retry connection with brief backoff (100ms, 200ms, 400ms — 3 attempts max)
|
||||
4. If still unreachable, exit with clear error message
|
||||
|
||||
### dmenu Mode
|
||||
|
||||
Completely self-contained in the UI binary:
|
||||
- Reads items from stdin
|
||||
- Renders GTK picker
|
||||
- Prints selected item to stdout
|
||||
- No daemon connection, no frecency, no plugins
|
||||
- `-p "prompt"` sets the prompt text
|
||||
|
||||
---
|
||||
|
||||
## 6. Plugin API Decoupling
|
||||
|
||||
### `owlry-plugin-api` as Published Crate
|
||||
|
||||
- Published to crates.io (or referenced as git dep from core repo)
|
||||
- Versioned independently — bumped only when ABI changes
|
||||
- Current `API_VERSION = 3` continues
|
||||
- Third-party plugin authors: `cargo add owlry-plugin-api`, implement `owlry_plugin_vtable`, build as cdylib
|
||||
|
||||
### Plugin Dependencies in `owlry-plugins` Repo
|
||||
|
||||
Each plugin's `Cargo.toml`:
|
||||
```toml
|
||||
[dependencies]
|
||||
owlry-plugin-api = "0.5" # from crates.io
|
||||
# or
|
||||
owlry-plugin-api = { git = "https://somegit.dev/Owlibou/owlry.git", tag = "plugin-api-v0.5.0" }
|
||||
```
|
||||
|
||||
No path dependencies between repos.
|
||||
|
||||
### Plugin Discovery (daemon)
|
||||
|
||||
Auto-discovers all `.so` files in plugin directories. No explicit enable list needed.
|
||||
|
||||
Disabling in `config.toml`:
|
||||
```toml
|
||||
[plugins]
|
||||
disabled = ["pomodoro", "media"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Versioning Strategy
|
||||
|
||||
### Core Repo (`owlry`)
|
||||
|
||||
| Crate | Versioning | When to bump |
|
||||
|-------|-----------|-------------|
|
||||
| `owlry-plugin-api` | Independent semver | ABI changes only (rare) |
|
||||
| `owlry-core` | Independent semver | Daemon logic, IPC, provider changes |
|
||||
| `owlry` | Independent semver | UI changes, CLI changes |
|
||||
|
||||
These can share a workspace version if convenient, but are not required to stay in lockstep.
|
||||
|
||||
### Plugins Repo (`owlry-plugins`)
|
||||
|
||||
| Crate | Versioning | When to bump |
|
||||
|-------|-----------|-------------|
|
||||
| Each plugin | Independent semver | That plugin's code changes |
|
||||
| `owlry-lua` | Independent semver | Lua runtime changes |
|
||||
| `owlry-rune` | Independent semver | Rune runtime changes |
|
||||
|
||||
A calculator bugfix bumps only `owlry-plugin-calculator`. Nothing else changes.
|
||||
|
||||
### AUR Packages
|
||||
|
||||
- Core PKGBUILDs source from `owlry` repo tags
|
||||
- Plugin PKGBUILDs source from `owlry-plugins` repo, each with their own tag
|
||||
- Meta-packages stay at `1.0.0`, only `pkgrel` changes when included packages update
|
||||
|
||||
---
|
||||
|
||||
## 8. Dependency Refresh
|
||||
|
||||
Before starting implementation, update all external crate dependencies to latest stable versions:
|
||||
- Run `cargo update` to refresh `Cargo.lock`
|
||||
- Review and bump version constraints in `Cargo.toml` files where appropriate (e.g., `gtk4`, `abi_stable`, `reqwest`, `mlua`, `rune`, etc.)
|
||||
- Verify the workspace still builds and tests pass after updates
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration Phases
|
||||
|
||||
### Phase 0 — Dependency Refresh
|
||||
- Update all external crates to latest stable versions
|
||||
- Verify build + tests pass
|
||||
- Commit
|
||||
|
||||
### Phase 1 — Extract `owlry-core` Crate (library, no IPC yet)
|
||||
- Create `crates/owlry-core/` with its own `Cargo.toml`
|
||||
- Move from `crates/owlry/src/` into `crates/owlry-core/src/`:
|
||||
- `config/` (config loading)
|
||||
- `data/` (frecency)
|
||||
- `filter.rs` (provider filter, prefix parsing)
|
||||
- `providers/` — `mod.rs`, `application.rs`, `command.rs`, `native_provider.rs`, `lua_provider.rs`
|
||||
- `plugins/` — entire directory (native_loader, runtime_loader, manifest, registry, commands, error, runtime, api/)
|
||||
- `notify.rs`
|
||||
- `paths.rs`
|
||||
- `owlry` depends on `owlry-core` as a library (path dep)
|
||||
- Launcher still works as before — no behavioral change
|
||||
- Update justfile for new crate
|
||||
- Update README to reflect new structure
|
||||
- Commit
|
||||
|
||||
### Phase 2 — Add IPC Layer (daemon + client)
|
||||
- Add `server.rs` to `owlry-core` — Unix socket listener, JSON protocol
|
||||
- Add `main.rs` to `owlry-core` — daemon entry point
|
||||
- Add `[[bin]]` target to `owlry-core` alongside the library (`lib` + `bin` in same crate)
|
||||
- Add `client.rs` to `owlry` — connects to daemon, sends queries
|
||||
- Wire up `owlry` UI to use IPC client instead of direct library calls
|
||||
- Implement toggle behavior
|
||||
- Implement auto-start fallback
|
||||
- Implement profiles in config
|
||||
- Add systemd service + socket files
|
||||
- dmenu mode stays self-contained in `owlry`
|
||||
- Remove `-p` provider flag, repurpose as `--prompt` for dmenu
|
||||
- Update justfile
|
||||
- Update README
|
||||
- Commit
|
||||
|
||||
### Phase 3 — Split Repos
|
||||
- Create `owlry-plugins/` directory with its own workspace
|
||||
- Move all `crates/owlry-plugin-*/` into `owlry-plugins/crates/`
|
||||
- Move `crates/owlry-lua/` and `crates/owlry-rune/` into `owlry-plugins/crates/`
|
||||
- Update plugin `Cargo.toml` files: path dep → git/crates.io dep for `owlry-plugin-api`
|
||||
- Move plugin AUR PKGBUILDs into `owlry-plugins/aur/`
|
||||
- Move runtime AUR PKGBUILDs into `owlry-plugins/aur/`
|
||||
- Create `owlry-plugins/justfile`
|
||||
- Move `docs/PLUGIN_DEVELOPMENT.md` and `docs/PLUGINS.md` into `owlry-plugins/docs/`
|
||||
- Create `owlry-plugins/README.md`
|
||||
- Update core `owlry/justfile` (remove plugin-related targets)
|
||||
- Update core `owlry/README.md`
|
||||
- Add `owlry-core` AUR PKGBUILD
|
||||
- Update meta-package PKGBUILDs to reflect new source structure
|
||||
- Update `CLAUDE.md`
|
||||
- Commit both repos
|
||||
|
||||
### Phase 4 — Polish & Verify
|
||||
- Verify all AUR PKGBUILDs build correctly
|
||||
- Verify `owlry-core` daemon starts and responds to IPC
|
||||
- Verify `owlry` UI connects, queries, renders, and launches
|
||||
- Verify dmenu mode still works standalone
|
||||
- Verify toggle behavior
|
||||
- Verify profile loading
|
||||
- Verify auto-start fallback
|
||||
- Verify systemd service and socket activation
|
||||
- Verify plugin loading from both system and user paths
|
||||
- Clean up any leftover references to old structure
|
||||
- Final README review
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of Scope
|
||||
|
||||
- Publishing to crates.io (can be done later)
|
||||
- Pushing updated PKGBUILDs to AUR git repos (done after verification)
|
||||
- Alternative frontends (TUI, etc.) — the architecture enables this but it's future work
|
||||
- Plugin hot-reloading in the daemon
|
||||
- Multi-client support (only one UI at a time for now)
|
||||
10
examples/plugins/hello-rune/init.rn
Normal file
@@ -0,0 +1,10 @@
|
||||
// Hello World Plugin for Owlry
|
||||
//
|
||||
// This minimal plugin demonstrates:
|
||||
// 1. Using owlry::log_* for logging
|
||||
// 2. Basic Rune syntax
|
||||
|
||||
pub fn main() {
|
||||
owlry::log_info("Hello Rune plugin loading...");
|
||||
owlry::log_info("Hello Rune plugin loaded successfully!");
|
||||
}
|
||||
16
examples/plugins/hello-rune/plugin.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Hello World Plugin for Owlry (Rune version)
|
||||
#
|
||||
# This example demonstrates a minimal Rune plugin.
|
||||
|
||||
[plugin]
|
||||
id = "hello-rune"
|
||||
name = "Hello Rune"
|
||||
version = "1.0.0"
|
||||
description = "A simple greeting plugin written in Rune"
|
||||
author = "Owlry Team"
|
||||
license = "GPL-3.0-or-later"
|
||||
owlry_version = ">=0.3.0"
|
||||
entry = "init.rn"
|
||||
|
||||
[provides]
|
||||
providers = ["greeting"]
|
||||
64
examples/plugins/hello-world/init.lua
Normal file
@@ -0,0 +1,64 @@
|
||||
-- Hello World Plugin for Owlry
|
||||
--
|
||||
-- This minimal plugin demonstrates:
|
||||
-- 1. Using owlry.log for logging
|
||||
-- 2. Registering a static provider
|
||||
-- 3. Returning items with all available fields
|
||||
|
||||
owlry.log.info("Hello World plugin loading...")
|
||||
|
||||
-- Register a static provider
|
||||
-- Static providers have a 'refresh' function that returns items once
|
||||
owlry.provider.register({
|
||||
-- Required: unique provider name (used internally)
|
||||
name = "greeting",
|
||||
|
||||
-- Optional: human-readable display name
|
||||
display_name = "Hello World",
|
||||
|
||||
-- Optional: icon name (freedesktop icon spec)
|
||||
default_icon = "face-smile",
|
||||
|
||||
-- Optional: prefix to trigger this provider (e.g., ":hello")
|
||||
prefix = ":hello",
|
||||
|
||||
-- Required for static providers: function that returns items
|
||||
refresh = function()
|
||||
-- Get username from environment or default to "User"
|
||||
local user = os.getenv("USER") or "User"
|
||||
|
||||
return {
|
||||
{
|
||||
id = "greeting-1",
|
||||
name = "Hello, " .. user .. "!",
|
||||
description = "A friendly greeting from Lua",
|
||||
icon = "face-smile",
|
||||
-- Command to run when selected (optional)
|
||||
command = "notify-send 'Hello' 'Greetings from Owlry!'",
|
||||
-- Whether to run in terminal (optional, default false)
|
||||
terminal = false,
|
||||
-- Tags for search/filtering (optional)
|
||||
tags = { "greeting", "hello", "example" }
|
||||
},
|
||||
{
|
||||
id = "greeting-2",
|
||||
name = "Current time: " .. os.date("%H:%M:%S"),
|
||||
description = "The current system time",
|
||||
icon = "appointment-soon",
|
||||
-- Empty command = info only, no action
|
||||
command = "",
|
||||
tags = { "time", "clock" }
|
||||
},
|
||||
{
|
||||
id = "greeting-3",
|
||||
name = "Open Owlry docs",
|
||||
description = "Visit the Owlry documentation",
|
||||
icon = "help-browser",
|
||||
command = "xdg-open 'https://github.com/Owlibou/owlry'",
|
||||
tags = { "help", "docs", "documentation" }
|
||||
}
|
||||
}
|
||||
end
|
||||
})
|
||||
|
||||
owlry.log.info("Hello World plugin loaded successfully!")
|
||||
22
examples/plugins/hello-world/plugin.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
# Hello World Plugin for Owlry
|
||||
#
|
||||
# This is a minimal example plugin demonstrating:
|
||||
# - Plugin manifest structure
|
||||
# - Static provider registration
|
||||
# - Returning items from Lua
|
||||
|
||||
[plugin]
|
||||
id = "hello-world"
|
||||
name = "Hello World"
|
||||
version = "1.0.0"
|
||||
description = "A minimal example plugin"
|
||||
author = "Owlry Team"
|
||||
license = "GPL-3.0-or-later"
|
||||
owlry_version = ">=0.3.0"
|
||||
|
||||
[provides]
|
||||
providers = ["greeting"]
|
||||
|
||||
# No special permissions needed for this simple plugin
|
||||
[permissions]
|
||||
network = false
|
||||
95
examples/plugins/quick-note/init.lua
Normal file
@@ -0,0 +1,95 @@
|
||||
-- Quick Note Plugin for Owlry
|
||||
--
|
||||
-- This plugin demonstrates:
|
||||
-- 1. Dynamic providers (query-based)
|
||||
-- 2. Using owlry.fs for file operations
|
||||
-- 3. Using owlry.path for directory helpers
|
||||
-- 4. Using owlry.json for data storage
|
||||
|
||||
owlry.log.info("Quick Note plugin loading...")
|
||||
|
||||
-- Get notes file path
|
||||
local notes_dir = owlry.path.join(owlry.path.data(), "notes")
|
||||
local notes_file = owlry.path.join(notes_dir, "quick-notes.json")
|
||||
|
||||
-- Load existing notes
|
||||
local function load_notes()
|
||||
local content = owlry.fs.read(notes_file)
|
||||
if content then
|
||||
local data = owlry.json.decode(content)
|
||||
if data and data.notes then
|
||||
return data.notes
|
||||
end
|
||||
end
|
||||
return {}
|
||||
end
|
||||
|
||||
-- Save notes
|
||||
local function save_notes(notes)
|
||||
local data = { notes = notes }
|
||||
local json = owlry.json.encode(data)
|
||||
owlry.fs.write(notes_file, json)
|
||||
end
|
||||
|
||||
-- Register a dynamic provider
|
||||
-- Dynamic providers have a 'query' function that receives user input
|
||||
owlry.provider.register({
|
||||
name = "quick-note",
|
||||
display_name = "Quick Notes",
|
||||
default_icon = "text-x-generic",
|
||||
|
||||
-- Prefix to activate this provider
|
||||
prefix = ":note",
|
||||
|
||||
-- Dynamic query function - called as user types
|
||||
query = function(q)
|
||||
local items = {}
|
||||
local query = q or ""
|
||||
|
||||
-- If query is not empty, offer to save it as a note
|
||||
if #query > 0 then
|
||||
table.insert(items, {
|
||||
id = "save-note",
|
||||
name = "Save: " .. query,
|
||||
description = "Save this as a new note",
|
||||
icon = "document-save",
|
||||
-- Special command format - would be handled by plugin action
|
||||
command = "PLUGIN:quick-note:save:" .. query,
|
||||
tags = { "save", "new" }
|
||||
})
|
||||
end
|
||||
|
||||
-- Load and display existing notes
|
||||
local notes = load_notes()
|
||||
for i, note in ipairs(notes) do
|
||||
-- Filter notes by query
|
||||
if #query == 0 or string.find(string.lower(note.text), string.lower(query)) then
|
||||
table.insert(items, {
|
||||
id = "note-" .. i,
|
||||
name = note.text,
|
||||
description = "Saved on " .. (note.date or "unknown"),
|
||||
icon = "text-x-generic",
|
||||
-- Copy to clipboard command
|
||||
command = "echo -n '" .. note.text:gsub("'", "'\\''") .. "' | wl-copy",
|
||||
tags = { "note", "saved" }
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- If no notes exist, show a hint
|
||||
if #items == 0 then
|
||||
table.insert(items, {
|
||||
id = "hint",
|
||||
name = "Type something to save a note",
|
||||
description = "Notes are saved to " .. notes_file,
|
||||
icon = "dialog-information",
|
||||
command = "",
|
||||
tags = { "hint" }
|
||||
})
|
||||
end
|
||||
|
||||
return items
|
||||
end
|
||||
})
|
||||
|
||||
owlry.log.info("Quick Note plugin loaded!")
|
||||
22
examples/plugins/quick-note/plugin.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
# Quick Note Plugin for Owlry
|
||||
#
|
||||
# This example demonstrates:
|
||||
# - Dynamic (query-based) provider
|
||||
# - Using owlry.fs for file operations
|
||||
# - Using owlry.path for path helpers
|
||||
|
||||
[plugin]
|
||||
id = "quick-note"
|
||||
name = "Quick Note"
|
||||
version = "1.0.0"
|
||||
description = "Save quick notes to a file"
|
||||
author = "Owlry Team"
|
||||
license = "GPL-3.0-or-later"
|
||||
owlry_version = ">=0.3.0"
|
||||
|
||||
[provides]
|
||||
providers = ["quick-note"]
|
||||
|
||||
[permissions]
|
||||
# Allow writing to filesystem
|
||||
filesystem = ["~/.local/share/owlry/notes"]
|
||||
338
justfile
@@ -4,48 +4,185 @@
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Build debug
|
||||
# Build debug (all workspace members)
|
||||
build:
|
||||
cargo build
|
||||
cargo build --workspace
|
||||
|
||||
# Build UI binary only
|
||||
build-ui:
|
||||
cargo build -p owlry
|
||||
|
||||
# Build core daemon only
|
||||
build-daemon:
|
||||
cargo build -p owlry-core
|
||||
|
||||
# Build core daemon release
|
||||
release-daemon:
|
||||
cargo build -p owlry-core --release
|
||||
|
||||
# Run core daemon
|
||||
run-daemon *ARGS:
|
||||
cargo run -p owlry-core -- {{ARGS}}
|
||||
|
||||
# Build release
|
||||
release:
|
||||
cargo build --release
|
||||
cargo build --workspace --release
|
||||
|
||||
# Run in debug mode
|
||||
run *ARGS:
|
||||
cargo run -- {{ARGS}}
|
||||
cargo run -p owlry -- {{ARGS}}
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
cargo test
|
||||
cargo test --workspace
|
||||
|
||||
# Check code
|
||||
check:
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
cargo fmt
|
||||
cargo fmt --all
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
# Install locally (core + runtimes)
|
||||
install-local:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "Building release..."
|
||||
# Build UI without embedded Lua (smaller binary)
|
||||
cargo build -p owlry --release --no-default-features
|
||||
# Build core daemon
|
||||
cargo build -p owlry-core --release
|
||||
# Build runtimes
|
||||
cargo build -p owlry-lua -p owlry-rune --release
|
||||
|
||||
echo "Creating directories..."
|
||||
sudo mkdir -p /usr/lib/owlry/plugins
|
||||
sudo mkdir -p /usr/lib/owlry/runtimes
|
||||
|
||||
echo "Installing binaries..."
|
||||
sudo install -Dm755 target/release/owlry /usr/bin/owlry
|
||||
sudo install -Dm755 target/release/owlry-core /usr/bin/owlry-core
|
||||
|
||||
echo "Installing runtimes..."
|
||||
if [ -f "target/release/libowlry_lua.so" ]; then
|
||||
sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so
|
||||
echo " → liblua.so"
|
||||
fi
|
||||
if [ -f "target/release/libowlry_rune.so" ]; then
|
||||
sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so
|
||||
echo " → librune.so"
|
||||
fi
|
||||
|
||||
echo "Installing systemd service files..."
|
||||
if [ -f "systemd/owlry-core.service" ]; then
|
||||
sudo install -Dm644 systemd/owlry-core.service /usr/lib/systemd/user/owlry-core.service
|
||||
echo " → owlry-core.service"
|
||||
fi
|
||||
if [ -f "systemd/owlry-core.socket" ]; then
|
||||
sudo install -Dm644 systemd/owlry-core.socket /usr/lib/systemd/user/owlry-core.socket
|
||||
echo " → owlry-core.socket"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Installation complete!"
|
||||
echo " - /usr/bin/owlry (UI)"
|
||||
echo " - /usr/bin/owlry-core (daemon)"
|
||||
echo " - $(ls /usr/lib/owlry/runtimes/*.so 2>/dev/null | wc -l) runtimes"
|
||||
echo " - systemd: owlry-core.service, owlry-core.socket"
|
||||
echo ""
|
||||
echo "To start the daemon:"
|
||||
echo " systemctl --user enable --now owlry-core.service"
|
||||
echo " OR add 'exec-once = owlry-core' to your compositor config"
|
||||
echo ""
|
||||
echo "Note: Install plugins separately from the owlry-plugins repo."
|
||||
|
||||
# === Release Management ===
|
||||
|
||||
# AUR package directory
|
||||
aur_dir := "/home/cnachtigall/data/git/aur/owlry"
|
||||
# AUR package directories (relative to project root)
|
||||
aur_core_dir := "aur/owlry"
|
||||
|
||||
# Get current version from Cargo.toml
|
||||
version := `grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'`
|
||||
# Get current version from core crate
|
||||
version := `grep '^version' crates/owlry/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'`
|
||||
|
||||
# Show current version
|
||||
show-version:
|
||||
@echo "Current version: {{version}}"
|
||||
|
||||
# Bump version (usage: just bump 0.2.0)
|
||||
# Show all crate versions
|
||||
show-versions:
|
||||
#!/usr/bin/env bash
|
||||
echo "=== Crate Versions ==="
|
||||
for toml in Cargo.toml crates/*/Cargo.toml; do
|
||||
name=$(grep '^name' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
ver=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
printf " %-30s %s\n" "$name" "$ver"
|
||||
done
|
||||
|
||||
# Get version of a specific crate
|
||||
crate-version crate:
|
||||
@grep '^version' crates/{{crate}}/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'
|
||||
|
||||
# Bump a specific crate version (usage: just bump-crate owlry-core 0.2.0)
|
||||
bump-crate crate new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
toml="crates/{{crate}}/Cargo.toml"
|
||||
if [ ! -f "$toml" ]; then
|
||||
echo "Error: $toml not found"
|
||||
exit 1
|
||||
fi
|
||||
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
if [ "$old" = "{{new_version}}" ]; then
|
||||
echo "{{crate}} is already at {{new_version}}, skipping"
|
||||
exit 0
|
||||
fi
|
||||
echo "Bumping {{crate}} from $old to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
|
||||
cargo check -p {{crate}}
|
||||
git add "$toml" Cargo.lock
|
||||
git commit -m "chore({{crate}}): bump version to {{new_version}}"
|
||||
echo "{{crate}} bumped to {{new_version}}"
|
||||
|
||||
# Bump meta-packages (no crate, just AUR version)
|
||||
bump-meta new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
|
||||
file="aur/$pkg/PKGBUILD"
|
||||
old=$(grep '^pkgver=' "$file" | sed 's/pkgver=//')
|
||||
if [ "$old" != "{{new_version}}" ]; then
|
||||
echo "Bumping $pkg from $old to {{new_version}}"
|
||||
sed -i 's/^pkgver=.*/pkgver={{new_version}}/' "$file"
|
||||
(cd "aur/$pkg" && makepkg --printsrcinfo > .SRCINFO)
|
||||
fi
|
||||
done
|
||||
echo "Meta-packages bumped to {{new_version}}"
|
||||
|
||||
# Bump all crates (core UI + daemon + plugin-api + runtimes) to same version
|
||||
bump-all new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for toml in crates/*/Cargo.toml; do
|
||||
crate=$(basename $(dirname "$toml"))
|
||||
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
if [ "$old" != "{{new_version}}" ]; then
|
||||
echo "Bumping $crate from $old to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
|
||||
fi
|
||||
done
|
||||
cargo check --workspace
|
||||
git add crates/*/Cargo.toml Cargo.lock
|
||||
git commit -m "chore: bump all crates to {{new_version}}"
|
||||
echo "All crates bumped to {{new_version}}"
|
||||
|
||||
# Bump core version (usage: just bump 0.2.0)
|
||||
bump new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -53,10 +190,10 @@ bump new_version:
|
||||
echo "Version is already {{new_version}}, skipping bump"
|
||||
exit 0
|
||||
fi
|
||||
echo "Bumping version from {{version}} to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' Cargo.toml
|
||||
cargo check
|
||||
git add Cargo.toml Cargo.lock
|
||||
echo "Bumping core version from {{version}} to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' crates/owlry/Cargo.toml
|
||||
cargo check -p owlry
|
||||
git add crates/owlry/Cargo.toml Cargo.lock
|
||||
git commit -m "chore: bump version to {{new_version}}"
|
||||
echo "Version bumped to {{new_version}}"
|
||||
|
||||
@@ -73,11 +210,11 @@ tag:
|
||||
git push origin "v{{version}}"
|
||||
echo "Tag v{{version}} pushed"
|
||||
|
||||
# Update AUR package
|
||||
# Update AUR package (core UI)
|
||||
aur-update:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "{{aur_dir}}"
|
||||
cd "{{aur_core_dir}}"
|
||||
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
|
||||
@@ -101,11 +238,11 @@ aur-update:
|
||||
echo "AUR package updated. Review changes above."
|
||||
echo "Run 'just aur-publish' to commit and push."
|
||||
|
||||
# Publish AUR package
|
||||
# Publish AUR package (core UI)
|
||||
aur-publish:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "{{aur_dir}}"
|
||||
cd "{{aur_core_dir}}"
|
||||
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to v{{version}}"
|
||||
@@ -113,11 +250,11 @@ aur-publish:
|
||||
|
||||
echo "AUR package v{{version}} published!"
|
||||
|
||||
# Test AUR package build locally
|
||||
# Test AUR package build locally (core UI)
|
||||
aur-test:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "{{aur_dir}}"
|
||||
cd "{{aur_core_dir}}"
|
||||
|
||||
echo "Testing PKGBUILD..."
|
||||
makepkg -sf
|
||||
@@ -126,8 +263,157 @@ aur-test:
|
||||
echo "Package built successfully!"
|
||||
ls -lh *.pkg.tar.zst
|
||||
|
||||
# Full release workflow (bump + tag + aur)
|
||||
release-full new_version: (bump new_version)
|
||||
# === AUR Package Management (individual packages) ===
|
||||
|
||||
# Update a specific AUR package (usage: just aur-update-pkg owlry-core)
|
||||
aur-update-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
aur_dir="aur/{{pkg}}"
|
||||
|
||||
if [ ! -d "$aur_dir" ]; then
|
||||
echo "Error: $aur_dir not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
|
||||
# Determine crate version
|
||||
case "{{pkg}}" in
|
||||
owlry-meta-essentials|owlry-meta-tools|owlry-meta-widgets|owlry-meta-full)
|
||||
# Meta-packages use static versioning (1.0.0), only bump pkgrel for dep changes
|
||||
crate_ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
|
||||
;;
|
||||
*)
|
||||
# Get version from crate
|
||||
crate_dir="crates/{{pkg}}"
|
||||
if [ ! -d "$crate_dir" ]; then
|
||||
echo "Error: $crate_dir not found"
|
||||
exit 1
|
||||
fi
|
||||
crate_ver=$(grep '^version' "$crate_dir/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
;;
|
||||
esac
|
||||
|
||||
cd "$aur_dir"
|
||||
|
||||
echo "Updating {{pkg}} PKGBUILD:"
|
||||
echo " pkgver=$crate_ver"
|
||||
|
||||
sed -i "s/^pkgver=.*/pkgver=$crate_ver/" PKGBUILD
|
||||
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
|
||||
|
||||
# Update checksums
|
||||
if grep -q "^source=" PKGBUILD; then
|
||||
echo "Updating checksums..."
|
||||
b2sum=$(curl -sL "$url/archive/v$crate_ver.tar.gz" | b2sum | cut -d' ' -f1)
|
||||
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
|
||||
fi
|
||||
|
||||
# Generate .SRCINFO
|
||||
echo "Generating .SRCINFO..."
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
git diff --stat
|
||||
echo ""
|
||||
echo "{{pkg}} updated. Run 'just aur-publish-pkg {{pkg}}' to publish."
|
||||
|
||||
# Publish a specific AUR package
|
||||
aur-publish-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
aur_dir="aur/{{pkg}}"
|
||||
|
||||
if [ ! -d "$aur_dir" ]; then
|
||||
echo "Error: $aur_dir not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$aur_dir"
|
||||
ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//')
|
||||
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to v$ver"
|
||||
git push origin master
|
||||
|
||||
echo "{{pkg}} v$ver published!"
|
||||
|
||||
# Test a specific AUR package build locally
|
||||
aur-test-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "aur/{{pkg}}"
|
||||
|
||||
echo "Testing {{pkg}} PKGBUILD..."
|
||||
makepkg -sf
|
||||
|
||||
echo ""
|
||||
echo "Package built successfully!"
|
||||
ls -lh *.pkg.tar.zst
|
||||
|
||||
# List all AUR packages with their versions
|
||||
aur-status:
|
||||
#!/usr/bin/env bash
|
||||
echo "=== AUR Package Status ==="
|
||||
for dir in aur/*/; do
|
||||
pkg=$(basename "$dir")
|
||||
if [ -f "$dir/PKGBUILD" ]; then
|
||||
ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//')
|
||||
if [ -d "$dir/.git" ]; then
|
||||
status="✓"
|
||||
else
|
||||
status="✗ (not initialized)"
|
||||
fi
|
||||
printf " %s %-30s %s\n" "$status" "$pkg" "$ver"
|
||||
fi
|
||||
done
|
||||
|
||||
# Update ALL AUR packages (core + daemon + runtimes + meta)
|
||||
aur-update-all:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "=== Updating core UI ==="
|
||||
just aur-update
|
||||
echo ""
|
||||
echo "=== Updating core daemon ==="
|
||||
just aur-update-pkg owlry-core
|
||||
echo ""
|
||||
echo "=== Updating runtimes ==="
|
||||
just aur-update-pkg owlry-lua
|
||||
just aur-update-pkg owlry-rune
|
||||
echo ""
|
||||
echo "=== Updating meta-packages ==="
|
||||
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
|
||||
echo "--- $pkg ---"
|
||||
(cd "aur/$pkg" && makepkg --printsrcinfo > .SRCINFO)
|
||||
done
|
||||
echo ""
|
||||
echo "All AUR packages updated. Run 'just aur-publish-all' to publish."
|
||||
|
||||
# Publish ALL AUR packages
|
||||
aur-publish-all:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "=== Publishing core UI ==="
|
||||
just aur-publish
|
||||
echo ""
|
||||
echo "=== Publishing core daemon ==="
|
||||
just aur-publish-pkg owlry-core
|
||||
echo ""
|
||||
echo "=== Publishing runtimes ==="
|
||||
just aur-publish-pkg owlry-lua
|
||||
just aur-publish-pkg owlry-rune
|
||||
echo ""
|
||||
echo "=== Publishing meta-packages ==="
|
||||
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
|
||||
echo "--- $pkg ---"
|
||||
just aur-publish-pkg "$pkg"
|
||||
done
|
||||
echo ""
|
||||
echo "All AUR packages published!"
|
||||
|
||||
# Full release workflow for core only (bump + tag + aur)
|
||||
release-core new_version: (bump new_version)
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
@@ -145,5 +431,5 @@ release-full new_version: (bump new_version)
|
||||
just aur-update
|
||||
|
||||
echo ""
|
||||
echo "Release v{{new_version}} prepared!"
|
||||
echo "Core release v{{new_version}} prepared!"
|
||||
echo "Review AUR changes, then run 'just aur-publish'"
|
||||
|
||||
145
src/app.rs
@@ -1,145 +0,0 @@
|
||||
use crate::cli::CliArgs;
|
||||
use crate::config::Config;
|
||||
use crate::data::FrecencyStore;
|
||||
use crate::filter::ProviderFilter;
|
||||
use crate::paths;
|
||||
use crate::providers::ProviderManager;
|
||||
use crate::theme;
|
||||
use crate::ui::MainWindow;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{gio, Application, CssProvider};
|
||||
use gtk4_layer_shell::{Edge, Layer, LayerShell};
|
||||
use log::debug;
|
||||
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, --providers, etc.
|
||||
self.app.run_with_args(&[] as &[&str]).into()
|
||||
}
|
||||
|
||||
fn on_activate(app: &Application, args: &CliArgs) {
|
||||
debug!("Activating Owlry");
|
||||
|
||||
let config = Rc::new(RefCell::new(Config::load_or_default()));
|
||||
let search_engine = config.borrow().providers.search_engine.clone();
|
||||
let terminal = config.borrow().general.terminal_command.clone();
|
||||
let providers = Rc::new(RefCell::new(ProviderManager::with_config(&search_engine, &terminal)));
|
||||
let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default()));
|
||||
|
||||
// Create filter from CLI args and config
|
||||
let filter = ProviderFilter::new(
|
||||
args.mode,
|
||||
args.providers.clone(),
|
||||
&config.borrow().providers,
|
||||
);
|
||||
let filter = Rc::new(RefCell::new(filter));
|
||||
|
||||
let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.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);
|
||||
|
||||
// Load CSS styling with config for theming
|
||||
Self::load_css(&config.borrow());
|
||||
|
||||
window.present();
|
||||
}
|
||||
|
||||
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() {
|
||||
if 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");
|
||||
}
|
||||
}
|
||||
29
src/cli.rs
@@ -1,29 +0,0 @@
|
||||
use clap::Parser;
|
||||
|
||||
use crate::providers::ProviderType;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(
|
||||
name = "owlry",
|
||||
about = "An owl-themed application launcher for Wayland",
|
||||
version
|
||||
)]
|
||||
pub struct CliArgs {
|
||||
/// Start in single-provider mode (app, cmd, uuctl)
|
||||
#[arg(long, short = 'm', value_parser = parse_provider)]
|
||||
pub mode: Option<ProviderType>,
|
||||
|
||||
/// Comma-separated list of enabled providers (app,cmd,uuctl)
|
||||
#[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider)]
|
||||
pub providers: Option<Vec<ProviderType>>,
|
||||
}
|
||||
|
||||
fn parse_provider(s: &str) -> Result<ProviderType, String> {
|
||||
s.parse()
|
||||
}
|
||||
|
||||
impl CliArgs {
|
||||
pub fn parse_args() -> Self {
|
||||
Self::parse()
|
||||
}
|
||||
}
|
||||