Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e2d60466b | |||
| 8c1cf88474 | |||
| ecaaae39e3 | |||
| 96e9b09a31 | |||
| e053f7d5d5 | |||
| b1f11c076b | |||
| 2d7fb33f30 | |||
| 3b1ff03ff8 | |||
| e1fb63d6c4 | |||
| 33e2f9cb5e | |||
| 6b21602a07 | |||
| 4516865c21 | |||
| 4fbc7fc4c9 | |||
| 536c5c5012 | |||
| abd4df6939 | |||
| 43f7228be2 | |||
| a1b47b8ba0 | |||
| ccce9b8572 | |||
| ffb4c2f127 | |||
| cde599db03 | |||
| cf8e33c976 | |||
| 85a18fc271 | |||
| 67dad9c269 | |||
| 3e8be3a4c5 | |||
| e83feb6ce4 | |||
| bead9e4b4a | |||
| 10722bc016 | |||
| 384dd016a0 | |||
| a582f0181c | |||
| 97c6f655ca | |||
| 8670909480 | |||
| cb12ffbeca | |||
| 892333dbca | |||
| 6d3d69d103 | |||
| bec8fc332b | |||
| a750ef8559 | |||
| 7cbebd324f | |||
| 5519381d8c | |||
| 38025279f9 | |||
| 405b598b9b | |||
| d086995399 | |||
| 7ca8a1f443 | |||
| 2a2a22f72c | |||
| 0eccdc5883 | |||
| 3f7a8950eb | |||
| b38bf082e1 | |||
| 617dbbce3e | |||
| 4ff054afe0 |
14
.gitignore
vendored
@@ -1 +1,15 @@
|
||||
/target
|
||||
CLAUDE.md
|
||||
media.md
|
||||
|
||||
# AUR packages (each is its own git repo for aur.archlinux.org)
|
||||
aur/*/.git/
|
||||
aur/*/pkg/
|
||||
aur/*/src/
|
||||
aur/*/*.tar.zst
|
||||
aur/*/*.tar.gz
|
||||
aur/*/*.tar.xz
|
||||
aur/*/*.pkg.tar.*
|
||||
# Keep PKGBUILD and .SRCINFO tracked
|
||||
.SRCINFO
|
||||
aur/
|
||||
|
||||
32
CLAUDE.md
@@ -1,32 +0,0 @@
|
||||
# Owlry - Claude Code Instructions
|
||||
|
||||
## Release Workflow
|
||||
|
||||
Always use `just` for releases and AUR deployment:
|
||||
|
||||
```bash
|
||||
# Bump version (updates Cargo.toml + Cargo.lock, commits)
|
||||
just bump 0.x.y
|
||||
|
||||
# Push and create tag
|
||||
git push && just tag
|
||||
|
||||
# Update AUR package
|
||||
just aur-update
|
||||
|
||||
# Review changes, then publish
|
||||
just aur-publish
|
||||
```
|
||||
|
||||
Do NOT manually edit Cargo.toml for version bumps - use `just bump`.
|
||||
|
||||
## Available just recipes
|
||||
|
||||
- `just build` / `just release` - Build debug/release
|
||||
- `just check` - Run cargo check + clippy
|
||||
- `just test` - Run tests
|
||||
- `just bump <version>` - Bump version
|
||||
- `just tag` - Create and push git tag
|
||||
- `just aur-update` - Update PKGBUILD checksums
|
||||
- `just aur-publish` - Commit and push to AUR
|
||||
- `just aur-test` - Test PKGBUILD locally
|
||||
3253
Cargo.lock
generated
85
Cargo.toml
@@ -1,67 +1,46 @@
|
||||
[package]
|
||||
name = "owlry"
|
||||
version = "0.3.2"
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/owlry",
|
||||
"crates/owlry-plugin-api",
|
||||
"crates/owlry-plugin-calculator",
|
||||
"crates/owlry-plugin-system",
|
||||
"crates/owlry-plugin-ssh",
|
||||
"crates/owlry-plugin-clipboard",
|
||||
"crates/owlry-plugin-emoji",
|
||||
"crates/owlry-plugin-scripts",
|
||||
"crates/owlry-plugin-bookmarks",
|
||||
"crates/owlry-plugin-websearch",
|
||||
"crates/owlry-plugin-filesearch",
|
||||
"crates/owlry-plugin-weather",
|
||||
"crates/owlry-plugin-media",
|
||||
"crates/owlry-plugin-pomodoro",
|
||||
"crates/owlry-plugin-systemd",
|
||||
"crates/owlry-lua",
|
||||
"crates/owlry-rune",
|
||||
]
|
||||
|
||||
# Shared workspace settings
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
description = "A lightweight, owl-themed application launcher for Wayland"
|
||||
authors = ["Your Name <you@example.com>"]
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://somegit.dev/Owlibou/owlry"
|
||||
keywords = ["launcher", "wayland", "gtk4", "linux"]
|
||||
categories = ["gui"]
|
||||
|
||||
[dependencies]
|
||||
# GTK4 for the UI
|
||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
||||
|
||||
# Layer shell support for Wayland overlay behavior
|
||||
gtk4-layer-shell = "0.4"
|
||||
|
||||
# Async runtime for non-blocking operations
|
||||
tokio = { version = "1", features = ["rt", "sync", "process", "fs"] }
|
||||
|
||||
# Fuzzy matching for search
|
||||
fuzzy-matcher = "0.3"
|
||||
|
||||
# XDG desktop entry parsing
|
||||
freedesktop-desktop-entry = "0.7"
|
||||
|
||||
# Directory utilities
|
||||
dirs = "5"
|
||||
|
||||
# Low-level syscalls for stdin detection
|
||||
libc = "0.2"
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
# Configuration
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
|
||||
# CLI argument parsing
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# Math expression evaluation for calculator
|
||||
meval = "0.2"
|
||||
|
||||
# JSON serialization for data persistence
|
||||
serde_json = "1"
|
||||
|
||||
# Date/time for frecency calculations
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Release profile (shared across all crates)
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
opt-level = "z" # Optimize for size
|
||||
opt-level = "z"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
debug = true
|
||||
|
||||
# For installing a testable build: cargo install --path crates/owlry --profile dev-install --features dev-logging
|
||||
[profile.dev-install]
|
||||
inherits = "release"
|
||||
strip = false
|
||||
debug = 1
|
||||
|
||||
367
README.md
@@ -10,32 +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
|
||||
- **Fuzzy search** - Fast, typo-tolerant matching across all providers
|
||||
- **Filter tabs & prefixes** - Scope searches with UI tabs or `:app`, `:cmd`, `:sys` prefixes
|
||||
- **Calculator** - Quick math with `= 5+3` or `calc sin(pi/2)`
|
||||
- **Web search** - Search the web with `? query` or `web query`
|
||||
- **File search** - Find files with `/ filename` or `find config` (requires `fd` or `locate`)
|
||||
- **Frecency ranking** - Frequently/recently used items rank higher
|
||||
- **GTK4 theming** - Respects system theme by default, with optional custom themes
|
||||
- **Wayland native** - Uses Layer Shell for proper overlay behavior
|
||||
- **Modular plugin architecture** — Install only what you need
|
||||
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
|
||||
- **13 native plugins** — Calculator, clipboard, emoji, weather, media, and more
|
||||
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
|
||||
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc.
|
||||
- **Frecency ranking** — Frequently/recently used items rank higher
|
||||
- **GTK4 theming** — System theme by default, with 9 built-in themes
|
||||
- **Wayland native** — Uses Layer Shell for proper overlay behavior
|
||||
- **Extensible** — Create custom plugins in Lua or Rune
|
||||
|
||||
## Installation
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
# Minimal core (applications + commands only)
|
||||
yay -S owlry
|
||||
|
||||
# Using paru
|
||||
paru -S owlry
|
||||
# Add individual plugins
|
||||
yay -S owlry-plugin-calculator owlry-plugin-weather
|
||||
|
||||
# Or install bundles:
|
||||
yay -S owlry-essentials # calculator, system, ssh, scripts, bookmarks
|
||||
yay -S owlry-widgets # weather, media, pomodoro
|
||||
yay -S owlry-tools # clipboard, emoji, websearch, filesearch, systemd
|
||||
yay -S owlry-full # everything
|
||||
|
||||
# For custom Lua/Rune plugins
|
||||
yay -S owlry-lua # Lua 5.4 runtime
|
||||
yay -S owlry-rune # Rune runtime
|
||||
```
|
||||
|
||||
### Build from source
|
||||
### Available Packages
|
||||
|
||||
#### Dependencies
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `owlry` | Core binary with applications and commands |
|
||||
| `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` | Chrome, Brave, Edge bookmarks |
|
||||
| `owlry-plugin-websearch` | Web search (`? query`) |
|
||||
| `owlry-plugin-filesearch` | File search (`/ filename`) |
|
||||
| `owlry-plugin-systemd` | User services with actions |
|
||||
| `owlry-plugin-weather` | Weather widget |
|
||||
| `owlry-plugin-media` | MPRIS media controls |
|
||||
| `owlry-plugin-pomodoro` | Pomodoro timer widget |
|
||||
|
||||
### Build from Source
|
||||
|
||||
**Dependencies:**
|
||||
```bash
|
||||
# Arch Linux
|
||||
sudo pacman -S gtk4 gtk4-layer-shell
|
||||
@@ -47,42 +75,34 @@ sudo apt install libgtk-4-dev libgtk4-layer-shell-dev
|
||||
sudo dnf install gtk4-devel gtk4-layer-shell-devel
|
||||
```
|
||||
|
||||
#### Optional dependencies
|
||||
|
||||
```bash
|
||||
# For clipboard history
|
||||
sudo pacman -S cliphist wl-clipboard
|
||||
|
||||
# For file search
|
||||
sudo pacman -S fd # or: mlocate
|
||||
```
|
||||
|
||||
#### Build
|
||||
|
||||
Requires Rust 1.90 or later.
|
||||
|
||||
**Build (requires Rust 1.90+):**
|
||||
```bash
|
||||
git clone https://somegit.dev/Owlibou/owlry.git
|
||||
cd owlry
|
||||
cargo build --release
|
||||
|
||||
# Build core only
|
||||
cargo build --release -p owlry
|
||||
|
||||
# Build specific plugin
|
||||
cargo build --release -p owlry-plugin-calculator
|
||||
|
||||
# Build everything
|
||||
cargo build --release --workspace
|
||||
```
|
||||
|
||||
The binary will be at `target/release/owlry`.
|
||||
**Install plugins manually:**
|
||||
```bash
|
||||
sudo mkdir -p /usr/lib/owlry/plugins
|
||||
sudo cp target/release/libowlry_plugin_*.so /usr/lib/owlry/plugins/
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Launch with default settings (GTK theme, all providers)
|
||||
owlry
|
||||
|
||||
# Launch with only applications
|
||||
owlry --mode app
|
||||
|
||||
# Launch with specific providers
|
||||
owlry --providers app,cmd
|
||||
|
||||
# Show help
|
||||
owlry --help
|
||||
owlry # Launch with defaults
|
||||
owlry --mode app # Applications only
|
||||
owlry --providers app,cmd # Specific providers
|
||||
owlry --help # Show all options
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
@@ -92,216 +112,122 @@ owlry --help
|
||||
| `Enter` | Launch selected item |
|
||||
| `Escape` | Close launcher / exit submenu |
|
||||
| `Up` / `Down` | Navigate results |
|
||||
| `Tab` | Cycle filter modes |
|
||||
| `Shift+Tab` | Cycle filter modes (reverse) |
|
||||
| `Ctrl+1` | Toggle Applications filter |
|
||||
| `Ctrl+2` | Toggle Commands filter |
|
||||
| `Ctrl+3` | Toggle systemd filter |
|
||||
| `Tab` | Cycle filter tabs |
|
||||
| `Shift+Tab` | Cycle filter tabs (reverse) |
|
||||
| `Ctrl+1..9` | Toggle tab by position |
|
||||
|
||||
### Search Prefixes
|
||||
|
||||
Filter results by provider using prefixes:
|
||||
|
||||
| Prefix | Provider | Example |
|
||||
|--------|----------|---------|
|
||||
| `:app` | Applications | `:app firefox` |
|
||||
| `:cmd` | PATH commands | `:cmd git` |
|
||||
| `:sys` | System actions | `:sys shutdown` |
|
||||
| `:ssh` | SSH hosts | `:ssh server` |
|
||||
| `:clip` | Clipboard history | `:clip password` |
|
||||
| `:bm` | Browser bookmarks | `:bm github` |
|
||||
| `:emoji` | Emoji picker | `:emoji heart` |
|
||||
| `:script` | Custom scripts | `:script backup` |
|
||||
| `:file` | File search | `:file config.toml` |
|
||||
| `:calc` | Calculator | `:calc 5+3` |
|
||||
| `:clip` | Clipboard | `:clip password` |
|
||||
| `:bm` | Bookmarks | `:bm github` |
|
||||
| `:emoji` | Emoji | `:emoji heart` |
|
||||
| `:script` | Scripts | `:script backup` |
|
||||
| `:file` | Files | `:file config` |
|
||||
| `:calc` | Calculator | `:calc sqrt(16)` |
|
||||
| `:web` | Web search | `:web rust docs` |
|
||||
| `:uuctl` | systemd services | `:uuctl docker` |
|
||||
| `:uuctl` | systemd | `:uuctl docker` |
|
||||
| `:tag:X` | Filter by tag | `:tag:development` |
|
||||
|
||||
### Trigger Prefixes
|
||||
|
||||
Some providers can be triggered directly without filter mode:
|
||||
|
||||
| Trigger | Provider | Example |
|
||||
|---------|----------|---------|
|
||||
| `=` | Calculator | `= 5+3` or `=5*2` |
|
||||
| `=` | Calculator | `= 5+3` |
|
||||
| `calc ` | Calculator | `calc sqrt(16)` |
|
||||
| `?` | Web search | `? rust programming` |
|
||||
| `web ` | Web search | `web linux tips` |
|
||||
| `search ` | Web search | `search owlry` |
|
||||
| `/` | File search | `/ .bashrc` |
|
||||
| `find ` | File search | `find config` |
|
||||
|
||||
## Providers
|
||||
|
||||
### Applications
|
||||
Searches `.desktop` files from standard XDG directories.
|
||||
|
||||
### Commands
|
||||
Searches executable files in `$PATH`.
|
||||
|
||||
### System
|
||||
Quick access to system actions:
|
||||
- Shutdown, Reboot, Suspend, Hibernate
|
||||
- Lock Screen, Log Out
|
||||
- **Reboot into BIOS** - Restart directly into UEFI/BIOS setup
|
||||
|
||||
### SSH
|
||||
Parses `~/.ssh/config` and offers quick connections to configured hosts. Opens in your configured terminal.
|
||||
|
||||
### Clipboard (requires cliphist)
|
||||
Search and paste from clipboard history. Requires `cliphist` and `wl-clipboard`:
|
||||
```bash
|
||||
sudo pacman -S cliphist wl-clipboard
|
||||
```
|
||||
|
||||
### Bookmarks
|
||||
Reads bookmarks from Chromium-based browsers:
|
||||
- Chrome, Chromium, Brave, Edge, Vivaldi
|
||||
|
||||
### Emoji
|
||||
Search 300+ emojis by name or keywords. Selected emoji is copied to clipboard via `wl-copy`.
|
||||
|
||||
### Scripts
|
||||
Runs executable scripts from `~/.config/owlry/scripts/`. Create the directory and add your scripts:
|
||||
```bash
|
||||
mkdir -p ~/.config/owlry/scripts
|
||||
echo '#!/bin/bash
|
||||
# My backup script
|
||||
rsync -av ~/Documents /backup/' > ~/.config/owlry/scripts/backup
|
||||
chmod +x ~/.config/owlry/scripts/backup
|
||||
```
|
||||
|
||||
### Calculator
|
||||
Evaluate math expressions with `= expr` or `calc expr`:
|
||||
- Basic: `= 5+3`, `= 10/3`
|
||||
- Functions: `= sqrt(16)`, `= sin(pi/2)`
|
||||
- Constants: `= pi`, `= e`
|
||||
|
||||
### Web Search
|
||||
Search the web with `? query` or `web query`. Configurable search engine:
|
||||
- Google, DuckDuckGo, Bing, Brave, Ecosia, Startpage, SearXNG
|
||||
- Or custom URL with `{query}` placeholder
|
||||
|
||||
### File Search (requires fd or locate)
|
||||
Search files with `/ pattern` or `find pattern`:
|
||||
```bash
|
||||
sudo pacman -S fd # recommended, faster
|
||||
# or
|
||||
sudo pacman -S mlocate && sudo updatedb
|
||||
```
|
||||
|
||||
### systemd User Services
|
||||
Lists and controls user-level systemd services. Select a service to access actions:
|
||||
- Start / Stop / Restart / Reload
|
||||
- Kill (force stop)
|
||||
- Status (opens in terminal)
|
||||
- Journal (live logs in terminal)
|
||||
- Enable / Disable (autostart)
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration file: `~/.config/owlry/config.toml`
|
||||
Owlry follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/owlry
|
||||
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
|
||||
```
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `~/.config/owlry/config.toml` | Main configuration |
|
||||
| `~/.config/owlry/themes/*.css` | Custom themes |
|
||||
| `~/.config/owlry/style.css` | CSS overrides |
|
||||
| `~/.config/owlry/plugins/` | User plugins (Lua/Rune) |
|
||||
| `~/.local/share/owlry/scripts/` | User scripts |
|
||||
| `~/.local/share/owlry/frecency.json` | Usage history |
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```toml
|
||||
[general]
|
||||
show_icons = true
|
||||
max_results = 10
|
||||
# terminal_command = "kitty" # Auto-detected if not set
|
||||
# launch_wrapper = "uwsm app --" # Auto-detected for uwsm/hyprland
|
||||
tabs = ["app", "cmd", "uuctl"]
|
||||
# terminal_command = "kitty" # Auto-detected
|
||||
# launch_wrapper = "uwsm app --" # Auto-detected
|
||||
|
||||
[appearance]
|
||||
width = 600
|
||||
height = 400
|
||||
width = 700
|
||||
height = 500
|
||||
font_size = 14
|
||||
border_radius = 12
|
||||
# theme = "owl" # Optional: "owl" or custom theme name
|
||||
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc.
|
||||
|
||||
[providers]
|
||||
applications = true
|
||||
commands = true
|
||||
uuctl = true
|
||||
calculator = true
|
||||
websearch = true
|
||||
search_engine = "duckduckgo" # google, bing, brave, ecosia, startpage, searxng
|
||||
system = true
|
||||
ssh = true
|
||||
clipboard = true
|
||||
bookmarks = true
|
||||
emoji = true
|
||||
scripts = true
|
||||
files = true
|
||||
frecency = true
|
||||
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
|
||||
[plugins]
|
||||
disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
|
||||
|
||||
# Per-plugin configuration (new in 0.4.0)
|
||||
[plugins.weather]
|
||||
provider = "wttr.in" # or: openweathermap, open-meteo
|
||||
location = "Berlin" # city name or "lat,lon"
|
||||
# api_key = "..." # Required for OpenWeatherMap
|
||||
|
||||
[plugins.pomodoro]
|
||||
work_mins = 25 # Work session duration
|
||||
break_mins = 5 # Break duration
|
||||
```
|
||||
|
||||
### Default Values
|
||||
## Plugin System
|
||||
|
||||
| Setting | Default |
|
||||
|---------|---------|
|
||||
| `show_icons` | `true` |
|
||||
| `max_results` | `10` |
|
||||
| `terminal_command` | Auto-detected ($TERMINAL -> xdg-terminal-exec -> kitty/alacritty/etc) |
|
||||
| `launch_wrapper` | Auto-detected (uwsm -> hyprctl -> none) |
|
||||
| `width` | `600` |
|
||||
| `height` | `400` |
|
||||
| `font_size` | `14` |
|
||||
| `border_radius` | `12` |
|
||||
| `theme` | None (GTK default) |
|
||||
Owlry uses a modular plugin architecture. Plugins are loaded from:
|
||||
|
||||
### Launch Wrapper
|
||||
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
|
||||
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
|
||||
|
||||
When running in uwsm-managed or Hyprland sessions, owlry auto-detects and uses the appropriate launch wrapper:
|
||||
### Disabling Plugins
|
||||
|
||||
| Session | Wrapper | Purpose |
|
||||
|---------|---------|---------|
|
||||
| uwsm | `uwsm app --` | Proper systemd scope and session management |
|
||||
| Hyprland | `hyprctl dispatch exec --` | Native Hyprland window management |
|
||||
| Other | None (direct `sh -c`) | Standard shell execution |
|
||||
Add plugin IDs to the disabled list in your config:
|
||||
|
||||
```toml
|
||||
[plugins]
|
||||
disabled = ["emoji", "pomodoro"]
|
||||
```
|
||||
|
||||
### Creating Custom Plugins
|
||||
|
||||
See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
|
||||
- Native plugin development (Rust)
|
||||
- Lua plugin development
|
||||
- Rune plugin development
|
||||
- Available APIs
|
||||
|
||||
## Theming
|
||||
|
||||
### GTK Theme (Default)
|
||||
|
||||
By default, Owlry inherits colors from your system GTK4 theme (Adwaita, Breeze, etc.).
|
||||
|
||||
### Built-in Themes
|
||||
|
||||
Owlry includes an owl-inspired dark theme:
|
||||
|
||||
```toml
|
||||
[appearance]
|
||||
theme = "owl"
|
||||
```
|
||||
|
||||
### Included Example Themes
|
||||
|
||||
Example themes are installed to `/usr/share/owlry/themes/`:
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| `owl` | Owl-inspired dark theme with amber accents |
|
||||
| `catppuccin-mocha` | Soothing pastel theme |
|
||||
| `nord` | Arctic, north-bluish palette |
|
||||
| `rose-pine` | All natural pine, faux fur and soho vibes |
|
||||
| `dracula` | Dark theme for vampires |
|
||||
| `gruvbox-dark` | Retro groove color scheme |
|
||||
| `tokyo-night` | Lights of Tokyo at night |
|
||||
| `solarized-dark` | Precision colors for machines and people |
|
||||
| `one-dark` | Atom's iconic One Dark theme |
|
||||
|
||||
To use an example theme:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/owlry/themes
|
||||
cp /usr/share/owlry/themes/catppuccin-mocha.css ~/.config/owlry/themes/
|
||||
```
|
||||
|
||||
Then set in config:
|
||||
| `owl` | Dark theme with amber accents |
|
||||
| `catppuccin-mocha` | Soothing pastel |
|
||||
| `nord` | Arctic blue palette |
|
||||
| `rose-pine` | Natural pine vibes |
|
||||
| `dracula` | Dark vampire theme |
|
||||
| `gruvbox-dark` | Retro groove |
|
||||
| `tokyo-night` | Tokyo city lights |
|
||||
| `solarized-dark` | Precision colors |
|
||||
| `one-dark` | Atom's One Dark |
|
||||
|
||||
```toml
|
||||
[appearance]
|
||||
@@ -310,7 +236,7 @@ theme = "catppuccin-mocha"
|
||||
|
||||
### Custom Theme
|
||||
|
||||
Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
|
||||
Create `~/.config/owlry/themes/mytheme.css`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
@@ -324,7 +250,7 @@ Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Variables Reference
|
||||
### CSS Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
@@ -333,22 +259,31 @@ Create a custom theme file at `~/.config/owlry/themes/mytheme.css`:
|
||||
| `--owlry-border` | Border color |
|
||||
| `--owlry-text` | Primary text |
|
||||
| `--owlry-text-secondary` | Muted text |
|
||||
| `--owlry-accent` | Accent/highlight color |
|
||||
| `--owlry-accent` | Accent color |
|
||||
| `--owlry-accent-bright` | Bright accent |
|
||||
| `--owlry-font-size` | Base font size |
|
||||
| `--owlry-border-radius` | Border radius |
|
||||
| `--owlry-badge-*` | Provider badge colors (app, cmd, sys, ssh, clip, emoji, etc.) |
|
||||
|
||||
### Custom Stylesheet
|
||||
## Architecture
|
||||
|
||||
For full control, create `~/.config/owlry/style.css` with any GTK4 CSS.
|
||||
```
|
||||
owlry (core)
|
||||
├── Applications provider (XDG .desktop files)
|
||||
├── Commands provider (PATH executables)
|
||||
├── Dmenu provider (pipe compatibility)
|
||||
└── Plugin loader
|
||||
├── /usr/lib/owlry/plugins/*.so (native plugins)
|
||||
├── /usr/lib/owlry/runtimes/ (Lua/Rune runtimes)
|
||||
└── ~/.config/owlry/plugins/ (user plugins)
|
||||
```
|
||||
|
||||
For detailed architecture information, see [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 or later - see [LICENSE](LICENSE) for details.
|
||||
GNU General Public License v3.0 — see [LICENSE](LICENSE).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [GTK4](https://gtk.org/) - UI toolkit
|
||||
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) - Wayland Layer Shell bindings
|
||||
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) - Fuzzy search algorithm
|
||||
- [GTK4](https://gtk.org/) — UI toolkit
|
||||
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) — Wayland Layer Shell
|
||||
- [abi_stable](https://crates.io/crates/abi_stable) — ABI-stable Rust plugins
|
||||
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) — Fuzzy search
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# Owlry Configuration
|
||||
# Copy to ~/.config/owlry/config.toml
|
||||
|
||||
[general]
|
||||
show_icons = true
|
||||
max_results = 10
|
||||
terminal_command = "kitty" # Auto-detected if not set
|
||||
|
||||
# Launch wrapper for app execution (auto-detected if not set)
|
||||
# Examples:
|
||||
# "uwsm app --" # For uwsm sessions
|
||||
# "hyprctl dispatch exec --" # For Hyprland
|
||||
# "" # Direct execution
|
||||
# launch_wrapper = "uwsm app --"
|
||||
|
||||
[appearance]
|
||||
width = 600
|
||||
height = 400
|
||||
font_size = 14
|
||||
border_radius = 12
|
||||
|
||||
# Theme: "owl" for built-in dark theme, or leave unset for GTK default
|
||||
# theme = "owl"
|
||||
|
||||
# Individual color overrides (CSS color values)
|
||||
# [appearance.colors]
|
||||
# background = "#1a1b26"
|
||||
# background_secondary = "#24283b"
|
||||
# border = "#414868"
|
||||
# text = "#c0caf5"
|
||||
# text_secondary = "#565f89"
|
||||
# accent = "#7aa2f7"
|
||||
# accent_bright = "#89b4fa"
|
||||
# badge_app = "#9ece6a"
|
||||
# badge_calc = "#e0af68"
|
||||
# badge_cmd = "#7aa2f7"
|
||||
# badge_dmenu = "#bb9af7"
|
||||
# badge_uuctl = "#f7768e"
|
||||
|
||||
[providers]
|
||||
applications = true
|
||||
commands = true
|
||||
uuctl = true
|
||||
|
||||
# Calculator provider (type "= 5+3" or "calc 5+3")
|
||||
calculator = true
|
||||
|
||||
# Frecency: boost frequently/recently used items in search results
|
||||
frecency = true
|
||||
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
|
||||
|
||||
# Web search provider (type "? query" or "web query")
|
||||
websearch = true
|
||||
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
|
||||
# Or custom URL with {query} placeholder, e.g. "https://search.example.com/?q={query}"
|
||||
search_engine = "duckduckgo"
|
||||
|
||||
# System commands (shutdown, reboot, lock, suspend, hibernate, logout, BIOS)
|
||||
system = true
|
||||
|
||||
# SSH connections from ~/.ssh/config
|
||||
ssh = true
|
||||
|
||||
# Clipboard history (requires cliphist)
|
||||
clipboard = true
|
||||
|
||||
# Browser bookmarks (Chrome, Chromium, Brave, Edge, Vivaldi)
|
||||
bookmarks = true
|
||||
|
||||
# Emoji picker (copies to clipboard)
|
||||
emoji = true
|
||||
|
||||
# Custom scripts from ~/.config/owlry/scripts/
|
||||
scripts = true
|
||||
|
||||
# File search (requires fd or locate, trigger with "/ pattern" or "find pattern")
|
||||
files = true
|
||||
46
crates/owlry-lua/Cargo.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "owlry-lua"
|
||||
version = "0.4.3"
|
||||
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.10", 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.12", 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)
|
||||
}
|
||||
237
crates/owlry-lua/src/api/provider.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
//! 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::{create_lua_runtime, SandboxConfig};
|
||||
|
||||
#[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()));
|
||||
}
|
||||
}
|
||||
370
crates/owlry-lua/src/api/utils.rs
Normal file
@@ -0,0 +1,370 @@
|
||||
//! 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::{create_lua_runtime, SandboxConfig};
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
349
crates/owlry-lua/src/lib.rs
Normal file
@@ -0,0 +1,349 @@
|
||||
//! 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>() };
|
||||
}
|
||||
}
|
||||
212
crates/owlry-lua/src/loader.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! 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::{create_lua_runtime, load_file, SandboxConfig};
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
173
crates/owlry-lua/src/manifest.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
153
crates/owlry-lua/src/runtime.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! 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 = "0.4.3"
|
||||
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"] }
|
||||
456
crates/owlry-plugin-api/src/lib.rs
Normal file
@@ -0,0 +1,456 @@
|
||||
//! # 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
31
crates/owlry-plugin-bookmarks/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "owlry-plugin-bookmarks"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Bookmarks plugin for owlry - browser bookmark search"
|
||||
keywords = ["owlry", "plugin", "bookmarks", "browser"]
|
||||
categories = ["web-programming"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# For finding browser config directories
|
||||
dirs = "5.0"
|
||||
|
||||
# For parsing Chrome bookmarks JSON
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# For reading Firefox bookmarks (places.sqlite)
|
||||
# Use bundled SQLite to avoid system library version conflicts
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
662
crates/owlry-plugin-bookmarks/src/lib.rs
Normal file
@@ -0,0 +1,662 @@
|
||||
//! Bookmarks Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that reads browser bookmarks from various browsers.
|
||||
//!
|
||||
//! Supported browsers:
|
||||
//! - Firefox (via places.sqlite using rusqlite with bundled SQLite)
|
||||
//! - Chrome
|
||||
//! - Chromium
|
||||
//! - Brave
|
||||
//! - Edge
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "bookmarks";
|
||||
const PLUGIN_NAME: &str = "Bookmarks";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Browser bookmark search";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "bookmarks";
|
||||
const PROVIDER_NAME: &str = "Bookmarks";
|
||||
const PROVIDER_PREFIX: &str = ":bm";
|
||||
const PROVIDER_ICON: &str = "user-bookmarks-symbolic";
|
||||
const PROVIDER_TYPE_ID: &str = "bookmarks";
|
||||
|
||||
/// Bookmarks provider state - holds cached items
|
||||
struct BookmarksState {
|
||||
/// Cached bookmark items (returned immediately on refresh)
|
||||
items: Vec<PluginItem>,
|
||||
/// Flag to prevent concurrent background loads
|
||||
loading: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl BookmarksState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
loading: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create the favicon cache directory
|
||||
fn favicon_cache_dir() -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|d| d.join("owlry/favicons"))
|
||||
}
|
||||
|
||||
/// Ensure the favicon cache directory exists
|
||||
fn ensure_favicon_cache_dir() -> Option<PathBuf> {
|
||||
Self::favicon_cache_dir().and_then(|dir| {
|
||||
fs::create_dir_all(&dir).ok()?;
|
||||
Some(dir)
|
||||
})
|
||||
}
|
||||
|
||||
/// Hash a URL to create a cache filename
|
||||
fn url_to_cache_filename(url: &str) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
url.hash(&mut hasher);
|
||||
format!("{:016x}.png", hasher.finish())
|
||||
}
|
||||
|
||||
/// Get the bookmark cache file path
|
||||
fn bookmark_cache_file() -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|d| d.join("owlry/bookmarks.json"))
|
||||
}
|
||||
|
||||
/// Load cached bookmarks from disk (fast)
|
||||
fn load_cached_bookmarks() -> Vec<PluginItem> {
|
||||
let cache_file = match Self::bookmark_cache_file() {
|
||||
Some(f) => f,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
if !cache_file.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(&cache_file) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
// Parse cached bookmarks (simple JSON format)
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CachedBookmark {
|
||||
id: String,
|
||||
name: String,
|
||||
command: String,
|
||||
description: Option<String>,
|
||||
icon: String,
|
||||
}
|
||||
|
||||
let cached: Vec<CachedBookmark> = match serde_json::from_str(&content) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
cached
|
||||
.into_iter()
|
||||
.map(|b| {
|
||||
let mut item = PluginItem::new(b.id, b.name, b.command)
|
||||
.with_icon(&b.icon)
|
||||
.with_keywords(vec!["bookmark".to_string()]);
|
||||
if let Some(desc) = b.description {
|
||||
item = item.with_description(desc);
|
||||
}
|
||||
item
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Save bookmarks to cache file
|
||||
fn save_cached_bookmarks(items: &[PluginItem]) {
|
||||
let cache_file = match Self::bookmark_cache_file() {
|
||||
Some(f) => f,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Ensure cache directory exists
|
||||
if let Some(parent) = cache_file.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct CachedBookmark {
|
||||
id: String,
|
||||
name: String,
|
||||
command: String,
|
||||
description: Option<String>,
|
||||
icon: String,
|
||||
}
|
||||
|
||||
let cached: Vec<CachedBookmark> = items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let desc: Option<String> = match &item.description {
|
||||
abi_stable::std_types::ROption::RSome(s) => Some(s.to_string()),
|
||||
abi_stable::std_types::ROption::RNone => None,
|
||||
};
|
||||
let icon: String = match &item.icon {
|
||||
abi_stable::std_types::ROption::RSome(s) => s.to_string(),
|
||||
abi_stable::std_types::ROption::RNone => PROVIDER_ICON.to_string(),
|
||||
};
|
||||
CachedBookmark {
|
||||
id: item.id.to_string(),
|
||||
name: item.name.to_string(),
|
||||
command: item.command.to_string(),
|
||||
description: desc,
|
||||
icon,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&cached) {
|
||||
let _ = fs::write(&cache_file, json);
|
||||
}
|
||||
}
|
||||
|
||||
fn chromium_bookmark_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
// Chrome
|
||||
paths.push(config_dir.join("google-chrome/Default/Bookmarks"));
|
||||
paths.push(config_dir.join("google-chrome-stable/Default/Bookmarks"));
|
||||
|
||||
// Chromium
|
||||
paths.push(config_dir.join("chromium/Default/Bookmarks"));
|
||||
|
||||
// Brave
|
||||
paths.push(config_dir.join("BraveSoftware/Brave-Browser/Default/Bookmarks"));
|
||||
|
||||
// Edge
|
||||
paths.push(config_dir.join("microsoft-edge/Default/Bookmarks"));
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn firefox_places_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let firefox_dir = home.join(".mozilla/firefox");
|
||||
if firefox_dir.exists() {
|
||||
// Find all profile directories
|
||||
if let Ok(entries) = fs::read_dir(&firefox_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let places = path.join("places.sqlite");
|
||||
if places.exists() {
|
||||
paths.push(places);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// Find Firefox favicons.sqlite paths (paired with places.sqlite)
|
||||
fn firefox_favicons_path(places_path: &Path) -> Option<PathBuf> {
|
||||
let favicons = places_path.parent()?.join("favicons.sqlite");
|
||||
if favicons.exists() {
|
||||
Some(favicons)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn load_bookmarks(&mut self) {
|
||||
// Fast path: load from cache immediately
|
||||
if self.items.is_empty() {
|
||||
self.items = Self::load_cached_bookmarks();
|
||||
}
|
||||
|
||||
// Don't start another background load if one is already running
|
||||
if self.loading.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn background thread to refresh bookmarks
|
||||
let loading = self.loading.clone();
|
||||
thread::spawn(move || {
|
||||
let mut items = Vec::new();
|
||||
|
||||
// Load Chrome/Chromium bookmarks (fast - just JSON parsing)
|
||||
for path in Self::chromium_bookmark_paths() {
|
||||
if path.exists() {
|
||||
Self::read_chrome_bookmarks_static(&path, &mut items);
|
||||
}
|
||||
}
|
||||
|
||||
// Load Firefox bookmarks with favicons (synchronous with rusqlite)
|
||||
for path in Self::firefox_places_paths() {
|
||||
Self::read_firefox_bookmarks(&path, &mut items);
|
||||
}
|
||||
|
||||
// Save to cache for next startup
|
||||
Self::save_cached_bookmarks(&items);
|
||||
|
||||
loading.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
/// Read Chrome bookmarks (static helper for background thread)
|
||||
fn read_chrome_bookmarks_static(path: &PathBuf, items: &mut Vec<PluginItem>) {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let bookmarks: ChromeBookmarks = match serde_json::from_str(&content) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Some(roots) = bookmarks.roots {
|
||||
if let Some(bar) = roots.bookmark_bar {
|
||||
Self::process_chrome_folder_static(&bar, items);
|
||||
}
|
||||
if let Some(other) = roots.other {
|
||||
Self::process_chrome_folder_static(&other, items);
|
||||
}
|
||||
if let Some(synced) = roots.synced {
|
||||
Self::process_chrome_folder_static(&synced, items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_chrome_folder_static(folder: &ChromeBookmarkNode, items: &mut Vec<PluginItem>) {
|
||||
if let Some(ref children) = folder.children {
|
||||
for child in children {
|
||||
match child.node_type.as_deref() {
|
||||
Some("url") => {
|
||||
if let Some(ref url) = child.url {
|
||||
let name = child.name.clone().unwrap_or_else(|| url.clone());
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
format!("bookmark:{}", url),
|
||||
name,
|
||||
format!("xdg-open '{}'", url.replace('\'', "'\\''")),
|
||||
)
|
||||
.with_description(url.clone())
|
||||
.with_icon(PROVIDER_ICON)
|
||||
.with_keywords(vec!["bookmark".to_string(), "chrome".to_string()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some("folder") => {
|
||||
Self::process_chrome_folder_static(child, items);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read Firefox bookmarks using rusqlite (synchronous, bundled SQLite)
|
||||
fn read_firefox_bookmarks(places_path: &PathBuf, items: &mut Vec<PluginItem>) {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_db = temp_dir.join("owlry_places_temp.sqlite");
|
||||
|
||||
// Copy database to temp location to avoid locking issues
|
||||
if fs::copy(places_path, &temp_db).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Also copy WAL file if it exists
|
||||
let wal_path = places_path.with_extension("sqlite-wal");
|
||||
if wal_path.exists() {
|
||||
let temp_wal = temp_db.with_extension("sqlite-wal");
|
||||
let _ = fs::copy(&wal_path, &temp_wal);
|
||||
}
|
||||
|
||||
// Copy favicons database if available
|
||||
let favicons_path = Self::firefox_favicons_path(places_path);
|
||||
let temp_favicons = temp_dir.join("owlry_favicons_temp.sqlite");
|
||||
if let Some(ref fp) = favicons_path {
|
||||
let _ = fs::copy(fp, &temp_favicons);
|
||||
let fav_wal = fp.with_extension("sqlite-wal");
|
||||
if fav_wal.exists() {
|
||||
let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal"));
|
||||
}
|
||||
}
|
||||
|
||||
let cache_dir = Self::ensure_favicon_cache_dir();
|
||||
|
||||
// Read bookmarks from places.sqlite
|
||||
let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref());
|
||||
|
||||
// Clean up temp files
|
||||
let _ = fs::remove_file(&temp_db);
|
||||
let _ = fs::remove_file(temp_db.with_extension("sqlite-wal"));
|
||||
let _ = fs::remove_file(&temp_favicons);
|
||||
let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal"));
|
||||
|
||||
for (title, url, favicon_path) in bookmarks {
|
||||
let icon = favicon_path.unwrap_or_else(|| PROVIDER_ICON.to_string());
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
format!("bookmark:firefox:{}", url),
|
||||
title,
|
||||
format!("xdg-open '{}'", url.replace('\'', "'\\''")),
|
||||
)
|
||||
.with_description(url)
|
||||
.with_icon(&icon)
|
||||
.with_keywords(vec!["bookmark".to_string(), "firefox".to_string()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch Firefox bookmarks with optional favicons
|
||||
fn fetch_firefox_bookmarks(
|
||||
places_path: &Path,
|
||||
favicons_path: &Path,
|
||||
cache_dir: Option<&PathBuf>,
|
||||
) -> Vec<(String, String, Option<String>)> {
|
||||
// Open places.sqlite in read-only mode
|
||||
let conn = match Connection::open_with_flags(
|
||||
places_path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
// Query bookmarks joining moz_bookmarks with moz_places
|
||||
// type=1 means URL bookmarks (not folders, separators, etc.)
|
||||
let query = r#"
|
||||
SELECT b.title, p.url
|
||||
FROM moz_bookmarks b
|
||||
JOIN moz_places p ON b.fk = p.id
|
||||
WHERE b.type = 1
|
||||
AND p.url NOT LIKE 'place:%'
|
||||
AND p.url NOT LIKE 'about:%'
|
||||
AND b.title IS NOT NULL
|
||||
AND b.title != ''
|
||||
ORDER BY b.dateAdded DESC
|
||||
LIMIT 500
|
||||
"#;
|
||||
|
||||
let mut stmt = match conn.prepare(query) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let bookmarks: Vec<(String, String)> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.ok()
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// If no favicons or cache dir, return without favicons
|
||||
let cache_dir = match cache_dir {
|
||||
Some(c) => c,
|
||||
None => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
|
||||
};
|
||||
|
||||
// Try to open favicons database
|
||||
let fav_conn = match Connection::open_with_flags(
|
||||
favicons_path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
|
||||
};
|
||||
|
||||
// Fetch favicons for each URL
|
||||
let mut results = Vec::new();
|
||||
for (title, url) in bookmarks {
|
||||
let favicon_path = Self::get_favicon_for_url(&fav_conn, &url, cache_dir);
|
||||
results.push((title, url, favicon_path));
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Get favicon for a URL, caching to file if needed
|
||||
fn get_favicon_for_url(
|
||||
conn: &Connection,
|
||||
page_url: &str,
|
||||
cache_dir: &Path,
|
||||
) -> Option<String> {
|
||||
// Check if already cached
|
||||
let cache_filename = Self::url_to_cache_filename(page_url);
|
||||
let cache_path = cache_dir.join(&cache_filename);
|
||||
if cache_path.exists() {
|
||||
return Some(cache_path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// Query favicon data from database
|
||||
// Join moz_pages_w_icons -> moz_icons_to_pages -> moz_icons
|
||||
// Prefer smaller icons (32px) for efficiency
|
||||
let query = r#"
|
||||
SELECT i.data
|
||||
FROM moz_pages_w_icons p
|
||||
JOIN moz_icons_to_pages ip ON p.id = ip.page_id
|
||||
JOIN moz_icons i ON ip.icon_id = i.id
|
||||
WHERE p.page_url = ?
|
||||
AND i.data IS NOT NULL
|
||||
ORDER BY ABS(i.width - 32) ASC
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
let data: Option<Vec<u8>> = conn
|
||||
.query_row(query, [page_url], |row| row.get(0))
|
||||
.ok();
|
||||
|
||||
let data = data?;
|
||||
if data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Write favicon data to cache file
|
||||
let mut file = fs::File::create(&cache_path).ok()?;
|
||||
file.write_all(&data).ok()?;
|
||||
|
||||
Some(cache_path.to_string_lossy().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Chrome bookmark JSON structures
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChromeBookmarks {
|
||||
roots: Option<ChromeBookmarkRoots>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChromeBookmarkRoots {
|
||||
bookmark_bar: Option<ChromeBookmarkNode>,
|
||||
other: Option<ChromeBookmarkNode>,
|
||||
synced: Option<ChromeBookmarkNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChromeBookmarkNode {
|
||||
name: Option<String>,
|
||||
url: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
node_type: Option<String>,
|
||||
children: Option<Vec<ChromeBookmarkNode>>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(BookmarksState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<BookmarksState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut BookmarksState) };
|
||||
|
||||
// Load bookmarks
|
||||
state.load_bookmarks();
|
||||
|
||||
// Return items
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query is handled by the core using cached items
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<BookmarksState>
|
||||
unsafe {
|
||||
handle.drop_as::<BookmarksState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bookmarks_state_new() {
|
||||
let state = BookmarksState::new();
|
||||
assert!(state.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chromium_paths() {
|
||||
let paths = BookmarksState::chromium_bookmark_paths();
|
||||
// Should have at least some paths configured
|
||||
assert!(!paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_firefox_paths() {
|
||||
// This will find paths if Firefox is installed
|
||||
let paths = BookmarksState::firefox_places_paths();
|
||||
// Path detection should work (may be empty if Firefox not installed)
|
||||
let _ = paths.len(); // Just ensure it doesn't panic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_chrome_bookmarks() {
|
||||
let json = r#"{
|
||||
"roots": {
|
||||
"bookmark_bar": {
|
||||
"type": "folder",
|
||||
"children": [
|
||||
{
|
||||
"type": "url",
|
||||
"name": "Example",
|
||||
"url": "https://example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let bookmarks: ChromeBookmarks = serde_json::from_str(json).unwrap();
|
||||
assert!(bookmarks.roots.is_some());
|
||||
|
||||
let roots = bookmarks.roots.unwrap();
|
||||
assert!(roots.bookmark_bar.is_some());
|
||||
|
||||
let bar = roots.bookmark_bar.unwrap();
|
||||
assert!(bar.children.is_some());
|
||||
assert_eq!(bar.children.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_folder() {
|
||||
let mut items = Vec::new();
|
||||
|
||||
let folder = ChromeBookmarkNode {
|
||||
name: Some("Test Folder".to_string()),
|
||||
url: None,
|
||||
node_type: Some("folder".to_string()),
|
||||
children: Some(vec![
|
||||
ChromeBookmarkNode {
|
||||
name: Some("Test Bookmark".to_string()),
|
||||
url: Some("https://test.com".to_string()),
|
||||
node_type: Some("url".to_string()),
|
||||
children: None,
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
BookmarksState::process_chrome_folder_static(&folder, &mut items);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].name.as_str(), "Test Bookmark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_escaping() {
|
||||
let url = "https://example.com/path?query='test'";
|
||||
let command = format!("xdg-open '{}'", url.replace('\'', "'\\''"));
|
||||
assert!(command.contains("'\\''"));
|
||||
}
|
||||
}
|
||||
23
crates/owlry-plugin-calculator/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "owlry-plugin-calculator"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Calculator plugin for owlry - evaluates mathematical expressions"
|
||||
keywords = ["owlry", "plugin", "calculator"]
|
||||
categories = ["mathematics"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# Math expression evaluation
|
||||
meval = "0.2"
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
231
crates/owlry-plugin-calculator/src/lib.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
//! Calculator Plugin for Owlry
|
||||
//!
|
||||
//! A dynamic provider that evaluates mathematical expressions.
|
||||
//! Supports queries prefixed with `=` or `calc `.
|
||||
//!
|
||||
//! Examples:
|
||||
//! - `= 5 + 3` → 8
|
||||
//! - `calc sqrt(16)` → 4
|
||||
//! - `= pi * 2` → 6.283185...
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "calculator";
|
||||
const PLUGIN_NAME: &str = "Calculator";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Evaluate mathematical expressions";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "calculator";
|
||||
const PROVIDER_NAME: &str = "Calculator";
|
||||
const PROVIDER_PREFIX: &str = "=";
|
||||
const PROVIDER_ICON: &str = "accessories-calculator";
|
||||
const PROVIDER_TYPE_ID: &str = "calc";
|
||||
|
||||
/// Calculator provider state (empty for now, but could cache results)
|
||||
struct CalculatorState;
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Dynamic,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 10000, // Dynamic: calculator results first
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
// Create state and return handle
|
||||
let state = Box::new(CalculatorState);
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
// Dynamic provider - refresh does nothing
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
let query_str = query.as_str();
|
||||
|
||||
// Extract expression from query
|
||||
let expr = match extract_expression(query_str) {
|
||||
Some(e) if !e.is_empty() => e,
|
||||
_ => return RVec::new(),
|
||||
};
|
||||
|
||||
// Evaluate the expression
|
||||
match evaluate_expression(expr) {
|
||||
Some(item) => vec![item].into(),
|
||||
None => RVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<CalculatorState>
|
||||
unsafe {
|
||||
handle.drop_as::<CalculatorState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Calculator Logic
|
||||
// ============================================================================
|
||||
|
||||
/// Extract expression from query (handles `= expr` and `calc expr` formats)
|
||||
fn extract_expression(query: &str) -> Option<&str> {
|
||||
let trimmed = query.trim();
|
||||
|
||||
// Support both "= expr" and "=expr" (with or without space)
|
||||
if let Some(expr) = trimmed.strip_prefix("= ") {
|
||||
Some(expr.trim())
|
||||
} else if let Some(expr) = trimmed.strip_prefix('=') {
|
||||
Some(expr.trim())
|
||||
} else if let Some(expr) = trimmed.strip_prefix("calc ") {
|
||||
Some(expr.trim())
|
||||
} else {
|
||||
// For filter mode - accept raw expressions
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a mathematical expression and return a PluginItem
|
||||
fn evaluate_expression(expr: &str) -> Option<PluginItem> {
|
||||
match meval::eval_str(expr) {
|
||||
Ok(result) => {
|
||||
// Format result nicely
|
||||
let result_str = format_result(result);
|
||||
|
||||
Some(
|
||||
PluginItem::new(
|
||||
format!("calc:{}", expr),
|
||||
result_str.clone(),
|
||||
format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str),
|
||||
)
|
||||
.with_description(format!("= {}", expr))
|
||||
.with_icon(PROVIDER_ICON)
|
||||
.with_keywords(vec!["math".to_string(), "calculator".to_string()]),
|
||||
)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a numeric result nicely
|
||||
fn format_result(result: f64) -> String {
|
||||
if result.fract() == 0.0 && result.abs() < 1e15 {
|
||||
// Integer result
|
||||
format!("{}", result as i64)
|
||||
} else {
|
||||
// Float result with reasonable precision, trimming trailing zeros
|
||||
let formatted = format!("{:.10}", result);
|
||||
formatted
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_expression() {
|
||||
assert_eq!(extract_expression("= 5+3"), Some("5+3"));
|
||||
assert_eq!(extract_expression("=5+3"), Some("5+3"));
|
||||
assert_eq!(extract_expression("calc 5+3"), Some("5+3"));
|
||||
assert_eq!(extract_expression(" = 5 + 3 "), Some("5 + 3"));
|
||||
assert_eq!(extract_expression("5+3"), Some("5+3")); // Raw expression
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_result() {
|
||||
assert_eq!(format_result(8.0), "8");
|
||||
assert_eq!(format_result(2.5), "2.5");
|
||||
assert_eq!(format_result(3.14159265358979), "3.1415926536");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_basic() {
|
||||
let item = evaluate_expression("5+3").unwrap();
|
||||
assert_eq!(item.name.as_str(), "8");
|
||||
|
||||
let item = evaluate_expression("10 * 2").unwrap();
|
||||
assert_eq!(item.name.as_str(), "20");
|
||||
|
||||
let item = evaluate_expression("15 / 3").unwrap();
|
||||
assert_eq!(item.name.as_str(), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_float() {
|
||||
let item = evaluate_expression("5/2").unwrap();
|
||||
assert_eq!(item.name.as_str(), "2.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_functions() {
|
||||
let item = evaluate_expression("sqrt(16)").unwrap();
|
||||
assert_eq!(item.name.as_str(), "4");
|
||||
|
||||
let item = evaluate_expression("abs(-5)").unwrap();
|
||||
assert_eq!(item.name.as_str(), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_constants() {
|
||||
let item = evaluate_expression("pi").unwrap();
|
||||
assert!(item.name.as_str().starts_with("3.14159"));
|
||||
|
||||
let item = evaluate_expression("e").unwrap();
|
||||
assert!(item.name.as_str().starts_with("2.718"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_invalid() {
|
||||
assert!(evaluate_expression("").is_none());
|
||||
assert!(evaluate_expression("invalid").is_none());
|
||||
assert!(evaluate_expression("5 +").is_none());
|
||||
}
|
||||
}
|
||||
20
crates/owlry-plugin-clipboard/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "owlry-plugin-clipboard"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Clipboard plugin for owlry - clipboard history via cliphist"
|
||||
keywords = ["owlry", "plugin", "clipboard"]
|
||||
categories = ["os"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
259
crates/owlry-plugin-clipboard/src/lib.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
//! Clipboard Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that integrates with cliphist to show clipboard history.
|
||||
//! Requires cliphist and wl-clipboard to be installed.
|
||||
//!
|
||||
//! Dependencies:
|
||||
//! - cliphist: clipboard history manager
|
||||
//! - wl-clipboard: Wayland clipboard utilities (wl-copy)
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use std::process::Command;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "clipboard";
|
||||
const PLUGIN_NAME: &str = "Clipboard";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Clipboard history via cliphist";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "clipboard";
|
||||
const PROVIDER_NAME: &str = "Clipboard";
|
||||
const PROVIDER_PREFIX: &str = ":clip";
|
||||
const PROVIDER_ICON: &str = "edit-paste";
|
||||
const PROVIDER_TYPE_ID: &str = "clipboard";
|
||||
|
||||
// Default max entries to show
|
||||
const DEFAULT_MAX_ENTRIES: usize = 50;
|
||||
|
||||
/// Clipboard provider state - holds cached items
|
||||
struct ClipboardState {
|
||||
items: Vec<PluginItem>,
|
||||
max_entries: usize,
|
||||
}
|
||||
|
||||
impl ClipboardState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
max_entries: DEFAULT_MAX_ENTRIES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if cliphist is available
|
||||
fn has_cliphist() -> bool {
|
||||
Command::new("which")
|
||||
.arg("cliphist")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn load_clipboard_history(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
if !Self::has_cliphist() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get clipboard history from cliphist
|
||||
let output = match Command::new("cliphist").arg("list").output() {
|
||||
Ok(o) => o,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
for (idx, line) in content.lines().take(self.max_entries).enumerate() {
|
||||
// cliphist format: "id\tpreview"
|
||||
let parts: Vec<&str> = line.splitn(2, '\t').collect();
|
||||
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let clip_id = parts[0];
|
||||
let preview = if parts.len() > 1 {
|
||||
// Truncate long previews (char-safe for UTF-8)
|
||||
let p = parts[1];
|
||||
if p.chars().count() > 80 {
|
||||
let truncated: String = p.chars().take(77).collect();
|
||||
format!("{}...", truncated)
|
||||
} else {
|
||||
p.to_string()
|
||||
}
|
||||
} else {
|
||||
"[binary data]".to_string()
|
||||
};
|
||||
|
||||
// Clean up preview - replace newlines with spaces
|
||||
let preview_clean = preview
|
||||
.replace('\n', " ")
|
||||
.replace('\r', "")
|
||||
.replace('\t', " ");
|
||||
|
||||
// Command to paste this entry
|
||||
// echo "id" | cliphist decode | wl-copy
|
||||
let command = format!(
|
||||
"echo '{}' | cliphist decode | wl-copy",
|
||||
clip_id.replace('\'', "'\\''")
|
||||
);
|
||||
|
||||
self.items.push(
|
||||
PluginItem::new(format!("clipboard:{}", idx), preview_clean, command)
|
||||
.with_description("Copy to clipboard")
|
||||
.with_icon(PROVIDER_ICON),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(ClipboardState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<ClipboardState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut ClipboardState) };
|
||||
|
||||
// Load clipboard history
|
||||
state.load_clipboard_history();
|
||||
|
||||
// Return items
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query is handled by the core using cached items
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<ClipboardState>
|
||||
unsafe {
|
||||
handle.drop_as::<ClipboardState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_state_new() {
|
||||
let state = ClipboardState::new();
|
||||
assert!(state.items.is_empty());
|
||||
assert_eq!(state.max_entries, DEFAULT_MAX_ENTRIES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preview_truncation() {
|
||||
// Test that long strings would be truncated (char-safe)
|
||||
let long_text = "a".repeat(100);
|
||||
let truncated = if long_text.chars().count() > 80 {
|
||||
let t: String = long_text.chars().take(77).collect();
|
||||
format!("{}...", t)
|
||||
} else {
|
||||
long_text.clone()
|
||||
};
|
||||
assert_eq!(truncated.chars().count(), 80);
|
||||
assert!(truncated.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preview_truncation_utf8() {
|
||||
// Test with multi-byte UTF-8 characters (box-drawing chars are 3 bytes each)
|
||||
let utf8_text = "├── ".repeat(30); // Each "├── " is 7 bytes but 4 chars
|
||||
let truncated = if utf8_text.chars().count() > 80 {
|
||||
let t: String = utf8_text.chars().take(77).collect();
|
||||
format!("{}...", t)
|
||||
} else {
|
||||
utf8_text.clone()
|
||||
};
|
||||
assert_eq!(truncated.chars().count(), 80);
|
||||
assert!(truncated.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preview_cleaning() {
|
||||
let dirty = "line1\nline2\tcolumn\rend";
|
||||
let clean = dirty
|
||||
.replace('\n', " ")
|
||||
.replace('\r', "")
|
||||
.replace('\t', " ");
|
||||
assert_eq!(clean, "line1 line2 columnend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_escaping() {
|
||||
let clip_id = "test'id";
|
||||
let command = format!(
|
||||
"echo '{}' | cliphist decode | wl-copy",
|
||||
clip_id.replace('\'', "'\\''")
|
||||
);
|
||||
assert!(command.contains("test'\\''id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_cliphist_runs() {
|
||||
// Just ensure it doesn't panic - cliphist may or may not be installed
|
||||
let _ = ClipboardState::has_cliphist();
|
||||
}
|
||||
}
|
||||
20
crates/owlry-plugin-emoji/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "owlry-plugin-emoji"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Emoji plugin for owlry - search and copy emojis"
|
||||
keywords = ["owlry", "plugin", "emoji"]
|
||||
categories = ["text-processing"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,12 +1,38 @@
|
||||
use crate::providers::{LaunchItem, Provider, ProviderType};
|
||||
//! Emoji Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that provides emoji search and copy functionality.
|
||||
//! Requires wl-clipboard (wl-copy) for copying to clipboard.
|
||||
//!
|
||||
//! Examples:
|
||||
//! - Search "smile" → 😀 😃 😄 etc.
|
||||
//! - Search "heart" → ❤️ 💙 💚 etc.
|
||||
|
||||
/// Emoji picker provider - search and copy emojis
|
||||
pub struct EmojiProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "emoji";
|
||||
const PLUGIN_NAME: &str = "Emoji";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Search and copy emojis";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "emoji";
|
||||
const PROVIDER_NAME: &str = "Emoji";
|
||||
const PROVIDER_PREFIX: &str = ":emoji";
|
||||
const PROVIDER_ICON: &str = "face-smile";
|
||||
const PROVIDER_TYPE_ID: &str = "emoji";
|
||||
|
||||
/// Emoji provider state - holds cached items
|
||||
struct EmojiState {
|
||||
items: Vec<PluginItem>,
|
||||
}
|
||||
|
||||
impl EmojiProvider {
|
||||
pub fn new() -> Self {
|
||||
impl EmojiState {
|
||||
fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
@@ -60,14 +86,13 @@ impl EmojiProvider {
|
||||
("🤠", "cowboy hat face", "yeehaw western"),
|
||||
("🥳", "partying face", "celebration party"),
|
||||
("🥸", "disguised face", "incognito"),
|
||||
("😎", "cool face", "sunglasses"),
|
||||
("🤡", "clown face", "circus"),
|
||||
("👻", "ghost", "halloween spooky"),
|
||||
("💀", "skull", "dead death"),
|
||||
("☠️", "skull and crossbones", "danger death"),
|
||||
("👽", "alien", "ufo extraterrestrial"),
|
||||
("🤖", "robot", "bot android"),
|
||||
("💩", "pile of poo", "poop shit"),
|
||||
("💩", "pile of poo", "poop"),
|
||||
("😈", "smiling face with horns", "devil evil"),
|
||||
("👿", "angry face with horns", "devil evil"),
|
||||
// Gestures & People
|
||||
@@ -368,7 +393,6 @@ impl EmojiProvider {
|
||||
("🌕", "full moon", ""),
|
||||
("☀️", "sun", "sunny"),
|
||||
("🌙", "crescent moon", "night"),
|
||||
("⭐", "star", ""),
|
||||
("☁️", "cloud", ""),
|
||||
("🌧️", "cloud with rain", "rainy"),
|
||||
("⛈️", "cloud with lightning", "storm thunder"),
|
||||
@@ -394,53 +418,148 @@ impl EmojiProvider {
|
||||
];
|
||||
|
||||
for (emoji, name, keywords) in emojis {
|
||||
// Combine name and keywords for better searching
|
||||
let search_text = format!("{} {}", name, keywords);
|
||||
|
||||
self.items.push(LaunchItem {
|
||||
id: format!("emoji:{}", emoji),
|
||||
name: name.to_string(),
|
||||
description: Some(format!("{} {}", emoji, keywords)),
|
||||
icon: None,
|
||||
provider: ProviderType::Emoji,
|
||||
// Copy emoji to clipboard using wl-copy
|
||||
command: format!("printf '%s' '{}' | wl-copy", emoji),
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
// Store the search text for matching (not used directly but could be)
|
||||
let _ = search_text;
|
||||
self.items.push(
|
||||
PluginItem::new(
|
||||
format!("emoji:{}", emoji),
|
||||
name.to_string(),
|
||||
format!("printf '%s' '{}' | wl-copy", emoji),
|
||||
)
|
||||
.with_icon(*emoji) // Use emoji character as icon
|
||||
.with_description(format!("{} {}", emoji, keywords))
|
||||
.with_keywords(vec![name.to_string(), keywords.to_string()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for EmojiProvider {
|
||||
fn name(&self) -> &str {
|
||||
"Emoji"
|
||||
}
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Emoji
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.load_emojis();
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(EmojiState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<EmojiState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut EmojiState) };
|
||||
|
||||
// Load emojis
|
||||
state.load_emojis();
|
||||
|
||||
// Return items
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query is handled by the core using cached items
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<EmojiState>
|
||||
unsafe {
|
||||
handle.drop_as::<EmojiState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_emoji_provider() {
|
||||
let mut provider = EmojiProvider::new();
|
||||
provider.refresh();
|
||||
assert!(provider.items().len() > 100);
|
||||
assert!(provider.items().iter().any(|i| i.name.contains("😀")));
|
||||
fn test_emoji_state_new() {
|
||||
let state = EmojiState::new();
|
||||
assert!(state.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emoji_count() {
|
||||
let mut state = EmojiState::new();
|
||||
state.load_emojis();
|
||||
assert!(state.items.len() > 100, "Should have more than 100 emojis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emoji_has_grinning_face() {
|
||||
let mut state = EmojiState::new();
|
||||
state.load_emojis();
|
||||
|
||||
let grinning = state
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.name.as_str() == "grinning face");
|
||||
assert!(grinning.is_some());
|
||||
|
||||
let item = grinning.unwrap();
|
||||
assert!(item.description.as_ref().unwrap().as_str().contains("😀"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emoji_command_format() {
|
||||
let mut state = EmojiState::new();
|
||||
state.load_emojis();
|
||||
|
||||
let item = &state.items[0];
|
||||
assert!(item.command.as_str().contains("wl-copy"));
|
||||
assert!(item.command.as_str().contains("printf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emojis_have_keywords() {
|
||||
let mut state = EmojiState::new();
|
||||
state.load_emojis();
|
||||
|
||||
// Check that items have keywords for searching
|
||||
let heart = state
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.name.as_str() == "red heart");
|
||||
assert!(heart.is_some());
|
||||
}
|
||||
}
|
||||
23
crates/owlry-plugin-filesearch/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "owlry-plugin-filesearch"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "File search plugin for owlry - find files with fd or locate"
|
||||
keywords = ["owlry", "plugin", "files", "search"]
|
||||
categories = ["filesystem"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# For finding home directory
|
||||
dirs = "5.0"
|
||||
322
crates/owlry-plugin-filesearch/src/lib.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! File Search Plugin for Owlry
|
||||
//!
|
||||
//! A dynamic provider that searches for files using `fd` or `locate`.
|
||||
//!
|
||||
//! Examples:
|
||||
//! - `/ config.toml` → Search for files matching "config.toml"
|
||||
//! - `file bashrc` → Search for files matching "bashrc"
|
||||
//! - `find readme` → Search for files matching "readme"
|
||||
//!
|
||||
//! Dependencies:
|
||||
//! - fd (preferred) or locate
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "filesearch";
|
||||
const PLUGIN_NAME: &str = "File Search";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Find files with fd or locate";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "filesearch";
|
||||
const PROVIDER_NAME: &str = "Files";
|
||||
const PROVIDER_PREFIX: &str = "/";
|
||||
const PROVIDER_ICON: &str = "folder";
|
||||
const PROVIDER_TYPE_ID: &str = "filesearch";
|
||||
|
||||
// Maximum results to return
|
||||
const MAX_RESULTS: usize = 20;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum SearchTool {
|
||||
Fd,
|
||||
Locate,
|
||||
None,
|
||||
}
|
||||
|
||||
/// File search provider state
|
||||
struct FileSearchState {
|
||||
search_tool: SearchTool,
|
||||
home: String,
|
||||
}
|
||||
|
||||
impl FileSearchState {
|
||||
fn new() -> Self {
|
||||
let search_tool = Self::detect_search_tool();
|
||||
let home = dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
|
||||
Self { search_tool, home }
|
||||
}
|
||||
|
||||
fn detect_search_tool() -> SearchTool {
|
||||
// Prefer fd (faster, respects .gitignore)
|
||||
if Self::command_exists("fd") {
|
||||
return SearchTool::Fd;
|
||||
}
|
||||
// Fall back to locate (requires updatedb)
|
||||
if Self::command_exists("locate") {
|
||||
return SearchTool::Locate;
|
||||
}
|
||||
SearchTool::None
|
||||
}
|
||||
|
||||
fn command_exists(cmd: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Extract the search term from the query
|
||||
fn extract_search_term(query: &str) -> Option<&str> {
|
||||
let trimmed = query.trim();
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("/ ") {
|
||||
Some(rest.trim())
|
||||
} else if let Some(rest) = trimmed.strip_prefix("/") {
|
||||
Some(rest.trim())
|
||||
} else {
|
||||
// Handle "file " and "find " prefixes (case-insensitive), or raw query in filter mode
|
||||
let lower = trimmed.to_lowercase();
|
||||
if lower.starts_with("file ") || lower.starts_with("find ") {
|
||||
Some(trimmed[5..].trim())
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a query and return file results
|
||||
fn evaluate(&self, query: &str) -> Vec<PluginItem> {
|
||||
let search_term = match Self::extract_search_term(query) {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
self.search_files(search_term)
|
||||
}
|
||||
|
||||
fn search_files(&self, pattern: &str) -> Vec<PluginItem> {
|
||||
match self.search_tool {
|
||||
SearchTool::Fd => self.search_with_fd(pattern),
|
||||
SearchTool::Locate => self.search_with_locate(pattern),
|
||||
SearchTool::None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn search_with_fd(&self, pattern: &str) -> Vec<PluginItem> {
|
||||
let output = match Command::new("fd")
|
||||
.args([
|
||||
"--max-results",
|
||||
&MAX_RESULTS.to_string(),
|
||||
"--type",
|
||||
"f", // Files only
|
||||
"--type",
|
||||
"d", // And directories
|
||||
pattern,
|
||||
])
|
||||
.current_dir(&self.home)
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
self.parse_file_results(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
fn search_with_locate(&self, pattern: &str) -> Vec<PluginItem> {
|
||||
let output = match Command::new("locate")
|
||||
.args([
|
||||
"--limit",
|
||||
&MAX_RESULTS.to_string(),
|
||||
"--ignore-case",
|
||||
pattern,
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
self.parse_file_results(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
fn parse_file_results(&self, output: &str) -> Vec<PluginItem> {
|
||||
output
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|path| {
|
||||
let path = path.trim();
|
||||
let full_path = if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.home, path)
|
||||
};
|
||||
|
||||
// Get filename for display
|
||||
let filename = Path::new(&full_path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| full_path.clone());
|
||||
|
||||
// Determine icon based on whether it's a directory
|
||||
let is_dir = Path::new(&full_path).is_dir();
|
||||
let icon = if is_dir { "folder" } else { "text-x-generic" };
|
||||
|
||||
// Command to open with xdg-open
|
||||
let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''"));
|
||||
|
||||
PluginItem::new(format!("file:{}", full_path), filename, command)
|
||||
.with_description(full_path.clone())
|
||||
.with_icon(icon)
|
||||
.with_keywords(vec!["file".to_string()])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Dynamic,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 8000, // Dynamic: file search
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(FileSearchState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
// Dynamic provider - refresh does nothing
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<FileSearchState>
|
||||
let state = unsafe { &*(handle.ptr as *const FileSearchState) };
|
||||
|
||||
let query_str = query.as_str();
|
||||
|
||||
state.evaluate(query_str).into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<FileSearchState>
|
||||
unsafe {
|
||||
handle.drop_as::<FileSearchState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_search_term() {
|
||||
assert_eq!(
|
||||
FileSearchState::extract_search_term("/ config.toml"),
|
||||
Some("config.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
FileSearchState::extract_search_term("/config"),
|
||||
Some("config")
|
||||
);
|
||||
assert_eq!(
|
||||
FileSearchState::extract_search_term("file bashrc"),
|
||||
Some("bashrc")
|
||||
);
|
||||
assert_eq!(
|
||||
FileSearchState::extract_search_term("find readme"),
|
||||
Some("readme")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_search_term_empty() {
|
||||
assert_eq!(FileSearchState::extract_search_term("/"), Some(""));
|
||||
assert_eq!(FileSearchState::extract_search_term("/ "), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_exists() {
|
||||
// 'which' should exist on any Unix system
|
||||
assert!(FileSearchState::command_exists("which"));
|
||||
// This should not exist
|
||||
assert!(!FileSearchState::command_exists("nonexistent-command-12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_search_tool() {
|
||||
// Just ensure it doesn't panic
|
||||
let _ = FileSearchState::detect_search_tool();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_new() {
|
||||
let state = FileSearchState::new();
|
||||
assert!(!state.home.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_empty() {
|
||||
let state = FileSearchState::new();
|
||||
let results = state.evaluate("/");
|
||||
assert!(results.is_empty());
|
||||
|
||||
let results = state.evaluate("/ ");
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
}
|
||||
23
crates/owlry-plugin-media/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "owlry-plugin-media"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "MPRIS media player widget plugin for owlry - shows and controls currently playing media. Requires playerctl."
|
||||
keywords = ["owlry", "plugin", "media", "mpris", "widget", "playerctl"]
|
||||
categories = ["gui"]
|
||||
|
||||
# System dependencies (for packagers):
|
||||
# - playerctl: for media control commands
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
468
crates/owlry-plugin-media/src/lib.rs
Normal file
@@ -0,0 +1,468 @@
|
||||
//! MPRIS Media Player Widget Plugin for Owlry
|
||||
//!
|
||||
//! Shows currently playing track as a single row with play/pause action.
|
||||
//! Uses D-Bus via dbus-send to communicate with MPRIS-compatible players.
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use std::process::Command;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "media";
|
||||
const PLUGIN_NAME: &str = "Media Player";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "MPRIS media player widget - shows and controls currently playing media";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "media";
|
||||
const PROVIDER_NAME: &str = "Media";
|
||||
const PROVIDER_ICON: &str = "applications-multimedia";
|
||||
const PROVIDER_TYPE_ID: &str = "media";
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct MediaState {
|
||||
player_name: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
is_playing: bool,
|
||||
}
|
||||
|
||||
/// Media provider state
|
||||
struct MediaProviderState {
|
||||
items: Vec<PluginItem>,
|
||||
/// Current player name for submenu actions
|
||||
current_player: Option<String>,
|
||||
/// Current playback state
|
||||
is_playing: bool,
|
||||
}
|
||||
|
||||
impl MediaProviderState {
|
||||
fn new() -> Self {
|
||||
// Don't query D-Bus during init - defer to first refresh() call
|
||||
// This prevents blocking the main thread during startup
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
current_player: None,
|
||||
is_playing: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
let players = Self::find_players();
|
||||
if players.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find first active player
|
||||
for player in &players {
|
||||
if let Some(state) = Self::get_player_state(player) {
|
||||
self.generate_items(&state);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find active MPRIS players via dbus-send
|
||||
fn find_players() -> Vec<String> {
|
||||
let output = Command::new("dbus-send")
|
||||
.args([
|
||||
"--session",
|
||||
"--dest=org.freedesktop.DBus",
|
||||
"--type=method_call",
|
||||
"--print-reply",
|
||||
"/org/freedesktop/DBus",
|
||||
"org.freedesktop.DBus.ListNames",
|
||||
])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("string \"org.mpris.MediaPlayer2.") {
|
||||
let start = "string \"org.mpris.MediaPlayer2.".len();
|
||||
let end = trimmed.len() - 1;
|
||||
Some(trimmed[start..end].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get metadata from an MPRIS player
|
||||
fn get_player_state(player: &str) -> Option<MediaState> {
|
||||
let dest = format!("org.mpris.MediaPlayer2.{}", player);
|
||||
|
||||
// Get playback status
|
||||
let status_output = Command::new("dbus-send")
|
||||
.args([
|
||||
"--session",
|
||||
&format!("--dest={}", dest),
|
||||
"--type=method_call",
|
||||
"--print-reply",
|
||||
"/org/mpris/MediaPlayer2",
|
||||
"org.freedesktop.DBus.Properties.Get",
|
||||
"string:org.mpris.MediaPlayer2.Player",
|
||||
"string:PlaybackStatus",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
let status_str = String::from_utf8_lossy(&status_output.stdout);
|
||||
let is_playing = status_str.contains("\"Playing\"");
|
||||
let is_paused = status_str.contains("\"Paused\"");
|
||||
|
||||
// Only show if playing or paused (not stopped)
|
||||
if !is_playing && !is_paused {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Get metadata
|
||||
let metadata_output = Command::new("dbus-send")
|
||||
.args([
|
||||
"--session",
|
||||
&format!("--dest={}", dest),
|
||||
"--type=method_call",
|
||||
"--print-reply",
|
||||
"/org/mpris/MediaPlayer2",
|
||||
"org.freedesktop.DBus.Properties.Get",
|
||||
"string:org.mpris.MediaPlayer2.Player",
|
||||
"string:Metadata",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
let metadata_str = String::from_utf8_lossy(&metadata_output.stdout);
|
||||
|
||||
let title = Self::extract_string(&metadata_str, "xesam:title")
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let artist = Self::extract_array(&metadata_str, "xesam:artist")
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
Some(MediaState {
|
||||
player_name: player.to_string(),
|
||||
title,
|
||||
artist,
|
||||
is_playing,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract string value from D-Bus output
|
||||
fn extract_string(output: &str, key: &str) -> Option<String> {
|
||||
let key_pattern = format!("\"{}\"", key);
|
||||
let mut found = false;
|
||||
|
||||
for line in output.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.contains(&key_pattern) {
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
if found {
|
||||
if let Some(pos) = trimmed.find("string \"") {
|
||||
let start = pos + "string \"".len();
|
||||
if let Some(end) = trimmed[start..].find('"') {
|
||||
let value = &trimmed[start..start + end];
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !trimmed.starts_with("variant") {
|
||||
found = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract array value from D-Bus output
|
||||
fn extract_array(output: &str, key: &str) -> Option<String> {
|
||||
let key_pattern = format!("\"{}\"", key);
|
||||
let mut found = false;
|
||||
let mut in_array = false;
|
||||
let mut values = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.contains(&key_pattern) {
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
if found && trimmed.contains("array [") {
|
||||
in_array = true;
|
||||
continue;
|
||||
}
|
||||
if in_array {
|
||||
if let Some(pos) = trimmed.find("string \"") {
|
||||
let start = pos + "string \"".len();
|
||||
if let Some(end) = trimmed[start..].find('"') {
|
||||
values.push(trimmed[start..start + end].to_string());
|
||||
}
|
||||
}
|
||||
if trimmed.contains(']') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(values.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate single LaunchItem for media state (opens submenu)
|
||||
fn generate_items(&mut self, state: &MediaState) {
|
||||
self.items.clear();
|
||||
|
||||
// Store state for submenu
|
||||
self.current_player = Some(state.player_name.clone());
|
||||
self.is_playing = state.is_playing;
|
||||
|
||||
// Single row: "Title — Artist"
|
||||
let name = format!("{} — {}", state.title, state.artist);
|
||||
|
||||
// Extract player display name (e.g., "firefox.instance_1_94" -> "Firefox")
|
||||
let player_display = Self::format_player_name(&state.player_name);
|
||||
|
||||
// Opens submenu with media controls
|
||||
self.items.push(
|
||||
PluginItem::new("media-now-playing", name, "SUBMENU:media:controls")
|
||||
.with_description(format!("{} · Select for controls", player_display))
|
||||
.with_icon("/org/owlry/launcher/icons/media/music-note.svg")
|
||||
.with_keywords(vec!["media".to_string(), "widget".to_string()]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Format player name for display
|
||||
fn format_player_name(player_name: &str) -> String {
|
||||
let player_display = player_name.split('.').next().unwrap_or(player_name);
|
||||
if player_display.is_empty() {
|
||||
"Player".to_string()
|
||||
} else {
|
||||
let mut chars = player_display.chars();
|
||||
match chars.next() {
|
||||
None => "Player".to_string(),
|
||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate submenu items for media controls
|
||||
fn generate_submenu_items(&self) -> Vec<PluginItem> {
|
||||
let player = match &self.current_player {
|
||||
Some(p) => p,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
// Use playerctl for simpler, more reliable media control
|
||||
// playerctl -p <player> <command>
|
||||
|
||||
// Play/Pause
|
||||
if self.is_playing {
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
"media-pause",
|
||||
"Pause",
|
||||
format!("playerctl -p {} pause", player),
|
||||
)
|
||||
.with_description("Pause playback")
|
||||
.with_icon("media-playback-pause"),
|
||||
);
|
||||
} else {
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
"media-play",
|
||||
"Play",
|
||||
format!("playerctl -p {} play", player),
|
||||
)
|
||||
.with_description("Resume playback")
|
||||
.with_icon("media-playback-start"),
|
||||
);
|
||||
}
|
||||
|
||||
// Next track
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
"media-next",
|
||||
"Next",
|
||||
format!("playerctl -p {} next", player),
|
||||
)
|
||||
.with_description("Skip to next track")
|
||||
.with_icon("media-skip-forward"),
|
||||
);
|
||||
|
||||
// Previous track
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
"media-previous",
|
||||
"Previous",
|
||||
format!("playerctl -p {} previous", player),
|
||||
)
|
||||
.with_description("Go to previous track")
|
||||
.with_icon("media-skip-backward"),
|
||||
);
|
||||
|
||||
// Stop
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
"media-stop",
|
||||
"Stop",
|
||||
format!("playerctl -p {} stop", player),
|
||||
)
|
||||
.with_description("Stop playback")
|
||||
.with_icon("media-playback-stop"),
|
||||
);
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RNone,
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Widget,
|
||||
priority: 11000, // Widget: media player
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(MediaProviderState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<MediaProviderState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut MediaProviderState) };
|
||||
|
||||
state.refresh();
|
||||
state.items.clone().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
let query_str = query.as_str();
|
||||
let state = unsafe { &*(handle.ptr as *const MediaProviderState) };
|
||||
|
||||
// Handle submenu request
|
||||
if query_str == "?SUBMENU:controls" {
|
||||
return state.generate_submenu_items().into();
|
||||
}
|
||||
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<MediaProviderState>
|
||||
unsafe {
|
||||
handle.drop_as::<MediaProviderState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_string() {
|
||||
let output = r#"
|
||||
string "xesam:title"
|
||||
variant string "My Song Title"
|
||||
"#;
|
||||
assert_eq!(
|
||||
MediaProviderState::extract_string(output, "xesam:title"),
|
||||
Some("My Song Title".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_array() {
|
||||
let output = r#"
|
||||
string "xesam:artist"
|
||||
variant array [
|
||||
string "Artist One"
|
||||
string "Artist Two"
|
||||
]
|
||||
"#;
|
||||
assert_eq!(
|
||||
MediaProviderState::extract_array(output, "xesam:artist"),
|
||||
Some("Artist One, Artist Two".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_string_not_found() {
|
||||
let output = "some other output";
|
||||
assert_eq!(
|
||||
MediaProviderState::extract_string(output, "xesam:title"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_players_empty() {
|
||||
// This will return empty on systems without D-Bus
|
||||
let players = MediaProviderState::find_players();
|
||||
// Just verify it doesn't panic
|
||||
let _ = players;
|
||||
}
|
||||
}
|
||||
30
crates/owlry-plugin-pomodoro/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "owlry-plugin-pomodoro"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Pomodoro timer widget plugin for owlry - work/break cycles with persistent state"
|
||||
keywords = ["owlry", "plugin", "pomodoro", "timer", "widget"]
|
||||
categories = ["gui"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# JSON serialization for persistent state
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# TOML config parsing
|
||||
toml = "0.8"
|
||||
|
||||
# For finding data directory
|
||||
dirs = "5.0"
|
||||
478
crates/owlry-plugin-pomodoro/src/lib.rs
Normal file
@@ -0,0 +1,478 @@
|
||||
//! Pomodoro Timer Widget Plugin for Owlry
|
||||
//!
|
||||
//! Shows timer with work/break cycles. Select to open controls submenu.
|
||||
//! State persists across sessions via JSON file.
|
||||
//!
|
||||
//! ## Configuration
|
||||
//!
|
||||
//! Configure via `~/.config/owlry/config.toml`:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [plugins.pomodoro]
|
||||
//! work_mins = 25 # Work session duration (default: 25)
|
||||
//! break_mins = 5 # Break duration (default: 5)
|
||||
//! ```
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
notify_with_urgency, owlry_plugin, NotifyUrgency, PluginInfo, PluginItem, ProviderHandle,
|
||||
ProviderInfo, ProviderKind, ProviderPosition, API_VERSION,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "pomodoro";
|
||||
const PLUGIN_NAME: &str = "Pomodoro Timer";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Pomodoro timer widget with work/break cycles";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "pomodoro";
|
||||
const PROVIDER_NAME: &str = "Pomodoro";
|
||||
const PROVIDER_ICON: &str = "alarm";
|
||||
const PROVIDER_TYPE_ID: &str = "pomodoro";
|
||||
|
||||
// Default timing (in minutes)
|
||||
const DEFAULT_WORK_MINS: u32 = 25;
|
||||
const DEFAULT_BREAK_MINS: u32 = 5;
|
||||
|
||||
/// Pomodoro configuration
|
||||
#[derive(Debug, Clone)]
|
||||
struct PomodoroConfig {
|
||||
work_mins: u32,
|
||||
break_mins: u32,
|
||||
}
|
||||
|
||||
impl PomodoroConfig {
|
||||
/// Load config from ~/.config/owlry/config.toml
|
||||
///
|
||||
/// Reads from [plugins.pomodoro] section, with fallback to [providers] for compatibility.
|
||||
fn load() -> Self {
|
||||
let config_path = dirs::config_dir()
|
||||
.map(|d| d.join("owlry").join("config.toml"));
|
||||
|
||||
let config_content = config_path
|
||||
.and_then(|p| fs::read_to_string(p).ok());
|
||||
|
||||
if let Some(content) = config_content
|
||||
&& let Ok(toml) = content.parse::<toml::Table>()
|
||||
{
|
||||
// Try [plugins.pomodoro] first (new format)
|
||||
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
|
||||
&& let Some(pomodoro) = plugins.get("pomodoro").and_then(|v| v.as_table())
|
||||
{
|
||||
return Self::from_toml_table(pomodoro);
|
||||
}
|
||||
|
||||
// Fallback to [providers] section (old format)
|
||||
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
|
||||
let work_mins = providers
|
||||
.get("pomodoro_work_mins")
|
||||
.and_then(|v| v.as_integer())
|
||||
.map(|v| v as u32)
|
||||
.unwrap_or(DEFAULT_WORK_MINS);
|
||||
|
||||
let break_mins = providers
|
||||
.get("pomodoro_break_mins")
|
||||
.and_then(|v| v.as_integer())
|
||||
.map(|v| v as u32)
|
||||
.unwrap_or(DEFAULT_BREAK_MINS);
|
||||
|
||||
return Self { work_mins, break_mins };
|
||||
}
|
||||
}
|
||||
|
||||
// Default config
|
||||
Self {
|
||||
work_mins: DEFAULT_WORK_MINS,
|
||||
break_mins: DEFAULT_BREAK_MINS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse config from a TOML table
|
||||
fn from_toml_table(table: &toml::Table) -> Self {
|
||||
let work_mins = table
|
||||
.get("work_mins")
|
||||
.and_then(|v| v.as_integer())
|
||||
.map(|v| v as u32)
|
||||
.unwrap_or(DEFAULT_WORK_MINS);
|
||||
|
||||
let break_mins = table
|
||||
.get("break_mins")
|
||||
.and_then(|v| v.as_integer())
|
||||
.map(|v| v as u32)
|
||||
.unwrap_or(DEFAULT_BREAK_MINS);
|
||||
|
||||
Self { work_mins, break_mins }
|
||||
}
|
||||
}
|
||||
|
||||
/// Timer phase
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
|
||||
enum PomodoroPhase {
|
||||
#[default]
|
||||
Idle,
|
||||
Working,
|
||||
WorkPaused,
|
||||
Break,
|
||||
BreakPaused,
|
||||
}
|
||||
|
||||
/// Persistent state (saved to disk)
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
struct PomodoroState {
|
||||
phase: PomodoroPhase,
|
||||
remaining_secs: u32,
|
||||
sessions: u32,
|
||||
last_update: u64,
|
||||
}
|
||||
|
||||
/// Pomodoro provider state
|
||||
struct PomodoroProviderState {
|
||||
items: Vec<PluginItem>,
|
||||
state: PomodoroState,
|
||||
work_mins: u32,
|
||||
break_mins: u32,
|
||||
}
|
||||
|
||||
impl PomodoroProviderState {
|
||||
fn new() -> Self {
|
||||
let config = PomodoroConfig::load();
|
||||
|
||||
let state = Self::load_state().unwrap_or_else(|| PomodoroState {
|
||||
phase: PomodoroPhase::Idle,
|
||||
remaining_secs: config.work_mins * 60,
|
||||
sessions: 0,
|
||||
last_update: Self::now_secs(),
|
||||
});
|
||||
|
||||
let mut provider = Self {
|
||||
items: Vec::new(),
|
||||
state,
|
||||
work_mins: config.work_mins,
|
||||
break_mins: config.break_mins,
|
||||
};
|
||||
|
||||
provider.update_elapsed_time();
|
||||
provider.generate_items();
|
||||
provider
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn data_dir() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join("owlry"))
|
||||
}
|
||||
|
||||
fn load_state() -> Option<PomodoroState> {
|
||||
let path = Self::data_dir()?.join("pomodoro.json");
|
||||
let content = fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
fn save_state(&self) {
|
||||
if let Some(data_dir) = Self::data_dir() {
|
||||
let path = data_dir.join("pomodoro.json");
|
||||
if fs::create_dir_all(&data_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let mut state = self.state.clone();
|
||||
state.last_update = Self::now_secs();
|
||||
if let Ok(json) = serde_json::to_string_pretty(&state) {
|
||||
let _ = fs::write(&path, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_elapsed_time(&mut self) {
|
||||
let now = Self::now_secs();
|
||||
let elapsed = now.saturating_sub(self.state.last_update);
|
||||
|
||||
match self.state.phase {
|
||||
PomodoroPhase::Working | PomodoroPhase::Break => {
|
||||
if elapsed >= self.state.remaining_secs as u64 {
|
||||
self.complete_phase();
|
||||
} else {
|
||||
self.state.remaining_secs -= elapsed as u32;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.state.last_update = now;
|
||||
}
|
||||
|
||||
fn complete_phase(&mut self) {
|
||||
match self.state.phase {
|
||||
PomodoroPhase::Working => {
|
||||
self.state.sessions += 1;
|
||||
self.state.phase = PomodoroPhase::Break;
|
||||
self.state.remaining_secs = self.break_mins * 60;
|
||||
notify_with_urgency(
|
||||
"Pomodoro Complete!",
|
||||
&format!(
|
||||
"Great work! Session {} complete. Time for a {}-minute break.",
|
||||
self.state.sessions, self.break_mins
|
||||
),
|
||||
"alarm",
|
||||
NotifyUrgency::Normal,
|
||||
);
|
||||
}
|
||||
PomodoroPhase::Break => {
|
||||
self.state.phase = PomodoroPhase::Idle;
|
||||
self.state.remaining_secs = self.work_mins * 60;
|
||||
notify_with_urgency(
|
||||
"Break Complete",
|
||||
"Break time's over! Ready for another work session?",
|
||||
"alarm",
|
||||
NotifyUrgency::Normal,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.save_state();
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.update_elapsed_time();
|
||||
self.generate_items();
|
||||
}
|
||||
|
||||
fn handle_action(&mut self, action: &str) {
|
||||
match action {
|
||||
"start" => {
|
||||
self.state.phase = PomodoroPhase::Working;
|
||||
self.state.remaining_secs = self.work_mins * 60;
|
||||
self.state.last_update = Self::now_secs();
|
||||
}
|
||||
"pause" => match self.state.phase {
|
||||
PomodoroPhase::Working => self.state.phase = PomodoroPhase::WorkPaused,
|
||||
PomodoroPhase::Break => self.state.phase = PomodoroPhase::BreakPaused,
|
||||
_ => {}
|
||||
},
|
||||
"resume" => {
|
||||
self.state.last_update = Self::now_secs();
|
||||
match self.state.phase {
|
||||
PomodoroPhase::WorkPaused => self.state.phase = PomodoroPhase::Working,
|
||||
PomodoroPhase::BreakPaused => self.state.phase = PomodoroPhase::Break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"skip" => self.complete_phase(),
|
||||
"reset" => {
|
||||
self.state.phase = PomodoroPhase::Idle;
|
||||
self.state.remaining_secs = self.work_mins * 60;
|
||||
self.state.sessions = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.save_state();
|
||||
self.generate_items();
|
||||
}
|
||||
|
||||
fn format_time(secs: u32) -> String {
|
||||
let mins = secs / 60;
|
||||
let secs = secs % 60;
|
||||
format!("{:02}:{:02}", mins, secs)
|
||||
}
|
||||
|
||||
/// Generate single main item with submenu for controls
|
||||
fn generate_items(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
let (phase_name, _is_running) = match self.state.phase {
|
||||
PomodoroPhase::Idle => ("Ready", false),
|
||||
PomodoroPhase::Working => ("Work", true),
|
||||
PomodoroPhase::WorkPaused => ("Paused", false),
|
||||
PomodoroPhase::Break => ("Break", true),
|
||||
PomodoroPhase::BreakPaused => ("Paused", false),
|
||||
};
|
||||
|
||||
let time_str = Self::format_time(self.state.remaining_secs);
|
||||
let name = format!("{}: {}", phase_name, time_str);
|
||||
|
||||
let description = if self.state.sessions > 0 {
|
||||
format!(
|
||||
"Sessions: {} | {}min work / {}min break",
|
||||
self.state.sessions, self.work_mins, self.break_mins
|
||||
)
|
||||
} else {
|
||||
format!("{}min work / {}min break", self.work_mins, self.break_mins)
|
||||
};
|
||||
|
||||
// Single item that opens submenu with controls
|
||||
self.items.push(
|
||||
PluginItem::new("pomo-timer", name, "SUBMENU:pomodoro:controls")
|
||||
.with_description(description)
|
||||
.with_icon("/org/owlry/launcher/icons/pomodoro/tomato.svg")
|
||||
.with_keywords(vec![
|
||||
"pomodoro".to_string(),
|
||||
"widget".to_string(),
|
||||
"timer".to_string(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate submenu items for controls
|
||||
fn generate_submenu_items(&self) -> Vec<PluginItem> {
|
||||
let mut items = Vec::new();
|
||||
let is_running = matches!(
|
||||
self.state.phase,
|
||||
PomodoroPhase::Working | PomodoroPhase::Break
|
||||
);
|
||||
|
||||
// Primary control: Start/Pause/Resume
|
||||
if is_running {
|
||||
items.push(
|
||||
PluginItem::new("pomo-pause", "Pause", "POMODORO:pause")
|
||||
.with_description("Pause the timer")
|
||||
.with_icon("media-playback-pause"),
|
||||
);
|
||||
} else {
|
||||
match self.state.phase {
|
||||
PomodoroPhase::Idle => {
|
||||
items.push(
|
||||
PluginItem::new("pomo-start", "Start Work", "POMODORO:start")
|
||||
.with_description("Start a new work session")
|
||||
.with_icon("media-playback-start"),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
items.push(
|
||||
PluginItem::new("pomo-resume", "Resume", "POMODORO:resume")
|
||||
.with_description("Resume the timer")
|
||||
.with_icon("media-playback-start"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip (only when not idle)
|
||||
if self.state.phase != PomodoroPhase::Idle {
|
||||
items.push(
|
||||
PluginItem::new("pomo-skip", "Skip", "POMODORO:skip")
|
||||
.with_description("Skip to next phase")
|
||||
.with_icon("media-skip-forward"),
|
||||
);
|
||||
}
|
||||
|
||||
// Reset
|
||||
items.push(
|
||||
PluginItem::new("pomo-reset", "Reset", "POMODORO:reset")
|
||||
.with_description("Reset timer and sessions")
|
||||
.with_icon("view-refresh"),
|
||||
);
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RNone,
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Widget,
|
||||
priority: 11500, // Widget: pomodoro timer
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(PomodoroProviderState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
let state = unsafe { &mut *(handle.ptr as *mut PomodoroProviderState) };
|
||||
state.refresh();
|
||||
state.items.clone().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
let query_str = query.as_str();
|
||||
let state = unsafe { &mut *(handle.ptr as *mut PomodoroProviderState) };
|
||||
|
||||
// Handle submenu request
|
||||
if query_str == "?SUBMENU:controls" {
|
||||
return state.generate_submenu_items().into();
|
||||
}
|
||||
|
||||
// Handle action commands
|
||||
if let Some(action) = query_str.strip_prefix("!POMODORO:") {
|
||||
state.handle_action(action);
|
||||
}
|
||||
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
let state = unsafe { &*(handle.ptr as *const PomodoroProviderState) };
|
||||
state.save_state();
|
||||
unsafe {
|
||||
handle.drop_as::<PomodoroProviderState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_time() {
|
||||
assert_eq!(PomodoroProviderState::format_time(0), "00:00");
|
||||
assert_eq!(PomodoroProviderState::format_time(60), "01:00");
|
||||
assert_eq!(PomodoroProviderState::format_time(90), "01:30");
|
||||
assert_eq!(PomodoroProviderState::format_time(1500), "25:00");
|
||||
assert_eq!(PomodoroProviderState::format_time(3599), "59:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_phase() {
|
||||
let phase: PomodoroPhase = Default::default();
|
||||
assert_eq!(phase, PomodoroPhase::Idle);
|
||||
}
|
||||
}
|
||||
23
crates/owlry-plugin-scripts/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "owlry-plugin-scripts"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Scripts plugin for owlry - run user scripts from ~/.local/share/owlry/scripts/"
|
||||
keywords = ["owlry", "plugin", "scripts"]
|
||||
categories = ["os"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# For finding ~/.local/share/owlry/scripts
|
||||
dirs = "5.0"
|
||||
290
crates/owlry-plugin-scripts/src/lib.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
//! Scripts Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that scans `~/.local/share/owlry/scripts/` for executable
|
||||
//! scripts and provides them as launch items.
|
||||
//!
|
||||
//! Scripts can include a description by adding a comment after the shebang:
|
||||
//! ```bash
|
||||
//! #!/bin/bash
|
||||
//! # This is my script description
|
||||
//! echo "Hello"
|
||||
//! ```
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "scripts";
|
||||
const PLUGIN_NAME: &str = "Scripts";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Run user scripts from ~/.local/share/owlry/scripts/";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "scripts";
|
||||
const PROVIDER_NAME: &str = "Scripts";
|
||||
const PROVIDER_PREFIX: &str = ":script";
|
||||
const PROVIDER_ICON: &str = "utilities-terminal";
|
||||
const PROVIDER_TYPE_ID: &str = "scripts";
|
||||
|
||||
/// Scripts provider state - holds cached items
|
||||
struct ScriptsState {
|
||||
items: Vec<PluginItem>,
|
||||
}
|
||||
|
||||
impl ScriptsState {
|
||||
fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
fn scripts_dir() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join("owlry").join("scripts"))
|
||||
}
|
||||
|
||||
fn load_scripts(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
let scripts_dir = match Self::scripts_dir() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
if !scripts_dir.exists() {
|
||||
// Create the directory for the user
|
||||
let _ = fs::create_dir_all(&scripts_dir);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(&scripts_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
// Skip directories
|
||||
if path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if executable
|
||||
let metadata = match path.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let is_executable = metadata.permissions().mode() & 0o111 != 0;
|
||||
if !is_executable {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get script name without extension
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let name = path
|
||||
.file_stem()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or(filename.clone());
|
||||
|
||||
// Try to read description from first line comment
|
||||
let description = Self::read_script_description(&path);
|
||||
|
||||
// Determine icon based on extension or shebang
|
||||
let icon = Self::determine_icon(&path);
|
||||
|
||||
let mut item = PluginItem::new(
|
||||
format!("script:{}", filename),
|
||||
format!("Script: {}", name),
|
||||
path.to_string_lossy().to_string(),
|
||||
)
|
||||
.with_icon(icon)
|
||||
.with_keywords(vec!["script".to_string()]);
|
||||
|
||||
if let Some(desc) = description {
|
||||
item = item.with_description(desc);
|
||||
}
|
||||
|
||||
self.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_script_description(path: &PathBuf) -> Option<String> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
let mut lines = content.lines();
|
||||
|
||||
// Skip shebang if present
|
||||
let first_line = lines.next()?;
|
||||
let check_line = if first_line.starts_with("#!") {
|
||||
lines.next()?
|
||||
} else {
|
||||
first_line
|
||||
};
|
||||
|
||||
// Look for a comment description
|
||||
if let Some(desc) = check_line.strip_prefix("# ") {
|
||||
Some(desc.trim().to_string())
|
||||
} else { check_line.strip_prefix("// ").map(|desc| desc.trim().to_string()) }
|
||||
}
|
||||
|
||||
fn determine_icon(path: &PathBuf) -> String {
|
||||
// Check extension first
|
||||
if let Some(ext) = path.extension() {
|
||||
match ext.to_string_lossy().as_ref() {
|
||||
"sh" | "bash" | "zsh" => return "utilities-terminal".to_string(),
|
||||
"py" | "python" => return "text-x-python".to_string(),
|
||||
"js" | "ts" => return "text-x-javascript".to_string(),
|
||||
"rb" => return "text-x-ruby".to_string(),
|
||||
"pl" => return "text-x-perl".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Check shebang
|
||||
if let Ok(content) = fs::read_to_string(path)
|
||||
&& let Some(first_line) = content.lines().next() {
|
||||
if first_line.contains("bash") || first_line.contains("sh") {
|
||||
return "utilities-terminal".to_string();
|
||||
} else if first_line.contains("python") {
|
||||
return "text-x-python".to_string();
|
||||
} else if first_line.contains("node") {
|
||||
return "text-x-javascript".to_string();
|
||||
} else if first_line.contains("ruby") {
|
||||
return "text-x-ruby".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
"application-x-executable".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(ScriptsState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<ScriptsState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut ScriptsState) };
|
||||
|
||||
// Load scripts
|
||||
state.load_scripts();
|
||||
|
||||
// Return items
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query is handled by the core using cached items
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<ScriptsState>
|
||||
unsafe {
|
||||
handle.drop_as::<ScriptsState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scripts_state_new() {
|
||||
let state = ScriptsState::new();
|
||||
assert!(state.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_icon_sh() {
|
||||
let path = PathBuf::from("/test/script.sh");
|
||||
let icon = ScriptsState::determine_icon(&path);
|
||||
assert_eq!(icon, "utilities-terminal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_icon_python() {
|
||||
let path = PathBuf::from("/test/script.py");
|
||||
let icon = ScriptsState::determine_icon(&path);
|
||||
assert_eq!(icon, "text-x-python");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_icon_js() {
|
||||
let path = PathBuf::from("/test/script.js");
|
||||
let icon = ScriptsState::determine_icon(&path);
|
||||
assert_eq!(icon, "text-x-javascript");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_icon_unknown() {
|
||||
let path = PathBuf::from("/test/script.xyz");
|
||||
let icon = ScriptsState::determine_icon(&path);
|
||||
assert_eq!(icon, "application-x-executable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scripts_dir() {
|
||||
// Should return Some path
|
||||
let dir = ScriptsState::scripts_dir();
|
||||
assert!(dir.is_some());
|
||||
assert!(dir.unwrap().ends_with("owlry/scripts"));
|
||||
}
|
||||
}
|
||||
23
crates/owlry-plugin-ssh/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "owlry-plugin-ssh"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "SSH plugin for owlry - quick connect to SSH hosts from ~/.ssh/config"
|
||||
keywords = ["owlry", "plugin", "ssh"]
|
||||
categories = ["network-programming"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# For finding ~/.ssh/config
|
||||
dirs = "5.0"
|
||||
328
crates/owlry-plugin-ssh/src/lib.rs
Normal file
@@ -0,0 +1,328 @@
|
||||
//! SSH Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that parses ~/.ssh/config and provides quick-connect
|
||||
//! entries for SSH hosts.
|
||||
//!
|
||||
//! Examples:
|
||||
//! - `SSH: myserver` → Connect to myserver
|
||||
//! - `SSH: work-box` → Connect to work-box with configured user/port
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "ssh";
|
||||
const PLUGIN_NAME: &str = "SSH";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Quick connect to SSH hosts from ~/.ssh/config";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "ssh";
|
||||
const PROVIDER_NAME: &str = "SSH";
|
||||
const PROVIDER_PREFIX: &str = ":ssh";
|
||||
const PROVIDER_ICON: &str = "utilities-terminal";
|
||||
const PROVIDER_TYPE_ID: &str = "ssh";
|
||||
|
||||
// Default terminal command (TODO: make configurable via plugin config)
|
||||
const DEFAULT_TERMINAL: &str = "kitty";
|
||||
|
||||
/// SSH provider state - holds cached items
|
||||
struct SshState {
|
||||
items: Vec<PluginItem>,
|
||||
terminal_command: String,
|
||||
}
|
||||
|
||||
impl SshState {
|
||||
fn new() -> Self {
|
||||
// Try to detect terminal from environment, fall back to default
|
||||
let terminal = std::env::var("TERMINAL")
|
||||
.unwrap_or_else(|_| DEFAULT_TERMINAL.to_string());
|
||||
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
terminal_command: terminal,
|
||||
}
|
||||
}
|
||||
|
||||
fn ssh_config_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".ssh").join("config"))
|
||||
}
|
||||
|
||||
fn parse_ssh_config(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
let config_path = match Self::ssh_config_path() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
if !config_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(&config_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut current_host: Option<String> = None;
|
||||
let mut current_hostname: Option<String> = None;
|
||||
let mut current_user: Option<String> = None;
|
||||
let mut current_port: Option<String> = None;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split on whitespace or '='
|
||||
let parts: Vec<&str> = line
|
||||
.splitn(2, |c: char| c.is_whitespace() || c == '=')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if parts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = parts[0].to_lowercase();
|
||||
let value = parts[1];
|
||||
|
||||
match key.as_str() {
|
||||
"host" => {
|
||||
// Save previous host if exists
|
||||
if let Some(host) = current_host.take() {
|
||||
self.add_host_item(
|
||||
&host,
|
||||
current_hostname.take(),
|
||||
current_user.take(),
|
||||
current_port.take(),
|
||||
);
|
||||
}
|
||||
|
||||
// Skip wildcards and patterns
|
||||
if !value.contains('*') && !value.contains('?') && value != "*" {
|
||||
current_host = Some(value.to_string());
|
||||
}
|
||||
current_hostname = None;
|
||||
current_user = None;
|
||||
current_port = None;
|
||||
}
|
||||
"hostname" => {
|
||||
current_hostname = Some(value.to_string());
|
||||
}
|
||||
"user" => {
|
||||
current_user = Some(value.to_string());
|
||||
}
|
||||
"port" => {
|
||||
current_port = Some(value.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last host
|
||||
if let Some(host) = current_host.take() {
|
||||
self.add_host_item(&host, current_hostname, current_user, current_port);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_host_item(
|
||||
&mut self,
|
||||
host: &str,
|
||||
hostname: Option<String>,
|
||||
user: Option<String>,
|
||||
port: Option<String>,
|
||||
) {
|
||||
// Build description
|
||||
let mut desc_parts = Vec::new();
|
||||
if let Some(ref h) = hostname {
|
||||
desc_parts.push(h.clone());
|
||||
}
|
||||
if let Some(ref u) = user {
|
||||
desc_parts.push(format!("user: {}", u));
|
||||
}
|
||||
if let Some(ref p) = port {
|
||||
desc_parts.push(format!("port: {}", p));
|
||||
}
|
||||
|
||||
let description = if desc_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(desc_parts.join(", "))
|
||||
};
|
||||
|
||||
// Build SSH command - just use the host alias, SSH will resolve the rest
|
||||
let ssh_command = format!("ssh {}", host);
|
||||
|
||||
// Wrap in terminal
|
||||
let command = format!("{} -e {}", self.terminal_command, ssh_command);
|
||||
|
||||
let mut item = PluginItem::new(
|
||||
format!("ssh:{}", host),
|
||||
format!("SSH: {}", host),
|
||||
command,
|
||||
)
|
||||
.with_icon(PROVIDER_ICON)
|
||||
.with_keywords(vec!["ssh".to_string(), "remote".to_string()]);
|
||||
|
||||
if let Some(desc) = description {
|
||||
item = item.with_description(desc);
|
||||
}
|
||||
|
||||
self.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(SshState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<SshState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut SshState) };
|
||||
|
||||
// Parse SSH config
|
||||
state.parse_ssh_config();
|
||||
|
||||
// Return items
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query is handled by the core using cached items
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<SshState>
|
||||
unsafe {
|
||||
handle.drop_as::<SshState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ssh_state_new() {
|
||||
let state = SshState::new();
|
||||
assert!(state.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_config() {
|
||||
let mut state = SshState::new();
|
||||
|
||||
// We can't easily test the full flow without mocking file paths,
|
||||
// but we can test the add_host_item method
|
||||
state.add_host_item(
|
||||
"myserver",
|
||||
Some("192.168.1.100".to_string()),
|
||||
Some("admin".to_string()),
|
||||
Some("2222".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(state.items.len(), 1);
|
||||
assert_eq!(state.items[0].name.as_str(), "SSH: myserver");
|
||||
assert!(state.items[0].command.as_str().contains("ssh myserver"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_host_without_details() {
|
||||
let mut state = SshState::new();
|
||||
state.add_host_item("simple-host", None, None, None);
|
||||
|
||||
assert_eq!(state.items.len(), 1);
|
||||
assert_eq!(state.items[0].name.as_str(), "SSH: simple-host");
|
||||
assert!(state.items[0].description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_host_with_partial_details() {
|
||||
let mut state = SshState::new();
|
||||
state.add_host_item("partial", Some("example.com".to_string()), None, None);
|
||||
|
||||
assert_eq!(state.items.len(), 1);
|
||||
let desc = state.items[0].description.as_ref().unwrap();
|
||||
assert_eq!(desc.as_str(), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_items_have_icons() {
|
||||
let mut state = SshState::new();
|
||||
state.add_host_item("test", None, None, None);
|
||||
|
||||
assert!(state.items[0].icon.is_some());
|
||||
assert_eq!(state.items[0].icon.as_ref().unwrap().as_str(), PROVIDER_ICON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_items_have_keywords() {
|
||||
let mut state = SshState::new();
|
||||
state.add_host_item("test", None, None, None);
|
||||
|
||||
assert!(!state.items[0].keywords.is_empty());
|
||||
let keywords: Vec<&str> = state.items[0].keywords.iter().map(|s| s.as_str()).collect();
|
||||
assert!(keywords.contains(&"ssh"));
|
||||
}
|
||||
}
|
||||
20
crates/owlry-plugin-system/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "owlry-plugin-system"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "System plugin for owlry - power and session management commands"
|
||||
keywords = ["owlry", "plugin", "system", "power"]
|
||||
categories = ["os"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
254
crates/owlry-plugin-system/src/lib.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
//! System Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that provides system power and session management commands.
|
||||
//!
|
||||
//! Commands:
|
||||
//! - Shutdown - Power off the system
|
||||
//! - Reboot - Restart the system
|
||||
//! - Reboot into BIOS - Restart into UEFI/BIOS setup
|
||||
//! - Suspend - Suspend to RAM
|
||||
//! - Hibernate - Suspend to disk
|
||||
//! - Lock Screen - Lock the session
|
||||
//! - Log Out - End the current session
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "system";
|
||||
const PLUGIN_NAME: &str = "System";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Power and session management commands";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "system";
|
||||
const PROVIDER_NAME: &str = "System";
|
||||
const PROVIDER_PREFIX: &str = ":sys";
|
||||
const PROVIDER_ICON: &str = "system-shutdown";
|
||||
const PROVIDER_TYPE_ID: &str = "system";
|
||||
|
||||
/// System provider state - holds cached items
|
||||
struct SystemState {
|
||||
items: Vec<PluginItem>,
|
||||
}
|
||||
|
||||
impl SystemState {
|
||||
fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
fn load_commands(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
// Define system commands
|
||||
// Format: (id, name, description, icon, command)
|
||||
let commands: &[(&str, &str, &str, &str, &str)] = &[
|
||||
(
|
||||
"system:shutdown",
|
||||
"Shutdown",
|
||||
"Power off the system",
|
||||
"system-shutdown",
|
||||
"systemctl poweroff",
|
||||
),
|
||||
(
|
||||
"system:reboot",
|
||||
"Reboot",
|
||||
"Restart the system",
|
||||
"system-reboot",
|
||||
"systemctl reboot",
|
||||
),
|
||||
(
|
||||
"system:reboot-bios",
|
||||
"Reboot into BIOS",
|
||||
"Restart into UEFI/BIOS setup",
|
||||
"system-reboot",
|
||||
"systemctl reboot --firmware-setup",
|
||||
),
|
||||
(
|
||||
"system:suspend",
|
||||
"Suspend",
|
||||
"Suspend to RAM",
|
||||
"system-suspend",
|
||||
"systemctl suspend",
|
||||
),
|
||||
(
|
||||
"system:hibernate",
|
||||
"Hibernate",
|
||||
"Suspend to disk",
|
||||
"system-suspend-hibernate",
|
||||
"systemctl hibernate",
|
||||
),
|
||||
(
|
||||
"system:lock",
|
||||
"Lock Screen",
|
||||
"Lock the session",
|
||||
"system-lock-screen",
|
||||
"loginctl lock-session",
|
||||
),
|
||||
(
|
||||
"system:logout",
|
||||
"Log Out",
|
||||
"End the current session",
|
||||
"system-log-out",
|
||||
"loginctl terminate-session self",
|
||||
),
|
||||
];
|
||||
|
||||
for (id, name, description, icon, command) in commands {
|
||||
self.items.push(
|
||||
PluginItem::new(*id, *name, *command)
|
||||
.with_description(*description)
|
||||
.with_icon(*icon)
|
||||
.with_keywords(vec!["power".to_string(), "system".to_string()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(SystemState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<SystemState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut SystemState) };
|
||||
|
||||
// Load/reload commands
|
||||
state.load_commands();
|
||||
|
||||
// Return items
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query is handled by the core using cached items
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<SystemState>
|
||||
unsafe {
|
||||
handle.drop_as::<SystemState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_system_state_new() {
|
||||
let state = SystemState::new();
|
||||
assert!(state.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_commands_loaded() {
|
||||
let mut state = SystemState::new();
|
||||
state.load_commands();
|
||||
|
||||
assert!(state.items.len() >= 6);
|
||||
|
||||
// Check for specific commands
|
||||
let names: Vec<&str> = state.items.iter().map(|i| i.name.as_str()).collect();
|
||||
assert!(names.contains(&"Shutdown"));
|
||||
assert!(names.contains(&"Reboot"));
|
||||
assert!(names.contains(&"Suspend"));
|
||||
assert!(names.contains(&"Lock Screen"));
|
||||
assert!(names.contains(&"Log Out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reboot_bios_command() {
|
||||
let mut state = SystemState::new();
|
||||
state.load_commands();
|
||||
|
||||
let bios_cmd = state
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.name.as_str() == "Reboot into BIOS")
|
||||
.expect("Reboot into BIOS should exist");
|
||||
|
||||
assert_eq!(bios_cmd.command.as_str(), "systemctl reboot --firmware-setup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commands_have_icons() {
|
||||
let mut state = SystemState::new();
|
||||
state.load_commands();
|
||||
|
||||
for item in &state.items {
|
||||
assert!(
|
||||
item.icon.is_some(),
|
||||
"Item '{}' should have an icon",
|
||||
item.name.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commands_have_descriptions() {
|
||||
let mut state = SystemState::new();
|
||||
state.load_commands();
|
||||
|
||||
for item in &state.items {
|
||||
assert!(
|
||||
item.description.is_some(),
|
||||
"Item '{}' should have a description",
|
||||
item.name.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
crates/owlry-plugin-systemd/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "owlry-plugin-systemd"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "systemd user services plugin for owlry - list and control user-level systemd services"
|
||||
keywords = ["owlry", "plugin", "systemd", "services"]
|
||||
categories = ["os"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
457
crates/owlry-plugin-systemd/src/lib.rs
Normal file
@@ -0,0 +1,457 @@
|
||||
//! systemd User Services Plugin for Owlry
|
||||
//!
|
||||
//! Lists and controls systemd user-level services.
|
||||
//! Uses `systemctl --user` commands to interact with services.
|
||||
//!
|
||||
//! Each service item opens a submenu with actions like:
|
||||
//! - Start/Stop/Restart/Reload/Kill
|
||||
//! - Enable/Disable on startup
|
||||
//! - View status and journal logs
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use std::process::Command;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "systemd";
|
||||
const PLUGIN_NAME: &str = "systemd Services";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "List and control systemd user services";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "systemd";
|
||||
const PROVIDER_NAME: &str = "User Units";
|
||||
const PROVIDER_PREFIX: &str = ":uuctl";
|
||||
const PROVIDER_ICON: &str = "system-run";
|
||||
const PROVIDER_TYPE_ID: &str = "uuctl";
|
||||
|
||||
/// systemd provider state
|
||||
struct SystemdState {
|
||||
items: Vec<PluginItem>,
|
||||
}
|
||||
|
||||
impl SystemdState {
|
||||
fn new() -> Self {
|
||||
let mut state = Self { items: Vec::new() };
|
||||
state.refresh();
|
||||
state
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
if !Self::systemctl_available() {
|
||||
return;
|
||||
}
|
||||
|
||||
// List all user services (both running and available)
|
||||
let output = match Command::new("systemctl")
|
||||
.args([
|
||||
"--user",
|
||||
"list-units",
|
||||
"--type=service",
|
||||
"--all",
|
||||
"--no-legend",
|
||||
"--no-pager",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) if o.status.success() => o,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
self.items = Self::parse_systemctl_output(&stdout);
|
||||
|
||||
// Sort by name
|
||||
self.items.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
|
||||
}
|
||||
|
||||
fn systemctl_available() -> bool {
|
||||
Command::new("systemctl")
|
||||
.args(["--user", "--version"])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn parse_systemctl_output(output: &str) -> Vec<PluginItem> {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse systemctl output - handle variable whitespace
|
||||
// Format: UNIT LOAD ACTIVE SUB DESCRIPTION...
|
||||
let mut parts = line.split_whitespace();
|
||||
|
||||
let unit_name = match parts.next() {
|
||||
Some(u) => u,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Skip if not a proper service name
|
||||
if !unit_name.ends_with(".service") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _load_state = parts.next().unwrap_or("");
|
||||
let active_state = parts.next().unwrap_or("");
|
||||
let sub_state = parts.next().unwrap_or("");
|
||||
let description: String = parts.collect::<Vec<_>>().join(" ");
|
||||
|
||||
// Create a clean display name
|
||||
let display_name = unit_name
|
||||
.trim_end_matches(".service")
|
||||
.replace("app-", "")
|
||||
.replace("@autostart", "")
|
||||
.replace("\\x2d", "-");
|
||||
|
||||
let is_active = active_state == "active";
|
||||
let status_icon = if is_active { "●" } else { "○" };
|
||||
|
||||
let status_desc = if description.is_empty() {
|
||||
format!("{} {} ({})", status_icon, sub_state, active_state)
|
||||
} else {
|
||||
format!("{} {} ({})", status_icon, description, sub_state)
|
||||
};
|
||||
|
||||
// Store service info in the command field as encoded data
|
||||
// Format: SUBMENU:type_id:data where data is "unit_name:is_active"
|
||||
let submenu_data = format!("SUBMENU:uuctl:{}:{}", unit_name, is_active);
|
||||
|
||||
let icon = if is_active {
|
||||
"emblem-ok-symbolic"
|
||||
} else {
|
||||
"emblem-pause-symbolic"
|
||||
};
|
||||
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:service:{}", unit_name),
|
||||
display_name,
|
||||
submenu_data,
|
||||
)
|
||||
.with_description(status_desc)
|
||||
.with_icon(icon)
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Submenu Action Generation (exported for core to use)
|
||||
// ============================================================================
|
||||
|
||||
/// Generate submenu actions for a given service
|
||||
/// This function is called by the core when a service is selected
|
||||
pub fn actions_for_service(unit_name: &str, display_name: &str, is_active: bool) -> Vec<PluginItem> {
|
||||
let mut actions = Vec::new();
|
||||
|
||||
if is_active {
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:restart:{}", unit_name),
|
||||
"↻ Restart",
|
||||
format!("systemctl --user restart {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Restart {}", display_name))
|
||||
.with_icon("view-refresh")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:stop:{}", unit_name),
|
||||
"■ Stop",
|
||||
format!("systemctl --user stop {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Stop {}", display_name))
|
||||
.with_icon("process-stop")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:reload:{}", unit_name),
|
||||
"⟳ Reload",
|
||||
format!("systemctl --user reload {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Reload {} configuration", display_name))
|
||||
.with_icon("view-refresh")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:kill:{}", unit_name),
|
||||
"✗ Kill",
|
||||
format!("systemctl --user kill {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Force kill {}", display_name))
|
||||
.with_icon("edit-delete")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
} else {
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:start:{}", unit_name),
|
||||
"▶ Start",
|
||||
format!("systemctl --user start {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Start {}", display_name))
|
||||
.with_icon("media-playback-start")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
}
|
||||
|
||||
// Always available actions
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:status:{}", unit_name),
|
||||
"ℹ Status",
|
||||
format!("systemctl --user status {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Show {} status", display_name))
|
||||
.with_icon("dialog-information")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()])
|
||||
.with_terminal(true),
|
||||
);
|
||||
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:journal:{}", unit_name),
|
||||
"📋 Journal",
|
||||
format!("journalctl --user -u {} -f", unit_name),
|
||||
)
|
||||
.with_description(format!("Show {} logs", display_name))
|
||||
.with_icon("utilities-system-monitor")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()])
|
||||
.with_terminal(true),
|
||||
);
|
||||
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:enable:{}", unit_name),
|
||||
"⊕ Enable",
|
||||
format!("systemctl --user enable {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Enable {} on startup", display_name))
|
||||
.with_icon("emblem-default")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:disable:{}", unit_name),
|
||||
"⊖ Disable",
|
||||
format!("systemctl --user disable {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Disable {} on startup", display_name))
|
||||
.with_icon("emblem-unreadable")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(SystemdState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<SystemdState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut SystemdState) };
|
||||
|
||||
state.refresh();
|
||||
state.items.clone().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
let query_str = query.as_str();
|
||||
|
||||
// Handle submenu action requests: ?SUBMENU:unit.service:is_active
|
||||
if let Some(data) = query_str.strip_prefix("?SUBMENU:") {
|
||||
// Parse data format: "unit_name:is_active"
|
||||
let parts: Vec<&str> = data.splitn(2, ':').collect();
|
||||
if parts.len() >= 2 {
|
||||
let unit_name = parts[0];
|
||||
let is_active = parts[1] == "true";
|
||||
let display_name = unit_name
|
||||
.trim_end_matches(".service")
|
||||
.replace("app-", "")
|
||||
.replace("@autostart", "")
|
||||
.replace("\\x2d", "-");
|
||||
|
||||
return actions_for_service(unit_name, &display_name, is_active).into();
|
||||
} else if !data.is_empty() {
|
||||
// Fallback: just unit name, assume not active
|
||||
let display_name = data
|
||||
.trim_end_matches(".service")
|
||||
.replace("app-", "")
|
||||
.replace("@autostart", "")
|
||||
.replace("\\x2d", "-");
|
||||
return actions_for_service(data, &display_name, false).into();
|
||||
}
|
||||
}
|
||||
|
||||
// Static provider - normal queries not used
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<SystemdState>
|
||||
unsafe {
|
||||
handle.drop_as::<SystemdState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_systemctl_output() {
|
||||
let output = r#"
|
||||
foo.service loaded active running Foo Service
|
||||
bar.service loaded inactive dead Bar Service
|
||||
baz@autostart.service loaded active running Baz App
|
||||
"#;
|
||||
let items = SystemdState::parse_systemctl_output(output);
|
||||
assert_eq!(items.len(), 3);
|
||||
|
||||
// Check first item
|
||||
assert_eq!(items[0].name.as_str(), "foo");
|
||||
assert!(items[0].command.as_str().contains("SUBMENU:uuctl:foo.service:true"));
|
||||
|
||||
// Check second item (inactive)
|
||||
assert_eq!(items[1].name.as_str(), "bar");
|
||||
assert!(items[1].command.as_str().contains("SUBMENU:uuctl:bar.service:false"));
|
||||
|
||||
// Check third item (cleaned name)
|
||||
assert_eq!(items[2].name.as_str(), "baz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actions_for_active_service() {
|
||||
let actions = actions_for_service("test.service", "Test", true);
|
||||
|
||||
// Active services should have restart, stop, reload, kill + common actions
|
||||
let action_ids: Vec<_> = actions.iter().map(|a| a.id.as_str()).collect();
|
||||
assert!(action_ids.contains(&"systemd:restart:test.service"));
|
||||
assert!(action_ids.contains(&"systemd:stop:test.service"));
|
||||
assert!(action_ids.contains(&"systemd:status:test.service"));
|
||||
assert!(!action_ids.contains(&"systemd:start:test.service")); // Not for active
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actions_for_inactive_service() {
|
||||
let actions = actions_for_service("test.service", "Test", false);
|
||||
|
||||
// Inactive services should have start + common actions
|
||||
let action_ids: Vec<_> = actions.iter().map(|a| a.id.as_str()).collect();
|
||||
assert!(action_ids.contains(&"systemd:start:test.service"));
|
||||
assert!(action_ids.contains(&"systemd:status:test.service"));
|
||||
assert!(!action_ids.contains(&"systemd:stop:test.service")); // Not for inactive
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_actions() {
|
||||
let actions = actions_for_service("test.service", "Test", true);
|
||||
|
||||
// Status and journal should have terminal=true
|
||||
for action in &actions {
|
||||
let id = action.id.as_str();
|
||||
if id.contains(":status:") || id.contains(":journal:") {
|
||||
assert!(action.terminal, "Action {} should have terminal=true", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submenu_query() {
|
||||
// Test that provider_query handles ?SUBMENU: queries correctly
|
||||
let handle = ProviderHandle { ptr: std::ptr::null_mut() };
|
||||
|
||||
// Query for active service
|
||||
let query = RStr::from_str("?SUBMENU:test.service:true");
|
||||
let actions = provider_query(handle, query);
|
||||
assert!(!actions.is_empty(), "Should return actions for submenu query");
|
||||
|
||||
// Should have restart action for active service
|
||||
let has_restart = actions.iter().any(|a| a.id.as_str().contains(":restart:"));
|
||||
assert!(has_restart, "Active service should have restart action");
|
||||
|
||||
// Query for inactive service
|
||||
let query = RStr::from_str("?SUBMENU:test.service:false");
|
||||
let actions = provider_query(handle, query);
|
||||
assert!(!actions.is_empty(), "Should return actions for submenu query");
|
||||
|
||||
// Should have start action for inactive service
|
||||
let has_start = actions.iter().any(|a| a.id.as_str().contains(":start:"));
|
||||
assert!(has_start, "Inactive service should have start action");
|
||||
|
||||
// Normal query should return empty
|
||||
let query = RStr::from_str("some search");
|
||||
let actions = provider_query(handle, query);
|
||||
assert!(actions.is_empty(), "Normal query should return empty");
|
||||
}
|
||||
}
|
||||
33
crates/owlry-plugin-weather/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "owlry-plugin-weather"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Weather widget plugin for owlry - shows current weather with multiple API support"
|
||||
keywords = ["owlry", "plugin", "weather", "widget"]
|
||||
categories = ["gui"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# HTTP client for weather API requests
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
|
||||
# JSON parsing for API responses
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# TOML config parsing
|
||||
toml = "0.8"
|
||||
|
||||
# XDG directories for cache persistence
|
||||
dirs = "5.0"
|
||||
754
crates/owlry-plugin-weather/src/lib.rs
Normal file
@@ -0,0 +1,754 @@
|
||||
//! Weather Widget Plugin for Owlry
|
||||
//!
|
||||
//! Shows current weather with support for multiple APIs:
|
||||
//! - wttr.in (default, no API key required)
|
||||
//! - OpenWeatherMap (requires API key)
|
||||
//! - Open-Meteo (no API key required)
|
||||
//!
|
||||
//! Weather data is cached for 15 minutes.
|
||||
//!
|
||||
//! ## Configuration
|
||||
//!
|
||||
//! Configure via `~/.config/owlry/config.toml`:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [plugins.weather]
|
||||
//! provider = "wttr.in" # or: openweathermap, open-meteo
|
||||
//! location = "Berlin" # city name or "lat,lon"
|
||||
//! # api_key = "..." # Required for OpenWeatherMap
|
||||
//! ```
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "weather";
|
||||
const PLUGIN_NAME: &str = "Weather";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Weather widget with multiple API support";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "weather";
|
||||
const PROVIDER_NAME: &str = "Weather";
|
||||
const PROVIDER_ICON: &str = "weather-clear";
|
||||
const PROVIDER_TYPE_ID: &str = "weather";
|
||||
|
||||
// Timing constants
|
||||
const CACHE_DURATION_SECS: u64 = 900; // 15 minutes
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const USER_AGENT: &str = "owlry-launcher/0.3";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum WeatherProviderType {
|
||||
WttrIn,
|
||||
OpenWeatherMap,
|
||||
OpenMeteo,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for WeatherProviderType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"wttr.in" | "wttr" | "wttrin" => Ok(Self::WttrIn),
|
||||
"openweathermap" | "owm" => Ok(Self::OpenWeatherMap),
|
||||
"open-meteo" | "openmeteo" | "meteo" => Ok(Self::OpenMeteo),
|
||||
_ => Err(format!("Unknown weather provider: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WeatherConfig {
|
||||
provider: WeatherProviderType,
|
||||
api_key: Option<String>,
|
||||
location: String,
|
||||
}
|
||||
|
||||
impl WeatherConfig {
|
||||
/// Load config from ~/.config/owlry/config.toml
|
||||
///
|
||||
/// Reads from [plugins.weather] section, with fallback to [providers] for compatibility.
|
||||
fn load() -> Self {
|
||||
let config_path = dirs::config_dir()
|
||||
.map(|d| d.join("owlry").join("config.toml"));
|
||||
|
||||
let config_content = config_path
|
||||
.and_then(|p| fs::read_to_string(p).ok());
|
||||
|
||||
if let Some(content) = config_content
|
||||
&& let Ok(toml) = content.parse::<toml::Table>()
|
||||
{
|
||||
// Try [plugins.weather] first (new format)
|
||||
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
|
||||
&& let Some(weather) = plugins.get("weather").and_then(|v| v.as_table())
|
||||
{
|
||||
return Self::from_toml_table(weather);
|
||||
}
|
||||
|
||||
// Fallback to [providers] section (old format)
|
||||
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
|
||||
let provider_str = providers
|
||||
.get("weather_provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("wttr.in");
|
||||
|
||||
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
|
||||
|
||||
let api_key = providers
|
||||
.get("weather_api_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let location = providers
|
||||
.get("weather_location")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
return Self {
|
||||
provider,
|
||||
api_key,
|
||||
location,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Default config
|
||||
Self {
|
||||
provider: WeatherProviderType::WttrIn,
|
||||
api_key: None,
|
||||
location: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse config from a TOML table
|
||||
fn from_toml_table(table: &toml::Table) -> Self {
|
||||
let provider_str = table
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("wttr.in");
|
||||
|
||||
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
|
||||
|
||||
let api_key = table
|
||||
.get("api_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let location = table
|
||||
.get("location")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Self {
|
||||
provider,
|
||||
api_key,
|
||||
location,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached weather data (persisted to disk)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WeatherData {
|
||||
temperature: f32,
|
||||
feels_like: Option<f32>,
|
||||
condition: String,
|
||||
humidity: Option<u8>,
|
||||
wind_speed: Option<f32>,
|
||||
icon: String,
|
||||
location: String,
|
||||
}
|
||||
|
||||
/// Persistent cache structure (saved to ~/.local/share/owlry/weather_cache.json)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WeatherCache {
|
||||
last_fetch_epoch: u64,
|
||||
data: WeatherData,
|
||||
}
|
||||
|
||||
/// Weather provider state
|
||||
struct WeatherState {
|
||||
items: Vec<PluginItem>,
|
||||
config: WeatherConfig,
|
||||
last_fetch_epoch: u64,
|
||||
cached_data: Option<WeatherData>,
|
||||
}
|
||||
|
||||
impl WeatherState {
|
||||
fn new() -> Self {
|
||||
Self::with_config(WeatherConfig::load())
|
||||
}
|
||||
|
||||
fn with_config(config: WeatherConfig) -> Self {
|
||||
// Load cached weather from disk if available
|
||||
// This prevents blocking HTTP requests on every app open
|
||||
let (last_fetch_epoch, cached_data) = Self::load_cache()
|
||||
.map(|c| (c.last_fetch_epoch, Some(c.data)))
|
||||
.unwrap_or((0, None));
|
||||
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
config,
|
||||
last_fetch_epoch,
|
||||
cached_data,
|
||||
}
|
||||
}
|
||||
|
||||
fn data_dir() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join("owlry"))
|
||||
}
|
||||
|
||||
fn cache_path() -> Option<PathBuf> {
|
||||
Self::data_dir().map(|d| d.join("weather_cache.json"))
|
||||
}
|
||||
|
||||
fn load_cache() -> Option<WeatherCache> {
|
||||
let path = Self::cache_path()?;
|
||||
let content = fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
fn save_cache(&self) {
|
||||
if let (Some(data_dir), Some(cache_path), Some(data)) =
|
||||
(Self::data_dir(), Self::cache_path(), &self.cached_data)
|
||||
{
|
||||
if fs::create_dir_all(&data_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let cache = WeatherCache {
|
||||
last_fetch_epoch: self.last_fetch_epoch,
|
||||
data: data.clone(),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string_pretty(&cache) {
|
||||
let _ = fs::write(&cache_path, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_epoch() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn is_cache_valid(&self) -> bool {
|
||||
if self.last_fetch_epoch == 0 {
|
||||
return false;
|
||||
}
|
||||
let now = Self::now_epoch();
|
||||
now.saturating_sub(self.last_fetch_epoch) < CACHE_DURATION_SECS
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
// Use cache if still valid (works across app restarts)
|
||||
if self.is_cache_valid()
|
||||
&& let Some(data) = self.cached_data.clone() {
|
||||
self.generate_items(&data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch new data from API
|
||||
if let Some(data) = self.fetch_weather() {
|
||||
self.cached_data = Some(data.clone());
|
||||
self.last_fetch_epoch = Self::now_epoch();
|
||||
self.save_cache(); // Persist to disk for next app open
|
||||
self.generate_items(&data);
|
||||
} else {
|
||||
// On fetch failure, try to use stale cache if available
|
||||
if let Some(data) = self.cached_data.clone() {
|
||||
self.generate_items(&data);
|
||||
} else {
|
||||
self.items.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_weather(&self) -> Option<WeatherData> {
|
||||
match self.config.provider {
|
||||
WeatherProviderType::WttrIn => self.fetch_wttr_in(),
|
||||
WeatherProviderType::OpenWeatherMap => self.fetch_openweathermap(),
|
||||
WeatherProviderType::OpenMeteo => self.fetch_open_meteo(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_wttr_in(&self) -> Option<WeatherData> {
|
||||
let location = if self.config.location.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
self.config.location.clone()
|
||||
};
|
||||
|
||||
let url = format!("https://wttr.in/{}?format=j1", location);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let response = client.get(&url).send().ok()?;
|
||||
let json: WttrInResponse = response.json().ok()?;
|
||||
|
||||
let current = json.current_condition.first()?;
|
||||
let nearest = json.nearest_area.first()?;
|
||||
|
||||
let location_name = nearest
|
||||
.area_name
|
||||
.first()
|
||||
.map(|a| a.value.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
Some(WeatherData {
|
||||
temperature: current.temp_c.parse().unwrap_or(0.0),
|
||||
feels_like: current.feels_like_c.parse().ok(),
|
||||
condition: current
|
||||
.weather_desc
|
||||
.first()
|
||||
.map(|d| d.value.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
humidity: current.humidity.parse().ok(),
|
||||
wind_speed: current.windspeed_kmph.parse().ok(),
|
||||
icon: Self::wttr_code_to_icon(¤t.weather_code),
|
||||
location: location_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_openweathermap(&self) -> Option<WeatherData> {
|
||||
let api_key = self.config.api_key.as_ref()?;
|
||||
if self.config.location.is_empty() {
|
||||
return None; // OWM requires a location
|
||||
}
|
||||
|
||||
let url = format!(
|
||||
"https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric",
|
||||
self.config.location, api_key
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let response = client.get(&url).send().ok()?;
|
||||
let json: OpenWeatherMapResponse = response.json().ok()?;
|
||||
|
||||
let weather = json.weather.first()?;
|
||||
|
||||
Some(WeatherData {
|
||||
temperature: json.main.temp,
|
||||
feels_like: Some(json.main.feels_like),
|
||||
condition: weather.description.clone(),
|
||||
humidity: Some(json.main.humidity),
|
||||
wind_speed: Some(json.wind.speed * 3.6), // m/s to km/h
|
||||
icon: Self::owm_icon_to_freedesktop(&weather.icon),
|
||||
location: json.name,
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_open_meteo(&self) -> Option<WeatherData> {
|
||||
let (lat, lon, location_name) = self.get_coordinates()?;
|
||||
|
||||
let url = format!(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=auto",
|
||||
lat, lon
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let response = client.get(&url).send().ok()?;
|
||||
let json: OpenMeteoResponse = response.json().ok()?;
|
||||
|
||||
let current = json.current;
|
||||
|
||||
Some(WeatherData {
|
||||
temperature: current.temperature_2m,
|
||||
feels_like: None,
|
||||
condition: Self::wmo_code_to_description(current.weather_code),
|
||||
humidity: Some(current.relative_humidity_2m as u8),
|
||||
wind_speed: Some(current.wind_speed_10m),
|
||||
icon: Self::wmo_code_to_icon(current.weather_code),
|
||||
location: location_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_coordinates(&self) -> Option<(f64, f64, String)> {
|
||||
let location = &self.config.location;
|
||||
|
||||
// Check if location is already coordinates (lat,lon)
|
||||
if location.contains(',') {
|
||||
let parts: Vec<&str> = location.split(',').collect();
|
||||
if parts.len() == 2
|
||||
&& let (Ok(lat), Ok(lon)) = (
|
||||
parts[0].trim().parse::<f64>(),
|
||||
parts[1].trim().parse::<f64>(),
|
||||
) {
|
||||
return Some((lat, lon, location.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Use Open-Meteo geocoding API
|
||||
let url = format!(
|
||||
"https://geocoding-api.open-meteo.com/v1/search?name={}&count=1",
|
||||
location
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let response = client.get(&url).send().ok()?;
|
||||
let json: GeocodingResponse = response.json().ok()?;
|
||||
|
||||
let result = json.results?.into_iter().next()?;
|
||||
Some((result.latitude, result.longitude, result.name))
|
||||
}
|
||||
|
||||
fn wttr_code_to_icon(code: &str) -> String {
|
||||
match code {
|
||||
"113" => "weather-clear",
|
||||
"116" => "weather-few-clouds",
|
||||
"119" => "weather-overcast",
|
||||
"122" => "weather-overcast",
|
||||
"143" | "248" | "260" => "weather-fog",
|
||||
"176" | "263" | "266" | "293" | "296" | "299" | "302" | "305" | "308" => {
|
||||
"weather-showers"
|
||||
}
|
||||
"179" | "182" | "185" | "227" | "230" | "323" | "326" | "329" | "332" | "335"
|
||||
| "338" | "350" | "368" | "371" | "374" | "377" => "weather-snow",
|
||||
"200" | "386" | "389" | "392" | "395" => "weather-storm",
|
||||
_ => "weather-clear",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn owm_icon_to_freedesktop(icon: &str) -> String {
|
||||
match icon {
|
||||
"01d" | "01n" => "weather-clear",
|
||||
"02d" | "02n" => "weather-few-clouds",
|
||||
"03d" | "03n" | "04d" | "04n" => "weather-overcast",
|
||||
"09d" | "09n" | "10d" | "10n" => "weather-showers",
|
||||
"11d" | "11n" => "weather-storm",
|
||||
"13d" | "13n" => "weather-snow",
|
||||
"50d" | "50n" => "weather-fog",
|
||||
_ => "weather-clear",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn wmo_code_to_description(code: i32) -> String {
|
||||
match code {
|
||||
0 => "Clear sky",
|
||||
1 => "Mainly clear",
|
||||
2 => "Partly cloudy",
|
||||
3 => "Overcast",
|
||||
45 | 48 => "Foggy",
|
||||
51 | 53 | 55 => "Drizzle",
|
||||
61 | 63 | 65 => "Rain",
|
||||
66 | 67 => "Freezing rain",
|
||||
71 | 73 | 75 | 77 => "Snow",
|
||||
80..=82 => "Rain showers",
|
||||
85 | 86 => "Snow showers",
|
||||
95 | 96 | 99 => "Thunderstorm",
|
||||
_ => "Unknown",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn wmo_code_to_icon(code: i32) -> String {
|
||||
match code {
|
||||
0 | 1 => "weather-clear",
|
||||
2 => "weather-few-clouds",
|
||||
3 => "weather-overcast",
|
||||
45 | 48 => "weather-fog",
|
||||
51 | 53 | 55 | 61 | 63 | 65 | 80 | 81 | 82 => "weather-showers",
|
||||
66 | 67 | 71 | 73 | 75 | 77 | 85 | 86 => "weather-snow",
|
||||
95 | 96 | 99 => "weather-storm",
|
||||
_ => "weather-clear",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn icon_to_resource_path(icon: &str) -> String {
|
||||
let weather_icon = if icon.contains("clear") {
|
||||
"wi-day-sunny"
|
||||
} else if icon.contains("few-clouds") {
|
||||
"wi-day-cloudy"
|
||||
} else if icon.contains("overcast") || icon.contains("clouds") {
|
||||
"wi-cloudy"
|
||||
} else if icon.contains("fog") {
|
||||
"wi-fog"
|
||||
} else if icon.contains("showers") || icon.contains("rain") {
|
||||
"wi-rain"
|
||||
} else if icon.contains("snow") {
|
||||
"wi-snow"
|
||||
} else if icon.contains("storm") {
|
||||
"wi-thunderstorm"
|
||||
} else {
|
||||
"wi-thermometer"
|
||||
};
|
||||
format!("/org/owlry/launcher/icons/weather/{}.svg", weather_icon)
|
||||
}
|
||||
|
||||
fn generate_items(&mut self, data: &WeatherData) {
|
||||
self.items.clear();
|
||||
|
||||
let temp_str = format!("{}°C", data.temperature.round() as i32);
|
||||
let name = format!("{} {}", temp_str, data.condition);
|
||||
|
||||
let mut details = vec![data.location.clone()];
|
||||
if let Some(humidity) = data.humidity {
|
||||
details.push(format!("Humidity {}%", humidity));
|
||||
}
|
||||
if let Some(wind) = data.wind_speed {
|
||||
details.push(format!("Wind {} km/h", wind.round() as i32));
|
||||
}
|
||||
if let Some(feels) = data.feels_like
|
||||
&& (feels - data.temperature).abs() > 2.0 {
|
||||
details.push(format!("Feels like {}°C", feels.round() as i32));
|
||||
}
|
||||
|
||||
let encoded_location = data.location.replace(' ', "+");
|
||||
let command = format!("xdg-open 'https://wttr.in/{}'", encoded_location);
|
||||
|
||||
self.items.push(
|
||||
PluginItem::new("weather-current", name, command)
|
||||
.with_description(details.join(" | "))
|
||||
.with_icon(Self::icon_to_resource_path(&data.icon))
|
||||
.with_keywords(vec!["weather".to_string(), "widget".to_string()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Response Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WttrInResponse {
|
||||
current_condition: Vec<WttrInCurrent>,
|
||||
nearest_area: Vec<WttrInArea>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WttrInCurrent {
|
||||
#[serde(rename = "temp_C")]
|
||||
temp_c: String,
|
||||
#[serde(rename = "FeelsLikeC")]
|
||||
feels_like_c: String,
|
||||
humidity: String,
|
||||
#[serde(rename = "weatherCode")]
|
||||
weather_code: String,
|
||||
#[serde(rename = "weatherDesc")]
|
||||
weather_desc: Vec<WttrInValue>,
|
||||
#[serde(rename = "windspeedKmph")]
|
||||
windspeed_kmph: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WttrInValue {
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WttrInArea {
|
||||
#[serde(rename = "areaName")]
|
||||
area_name: Vec<WttrInValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenWeatherMapResponse {
|
||||
main: OwmMain,
|
||||
weather: Vec<OwmWeather>,
|
||||
wind: OwmWind,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwmMain {
|
||||
temp: f32,
|
||||
feels_like: f32,
|
||||
humidity: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwmWeather {
|
||||
description: String,
|
||||
icon: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwmWind {
|
||||
speed: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenMeteoResponse {
|
||||
current: OpenMeteoCurrent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenMeteoCurrent {
|
||||
temperature_2m: f32,
|
||||
relative_humidity_2m: f32,
|
||||
weather_code: i32,
|
||||
wind_speed_10m: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeocodingResponse {
|
||||
results: Option<Vec<GeocodingResult>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeocodingResult {
|
||||
name: String,
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RNone,
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Widget,
|
||||
priority: 12000, // Widget: highest priority
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(WeatherState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<WeatherState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut WeatherState) };
|
||||
|
||||
state.refresh();
|
||||
state.items.clone().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query not used, return empty
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<WeatherState>
|
||||
unsafe {
|
||||
handle.drop_as::<WeatherState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_weather_provider_type_from_str() {
|
||||
assert_eq!(
|
||||
"wttr.in".parse::<WeatherProviderType>().unwrap(),
|
||||
WeatherProviderType::WttrIn
|
||||
);
|
||||
assert_eq!(
|
||||
"owm".parse::<WeatherProviderType>().unwrap(),
|
||||
WeatherProviderType::OpenWeatherMap
|
||||
);
|
||||
assert_eq!(
|
||||
"open-meteo".parse::<WeatherProviderType>().unwrap(),
|
||||
WeatherProviderType::OpenMeteo
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wttr_code_to_icon() {
|
||||
assert_eq!(WeatherState::wttr_code_to_icon("113"), "weather-clear");
|
||||
assert_eq!(WeatherState::wttr_code_to_icon("116"), "weather-few-clouds");
|
||||
assert_eq!(WeatherState::wttr_code_to_icon("176"), "weather-showers");
|
||||
assert_eq!(WeatherState::wttr_code_to_icon("200"), "weather-storm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wmo_code_to_description() {
|
||||
assert_eq!(WeatherState::wmo_code_to_description(0), "Clear sky");
|
||||
assert_eq!(WeatherState::wmo_code_to_description(3), "Overcast");
|
||||
assert_eq!(WeatherState::wmo_code_to_description(95), "Thunderstorm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_icon_to_resource_path() {
|
||||
assert_eq!(
|
||||
WeatherState::icon_to_resource_path("weather-clear"),
|
||||
"/org/owlry/launcher/icons/weather/wi-day-sunny.svg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_validity() {
|
||||
let state = WeatherState {
|
||||
items: Vec::new(),
|
||||
config: WeatherConfig {
|
||||
provider: WeatherProviderType::WttrIn,
|
||||
api_key: None,
|
||||
location: String::new(),
|
||||
},
|
||||
last_fetch_epoch: 0,
|
||||
cached_data: None,
|
||||
};
|
||||
assert!(!state.is_cache_valid());
|
||||
}
|
||||
}
|
||||
20
crates/owlry-plugin-websearch/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "owlry-plugin-websearch"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Web search plugin for owlry - search the web with configurable search engines"
|
||||
keywords = ["owlry", "plugin", "websearch", "search"]
|
||||
categories = ["web-programming"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
299
crates/owlry-plugin-websearch/src/lib.rs
Normal file
@@ -0,0 +1,299 @@
|
||||
//! Web Search Plugin for Owlry
|
||||
//!
|
||||
//! A dynamic provider that opens web searches in the browser.
|
||||
//! Supports multiple search engines.
|
||||
//!
|
||||
//! Examples:
|
||||
//! - `? rust programming` → Search DuckDuckGo for "rust programming"
|
||||
//! - `web rust docs` → Search for "rust docs"
|
||||
//! - `search how to rust` → Search for "how to rust"
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "websearch";
|
||||
const PLUGIN_NAME: &str = "Web Search";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Search the web with configurable search engines";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "websearch";
|
||||
const PROVIDER_NAME: &str = "Web Search";
|
||||
const PROVIDER_PREFIX: &str = "?";
|
||||
const PROVIDER_ICON: &str = "web-browser";
|
||||
const PROVIDER_TYPE_ID: &str = "websearch";
|
||||
|
||||
/// Common search engine URL templates
|
||||
/// {query} is replaced with the URL-encoded search term
|
||||
const SEARCH_ENGINES: &[(&str, &str)] = &[
|
||||
("google", "https://www.google.com/search?q={query}"),
|
||||
("duckduckgo", "https://duckduckgo.com/?q={query}"),
|
||||
("bing", "https://www.bing.com/search?q={query}"),
|
||||
("startpage", "https://www.startpage.com/search?q={query}"),
|
||||
("searxng", "https://searx.be/search?q={query}"),
|
||||
("brave", "https://search.brave.com/search?q={query}"),
|
||||
("ecosia", "https://www.ecosia.org/search?q={query}"),
|
||||
];
|
||||
|
||||
/// Default search engine if not configured
|
||||
const DEFAULT_ENGINE: &str = "duckduckgo";
|
||||
|
||||
/// Web search provider state
|
||||
struct WebSearchState {
|
||||
/// URL template with {query} placeholder
|
||||
url_template: String,
|
||||
}
|
||||
|
||||
impl WebSearchState {
|
||||
fn new() -> Self {
|
||||
Self::with_engine(DEFAULT_ENGINE)
|
||||
}
|
||||
|
||||
fn with_engine(engine_name: &str) -> Self {
|
||||
let url_template = SEARCH_ENGINES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == engine_name.to_lowercase())
|
||||
.map(|(_, url)| url.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
// If not a known engine, treat it as a custom URL template
|
||||
if engine_name.contains("{query}") {
|
||||
engine_name.to_string()
|
||||
} else {
|
||||
// Fall back to default
|
||||
SEARCH_ENGINES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == DEFAULT_ENGINE)
|
||||
.map(|(_, url)| url.to_string())
|
||||
.unwrap()
|
||||
}
|
||||
});
|
||||
|
||||
Self { url_template }
|
||||
}
|
||||
|
||||
/// Extract the search term from the query
|
||||
fn extract_search_term(query: &str) -> Option<&str> {
|
||||
let trimmed = query.trim();
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("? ") {
|
||||
Some(rest.trim())
|
||||
} else if let Some(rest) = trimmed.strip_prefix("?") {
|
||||
Some(rest.trim())
|
||||
} else if trimmed.to_lowercase().starts_with("web ") {
|
||||
Some(trimmed[4..].trim())
|
||||
} else if trimmed.to_lowercase().starts_with("search ") {
|
||||
Some(trimmed[7..].trim())
|
||||
} else {
|
||||
// In filter mode, accept raw query
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// URL-encode a search query
|
||||
fn url_encode(query: &str) -> String {
|
||||
query
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
' ' => "+".to_string(),
|
||||
'&' => "%26".to_string(),
|
||||
'=' => "%3D".to_string(),
|
||||
'?' => "%3F".to_string(),
|
||||
'#' => "%23".to_string(),
|
||||
'+' => "%2B".to_string(),
|
||||
'%' => "%25".to_string(),
|
||||
c if c.is_ascii_alphanumeric() || "-_.~".contains(c) => c.to_string(),
|
||||
c => format!("%{:02X}", c as u32),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the search URL from a query
|
||||
fn build_search_url(&self, search_term: &str) -> String {
|
||||
let encoded = Self::url_encode(search_term);
|
||||
self.url_template.replace("{query}", &encoded)
|
||||
}
|
||||
|
||||
/// Evaluate a query and return a PluginItem if valid
|
||||
fn evaluate(&self, query: &str) -> Option<PluginItem> {
|
||||
let search_term = Self::extract_search_term(query)?;
|
||||
|
||||
if search_term.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = self.build_search_url(search_term);
|
||||
|
||||
// Use xdg-open to open the browser
|
||||
let command = format!("xdg-open '{}'", url);
|
||||
|
||||
Some(
|
||||
PluginItem::new(
|
||||
format!("websearch:{}", search_term),
|
||||
format!("Search: {}", search_term),
|
||||
command,
|
||||
)
|
||||
.with_description("Open in browser")
|
||||
.with_icon(PROVIDER_ICON)
|
||||
.with_keywords(vec!["web".to_string(), "search".to_string()]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Dynamic,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 9000, // Dynamic: web search
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
// TODO: Read search engine from config when plugin config is available
|
||||
let state = Box::new(WebSearchState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
// Dynamic provider - refresh does nothing
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<WebSearchState>
|
||||
let state = unsafe { &*(handle.ptr as *const WebSearchState) };
|
||||
|
||||
let query_str = query.as_str();
|
||||
|
||||
match state.evaluate(query_str) {
|
||||
Some(item) => vec![item].into(),
|
||||
None => RVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<WebSearchState>
|
||||
unsafe {
|
||||
handle.drop_as::<WebSearchState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_search_term() {
|
||||
assert_eq!(
|
||||
WebSearchState::extract_search_term("? rust programming"),
|
||||
Some("rust programming")
|
||||
);
|
||||
assert_eq!(
|
||||
WebSearchState::extract_search_term("?rust"),
|
||||
Some("rust")
|
||||
);
|
||||
assert_eq!(
|
||||
WebSearchState::extract_search_term("web rust docs"),
|
||||
Some("rust docs")
|
||||
);
|
||||
assert_eq!(
|
||||
WebSearchState::extract_search_term("search how to rust"),
|
||||
Some("how to rust")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_encode() {
|
||||
assert_eq!(WebSearchState::url_encode("hello world"), "hello+world");
|
||||
assert_eq!(WebSearchState::url_encode("foo&bar"), "foo%26bar");
|
||||
assert_eq!(WebSearchState::url_encode("a=b"), "a%3Db");
|
||||
assert_eq!(WebSearchState::url_encode("test?query"), "test%3Fquery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_search_url() {
|
||||
let state = WebSearchState::with_engine("duckduckgo");
|
||||
let url = state.build_search_url("rust programming");
|
||||
assert_eq!(url, "https://duckduckgo.com/?q=rust+programming");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_search_url_google() {
|
||||
let state = WebSearchState::with_engine("google");
|
||||
let url = state.build_search_url("rust");
|
||||
assert_eq!(url, "https://www.google.com/search?q=rust");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate() {
|
||||
let state = WebSearchState::new();
|
||||
let item = state.evaluate("? rust docs").unwrap();
|
||||
assert_eq!(item.name.as_str(), "Search: rust docs");
|
||||
assert!(item.command.as_str().contains("xdg-open"));
|
||||
assert!(item.command.as_str().contains("duckduckgo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_empty() {
|
||||
let state = WebSearchState::new();
|
||||
assert!(state.evaluate("?").is_none());
|
||||
assert!(state.evaluate("? ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_url_template() {
|
||||
let state = WebSearchState::with_engine("https://custom.search/q={query}");
|
||||
let url = state.build_search_url("test");
|
||||
assert_eq!(url, "https://custom.search/q=test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_to_default() {
|
||||
let state = WebSearchState::with_engine("nonexistent");
|
||||
let url = state.build_search_url("test");
|
||||
assert!(url.contains("duckduckgo")); // Falls back to default
|
||||
}
|
||||
}
|
||||
44
crates/owlry-rune/Cargo.toml
Normal file
@@ -0,0 +1,44 @@
|
||||
[package]
|
||||
name = "owlry-rune"
|
||||
version = "0.4.3"
|
||||
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.12", default-features = false, features = ["rustls-tls", "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());
|
||||
}
|
||||
}
|
||||
251
crates/owlry-rune/src/lib.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
175
crates/owlry-rune/src/loader.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! 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::{compile_source, create_context, create_vm, SandboxConfig};
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
155
crates/owlry-rune/src/manifest.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
160
crates/owlry-rune/src/runtime.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
87
crates/owlry/Cargo.toml
Normal file
@@ -0,0 +1,87 @@
|
||||
[package]
|
||||
name = "owlry"
|
||||
version = "0.4.4"
|
||||
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]
|
||||
# Shared plugin API
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# GTK4 for the UI
|
||||
gtk4 = { version = "0.10", features = ["v4_12"] }
|
||||
|
||||
# Layer shell support for Wayland overlay behavior
|
||||
gtk4-layer-shell = "0.7"
|
||||
|
||||
# Fuzzy matching for search
|
||||
fuzzy-matcher = "0.3"
|
||||
|
||||
# XDG desktop entry parsing
|
||||
freedesktop-desktop-entry = "0.7"
|
||||
|
||||
# Directory utilities
|
||||
dirs = "5"
|
||||
|
||||
# Low-level syscalls for stdin detection
|
||||
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 Lua plugins)
|
||||
meval = { version = "0.2", optional = true }
|
||||
|
||||
# JSON serialization for data persistence
|
||||
serde_json = "1"
|
||||
|
||||
# Date/time for frecency calculations
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# HTTP client (for Lua plugins)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"], optional = true }
|
||||
|
||||
# Lua runtime for plugin system (optional - can be loaded dynamically via owlry-lua)
|
||||
mlua = { version = "0.10", features = ["lua54", "vendored", "send", "serialize"], optional = true }
|
||||
|
||||
# Semantic versioning for plugin compatibility
|
||||
semver = "1"
|
||||
|
||||
# Dynamic library loading for native plugins
|
||||
libloading = "0.8"
|
||||
|
||||
# Desktop notifications (freedesktop notification spec)
|
||||
notify-rust = "4"
|
||||
|
||||
[dev-dependencies]
|
||||
# Temporary directories for tests
|
||||
tempfile = "3"
|
||||
|
||||
[build-dependencies]
|
||||
# GResource compilation for bundled icons
|
||||
glib-build-tools = "0.20"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable verbose debug logging (for development/testing builds)
|
||||
dev-logging = []
|
||||
# Enable built-in Lua runtime (disable to use external owlry-lua package)
|
||||
# Includes: mlua, meval (math), reqwest (http)
|
||||
lua = ["dep:mlua", "dep:meval", "dep:reqwest"]
|
||||
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/");
|
||||
}
|
||||
279
crates/owlry/src/app.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
use crate::cli::CliArgs;
|
||||
use crate::config::Config;
|
||||
use crate::data::FrecencyStore;
|
||||
use crate::filter::ProviderFilter;
|
||||
use crate::paths;
|
||||
use crate::plugins::native_loader::NativePluginLoader;
|
||||
#[cfg(feature = "lua")]
|
||||
use crate::plugins::PluginManager;
|
||||
use crate::providers::native_provider::NativeProvider;
|
||||
use crate::providers::Provider; // For name() method
|
||||
use crate::providers::ProviderManager;
|
||||
use crate::theme;
|
||||
use crate::ui::MainWindow;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{gio, Application, CssProvider};
|
||||
use gtk4_layer_shell::{Edge, Layer, LayerShell};
|
||||
use log::{debug, info, warn};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
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");
|
||||
|
||||
// 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()));
|
||||
|
||||
// Load native plugins from /usr/lib/owlry/plugins/
|
||||
let native_providers = Self::load_native_plugins(&config.borrow());
|
||||
|
||||
// Create provider manager with native plugins
|
||||
#[cfg(feature = "lua")]
|
||||
let mut provider_manager = ProviderManager::with_native_plugins(native_providers);
|
||||
#[cfg(not(feature = "lua"))]
|
||||
let provider_manager = ProviderManager::with_native_plugins(native_providers);
|
||||
|
||||
// Load Lua plugins if enabled (requires lua feature)
|
||||
#[cfg(feature = "lua")]
|
||||
if config.borrow().plugins.enabled {
|
||||
Self::load_lua_plugins(&mut provider_manager, &config.borrow());
|
||||
}
|
||||
|
||||
let providers = Rc::new(RefCell::new(provider_manager));
|
||||
let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default()));
|
||||
|
||||
// Create filter from CLI args and config
|
||||
let filter = ProviderFilter::new(
|
||||
args.mode.clone(),
|
||||
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(), 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();
|
||||
}
|
||||
|
||||
/// Load native (.so) plugins from the system plugins directory
|
||||
/// Returns NativeProvider instances that can be passed to ProviderManager
|
||||
fn load_native_plugins(config: &Config) -> Vec<NativeProvider> {
|
||||
let mut loader = NativePluginLoader::new();
|
||||
|
||||
// Set disabled plugins from config
|
||||
loader.set_disabled(config.plugins.disabled_plugins.clone());
|
||||
|
||||
// Discover and load plugins
|
||||
match loader.discover() {
|
||||
Ok(count) => {
|
||||
if count == 0 {
|
||||
debug!("No native plugins found in {}",
|
||||
crate::plugins::native_loader::SYSTEM_PLUGINS_DIR);
|
||||
return Vec::new();
|
||||
}
|
||||
info!("Discovered {} native plugin(s)", count);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to discover native plugins: {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
// Get all plugins and create providers
|
||||
let plugins: Vec<Arc<crate::plugins::native_loader::NativePlugin>> =
|
||||
loader.into_plugins();
|
||||
|
||||
// Create NativeProvider instances from loaded plugins
|
||||
let mut providers = Vec::new();
|
||||
for plugin in plugins {
|
||||
for provider_info in &plugin.providers {
|
||||
let provider = NativeProvider::new(Arc::clone(&plugin), provider_info.clone());
|
||||
info!("Created native provider: {} ({})", provider.name(), provider.type_id());
|
||||
providers.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Loaded {} provider(s) from native plugins", providers.len());
|
||||
providers
|
||||
}
|
||||
|
||||
/// Load Lua plugins from the user plugins directory (requires lua feature)
|
||||
#[cfg(feature = "lua")]
|
||||
fn load_lua_plugins(provider_manager: &mut ProviderManager, config: &Config) {
|
||||
let plugins_dir = match paths::plugins_dir() {
|
||||
Some(dir) => dir,
|
||||
None => {
|
||||
warn!("Could not determine plugins directory");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Get owlry version from Cargo.toml at compile time
|
||||
let owlry_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
let mut plugin_manager = PluginManager::new(plugins_dir, owlry_version);
|
||||
|
||||
// Set disabled plugins from config
|
||||
plugin_manager.set_disabled(config.plugins.disabled_plugins.clone());
|
||||
|
||||
// Discover plugins
|
||||
match plugin_manager.discover() {
|
||||
Ok(count) => {
|
||||
if count == 0 {
|
||||
debug!("No Lua plugins found");
|
||||
return;
|
||||
}
|
||||
info!("Discovered {} Lua plugin(s)", count);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to discover Lua plugins: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize all plugins (load Lua code)
|
||||
let init_errors = plugin_manager.initialize_all();
|
||||
for error in &init_errors {
|
||||
warn!("Plugin initialization error: {}", error);
|
||||
}
|
||||
|
||||
// Create providers from initialized plugins
|
||||
let plugin_providers = plugin_manager.create_providers();
|
||||
let provider_count = plugin_providers.len();
|
||||
|
||||
// Add plugin providers to the main provider manager
|
||||
provider_manager.add_providers(plugin_providers);
|
||||
|
||||
if provider_count > 0 {
|
||||
info!("Loaded {} provider(s) from Lua plugins", provider_count);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
218
crates/owlry/src/cli.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
//! Command-line interface for owlry launcher
|
||||
//!
|
||||
//! Provides both the launcher interface and plugin management commands.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
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>>,
|
||||
|
||||
/// Custom prompt text for the search input (useful for dmenu mode)
|
||||
#[arg(long)]
|
||||
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()
|
||||
}
|
||||
}
|
||||
600
crates/owlry/src/config/mod.rs
Normal file
@@ -0,0 +1,600 @@
|
||||
use log::{debug, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
/// Launch wrapper command for app execution.
|
||||
/// Examples: "uwsm app --", "hyprctl dispatch exec --", "systemd-run --user --"
|
||||
/// If None or empty, launches directly via sh -c
|
||||
#[serde(default)]
|
||||
pub launch_wrapper: Option<String>,
|
||||
/// 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,
|
||||
launch_wrapper: None,
|
||||
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 launch wrapper for the current session
|
||||
/// Checks for uwsm (Universal Wayland Session Manager) and hyprland
|
||||
fn detect_launch_wrapper() -> Option<String> {
|
||||
// Check if running under uwsm (has UWSM_FINALIZE_VARNAMES or similar uwsm env vars)
|
||||
if (std::env::var("UWSM_FINALIZE_VARNAMES").is_ok()
|
||||
|| std::env::var("__UWSM_SELECT_TAG").is_ok())
|
||||
&& command_exists("uwsm") {
|
||||
debug!("Detected uwsm session, using 'uwsm app --' wrapper");
|
||||
return Some("uwsm app --".to_string());
|
||||
}
|
||||
|
||||
// Check if running under Hyprland
|
||||
if std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok()
|
||||
&& command_exists("hyprctl") {
|
||||
debug!("Detected Hyprland session, using 'hyprctl dispatch exec --' wrapper");
|
||||
return Some("hyprctl dispatch exec --".to_string());
|
||||
}
|
||||
|
||||
// No wrapper needed for other environments
|
||||
debug!("No launch wrapper detected, using direct execution");
|
||||
None
|
||||
}
|
||||
|
||||
/// Detect the best available terminal emulator
|
||||
/// Fallback chain:
|
||||
/// 1. $TERMINAL env var (user's explicit preference)
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect launch wrapper if not configured
|
||||
if config.general.launch_wrapper.is_none() {
|
||||
config.general.launch_wrapper = detect_launch_wrapper();
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let path = Self::config_path().ok_or("Could not determine config path")?;
|
||||
|
||||
paths::ensure_parent_dir(&path)?;
|
||||
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
std::fs::write(&path, content)?;
|
||||
info!("Saved config to {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// A single frecency entry tracking launch count and recency
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FrecencyEntry {
|
||||
@@ -56,10 +58,7 @@ impl FrecencyStore {
|
||||
|
||||
/// Get the path to the frecency data file
|
||||
fn data_path() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("owlry")
|
||||
.join("frecency.json")
|
||||
paths::frecency_file().unwrap_or_else(|| PathBuf::from("frecency.json"))
|
||||
}
|
||||
|
||||
/// Load frecency data from a file
|
||||
@@ -85,10 +84,7 @@ impl FrecencyStore {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
paths::ensure_parent_dir(&self.path)?;
|
||||
|
||||
let content = serde_json::to_string_pretty(&self.data)?;
|
||||
std::fs::write(&self.path, content)?;
|
||||
409
crates/owlry/src/filter.rs
Normal file
@@ -0,0 +1,409 @@
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
};
|
||||
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
self.enabled.contains(&provider)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if provider is in enabled set (ignoring prefix)
|
||||
pub fn is_enabled(&self, provider: ProviderType) -> bool {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
68
crates/owlry/src/main.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
mod app;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod data;
|
||||
mod filter;
|
||||
mod notify;
|
||||
mod paths;
|
||||
mod plugins;
|
||||
mod providers;
|
||||
mod theme;
|
||||
mod ui;
|
||||
|
||||
use app::OwlryApp;
|
||||
use cli::{CliArgs, Command};
|
||||
use log::{info, warn};
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
|
||||
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) = plugins::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);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
91
crates/owlry/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);
|
||||
}
|
||||
}
|
||||
169
crates/owlry/src/paths.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
//! Centralized path handling following XDG Base Directory Specification.
|
||||
//!
|
||||
//! XDG directories used:
|
||||
//! - `$XDG_CONFIG_HOME/owlry/` - User configuration (config.toml, themes/, style.css)
|
||||
//! - `$XDG_DATA_HOME/owlry/` - User data (scripts/, frecency.json)
|
||||
//! - `$XDG_CACHE_HOME/owlry/` - Cache files (future use)
|
||||
//!
|
||||
//! See: https://specifications.freedesktop.org/basedir-spec/latest/
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Application name used in XDG paths
|
||||
const APP_NAME: &str = "owlry";
|
||||
|
||||
// =============================================================================
|
||||
// XDG Base Directories
|
||||
// =============================================================================
|
||||
|
||||
/// Get XDG config home: `$XDG_CONFIG_HOME` or `~/.config`
|
||||
pub fn config_home() -> Option<PathBuf> {
|
||||
dirs::config_dir()
|
||||
}
|
||||
|
||||
/// Get XDG data home: `$XDG_DATA_HOME` or `~/.local/share`
|
||||
pub fn data_home() -> Option<PathBuf> {
|
||||
dirs::data_dir()
|
||||
}
|
||||
|
||||
/// Get XDG cache home: `$XDG_CACHE_HOME` or `~/.cache`
|
||||
#[allow(dead_code)]
|
||||
pub fn cache_home() -> Option<PathBuf> {
|
||||
dirs::cache_dir()
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Owlry-specific directories
|
||||
// =============================================================================
|
||||
|
||||
/// Owlry config directory: `$XDG_CONFIG_HOME/owlry/`
|
||||
pub fn owlry_config_dir() -> Option<PathBuf> {
|
||||
config_home().map(|p| p.join(APP_NAME))
|
||||
}
|
||||
|
||||
/// Owlry data directory: `$XDG_DATA_HOME/owlry/`
|
||||
pub fn owlry_data_dir() -> Option<PathBuf> {
|
||||
data_home().map(|p| p.join(APP_NAME))
|
||||
}
|
||||
|
||||
/// Owlry cache directory: `$XDG_CACHE_HOME/owlry/`
|
||||
#[allow(dead_code)]
|
||||
pub fn owlry_cache_dir() -> Option<PathBuf> {
|
||||
cache_home().map(|p| p.join(APP_NAME))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config files
|
||||
// =============================================================================
|
||||
|
||||
/// Main config file: `$XDG_CONFIG_HOME/owlry/config.toml`
|
||||
pub fn config_file() -> Option<PathBuf> {
|
||||
owlry_config_dir().map(|p| p.join("config.toml"))
|
||||
}
|
||||
|
||||
/// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css`
|
||||
pub fn custom_style_file() -> Option<PathBuf> {
|
||||
owlry_config_dir().map(|p| p.join("style.css"))
|
||||
}
|
||||
|
||||
/// User themes directory: `$XDG_CONFIG_HOME/owlry/themes/`
|
||||
pub fn themes_dir() -> Option<PathBuf> {
|
||||
owlry_config_dir().map(|p| p.join("themes"))
|
||||
}
|
||||
|
||||
/// Get path for a specific theme: `$XDG_CONFIG_HOME/owlry/themes/{name}.css`
|
||||
pub fn theme_file(name: &str) -> Option<PathBuf> {
|
||||
themes_dir().map(|p| p.join(format!("{}.css", name)))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Data files
|
||||
// =============================================================================
|
||||
|
||||
/// User plugins directory: `$XDG_CONFIG_HOME/owlry/plugins/`
|
||||
///
|
||||
/// Plugins are stored in config because they contain user-installed code
|
||||
/// that the user explicitly chose to add (similar to themes).
|
||||
pub fn plugins_dir() -> Option<PathBuf> {
|
||||
owlry_config_dir().map(|p| p.join("plugins"))
|
||||
}
|
||||
|
||||
/// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json`
|
||||
pub fn frecency_file() -> Option<PathBuf> {
|
||||
owlry_data_dir().map(|p| p.join("frecency.json"))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// System directories
|
||||
// =============================================================================
|
||||
|
||||
/// System data directories for applications (XDG_DATA_DIRS)
|
||||
pub fn system_data_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
// User data directory first
|
||||
if let Some(data) = data_home() {
|
||||
dirs.push(data.join("applications"));
|
||||
}
|
||||
|
||||
// System directories
|
||||
dirs.push(PathBuf::from("/usr/share/applications"));
|
||||
dirs.push(PathBuf::from("/usr/local/share/applications"));
|
||||
|
||||
// Flatpak directories
|
||||
if let Some(data) = data_home() {
|
||||
dirs.push(data.join("flatpak/exports/share/applications"));
|
||||
}
|
||||
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper functions
|
||||
// =============================================================================
|
||||
|
||||
/// Ensure parent directory of a file exists
|
||||
pub fn ensure_parent_dir(path: &std::path::Path) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_paths_are_consistent() {
|
||||
// All owlry paths should be under XDG directories
|
||||
if let (Some(config), Some(data)) = (owlry_config_dir(), owlry_data_dir()) {
|
||||
assert!(config.ends_with("owlry"));
|
||||
assert!(data.ends_with("owlry"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_file_path() {
|
||||
if let Some(path) = config_file() {
|
||||
assert!(path.ends_with("config.toml"));
|
||||
assert!(path.to_string_lossy().contains("owlry"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frecency_in_data_dir() {
|
||||
if let Some(path) = frecency_file() {
|
||||
assert!(path.ends_with("frecency.json"));
|
||||
// Should be in data dir, not config dir
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(
|
||||
path_str.contains(".local/share") || path_str.contains("XDG_DATA_HOME"),
|
||||
"frecency should be in data directory"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
322
crates/owlry/src/plugins/api/action.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
299
crates/owlry/src/plugins/api/cache.rs
Normal file
@@ -0,0 +1,299 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
410
crates/owlry/src/plugins/api/hook.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
345
crates/owlry/src/plugins/api/http.rs
Normal file
@@ -0,0 +1,345 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
181
crates/owlry/src/plugins/api/math.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
//! 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/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)
|
||||
}
|
||||
207
crates/owlry/src/plugins/api/process.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! 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/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());
|
||||
}
|
||||
}
|
||||
275
crates/owlry/src/plugins/api/theme.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
567
crates/owlry/src/plugins/api/utils.rs
Normal file
@@ -0,0 +1,567 @@
|
||||
//! 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]");
|
||||
}
|
||||
}
|
||||
1163
crates/owlry/src/plugins/commands.rs
Normal file
51
crates/owlry/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>;
|
||||
205
crates/owlry/src/plugins/loader.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! 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::{create_lua_runtime, load_file, SandboxConfig};
|
||||
|
||||
/// 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::*;
|
||||
use super::super::manifest::{check_compatibility, discover_plugins};
|
||||
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());
|
||||
}
|
||||
}
|
||||
318
crates/owlry/src/plugins/manifest.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
337
crates/owlry/src/plugins/mod.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
//! 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 commands;
|
||||
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::{check_compatibility, discover_plugins, PluginManifest};
|
||||
|
||||
// ============================================================================
|
||||
// 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::{discover_plugins, check_compatibility};
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
391
crates/owlry/src/plugins/native_loader.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
//! 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::{
|
||||
HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
RStr, API_VERSION,
|
||||
};
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
293
crates/owlry/src/plugins/registry.rs
Normal file
@@ -0,0 +1,293 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
153
crates/owlry/src/plugins/runtime.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
286
crates/owlry/src/plugins/runtime_loader.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
//! Dynamic runtime loader
|
||||
//!
|
||||
//! This module provides dynamic loading of script runtimes (Lua, Rune)
|
||||
//! when they're not compiled into the core binary.
|
||||
//!
|
||||
//! Runtimes are loaded from `/usr/lib/owlry/runtimes/`:
|
||||
//! - `liblua.so` - Lua runtime (from owlry-lua package)
|
||||
//! - `librune.so` - Rune runtime (from owlry-rune package)
|
||||
//!
|
||||
//! Note: This module is infrastructure for the runtime architecture. Full integration
|
||||
//! is pending Phase 5 (AUR Packaging) when runtime packages will be available.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use libloading::{Library, Symbol};
|
||||
use owlry_plugin_api::{PluginItem, RStr, RString, RVec};
|
||||
|
||||
use super::error::{PluginError, PluginResult};
|
||||
use crate::providers::{LaunchItem, Provider, ProviderType};
|
||||
|
||||
/// System directory for runtime libraries
|
||||
pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes";
|
||||
|
||||
/// Information about a loaded runtime
|
||||
#[repr(C)]
|
||||
#[derive(Debug)]
|
||||
pub struct RuntimeInfo {
|
||||
pub name: RString,
|
||||
pub version: RString,
|
||||
}
|
||||
|
||||
/// Information about a provider from a script runtime
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScriptProviderInfo {
|
||||
pub name: RString,
|
||||
pub display_name: RString,
|
||||
pub type_id: RString,
|
||||
pub default_icon: RString,
|
||||
pub is_static: bool,
|
||||
pub prefix: owlry_plugin_api::ROption<RString>,
|
||||
}
|
||||
|
||||
// Type alias for backwards compatibility
|
||||
pub type LuaProviderInfo = ScriptProviderInfo;
|
||||
|
||||
/// Handle to runtime-managed state
|
||||
#[repr(transparent)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RuntimeHandle(pub *mut ());
|
||||
|
||||
/// VTable for script runtime functions (used by both Lua and Rune)
|
||||
#[repr(C)]
|
||||
pub struct ScriptRuntimeVTable {
|
||||
pub info: extern "C" fn() -> RuntimeInfo,
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
|
||||
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<ScriptProviderInfo>,
|
||||
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
|
||||
pub query: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>, query: RStr<'_>) -> RVec<PluginItem>,
|
||||
pub drop: extern "C" fn(handle: RuntimeHandle),
|
||||
}
|
||||
|
||||
/// A loaded script runtime
|
||||
pub struct LoadedRuntime {
|
||||
/// Runtime name (for logging)
|
||||
name: &'static str,
|
||||
/// Keep library alive
|
||||
_library: Arc<Library>,
|
||||
/// Runtime vtable
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
/// Runtime handle (state)
|
||||
handle: RuntimeHandle,
|
||||
/// Provider information
|
||||
providers: Vec<ScriptProviderInfo>,
|
||||
}
|
||||
|
||||
impl LoadedRuntime {
|
||||
/// Load the Lua runtime from the system directory
|
||||
pub fn load_lua(plugins_dir: &Path) -> PluginResult<Self> {
|
||||
Self::load_from_path(
|
||||
"Lua",
|
||||
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"),
|
||||
b"owlry_lua_runtime_vtable",
|
||||
plugins_dir,
|
||||
)
|
||||
}
|
||||
|
||||
/// Load a runtime from a specific path
|
||||
fn load_from_path(
|
||||
name: &'static str,
|
||||
library_path: &Path,
|
||||
vtable_symbol: &[u8],
|
||||
plugins_dir: &Path,
|
||||
) -> PluginResult<Self> {
|
||||
if !library_path.exists() {
|
||||
return Err(PluginError::NotFound(library_path.display().to_string()));
|
||||
}
|
||||
|
||||
// SAFETY: We trust the runtime library to be correct
|
||||
let library = unsafe { Library::new(library_path) }.map_err(|e| {
|
||||
PluginError::LoadError(format!("{}: {}", library_path.display(), e))
|
||||
})?;
|
||||
|
||||
let library = Arc::new(library);
|
||||
|
||||
// Get the vtable
|
||||
let vtable: &'static ScriptRuntimeVTable = unsafe {
|
||||
let get_vtable: Symbol<extern "C" fn() -> &'static ScriptRuntimeVTable> =
|
||||
library.get(vtable_symbol).map_err(|e| {
|
||||
PluginError::LoadError(format!(
|
||||
"{}: Missing vtable symbol: {}",
|
||||
library_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
get_vtable()
|
||||
};
|
||||
|
||||
// Initialize the runtime
|
||||
let plugins_dir_str = plugins_dir.to_string_lossy();
|
||||
let handle = (vtable.init)(RStr::from_str(&plugins_dir_str));
|
||||
|
||||
// Get provider information
|
||||
let providers_rvec = (vtable.providers)(handle);
|
||||
let providers: Vec<ScriptProviderInfo> = providers_rvec.into_iter().collect();
|
||||
|
||||
log::info!(
|
||||
"Loaded {} runtime with {} provider(s)",
|
||||
name,
|
||||
providers.len()
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
_library: library,
|
||||
vtable,
|
||||
handle,
|
||||
providers,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all providers from this runtime
|
||||
pub fn providers(&self) -> &[ScriptProviderInfo] {
|
||||
&self.providers
|
||||
}
|
||||
|
||||
/// Create Provider trait objects for all providers in this runtime
|
||||
pub fn create_providers(&self) -> Vec<Box<dyn Provider>> {
|
||||
self.providers
|
||||
.iter()
|
||||
.map(|info| {
|
||||
let provider = RuntimeProvider::new(
|
||||
self.name,
|
||||
self.vtable,
|
||||
self.handle,
|
||||
info.clone(),
|
||||
);
|
||||
Box::new(provider) as Box<dyn Provider>
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LoadedRuntime {
|
||||
fn drop(&mut self) {
|
||||
(self.vtable.drop)(self.handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// A provider backed by a dynamically loaded runtime
|
||||
pub struct RuntimeProvider {
|
||||
/// Runtime name (for logging)
|
||||
#[allow(dead_code)]
|
||||
runtime_name: &'static str,
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
handle: RuntimeHandle,
|
||||
info: ScriptProviderInfo,
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl RuntimeProvider {
|
||||
fn new(
|
||||
runtime_name: &'static str,
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
handle: RuntimeHandle,
|
||||
info: ScriptProviderInfo,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime_name,
|
||||
vtable,
|
||||
handle,
|
||||
info,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_item(&self, item: PluginItem) -> LaunchItem {
|
||||
LaunchItem {
|
||||
id: item.id.to_string(),
|
||||
name: item.name.to_string(),
|
||||
description: item.description.into_option().map(|s| s.to_string()),
|
||||
icon: item.icon.into_option().map(|s| s.to_string()),
|
||||
provider: ProviderType::Plugin(self.info.type_id.to_string()),
|
||||
command: item.command.to_string(),
|
||||
terminal: item.terminal,
|
||||
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for RuntimeProvider {
|
||||
fn name(&self) -> &str {
|
||||
self.info.name.as_str()
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin(self.info.type_id.to_string())
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
if !self.info.is_static {
|
||||
return;
|
||||
}
|
||||
|
||||
let name_rstr = RStr::from_str(self.info.name.as_str());
|
||||
let items_rvec = (self.vtable.refresh)(self.handle, name_rstr);
|
||||
self.items = items_rvec.into_iter().map(|i| self.convert_item(i)).collect();
|
||||
|
||||
log::debug!(
|
||||
"[RuntimeProvider] '{}' refreshed with {} items",
|
||||
self.info.name,
|
||||
self.items.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
// RuntimeProvider needs to be Send for the Provider trait
|
||||
unsafe impl Send for RuntimeProvider {}
|
||||
|
||||
/// Check if the Lua runtime is available
|
||||
pub fn lua_runtime_available() -> bool {
|
||||
PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so").exists()
|
||||
}
|
||||
|
||||
/// Check if the Rune runtime is available
|
||||
pub fn rune_runtime_available() -> bool {
|
||||
PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so").exists()
|
||||
}
|
||||
|
||||
impl LoadedRuntime {
|
||||
/// Load the Rune runtime from the system directory
|
||||
pub fn load_rune(plugins_dir: &Path) -> PluginResult<Self> {
|
||||
Self::load_from_path(
|
||||
"Rune",
|
||||
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"),
|
||||
b"owlry_rune_runtime_vtable",
|
||||
plugins_dir,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_lua_runtime_check_doesnt_panic() {
|
||||
// Just verify the function runs without panicking
|
||||
// Result depends on whether runtime is installed
|
||||
let _available = lua_runtime_available();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rune_runtime_check_doesnt_panic() {
|
||||
// Just verify the function runs without panicking
|
||||
// Result depends on whether runtime is installed
|
||||
let _available = rune_runtime_available();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use crate::paths;
|
||||
use freedesktop_desktop_entry::{DesktopEntry, Iter};
|
||||
use log::{debug, warn};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Clean desktop file field codes from command string.
|
||||
/// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes
|
||||
@@ -75,25 +75,8 @@ impl ApplicationProvider {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
fn get_application_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
// User applications
|
||||
if let Some(data_home) = dirs::data_dir() {
|
||||
dirs.push(data_home.join("applications"));
|
||||
}
|
||||
|
||||
// System applications
|
||||
dirs.push(PathBuf::from("/usr/share/applications"));
|
||||
dirs.push(PathBuf::from("/usr/local/share/applications"));
|
||||
|
||||
// Flatpak applications
|
||||
if let Some(data_home) = dirs::data_dir() {
|
||||
dirs.push(data_home.join("flatpak/exports/share/applications"));
|
||||
}
|
||||
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
|
||||
|
||||
dirs
|
||||
fn get_application_dirs() -> Vec<std::path::PathBuf> {
|
||||
paths::system_data_dirs()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +135,12 @@ impl Provider for ApplicationProvider {
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract categories as tags (lowercase for consistency)
|
||||
let tags: Vec<String> = desktop_entry
|
||||
.categories()
|
||||
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let item = LaunchItem {
|
||||
id: path.to_string_lossy().to_string(),
|
||||
name,
|
||||
@@ -160,6 +149,7 @@ impl Provider for ApplicationProvider {
|
||||
provider: ProviderType::Application,
|
||||
command: run_cmd,
|
||||
terminal: desktop_entry.terminal(),
|
||||
tags,
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
@@ -87,6 +87,7 @@ impl Provider for CommandProvider {
|
||||
provider: ProviderType::Command,
|
||||
command: name,
|
||||
terminal: false,
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
@@ -101,6 +101,7 @@ impl Provider for DmenuProvider {
|
||||
provider: ProviderType::Dmenu,
|
||||
command: line.to_string(),
|
||||
terminal: false,
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
142
crates/owlry/src/providers/lua_provider.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
564
crates/owlry/src/providers/mod.rs
Normal file
@@ -0,0 +1,564 @@
|
||||
// Core providers (no plugin equivalents)
|
||||
mod application;
|
||||
mod command;
|
||||
mod dmenu;
|
||||
|
||||
// Native plugin bridge
|
||||
pub mod native_provider;
|
||||
|
||||
// Lua plugin bridge (optional)
|
||||
#[cfg(feature = "lua")]
|
||||
pub mod lua_provider;
|
||||
|
||||
// Re-exports for core providers
|
||||
pub use application::ApplicationProvider;
|
||||
pub use command::CommandProvider;
|
||||
pub use dmenu::DmenuProvider;
|
||||
|
||||
// Re-export native provider for plugin loading
|
||||
pub use native_provider::NativeProvider;
|
||||
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use log::info;
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
|
||||
use crate::data::FrecencyStore;
|
||||
|
||||
/// Represents a single searchable/launchable item
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchItem {
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub provider: ProviderType,
|
||||
pub command: String,
|
||||
pub terminal: bool,
|
||||
/// Tags/categories for filtering (e.g., from .desktop Categories)
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Provider type identifier for filtering and badge display
|
||||
///
|
||||
/// Core types are built-in providers. All native plugins use Plugin(type_id).
|
||||
/// This keeps the core app free of plugin-specific knowledge.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ProviderType {
|
||||
/// Built-in: Desktop applications from XDG directories
|
||||
Application,
|
||||
/// Built-in: Shell commands from PATH
|
||||
Command,
|
||||
/// Built-in: Pipe-based input (dmenu compatibility)
|
||||
Dmenu,
|
||||
/// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji")
|
||||
Plugin(String),
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ProviderType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
// Core built-in providers
|
||||
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
|
||||
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
|
||||
"dmenu" => Ok(ProviderType::Dmenu),
|
||||
// Everything else is a plugin
|
||||
other => Ok(ProviderType::Plugin(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProviderType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ProviderType::Application => write!(f, "app"),
|
||||
ProviderType::Command => write!(f, "cmd"),
|
||||
ProviderType::Dmenu => write!(f, "dmenu"),
|
||||
ProviderType::Plugin(type_id) => write!(f, "{}", type_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for all search providers
|
||||
pub trait Provider: Send {
|
||||
#[allow(dead_code)]
|
||||
fn name(&self) -> &str;
|
||||
fn provider_type(&self) -> ProviderType;
|
||||
fn refresh(&mut self);
|
||||
fn items(&self) -> &[LaunchItem];
|
||||
}
|
||||
|
||||
/// Manages all providers and handles searching
|
||||
pub struct ProviderManager {
|
||||
/// Static providers (apps, commands, and native static plugins)
|
||||
providers: Vec<Box<dyn Provider>>,
|
||||
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
|
||||
/// These are queried per-keystroke, not cached
|
||||
dynamic_providers: Vec<NativeProvider>,
|
||||
/// Widget providers from native plugins (weather, media, pomodoro)
|
||||
/// These appear at the top of results
|
||||
widget_providers: Vec<NativeProvider>,
|
||||
/// Fuzzy matcher for search
|
||||
matcher: SkimMatcherV2,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
/// Create a new ProviderManager with native plugins
|
||||
///
|
||||
/// Native plugins are loaded from /usr/lib/owlry/plugins/ and categorized based on
|
||||
/// their declared ProviderKind and ProviderPosition:
|
||||
/// - Static providers with Normal position (added to providers vec)
|
||||
/// - Dynamic providers (queried per-keystroke, declared via ProviderKind::Dynamic)
|
||||
/// - Widget providers (shown at top, declared via ProviderPosition::Widget)
|
||||
pub fn with_native_plugins(native_providers: Vec<NativeProvider>) -> Self {
|
||||
let mut manager = Self {
|
||||
providers: Vec::new(),
|
||||
dynamic_providers: Vec::new(),
|
||||
widget_providers: Vec::new(),
|
||||
matcher: SkimMatcherV2::default(),
|
||||
};
|
||||
|
||||
// Check if running in dmenu mode (stdin has data)
|
||||
let dmenu_mode = DmenuProvider::has_stdin_data();
|
||||
|
||||
if dmenu_mode {
|
||||
// In dmenu mode, only use dmenu provider
|
||||
let mut dmenu = DmenuProvider::new();
|
||||
dmenu.enable();
|
||||
manager.providers.push(Box::new(dmenu));
|
||||
} else {
|
||||
// Core providers (no plugin equivalents)
|
||||
manager.providers.push(Box::new(ApplicationProvider::new()));
|
||||
manager.providers.push(Box::new(CommandProvider::new()));
|
||||
|
||||
// Categorize native plugins based on their declared ProviderKind and ProviderPosition
|
||||
for provider in native_providers {
|
||||
let type_id = provider.type_id();
|
||||
|
||||
if provider.is_dynamic() {
|
||||
// Dynamic providers declare ProviderKind::Dynamic
|
||||
info!("Registered dynamic provider: {} ({})", provider.name(), type_id);
|
||||
manager.dynamic_providers.push(provider);
|
||||
} else if provider.is_widget() {
|
||||
// Widgets declare ProviderPosition::Widget
|
||||
info!("Registered widget provider: {} ({})", provider.name(), type_id);
|
||||
manager.widget_providers.push(provider);
|
||||
} else {
|
||||
// Static providers with Normal position
|
||||
info!("Registered static provider: {} ({})", provider.name(), type_id);
|
||||
manager.providers.push(Box::new(provider));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial refresh
|
||||
manager.refresh_all();
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_dmenu_mode(&self) -> bool {
|
||||
self.providers
|
||||
.iter()
|
||||
.any(|p| p.provider_type() == ProviderType::Dmenu)
|
||||
}
|
||||
|
||||
pub fn refresh_all(&mut self) {
|
||||
// Refresh static providers (fast, local operations)
|
||||
for provider in &mut self.providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Provider '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
|
||||
// Widget providers are refreshed separately to avoid blocking startup
|
||||
// Call refresh_widgets() after window is shown
|
||||
|
||||
// Dynamic providers don't need refresh (they query on demand)
|
||||
}
|
||||
|
||||
/// Refresh widget providers (weather, media, pomodoro)
|
||||
/// Call this separately from refresh_all() to avoid blocking startup
|
||||
/// since widgets may make network requests or spawn processes
|
||||
pub fn refresh_widgets(&mut self) {
|
||||
for provider in &mut self.widget_providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Widget '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a native provider by type ID
|
||||
/// Searches in widget providers and dynamic providers
|
||||
pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> {
|
||||
// Check widget providers first (pomodoro, weather, media)
|
||||
if let Some(p) = self.widget_providers.iter().find(|p| p.type_id() == type_id) {
|
||||
return Some(p);
|
||||
}
|
||||
// Then dynamic providers (calc, websearch, filesearch)
|
||||
self.dynamic_providers.iter().find(|p| p.type_id() == type_id)
|
||||
}
|
||||
|
||||
/// Execute a plugin action command
|
||||
/// Command format: PLUGIN_ID:action_data (e.g., "POMODORO:start", "SYSTEMD:unit:restart")
|
||||
/// Returns true if the command was handled by a plugin
|
||||
pub fn execute_plugin_action(&self, command: &str) -> bool {
|
||||
// Parse command format: PLUGIN_ID:action_data
|
||||
if let Some(colon_pos) = command.find(':') {
|
||||
let plugin_id = &command[..colon_pos];
|
||||
let action = command; // Pass full command to plugin
|
||||
|
||||
// Find provider by type ID (case-insensitive for convenience)
|
||||
let type_id = plugin_id.to_lowercase();
|
||||
|
||||
if let Some(provider) = self.find_native_provider(&type_id) {
|
||||
provider.execute_action(action);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Add a dynamic provider (e.g., from a Lua plugin)
|
||||
#[allow(dead_code)]
|
||||
pub fn add_provider(&mut self, provider: Box<dyn Provider>) {
|
||||
info!("Added plugin provider: {}", provider.name());
|
||||
self.providers.push(provider);
|
||||
}
|
||||
|
||||
/// Add multiple providers at once (for batch plugin loading)
|
||||
#[allow(dead_code)]
|
||||
pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) {
|
||||
for provider in providers {
|
||||
self.add_provider(provider);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
|
||||
if query.is_empty() {
|
||||
// Return recent/popular items when query is empty
|
||||
return self.providers
|
||||
.iter()
|
||||
.flat_map(|p| p.items().iter().cloned())
|
||||
.take(max_results)
|
||||
.map(|item| (item, 0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = self.providers
|
||||
.iter()
|
||||
.flat_map(|provider| {
|
||||
provider.items().iter().filter_map(|item| {
|
||||
// Match against name and description
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
let score = match (name_score, desc_score) {
|
||||
(Some(n), Some(d)) => Some(n.max(d)),
|
||||
(Some(n), None) => Some(n),
|
||||
(None, Some(d)) => Some(d / 2), // Lower weight for description matches
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
score.map(|s| (item.clone(), s))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by score (descending)
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
|
||||
/// Search with provider filtering
|
||||
pub fn search_filtered(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &crate::filter::ProviderFilter,
|
||||
) -> Vec<(LaunchItem, i64)> {
|
||||
if query.is_empty() {
|
||||
return self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned())
|
||||
.take(max_results)
|
||||
.map(|item| (item, 0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|provider| filter.is_active(provider.provider_type()))
|
||||
.flat_map(|provider| {
|
||||
provider.items().iter().filter_map(|item| {
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
let score = match (name_score, desc_score) {
|
||||
(Some(n), Some(d)) => Some(n.max(d)),
|
||||
(Some(n), None) => Some(n),
|
||||
(None, Some(d)) => Some(d / 2),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
score.map(|s| (item.clone(), s))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
|
||||
/// Search with frecency boosting, dynamic providers, and tag filtering
|
||||
pub fn search_with_frecency(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &crate::filter::ProviderFilter,
|
||||
frecency: &FrecencyStore,
|
||||
frecency_weight: f64,
|
||||
tag_filter: Option<&str>,
|
||||
) -> Vec<(LaunchItem, i64)> {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Search] query={:?}, max={}, frecency_weight={}", query, max_results, frecency_weight);
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
|
||||
|
||||
// Add widget items first (highest priority) - only when:
|
||||
// 1. No specific filter prefix is active
|
||||
// 2. Query is empty (user hasn't started searching)
|
||||
// This keeps widgets visible on launch but hides them during active search
|
||||
// Widgets are always visible regardless of filter settings (they declare position via API)
|
||||
if filter.active_prefix().is_none() && query.is_empty() {
|
||||
// Widget priority comes from plugin-declared priority field
|
||||
for provider in &self.widget_providers {
|
||||
let base_score = provider.priority() as i64;
|
||||
for (idx, item) in provider.items().iter().enumerate() {
|
||||
results.push((item.clone(), base_score - idx as i64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query dynamic providers (calculator, websearch, filesearch)
|
||||
// Only query if:
|
||||
// 1. Their specific filter is active (e.g., :file prefix or Files tab selected), OR
|
||||
// 2. No specific single-mode filter is active (showing all providers)
|
||||
if !query.is_empty() {
|
||||
for provider in &self.dynamic_providers {
|
||||
// Skip if this provider type is explicitly filtered out
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
let dynamic_results = provider.query(query);
|
||||
// Priority comes from plugin-declared priority field
|
||||
let base_score = provider.priority() as i64;
|
||||
for (idx, item) in dynamic_results.into_iter().enumerate() {
|
||||
results.push((item, base_score - idx as i64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty query (after checking special providers) - return frecency-sorted items
|
||||
if query.is_empty() {
|
||||
let items: Vec<(LaunchItem, i64)> = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned())
|
||||
.filter(|item| {
|
||||
// Apply tag filter if present
|
||||
if let Some(tag) = tag_filter {
|
||||
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.map(|item| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
|
||||
(item, boosted)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Combine widgets (already in results) with frecency items
|
||||
results.extend(items);
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Regular search with frecency boost and tag matching
|
||||
let search_results: Vec<(LaunchItem, i64)> = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|provider| filter.is_active(provider.provider_type()))
|
||||
.flat_map(|provider| {
|
||||
provider.items().iter().filter_map(|item| {
|
||||
// Apply tag filter if present
|
||||
if let Some(tag) = tag_filter
|
||||
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
// Also match against tags (lower weight)
|
||||
let tag_score = item
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|t| self.matcher.fuzzy_match(t, query))
|
||||
.max()
|
||||
.map(|s| s / 3); // Lower weight for tag matches
|
||||
|
||||
let base_score = match (name_score, desc_score, tag_score) {
|
||||
(Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)),
|
||||
(Some(n), Some(d), None) => Some(n.max(d)),
|
||||
(Some(n), None, Some(t)) => Some(n.max(t)),
|
||||
(Some(n), None, None) => Some(n),
|
||||
(None, Some(d), Some(t)) => Some((d / 2).max(t)),
|
||||
(None, Some(d), None) => Some(d / 2),
|
||||
(None, None, Some(t)) => Some(t),
|
||||
(None, None, None) => None,
|
||||
};
|
||||
|
||||
base_score.map(|s| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
|
||||
(item.clone(), s + frecency_boost)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.extend(search_results);
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
{
|
||||
debug!("[Search] Returning {} results", results.len());
|
||||
for (i, (item, score)) in results.iter().take(5).enumerate() {
|
||||
debug!("[Search] #{}: {} (score={}, provider={:?})", i + 1, item.name, score, item.provider);
|
||||
}
|
||||
if results.len() > 5 {
|
||||
debug!("[Search] ... and {} more", results.len() - 5);
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Get all available provider types (for UI tabs)
|
||||
#[allow(dead_code)]
|
||||
pub fn available_providers(&self) -> Vec<ProviderType> {
|
||||
self.providers.iter().map(|p| p.provider_type()).collect()
|
||||
}
|
||||
|
||||
/// Get a widget item by type_id (e.g., "pomodoro", "weather", "media")
|
||||
/// Returns the first item from the widget provider, if any
|
||||
pub fn get_widget_item(&self, type_id: &str) -> Option<LaunchItem> {
|
||||
self.widget_providers
|
||||
.iter()
|
||||
.find(|p| p.type_id() == type_id)
|
||||
.and_then(|p| p.items().first().cloned())
|
||||
}
|
||||
|
||||
/// Get all loaded widget provider type_ids
|
||||
/// Returns an iterator over the type_ids of currently loaded widget providers
|
||||
pub fn widget_type_ids(&self) -> impl Iterator<Item = &str> {
|
||||
self.widget_providers.iter().map(|p| p.type_id())
|
||||
}
|
||||
|
||||
/// Query a plugin for submenu actions
|
||||
///
|
||||
/// This is used when a user selects a SUBMENU:plugin_id:data item.
|
||||
/// The plugin is queried with "?SUBMENU:data" and returns action items.
|
||||
///
|
||||
/// Returns (display_name, actions) where display_name is the item name
|
||||
/// and actions are the submenu items returned by the plugin.
|
||||
pub fn query_submenu_actions(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
data: &str,
|
||||
display_name: &str,
|
||||
) -> Option<(String, Vec<LaunchItem>)> {
|
||||
// Build the submenu query
|
||||
let submenu_query = format!("?SUBMENU:{}", data);
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Submenu] Querying plugin '{}' with: {}",
|
||||
plugin_id, submenu_query
|
||||
);
|
||||
|
||||
// Search in dynamic providers
|
||||
for provider in &self.dynamic_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in widget providers
|
||||
for provider in &self.widget_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in static providers (boxed)
|
||||
// Note: Static providers don't typically have submenu support,
|
||||
// but we check for completeness
|
||||
for provider in &self.providers {
|
||||
if let ProviderType::Plugin(type_id) = provider.provider_type()
|
||||
&& type_id == plugin_id
|
||||
{
|
||||
// Static providers use the items() method, not query
|
||||
// Submenu support requires dynamic query capability
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Submenu] Plugin '{}' is static, cannot query for submenu",
|
||||
plugin_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Submenu] No submenu actions found for plugin '{}'", plugin_id);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
197
crates/owlry/src/providers/native_provider.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! 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
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
background-color: var(--owlry-bg, @theme_bg_color);
|
||||
border-radius: var(--owlry-border-radius, 12px);
|
||||
border: 1px solid var(--owlry-border, @borders);
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Search entry */
|
||||
@@ -43,8 +43,8 @@
|
||||
.owlry-result-row {
|
||||
background-color: transparent;
|
||||
border-radius: calc(var(--owlry-border-radius, 12px) - 4px);
|
||||
margin: 2px 0;
|
||||
padding: 8px 12px;
|
||||
margin: 1px 0;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.owlry-result-row:hover {
|
||||
@@ -67,6 +67,18 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Symbolic icons - inherit text color */
|
||||
.owlry-symbolic-icon {
|
||||
-gtk-icon-style: symbolic;
|
||||
}
|
||||
|
||||
/* Emoji icon - displayed as large text */
|
||||
.owlry-emoji-icon {
|
||||
font-size: 24px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* Result name */
|
||||
.owlry-result-name {
|
||||
font-size: var(--owlry-font-size, 14px);
|
||||
@@ -81,7 +93,7 @@
|
||||
/* Result description */
|
||||
.owlry-result-description {
|
||||
font-size: calc(var(--owlry-font-size, 14px) - 2px);
|
||||
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.7));
|
||||
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.85));
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@@ -166,6 +178,22 @@
|
||||
color: var(--owlry-badge-web, @teal_3);
|
||||
}
|
||||
|
||||
/* Widget provider badges */
|
||||
.owlry-badge-media {
|
||||
background-color: alpha(var(--owlry-badge-media, #ec4899), 0.2);
|
||||
color: var(--owlry-badge-media, #ec4899);
|
||||
}
|
||||
|
||||
.owlry-badge-weather {
|
||||
background-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.2);
|
||||
color: var(--owlry-badge-weather, #06b6d4);
|
||||
}
|
||||
|
||||
.owlry-badge-pomo {
|
||||
background-color: alpha(var(--owlry-badge-pomo, #f97316), 0.2);
|
||||
color: var(--owlry-badge-pomo, #f97316);
|
||||
}
|
||||
|
||||
/* Header bar */
|
||||
.owlry-header {
|
||||
margin-bottom: 4px;
|
||||
@@ -283,6 +311,25 @@
|
||||
border-color: alpha(var(--owlry-badge-web, @teal_3), 0.4);
|
||||
}
|
||||
|
||||
/* Widget filter buttons */
|
||||
.owlry-filter-media:checked {
|
||||
background-color: alpha(var(--owlry-badge-media, #ec4899), 0.2);
|
||||
color: var(--owlry-badge-media, #ec4899);
|
||||
border-color: alpha(var(--owlry-badge-media, #ec4899), 0.4);
|
||||
}
|
||||
|
||||
.owlry-filter-weather:checked {
|
||||
background-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.2);
|
||||
color: var(--owlry-badge-weather, #06b6d4);
|
||||
border-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.4);
|
||||
}
|
||||
|
||||
.owlry-filter-pomodoro:checked {
|
||||
background-color: alpha(var(--owlry-badge-pomo, #f97316), 0.2);
|
||||
color: var(--owlry-badge-pomo, #f97316);
|
||||
border-color: alpha(var(--owlry-badge-pomo, #f97316), 0.4);
|
||||
}
|
||||
|
||||
/* Hints bar at bottom */
|
||||
.owlry-hints {
|
||||
padding-top: 8px;
|
||||
@@ -291,7 +338,7 @@
|
||||
|
||||
.owlry-hints-label {
|
||||
font-size: calc(var(--owlry-font-size, 14px) - 4px);
|
||||
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.7));
|
||||
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.75));
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -315,6 +362,22 @@ scrollbar slider:active {
|
||||
background-color: var(--owlry-accent, @theme_selected_bg_color);
|
||||
}
|
||||
|
||||
/* Tag badges */
|
||||
.owlry-tag-badge {
|
||||
font-size: calc(var(--owlry-font-size, 14px) - 4px);
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: alpha(var(--owlry-border, @borders), 0.5);
|
||||
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.9));
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.owlry-result-row:selected .owlry-tag-badge {
|
||||
background-color: alpha(var(--owlry-accent-bright, @theme_selected_fg_color), 0.25);
|
||||
color: var(--owlry-accent-bright, @theme_selected_fg_color);
|
||||
}
|
||||
|
||||
/* Text selection */
|
||||
selection {
|
||||
background-color: alpha(var(--owlry-accent, @theme_selected_bg_color), 0.3);
|
||||
21
crates/owlry/src/resources/icons.gresource.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gresources>
|
||||
<gresource prefix="/org/owlry/launcher/icons">
|
||||
<!-- Weather icons (Erik Flowers Weather Icons - OFL license) -->
|
||||
<file>weather/wi-day-sunny.svg</file>
|
||||
<file>weather/wi-day-cloudy.svg</file>
|
||||
<file>weather/wi-cloudy.svg</file>
|
||||
<file>weather/wi-fog.svg</file>
|
||||
<file>weather/wi-rain.svg</file>
|
||||
<file>weather/wi-snow.svg</file>
|
||||
<file>weather/wi-thunderstorm.svg</file>
|
||||
<file>weather/wi-thermometer.svg</file>
|
||||
<file>weather/wi-night-clear.svg</file>
|
||||
|
||||
<!-- Media player icons -->
|
||||
<file>media/music-note.svg</file>
|
||||
|
||||
<!-- Pomodoro icons -->
|
||||
<file>pomodoro/tomato.svg</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
3
crates/owlry/src/resources/icons/media/music-note.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#e0e0e0">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 183 B |
12
crates/owlry/src/resources/icons/pomodoro/tomato.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<ellipse cx="50" cy="58" rx="38" ry="35" fill="#e53935"/>
|
||||
<ellipse cx="50" cy="58" rx="38" ry="35" fill="url(#tomato-gradient)"/>
|
||||
<path d="M50 25 C45 15, 55 15, 50 25" fill="#4caf50"/>
|
||||
<path d="M42 28 Q50 20 58 28" stroke="#2e7d32" stroke-width="3" fill="none"/>
|
||||
<defs>
|
||||
<radialGradient id="tomato-gradient" cx="30%" cy="30%">
|
||||
<stop offset="0%" stop-color="#ff5722" stop-opacity="0.3"/>
|
||||
<stop offset="100%" stop-color="#c62828" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 574 B |
18
crates/owlry/src/resources/icons/weather/wi-cloudy.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M3.89,17.6c0-0.99,0.31-1.88,0.93-2.65s1.41-1.27,2.38-1.49c0.26-1.17,0.85-2.14,1.78-2.88c0.93-0.75,2-1.12,3.22-1.12
|
||||
c1.18,0,2.24,0.36,3.16,1.09c0.93,0.73,1.53,1.66,1.8,2.8h0.27c1.18,0,2.18,0.41,3.01,1.24s1.25,1.83,1.25,3
|
||||
c0,1.18-0.42,2.18-1.25,3.01s-1.83,1.25-3.01,1.25H8.16c-0.58,0-1.13-0.11-1.65-0.34S5.52,21,5.14,20.62
|
||||
c-0.38-0.38-0.68-0.84-0.91-1.36S3.89,18.17,3.89,17.6z M5.34,17.6c0,0.76,0.28,1.42,0.82,1.96s1.21,0.82,1.99,0.82h9.28
|
||||
c0.77,0,1.44-0.27,1.99-0.82c0.55-0.55,0.83-1.2,0.83-1.96c0-0.76-0.27-1.42-0.83-1.96c-0.55-0.54-1.21-0.82-1.99-0.82h-1.39
|
||||
c-0.1,0-0.15-0.05-0.15-0.15l-0.07-0.49c-0.1-0.94-0.5-1.73-1.19-2.35s-1.51-0.93-2.45-0.93c-0.94,0-1.76,0.31-2.46,0.94
|
||||
c-0.7,0.62-1.09,1.41-1.18,2.34l-0.07,0.42c0,0.1-0.05,0.15-0.16,0.15l-0.45,0.07c-0.72,0.06-1.32,0.36-1.81,0.89
|
||||
C5.59,16.24,5.34,16.87,5.34,17.6z M14.19,8.88c-0.1,0.09-0.08,0.16,0.07,0.21c0.43,0.19,0.79,0.37,1.08,0.55
|
||||
c0.11,0.03,0.19,0.02,0.22-0.03c0.61-0.57,1.31-0.86,2.12-0.86c0.81,0,1.5,0.27,2.1,0.81c0.59,0.54,0.92,1.21,0.99,2l0.09,0.64h1.42
|
||||
c0.65,0,1.21,0.23,1.68,0.7c0.47,0.47,0.7,1.02,0.7,1.66c0,0.6-0.21,1.12-0.62,1.57s-0.92,0.7-1.53,0.77c-0.1,0-0.15,0.05-0.15,0.16
|
||||
v1.13c0,0.11,0.05,0.16,0.15,0.16c1.01-0.06,1.86-0.46,2.55-1.19s1.04-1.6,1.04-2.6c0-1.06-0.37-1.96-1.12-2.7
|
||||
c-0.75-0.75-1.65-1.12-2.7-1.12h-0.15c-0.26-1-0.81-1.82-1.65-2.47c-0.83-0.65-1.77-0.97-2.8-0.97C16.28,7.29,15.11,7.82,14.19,8.88
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
26
crates/owlry/src/resources/icons/weather/wi-day-cloudy.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M1.56,16.9c0,0.9,0.22,1.73,0.66,2.49s1.04,1.36,1.8,1.8c0.76,0.44,1.58,0.66,2.47,0.66h10.83c0.89,0,1.72-0.22,2.48-0.66
|
||||
c0.76-0.44,1.37-1.04,1.81-1.8c0.44-0.76,0.67-1.59,0.67-2.49c0-0.66-0.14-1.33-0.42-2C22.62,13.98,23,12.87,23,11.6
|
||||
c0-0.71-0.14-1.39-0.41-2.04c-0.27-0.65-0.65-1.2-1.12-1.67C21,7.42,20.45,7.04,19.8,6.77c-0.65-0.28-1.33-0.41-2.04-0.41
|
||||
c-1.48,0-2.77,0.58-3.88,1.74c-0.77-0.44-1.67-0.66-2.7-0.66c-1.41,0-2.65,0.44-3.73,1.31c-1.08,0.87-1.78,1.99-2.08,3.35
|
||||
c-1.12,0.26-2.03,0.83-2.74,1.73S1.56,15.75,1.56,16.9z M3.27,16.9c0-0.84,0.28-1.56,0.84-2.17c0.56-0.61,1.26-0.96,2.1-1.06
|
||||
l0.5-0.03c0.12,0,0.19-0.06,0.19-0.18l0.07-0.54c0.14-1.08,0.61-1.99,1.41-2.71c0.8-0.73,1.74-1.09,2.81-1.09
|
||||
c1.1,0,2.06,0.37,2.87,1.1c0.82,0.73,1.27,1.63,1.37,2.71l0.07,0.58c0.02,0.11,0.09,0.17,0.21,0.17h1.61c0.88,0,1.64,0.32,2.28,0.96
|
||||
c0.64,0.64,0.96,1.39,0.96,2.27c0,0.91-0.32,1.68-0.95,2.32c-0.63,0.64-1.4,0.96-2.28,0.96H6.49c-0.88,0-1.63-0.32-2.27-0.97
|
||||
C3.59,18.57,3.27,17.8,3.27,16.9z M9.97,4.63c0,0.24,0.08,0.45,0.24,0.63l0.66,0.64c0.25,0.19,0.46,0.27,0.64,0.25
|
||||
c0.21,0,0.39-0.09,0.55-0.26s0.24-0.38,0.24-0.62c0-0.24-0.09-0.44-0.26-0.59l-0.59-0.66c-0.18-0.16-0.38-0.24-0.61-0.24
|
||||
c-0.24,0-0.45,0.08-0.62,0.25C10.05,4.19,9.97,4.39,9.97,4.63z M15.31,9.06c0.69-0.67,1.51-1,2.45-1c0.99,0,1.83,0.34,2.52,1.03
|
||||
c0.69,0.69,1.04,1.52,1.04,2.51c0,0.62-0.17,1.24-0.51,1.84C19.84,12.48,18.68,12,17.32,12H17C16.75,10.91,16.19,9.93,15.31,9.06z
|
||||
M16.94,3.78c0,0.26,0.08,0.46,0.23,0.62s0.35,0.23,0.59,0.23c0.26,0,0.46-0.08,0.62-0.23c0.16-0.16,0.23-0.36,0.23-0.62V1.73
|
||||
c0-0.24-0.08-0.43-0.24-0.59s-0.36-0.23-0.61-0.23c-0.24,0-0.43,0.08-0.59,0.23s-0.23,0.35-0.23,0.59V3.78z M22.46,6.07
|
||||
c0,0.26,0.07,0.46,0.22,0.62c0.21,0.16,0.42,0.24,0.62,0.24c0.18,0,0.38-0.08,0.59-0.24l1.43-1.43c0.16-0.18,0.24-0.39,0.24-0.64
|
||||
c0-0.24-0.08-0.44-0.24-0.6c-0.16-0.16-0.36-0.24-0.59-0.24c-0.24,0-0.43,0.08-0.58,0.24l-1.47,1.43
|
||||
C22.53,5.64,22.46,5.84,22.46,6.07z M23.25,17.91c0,0.24,0.08,0.45,0.25,0.63l0.65,0.63c0.15,0.16,0.34,0.24,0.58,0.24
|
||||
s0.44-0.08,0.6-0.25c0.16-0.17,0.24-0.37,0.24-0.62c0-0.22-0.08-0.42-0.24-0.58l-0.65-0.65c-0.16-0.16-0.35-0.24-0.57-0.24
|
||||
c-0.24,0-0.44,0.08-0.6,0.24C23.34,17.47,23.25,17.67,23.25,17.91z M24.72,11.6c0,0.23,0.09,0.42,0.26,0.58
|
||||
c0.16,0.16,0.37,0.24,0.61,0.24h2.04c0.23,0,0.42-0.08,0.58-0.23s0.23-0.35,0.23-0.59c0-0.24-0.08-0.44-0.23-0.6
|
||||
s-0.35-0.25-0.58-0.25h-2.04c-0.24,0-0.44,0.08-0.61,0.25C24.8,11.17,24.72,11.37,24.72,11.6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
27
crates/owlry/src/resources/icons/weather/wi-day-sunny.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M4.37,14.62c0-0.24,0.08-0.45,0.25-0.62c0.17-0.16,0.38-0.24,0.6-0.24h2.04c0.23,0,0.42,0.08,0.58,0.25
|
||||
c0.15,0.17,0.23,0.37,0.23,0.61S8,15.06,7.85,15.23c-0.15,0.17-0.35,0.25-0.58,0.25H5.23c-0.23,0-0.43-0.08-0.6-0.25
|
||||
C4.46,15.06,4.37,14.86,4.37,14.62z M7.23,21.55c0-0.23,0.08-0.43,0.23-0.61l1.47-1.43c0.15-0.16,0.35-0.23,0.59-0.23
|
||||
c0.24,0,0.44,0.08,0.6,0.23s0.24,0.34,0.24,0.57c0,0.24-0.08,0.46-0.24,0.64L8.7,22.14c-0.41,0.32-0.82,0.32-1.23,0
|
||||
C7.31,21.98,7.23,21.78,7.23,21.55z M7.23,7.71c0-0.23,0.08-0.43,0.23-0.61C7.66,6.93,7.87,6.85,8.1,6.85
|
||||
c0.22,0,0.42,0.08,0.59,0.24l1.43,1.47c0.16,0.15,0.24,0.35,0.24,0.59c0,0.24-0.08,0.44-0.24,0.6s-0.36,0.24-0.6,0.24
|
||||
c-0.24,0-0.44-0.08-0.59-0.24L7.47,8.32C7.31,8.16,7.23,7.95,7.23,7.71z M9.78,14.62c0-0.93,0.23-1.8,0.7-2.6s1.1-1.44,1.91-1.91
|
||||
s1.67-0.7,2.6-0.7c0.7,0,1.37,0.14,2.02,0.42c0.64,0.28,1.2,0.65,1.66,1.12c0.47,0.47,0.84,1.02,1.11,1.66
|
||||
c0.27,0.64,0.41,1.32,0.41,2.02c0,0.94-0.23,1.81-0.7,2.61c-0.47,0.8-1.1,1.43-1.9,1.9c-0.8,0.47-1.67,0.7-2.61,0.7
|
||||
s-1.81-0.23-2.61-0.7c-0.8-0.47-1.43-1.1-1.9-1.9C10.02,16.43,9.78,15.56,9.78,14.62z M11.48,14.62c0,0.98,0.34,1.81,1.03,2.5
|
||||
c0.68,0.69,1.51,1.04,2.49,1.04s1.81-0.35,2.5-1.04s1.04-1.52,1.04-2.5c0-0.96-0.35-1.78-1.04-2.47c-0.69-0.68-1.52-1.02-2.5-1.02
|
||||
c-0.97,0-1.8,0.34-2.48,1.02C11.82,12.84,11.48,13.66,11.48,14.62z M14.14,22.4c0-0.24,0.08-0.44,0.25-0.6s0.37-0.24,0.6-0.24
|
||||
c0.24,0,0.45,0.08,0.61,0.24s0.24,0.36,0.24,0.6v1.99c0,0.24-0.08,0.45-0.25,0.62c-0.17,0.17-0.37,0.25-0.6,0.25
|
||||
s-0.44-0.08-0.6-0.25c-0.17-0.17-0.25-0.38-0.25-0.62V22.4z M14.14,6.9V4.86c0-0.23,0.08-0.43,0.25-0.6C14.56,4.09,14.76,4,15,4
|
||||
s0.43,0.08,0.6,0.25c0.17,0.17,0.25,0.37,0.25,0.6V6.9c0,0.23-0.08,0.42-0.25,0.58S15.23,7.71,15,7.71s-0.44-0.08-0.6-0.23
|
||||
S14.14,7.13,14.14,6.9z M19.66,20.08c0-0.23,0.08-0.42,0.23-0.56c0.15-0.16,0.34-0.23,0.56-0.23c0.24,0,0.44,0.08,0.6,0.23
|
||||
l1.46,1.43c0.16,0.17,0.24,0.38,0.24,0.61c0,0.23-0.08,0.43-0.24,0.59c-0.4,0.31-0.8,0.31-1.2,0l-1.42-1.42
|
||||
C19.74,20.55,19.66,20.34,19.66,20.08z M19.66,9.16c0-0.25,0.08-0.45,0.23-0.59l1.42-1.47c0.17-0.16,0.37-0.24,0.59-0.24
|
||||
c0.24,0,0.44,0.08,0.6,0.25c0.17,0.17,0.25,0.37,0.25,0.6c0,0.25-0.08,0.46-0.24,0.62l-1.46,1.43c-0.18,0.16-0.38,0.24-0.6,0.24
|
||||
c-0.23,0-0.41-0.08-0.56-0.24S19.66,9.4,19.66,9.16z M21.92,14.62c0-0.24,0.08-0.44,0.24-0.62c0.16-0.16,0.35-0.24,0.57-0.24h2.02
|
||||
c0.23,0,0.43,0.09,0.6,0.26c0.17,0.17,0.26,0.37,0.26,0.6s-0.09,0.43-0.26,0.6c-0.17,0.17-0.37,0.25-0.6,0.25h-2.02
|
||||
c-0.23,0-0.43-0.08-0.58-0.25S21.92,14.86,21.92,14.62z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
18
crates/owlry/src/resources/icons/weather/wi-fog.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M2.62,21.05c0-0.24,0.08-0.45,0.25-0.61c0.17-0.16,0.38-0.24,0.63-0.24h18.67c0.25,0,0.45,0.08,0.61,0.24
|
||||
c0.16,0.16,0.24,0.36,0.24,0.61c0,0.23-0.08,0.43-0.25,0.58c-0.17,0.16-0.37,0.23-0.6,0.23H3.5c-0.25,0-0.46-0.08-0.63-0.23
|
||||
C2.7,21.47,2.62,21.28,2.62,21.05z M5.24,17.91c0-0.24,0.09-0.44,0.26-0.6c0.15-0.15,0.35-0.23,0.59-0.23h18.67
|
||||
c0.23,0,0.42,0.08,0.58,0.24c0.16,0.16,0.23,0.35,0.23,0.59c0,0.24-0.08,0.44-0.23,0.6c-0.16,0.17-0.35,0.25-0.58,0.25H6.09
|
||||
c-0.24,0-0.44-0.08-0.6-0.25C5.32,18.34,5.24,18.14,5.24,17.91z M5.37,15.52c0,0.09,0.05,0.13,0.15,0.13h1.43
|
||||
c0.06,0,0.13-0.05,0.2-0.16c0.24-0.52,0.59-0.94,1.06-1.27c0.47-0.33,0.99-0.52,1.55-0.56l0.55-0.07c0.11,0,0.17-0.06,0.17-0.18
|
||||
l0.07-0.5c0.11-1.08,0.56-1.98,1.37-2.7c0.81-0.72,1.76-1.08,2.85-1.08c1.08,0,2.02,0.36,2.83,1.07c0.8,0.71,1.26,1.61,1.37,2.68
|
||||
l0.08,0.57c0,0.11,0.07,0.17,0.2,0.17h1.59c0.64,0,1.23,0.17,1.76,0.52s0.92,0.8,1.18,1.37c0.07,0.11,0.14,0.16,0.21,0.16h1.43
|
||||
c0.12,0,0.17-0.07,0.14-0.23c-0.29-1.02-0.88-1.86-1.74-2.51c-0.87-0.65-1.86-0.97-2.97-0.97h-0.32c-0.33-1.33-1.03-2.42-2.1-3.27
|
||||
s-2.28-1.27-3.65-1.27c-1.4,0-2.64,0.44-3.73,1.32s-1.78,2-2.09,3.36c-0.85,0.2-1.6,0.6-2.24,1.21c-0.64,0.61-1.09,1.33-1.34,2.18
|
||||
v-0.04C5.37,15.45,5.37,15.48,5.37,15.52z M6.98,24.11c0-0.24,0.09-0.43,0.26-0.59c0.15-0.15,0.35-0.23,0.6-0.23h18.68
|
||||
c0.24,0,0.44,0.08,0.6,0.23c0.17,0.16,0.25,0.35,0.25,0.58c0,0.24-0.08,0.44-0.25,0.61c-0.17,0.17-0.37,0.25-0.6,0.25H7.84
|
||||
c-0.23,0-0.43-0.09-0.6-0.26C7.07,24.55,6.98,24.34,6.98,24.11z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
13
crates/owlry/src/resources/icons/weather/wi-night-clear.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M7.91,14.48c0-0.96,0.19-1.87,0.56-2.75s0.88-1.63,1.51-2.26c0.63-0.63,1.39-1.14,2.27-1.52c0.88-0.38,1.8-0.57,2.75-0.57
|
||||
h1.14c0.16,0.04,0.23,0.14,0.23,0.28l0.05,0.88c0.04,1.27,0.49,2.35,1.37,3.24c0.88,0.89,1.94,1.37,3.19,1.42l0.82,0.07
|
||||
c0.16,0,0.24,0.08,0.24,0.23v0.98c0.01,1.28-0.3,2.47-0.93,3.56c-0.63,1.09-1.48,1.95-2.57,2.59c-1.08,0.63-2.27,0.95-3.55,0.95
|
||||
c-0.97,0-1.9-0.19-2.78-0.56s-1.63-0.88-2.26-1.51c-0.63-0.63-1.13-1.39-1.5-2.26C8.1,16.37,7.91,15.45,7.91,14.48z M9.74,14.48
|
||||
c0,0.76,0.15,1.48,0.45,2.16c0.3,0.67,0.7,1.24,1.19,1.7c0.49,0.46,1.05,0.82,1.69,1.08c0.63,0.27,1.28,0.4,1.94,0.4
|
||||
c0.58,0,1.17-0.11,1.76-0.34c0.59-0.23,1.14-0.55,1.65-0.96c0.51-0.41,0.94-0.93,1.31-1.57c0.37-0.64,0.6-1.33,0.71-2.09
|
||||
c-1.63-0.34-2.94-1.04-3.92-2.1s-1.55-2.3-1.7-3.74C13.86,9.08,13,9.37,12.21,9.9c-0.78,0.53-1.39,1.2-1.82,2.02
|
||||
C9.96,12.74,9.74,13.59,9.74,14.48z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
23
crates/owlry/src/resources/icons/weather/wi-rain.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M4.64,16.91c0-1.15,0.36-2.17,1.08-3.07c0.72-0.9,1.63-1.47,2.73-1.73c0.31-1.36,1.02-2.48,2.11-3.36s2.34-1.31,3.75-1.31
|
||||
c1.38,0,2.6,0.43,3.68,1.28c1.08,0.85,1.78,1.95,2.1,3.29h0.32c0.89,0,1.72,0.22,2.48,0.65s1.37,1.03,1.81,1.78
|
||||
c0.44,0.75,0.67,1.58,0.67,2.47c0,0.88-0.21,1.69-0.63,2.44c-0.42,0.75-1,1.35-1.73,1.8c-0.73,0.45-1.53,0.69-2.4,0.71
|
||||
c-0.13,0-0.2-0.06-0.2-0.17v-1.33c0-0.12,0.07-0.18,0.2-0.18c0.85-0.04,1.58-0.38,2.18-1.02s0.9-1.39,0.9-2.26s-0.33-1.62-0.98-2.26
|
||||
s-1.42-0.96-2.31-0.96h-1.61c-0.12,0-0.18-0.06-0.18-0.17l-0.08-0.58c-0.11-1.08-0.58-1.99-1.39-2.71
|
||||
c-0.82-0.73-1.76-1.09-2.85-1.09c-1.09,0-2.05,0.36-2.85,1.09c-0.81,0.73-1.26,1.63-1.36,2.71l-0.07,0.53c0,0.12-0.07,0.19-0.2,0.19
|
||||
l-0.53,0.03c-0.83,0.1-1.53,0.46-2.1,1.07s-0.85,1.33-0.85,2.16c0,0.87,0.3,1.62,0.9,2.26s1.33,0.98,2.18,1.02
|
||||
c0.11,0,0.17,0.06,0.17,0.18v1.33c0,0.11-0.06,0.17-0.17,0.17c-1.34-0.06-2.47-0.57-3.4-1.53S4.64,18.24,4.64,16.91z M9.99,23.6
|
||||
c0-0.04,0.01-0.11,0.04-0.2l1.63-5.77c0.06-0.19,0.17-0.34,0.32-0.44c0.15-0.1,0.31-0.15,0.46-0.15c0.07,0,0.15,0.01,0.24,0.03
|
||||
c0.24,0.04,0.42,0.17,0.54,0.37c0.12,0.2,0.15,0.42,0.08,0.67l-1.63,5.73c-0.12,0.43-0.4,0.64-0.82,0.64
|
||||
c-0.04,0-0.07-0.01-0.11-0.02c-0.06-0.02-0.09-0.03-0.1-0.03c-0.22-0.06-0.38-0.17-0.49-0.33C10.04,23.93,9.99,23.77,9.99,23.6z
|
||||
M12.61,26.41l2.44-8.77c0.04-0.19,0.14-0.34,0.3-0.44c0.16-0.1,0.32-0.15,0.49-0.15c0.09,0,0.18,0.01,0.27,0.03
|
||||
c0.22,0.06,0.38,0.19,0.49,0.39c0.11,0.2,0.13,0.41,0.07,0.64l-2.43,8.78c-0.04,0.17-0.13,0.31-0.29,0.43
|
||||
c-0.16,0.12-0.32,0.18-0.51,0.18c-0.09,0-0.18-0.02-0.25-0.05c-0.2-0.05-0.37-0.18-0.52-0.39C12.56,26.88,12.54,26.67,12.61,26.41z
|
||||
M16.74,23.62c0-0.04,0.01-0.11,0.04-0.23l1.63-5.77c0.06-0.19,0.16-0.34,0.3-0.44c0.15-0.1,0.3-0.15,0.46-0.15
|
||||
c0.08,0,0.17,0.01,0.26,0.03c0.21,0.06,0.36,0.16,0.46,0.31c0.1,0.15,0.15,0.31,0.15,0.47c0,0.03-0.01,0.08-0.02,0.14
|
||||
s-0.02,0.1-0.02,0.12l-1.63,5.73c-0.04,0.19-0.13,0.35-0.28,0.46s-0.32,0.17-0.51,0.17l-0.24-0.05c-0.2-0.06-0.35-0.16-0.46-0.32
|
||||
C16.79,23.94,16.74,23.78,16.74,23.62z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
27
crates/owlry/src/resources/icons/weather/wi-snow.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M4.64,16.95c0-1.16,0.35-2.18,1.06-3.08s1.62-1.48,2.74-1.76c0.31-1.36,1.01-2.48,2.1-3.36s2.34-1.31,3.75-1.31
|
||||
c1.38,0,2.6,0.43,3.68,1.28c1.08,0.85,1.78,1.95,2.1,3.29h0.32c0.89,0,1.72,0.22,2.48,0.66c0.76,0.44,1.37,1.04,1.81,1.8
|
||||
c0.44,0.76,0.67,1.59,0.67,2.48c0,1.32-0.46,2.47-1.39,3.42c-0.92,0.96-2.05,1.46-3.38,1.5c-0.13,0-0.2-0.06-0.2-0.17v-1.33
|
||||
c0-0.12,0.07-0.18,0.2-0.18c0.85-0.04,1.58-0.38,2.18-1.02s0.9-1.38,0.9-2.23c0-0.89-0.32-1.65-0.97-2.3s-1.42-0.97-2.32-0.97h-1.61
|
||||
c-0.12,0-0.18-0.06-0.18-0.17l-0.08-0.58c-0.11-1.08-0.58-1.99-1.39-2.72c-0.82-0.73-1.76-1.1-2.85-1.1c-1.1,0-2.05,0.37-2.86,1.11
|
||||
c-0.81,0.74-1.27,1.65-1.37,2.75l-0.06,0.5c0,0.12-0.07,0.19-0.2,0.19l-0.53,0.07c-0.83,0.07-1.53,0.41-2.1,1.04
|
||||
s-0.85,1.35-0.85,2.19c0,0.85,0.3,1.59,0.9,2.23s1.33,0.97,2.18,1.02c0.11,0,0.17,0.06,0.17,0.18v1.33c0,0.11-0.06,0.17-0.17,0.17
|
||||
c-1.34-0.04-2.47-0.54-3.4-1.5C5.1,19.42,4.64,18.27,4.64,16.95z M11,21.02c0-0.22,0.08-0.42,0.24-0.58
|
||||
c0.16-0.16,0.35-0.24,0.59-0.24c0.23,0,0.43,0.08,0.59,0.24c0.16,0.16,0.24,0.36,0.24,0.58c0,0.24-0.08,0.44-0.24,0.6
|
||||
c-0.16,0.17-0.35,0.25-0.59,0.25c-0.23,0-0.43-0.08-0.59-0.25C11.08,21.46,11,21.26,11,21.02z M11,24.65c0-0.24,0.08-0.44,0.24-0.6
|
||||
c0.16-0.15,0.35-0.23,0.58-0.23c0.23,0,0.43,0.08,0.59,0.23c0.16,0.16,0.24,0.35,0.24,0.59c0,0.24-0.08,0.43-0.24,0.59
|
||||
c-0.16,0.16-0.35,0.23-0.59,0.23c-0.23,0-0.43-0.08-0.59-0.23C11.08,25.08,11,24.88,11,24.65z M14.19,22.95
|
||||
c0-0.23,0.08-0.44,0.25-0.62c0.16-0.16,0.35-0.24,0.57-0.24c0.23,0,0.43,0.09,0.6,0.26c0.17,0.17,0.26,0.37,0.26,0.6
|
||||
c0,0.23-0.08,0.43-0.25,0.6c-0.17,0.17-0.37,0.25-0.61,0.25c-0.23,0-0.42-0.08-0.58-0.25S14.19,23.18,14.19,22.95z M14.19,19.33
|
||||
c0-0.23,0.08-0.43,0.25-0.6c0.18-0.16,0.37-0.24,0.57-0.24c0.24,0,0.44,0.08,0.61,0.25c0.17,0.17,0.25,0.36,0.25,0.6
|
||||
c0,0.23-0.08,0.43-0.25,0.59c-0.17,0.16-0.37,0.24-0.61,0.24c-0.23,0-0.42-0.08-0.58-0.24C14.27,19.76,14.19,19.56,14.19,19.33z
|
||||
M14.19,26.61c0-0.23,0.08-0.43,0.25-0.61c0.16-0.16,0.35-0.24,0.57-0.24c0.24,0,0.44,0.08,0.61,0.25c0.17,0.17,0.25,0.37,0.25,0.6
|
||||
s-0.08,0.43-0.25,0.59c-0.17,0.16-0.37,0.24-0.61,0.24c-0.23,0-0.42-0.08-0.58-0.24C14.27,27.03,14.19,26.84,14.19,26.61z
|
||||
M17.41,21.02c0-0.22,0.08-0.41,0.25-0.58c0.17-0.17,0.37-0.25,0.6-0.25c0.23,0,0.43,0.08,0.59,0.24c0.16,0.16,0.24,0.36,0.24,0.58
|
||||
c0,0.24-0.08,0.44-0.24,0.6c-0.16,0.17-0.35,0.25-0.59,0.25c-0.24,0-0.44-0.08-0.6-0.25C17.5,21.45,17.41,21.25,17.41,21.02z
|
||||
M17.41,24.65c0-0.22,0.08-0.42,0.25-0.6c0.16-0.15,0.36-0.23,0.6-0.23c0.24,0,0.43,0.08,0.59,0.23s0.23,0.35,0.23,0.59
|
||||
c0,0.24-0.08,0.43-0.23,0.59c-0.16,0.16-0.35,0.23-0.59,0.23c-0.24,0-0.44-0.08-0.6-0.24C17.5,25.07,17.41,24.88,17.41,24.65z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
15
crates/owlry/src/resources/icons/weather/wi-thermometer.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M9.91,19.56c0-0.85,0.2-1.64,0.59-2.38s0.94-1.35,1.65-1.84V5.42c0-0.8,0.27-1.48,0.82-2.03S14.2,2.55,15,2.55
|
||||
c0.81,0,1.49,0.28,2.04,0.83c0.55,0.56,0.83,1.23,0.83,2.03v9.92c0.71,0.49,1.25,1.11,1.64,1.84s0.58,1.53,0.58,2.38
|
||||
c0,0.92-0.23,1.78-0.68,2.56s-1.07,1.4-1.85,1.85s-1.63,0.68-2.56,0.68c-0.92,0-1.77-0.23-2.55-0.68s-1.4-1.07-1.86-1.85
|
||||
S9.91,20.48,9.91,19.56z M11.67,19.56c0,0.93,0.33,1.73,0.98,2.39c0.65,0.66,1.44,0.99,2.36,0.99c0.93,0,1.73-0.33,2.4-1
|
||||
s1.01-1.46,1.01-2.37c0-0.62-0.16-1.2-0.48-1.73c-0.32-0.53-0.76-0.94-1.32-1.23l-0.28-0.14c-0.1-0.04-0.15-0.14-0.15-0.29V5.42
|
||||
c0-0.32-0.11-0.59-0.34-0.81C15.62,4.4,15.34,4.29,15,4.29c-0.32,0-0.6,0.11-0.83,0.32c-0.23,0.21-0.34,0.48-0.34,0.81v10.74
|
||||
c0,0.15-0.05,0.25-0.14,0.29l-0.27,0.14c-0.55,0.29-0.98,0.7-1.29,1.23C11.82,18.35,11.67,18.92,11.67,19.56z M12.45,19.56
|
||||
c0,0.71,0.24,1.32,0.73,1.82s1.07,0.75,1.76,0.75s1.28-0.25,1.79-0.75c0.51-0.5,0.76-1.11,0.76-1.81c0-0.63-0.22-1.19-0.65-1.67
|
||||
c-0.43-0.48-0.96-0.77-1.58-0.85V9.69c0-0.06-0.03-0.13-0.1-0.19c-0.07-0.07-0.14-0.1-0.22-0.1c-0.09,0-0.16,0.03-0.21,0.08
|
||||
c-0.05,0.06-0.08,0.12-0.08,0.21v7.34c-0.61,0.09-1.13,0.37-1.56,0.85C12.66,18.37,12.45,18.92,12.45,19.56z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
21
crates/owlry/src/resources/icons/weather/wi-thunderstorm.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" fill="#e0e0e0" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M4.63,16.91c0,1.11,0.33,2.1,0.99,2.97s1.52,1.47,2.58,1.79l-0.66,1.68c-0.03,0.14,0.02,0.22,0.14,0.22h2.13l-0.98,4.3h0.28
|
||||
l3.92-5.75c0.04-0.04,0.04-0.09,0.01-0.14c-0.03-0.05-0.08-0.07-0.15-0.07h-2.18l2.48-4.64c0.07-0.14,0.02-0.22-0.14-0.22h-2.94
|
||||
c-0.09,0-0.17,0.05-0.23,0.15l-1.07,2.87c-0.71-0.18-1.3-0.57-1.77-1.16c-0.47-0.59-0.7-1.26-0.7-2.01c0-0.83,0.28-1.55,0.85-2.17
|
||||
c0.57-0.61,1.27-0.97,2.1-1.07l0.53-0.07c0.13,0,0.2-0.06,0.2-0.18l0.07-0.51c0.11-1.08,0.56-1.99,1.37-2.72
|
||||
c0.81-0.73,1.76-1.1,2.85-1.1c1.09,0,2.04,0.37,2.85,1.1c0.82,0.73,1.28,1.64,1.4,2.72l0.07,0.58c0,0.11,0.06,0.17,0.18,0.17h1.6
|
||||
c0.91,0,1.68,0.32,2.32,0.95c0.64,0.63,0.97,1.4,0.97,2.28c0,0.85-0.3,1.59-0.89,2.21c-0.59,0.62-1.33,0.97-2.2,1.04
|
||||
c-0.13,0-0.2,0.06-0.2,0.18v1.37c0,0.11,0.07,0.17,0.2,0.17c1.33-0.04,2.46-0.55,3.39-1.51s1.39-2.11,1.39-3.45
|
||||
c0-0.9-0.22-1.73-0.67-2.49c-0.44-0.76-1.05-1.36-1.81-1.8c-0.77-0.44-1.6-0.66-2.5-0.66H20.1c-0.33-1.33-1.04-2.42-2.11-3.26
|
||||
s-2.3-1.27-3.68-1.27c-1.41,0-2.67,0.44-3.76,1.31s-1.79,1.99-2.1,3.36c-1.11,0.26-2.02,0.83-2.74,1.73S4.63,15.76,4.63,16.91z
|
||||
M12.77,26.62c0,0.39,0.19,0.65,0.58,0.77c0.01,0,0.05,0,0.11,0.01c0.06,0.01,0.11,0.01,0.14,0.01c0.17,0,0.33-0.05,0.49-0.15
|
||||
c0.16-0.1,0.27-0.26,0.32-0.48l2.25-8.69c0.06-0.24,0.04-0.45-0.07-0.65c-0.11-0.19-0.27-0.32-0.5-0.39
|
||||
c-0.17-0.02-0.26-0.03-0.26-0.03c-0.16,0-0.32,0.05-0.47,0.15c-0.15,0.1-0.26,0.25-0.31,0.45l-2.26,8.72
|
||||
C12.78,26.44,12.77,26.53,12.77,26.62z M16.93,23.56c0,0.13,0.03,0.26,0.1,0.38c0.14,0.22,0.31,0.37,0.51,0.44
|
||||
c0.11,0.03,0.21,0.05,0.3,0.05s0.2-0.02,0.32-0.08c0.21-0.09,0.35-0.28,0.42-0.57l1.44-5.67c0.03-0.14,0.05-0.23,0.05-0.27
|
||||
c0-0.15-0.05-0.3-0.16-0.45s-0.26-0.26-0.46-0.32c-0.17-0.02-0.26-0.03-0.26-0.03c-0.17,0-0.33,0.05-0.47,0.15
|
||||
c-0.14,0.1-0.24,0.25-0.3,0.45l-1.46,5.7c0,0.02,0,0.05-0.01,0.11C16.93,23.5,16.93,23.53,16.93,23.56z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -72,6 +72,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
|
||||
}
|
||||